Runtime and DSL for Claude Code context: Cursor .mdc rules, skill-gate profiles, TSX prompt sections

A runtime and DSL for Claude Code context.
context-gate is one Claude Code mod plugin (Claude Code ≥ 2.1.287) that controls what reaches the model's context: Cursor .mdc rules, skills, MCP tools, subagents and the system prompt itself. Configuration lives in .claude/ and is committed, and the session state lives in the mod. The full design is in docs/SPEC.md (Ukrainian); docs/ARCHITECTURE.md is the consolidated layer-3 map.
You need Claude Code 2.1.287 or newer and Node.js 22.18 or newer on PATH. Installation has two parts: the plugin runs inside Claude Code, and the npm package gives your project the context-gate command.
1. Add the plugin to Claude Code. Run these two commands inside a Claude Code session:
/plugin marketplace add Ivlad003/context-gate
/plugin install context-gate@context-gate
The first command registers this GitHub repository as a plugin marketplace, and the second one installs the context-gate plugin from it. The same works from a terminal: claude plugin marketplace add Ivlad003/context-gate and then claude plugin install context-gate@context-gate. Add --scope project to the install command if the whole team should get the plugin through the repository's .claude/settings.json. Start a new claude session afterwards, so that the plugin loads. To get a newer version later, run claude plugin marketplace update context-gate and then claude plugin update context-gate@context-gate.
The plugin ships its built CLI (dist/cli.js), so nothing has to be built after install. Compiling TSX prompts needs esbuild, which the npm package below brings along. Markdown prompts need no build at all.
2. Add the CLI to your project. In the root of your repository:
npm i -D context-gate # the CLI from npm: https://www.npmjs.com/package/context-gate
npx context-gate init # .claude/gate.json with profiles guessed from the repo, classify: shadow
The local install matters for two reasons. Skills compiled from TSX prompts call npx --no-install context-gate when the plugin is absent (a teammate without the plugin, CI), and the editor integrations call npx --no context-gate. Neither of them downloads anything, so the package has to be in node_modules. To try the CLI once without installing it, run npx context-gate@latest init. You do not need @context-gate/jsx from npm: init and build write its type declarations into .claude/prompt/.types/jsx/, so TSX prompts get autocomplete without it.
3. Check that it works. Start claude in the repository. The status line shows the gate, /gate prints the active profile and tier, and npx context-gate health checks that gate.json is valid. A step-by-step course with many prompt examples is in docs/COURSE.md.
cd your-repo
npx context-gate init # writes .claude/gate.json (+ .gitignore lines)
claude # the mod loads; the status line shows the gate
In the session:
/gate status: profile, tier, how many skills / MCP tools / rules are on
/gate why the decision journal: who chose the profile and why
/gate rules every Cursor rule with its type, globs and whether it was delivered
/gate frontend fix a profile for this session; /gate +docs adds a group, /gate auto goes back
/gate apply leave shadow mode: the gate starts filtering
/gate health prompt health metrics (H0xx) with a "what to do" column
Day one is shadow mode: nothing is filtered, /gate why shows what the classifier would have chosen. After a week, npx context-gate report summarises the journal, and /gate apply turns the gate on.
| Layer | What it does | Where |
|---|---|---|
| 1. cursor-rules | .cursor/rules/*.mdc with Cursor semantics: Always rules go in after CLAUDE.md, Auto Attached rules arrive as context after the tool result of a matching Read/Edit/Write, Agent Requested rules become skills, Manual rules come in with @id or /rule <id>. Per-agent dedup, partial-read check, strictWrite. | hooks/layers/cursor-rules.ts, packages/core/src/mdc.ts |
| 2. skill-gate | Picks the skills, MCP tools and subagents for the task and the model: profiles built from groups, tiers by model, when signals (paths, branch, ticket type, expressions), a classifier once per task with hysteresis, budgets, escalation. Off items get a one-line description and { deny } with "enable with /gate +group". | hooks/layers/skill-gate.ts, packages/core/src/decide.ts |
| 3. prompt DSL | The system prompt as TSX (or Markdown with @ directives) compiled into a total AST, rendered on prompt.compose: conditions, loops, scripts (Run, Call), includes (inline/ref/lazy), tier variants. Static parts stay stable for the prompt cache. | packages/jsx, packages/core/src/render.ts, hooks/layers/dsl.ts |
A minimal prompt, .claude/prompt/main.prompt.tsx:
import { Prompt, Section, Each, Tier, Run, V } from '@context-gate/jsx'
export default (
<Prompt>
<Section id="identity" scope="static">You are a senior TypeScript engineer on this repository.</Section>
<Section id="rules" scope="profile" budget={4000}>
<Each of="cursor.always" as="r"><li><V expr="r.body" /></li></Each>
</Section>
<Section id="workflow" scope="profile">
<Tier is={['quick', 'standard']}>Plan 3–6 steps, show the plan, run the tests after every edit.</Tier>
</Section>
<Section id="repo-state" scope="volatile">
<Run lang="bash" cache="5m" as="log">git log --oneline -5</Run>
Recent commits: {'{{ log }}'}
</Section>
</Prompt>
)
npx context-gate build # → .claude/prompt/.compiled/main.json
npx context-gate run --trace --dry-scripts # what the model gets, with a trace table
.claude/gate.json referenceJSON Schema: schema/context-gate.schema.json. A complete example: examples/basic/.claude/gate.json; a monorepo with 12 rules, 20+ skills and 3 MCP servers: examples/reference/.
| Field | Meaning | |||||
|---|---|---|---|---|---|---|
groups | group → kind-prefixed globs: skill:react-*, tool:mcp__figma__*, agent:ui-reviewer, rule:api-* (legacy skillGroups/mcpGroups: context-gate migrate) | |||||
tiers | premium / standard / quick (any names): groups, preload (skill bodies inlined for weaker models), thresholds | |||||
models | model id glob → tier, or attributes { match, tier?, contextWindow, costPer1k } | |||||
profiles | name → groups plus when: paths, branch, ticketType, expr over providers | |||||
classify | `mode: shadow \ | auto, model, minConfidence, recheckOn, provider: builtin \ | jev \ | { kind: cli }` | ||
budgets, onExceed | softContextPct / hardContextPct per tier; actions section, notice, compact | |||||
escalation | order of tiers and after: { verifyFailed, stallTurns } → escalation-suggested in the journal | |||||
brief | a task brief written once per task by a strong model for weaker tiers | |||||
providers | named data sources for the DSL: cli (JSON stdout), file, mcp, module; schema, cache, onError, functions; cli: okExitCodes, parseOnError (eslint -f json exits 1) | |||||
executors | how Run/Call start a language (python3, node, bash, deno, …) | |||||
itemSources | item sources: cursor-mdc, markdown-dir, provider (field, as, template), prompt-dir (extra section dir, as: "section"), claude-skills, claude-tools | |||||
gates | deterministic checks: `on: write \ | commit \ | push \ | publish \ | turn \ | prompt, run or provider (or only a pass expression), pass expression (command for Bash gates, prompt for prompt gates; run of a prompt gate gets the prompt on stdin), drop (a failed prompt gate stops the prompt), message template, onlyNew + baseline, tiers; builtin read-before-write` |
cursorRules | enabled, nested (rules in sub-package .cursor/rules), maxCharsPerInjection, strictWrite | |||||
prompt | dir, runCacheDefault, `build: auto \ | never, commitCompiled, persist` | ||||
health | thresholds per code (H001: 12000, …) | |||||
debug, debugLog, assertFail | @debug evaluation and .claude/gate.debug.log (1 MB), a false @assert: skip or fail | |||||
env | env vars visible to the DSL as env.*, masked as *** in debug output | |||||
allowBinaries | narrows the user's binary whitelist (~/.claude/context-gate.json); never widens it | |||||
log | file: true also writes .claude/gate.log.jsonl (shared with the shiftwork runner) |
Provider and gate adapters for keylang, tsc and eslint: examples/providers/.
context-gate <command> [flags]; context-gate <command> --help for each one. Global flags: --root <dir>, --trust-repo, --no-user-skills (ignore ~/.claude/skills, also CONTEXT_GATE_NO_USER_SKILLS=1; bench does this by default). Exit codes: 0 ok, 1 failure, 2 bad arguments.
| Command | What it does | ||
|---|---|---|---|
| Prompts | |||
build | compile .claude/prompt/*.prompt.tsx into .compiled/*.json, prompt.lock.json and SKILL.md | ||
run | render the prompt, one section (--only) or a skill (run <skill> --args "…") with the same core as prompt.compose; --trace, --json, --dry-scripts, `--ctx-from session:latest\ | fixture.json, --diff, --watch, --debug` | |
render | render sections, or one section: render prompt://<id> | ||
health | prompt health metrics H0xx; --json for CI, --strict exits 1 over a threshold | ||
fmt | align @ directives in Markdown prompts | ||
expand | generate quick/standard variants of canonical sections into proposals/ | ||
explain <code> | explain a diagnostic code (G0xx…G5xx, H0xx, D0xx) | ||
index | write .claude/gate.index.json for the editor | ||
| Repository | |||
init | create .claude/gate.json from the repo structure (classify: shadow) and .gitignore lines | ||
migrate | convert legacy skillGroups/mcpGroups/ruleSources into groups/itemSources | ||
sync | the fallback without mods: .mdc → .claude/rules/cursor/ and skills, profile → skillOverrides, DSL → .claude/prompt.generated.md; --watch; --agents-md AGENTS.md,… writes only the sections without volatile ones between markers in files other agent CLIs read | ||
example skills | copy the example skill prompts into .claude/prompt/ | ||
trust | trust for the repository (Р2): processes, cli/module providers, @run/@call | ||
data | the script data store data.* | ||
schema infer <provider> | draft a provider JSON Schema from a real run | ||
tools | model tools: # gate-tool: headers of .claude/prompt/scripts and over exports of lib/*, module providers and use paths; --call <name> --input '{…}' runs one like the mod | ||
| Pipeline (JSONL) | |||
pipe "<stages>" | the whole pipeline in one line, the /gate grammar: `collect \ | decide --profile x \ | tokens` |
collect, normalize, signals, decide, budget, deliver --dry-run, observe | pipeline stages | ||
where, tokens, on, off, why, take, sort, preview | filters and views | ||
| Journal | |||
report | journal summary: when vs classifier vs manual, denies per tool with "add group X to profile Y" suggestions, rules never delivered, escalations, attempts and tokens per tier per task, runner vs mod by ticket, skill-prompt render cost | ||
bench | prompt and item tokens before/after the gate, unverified, over bench/repos.json |
What claude plugin validate --strict . reports for the mod (regenerate with scripts/validate-calls.sh --markdown; CI fails when a $ call outside scripts/expected-calls.txt appears).
| Hook | Purpose | |||
|---|---|---|---|---|
session.start | read gate.json, register /gate and /rule, build stale prompts, status line | |||
classic.SessionStart | watchPaths for .cursor/rules, gate.json, prompts; reset after /clear, recheck after compact | |||
session.end | state reset on /clear | |||
session.compact | instructions that keep the active profile and rules | |||
classic.FileChanged | rule cache drop, config reload, incremental prompt build | |||
command.run{command=gate}, command.run{command=rule}, command.run | /gate …, /rule <id>, skill args from /name args | |||
prompt.context | dedup reset; Always rules as instruction files after CLAUDE.md (or a cursorRules block) | |||
prompt.submit | @rule, @file → Auto Attached rules, [gate:x], signals, first-prompt classifier, brief, prompt gates | |||
prompt.attachment{type=skill_listing} | rewrite the skills listing for the gate | |||
| `tool.call{tool=Read\ | Edit\ | Write\ | NotebookEdit}` | glob rules after the result, strictWrite, write gates, read-before-write |
tool.call{tool=Bash} | commit, push and publish gates on git commit, git push and package publishes; failed test/lint runs count for escalation | |||
tool.call{tool=Skill} | skill args for skill.prompt; disabled skills | |||
tool.call{tool=/"^mcp__"/} | { deny } for MCP tools outside the profile; serves the plugin's own tools (lazy includes, script tools) | |||
tool.describe{tool=/"^mcp__"/} | one-line description and isDeferred for gated-off MCP tools | |||
agent.offer | hide subagents outside the profile | |||
skill.prompt | off text for a disabled skill; prompt-skill render with args | |||
turn.step | model and agent → tier recompute; prompt-cache usage | |||
turn.complete | turn gates, stall counter, budgets, escalation, journal flush | |||
session.measure | context percent, budgets, status line | |||
prompt.compose | render the DSL sections as context-gate:<id> session sections | |||
ui.render{component=AbovePrompt}, ui.render{component=Pane, requestId=?} | the band; the /gate why and health panes |
$ call | Why |
|---|---|
$.fs.read, $.fs.list, $.fs.exists, $.fs.stat | gate.json, .mdc, .compiled, skills, mtimes |
$.fs.write | .claude/gate.log.jsonl, gate.debug.log, baselines, .trace/last.json, gate.index.json |
$.session.root, .id, .model, .repo, .usage | root, journal key, tier, branch fallback, context percent |
$.session.append, $.session.compact | transcript notices; onExceed: compact |
$.state.get, $.state.set | session state atoms (context-gate.*) |
$.store.get, $.store.set, $.store.delete | per-repo trust, render cache, data.* |
$.tool.register, $.tool.list | lazy-include and script tools; MCP servers of the session |
$.command.register | /gate, /rule |
$.model.classify, $.model.complete | the classifier (with confidence) and the brief |
$.process.run, $.process.spawn | @run, cli providers, gates, the prompt build (trusted repos only); /gate edit |
$.mcp.call | @mcp and mcp providers (trusted repos only) |
$.settings.read, $.env.get | user binary whitelist and the env block; literal HOME/OS |
$.clock.after | defer $.session.compact past the running turn |
$.ui.* | ask (trust), toast, status, log, open/close panes, invalidate, resolve |
| Environment | What works | Replacement |
|---|---|---|
| CLI, Desktop Code tab | everything | — |
claude -p (shiftwork runner, CI agent) | hooks, @run, filtering; no /gate or panes | profile from userConfig or [gate:<profile>] in the prompt; --trust-repo; --append-system-prompt for preload |
| VS Code extension, cloud sessions | hooks without UI | as for -p |
Claude Code < 2.1.287, --bare, allowManagedModsOnly | the mod does not load | npx context-gate sync (native .claude/rules/cursor/, skills, skillOverrides, prompt.generated.md) or the settings-hooks adapter dist/hooks-adapter.js (docs/HOOKS-ADAPTER.md) |
| Cursor (same repo) | — | .cursor/rules stay the source; Cursor reads .claude/skills itself |
The shiftwork runner reads the same gate.json and the same journal (docs/SHIFTWORK.md).
The extension in editors/vscode needs nothing from npm: it ships the CLI (cli/dist/cli.js, run by VS Code's own Node runtime) with esbuild, the tsserver plugin and the gate.json schema.
npm run package:vscode # → editors/vscode/context-gate-vscode-0.1.0.vsix
code --install-extension editors/vscode/context-gate-vscode-0.1.0.vsix
.claude/prompt/**, imported files and .claude/gate.json; build diagnostics (G*) in Problems, status bar context-gate: ✓ built / ⚠ N (click: log); commands Build prompts, Build current file, Health, Preview section.init and build write .claude/prompt/tsconfig.json and .types/jsx/ (declarations of @context-gate/jsx), so components, props, arg.* and ctx resolve without the package; the tsserver plugin adds G* diagnostics, completion and hover inside expression strings (and live G160 for TSX level 2).@ and inside {{ }}, hover, outline, highlighting.gate.json validated against the schema; .mdc rules: G010–G015 and rule-type hover.Details, settings and limits: docs/EDITOR.md.
process.run and mcp.call that the repository's configuration starts (the prompt build, @run/@call, cli providers, command gates, @mcp). Until then only file reads and the plugin's own module providers run, and script sections render as unverified stubs. /gate trust revoke drops it; a gate.json with new commands asks again; claude -p and CI need --trust-repo.~/.claude/context-gate.json); a repository can narrow it (allowBinaries), never widen it.$.http is never called. Data reaches scripts through stdin as JSON..mdc and DSL files never become commands; provider results are data.env variables.deny is answered in tool.call, never in tool.check, so sec-default and managed PreToolUse hooks go first.npm ci
npm test # node:test unit tests (test/**/*.test.ts)
npx tsc -p tsconfig.json
npm run build # dist/cli.js (committed: the installed plugin runs it)
npm run build:hooks-adapter
npm run typecheck:mod && npm run test:mod # needs the claude CLI
npm run validate:mod # claude plugin validate --strict .
npm run build:jsx-types # dist/jsx-types (committed: copied into user repos as .claude/prompt/.types/jsx/)
npm run test:vscode # the extension in a real VS Code, isolated profile (docs/EDITOR.md)
scripts/validate-calls.sh # $ calls vs scripts/expected-calls.txt
scripts/e2e.sh # one claude -p turn on examples/reference with probe/context-gate-probe
dist/cli.js, dist/hooks-adapter.js and dist/jsx-types/ are committed; CI rebuilds them and fails on a diff. The live API probe for the open mods-API questions is probe/; the static results are in docs/PROBE.md. Bench: bench/.
MIT
hooks/register.ts 406 lines1// context-gate hooks module: the `claude-code-mod` harness adapter (docs/MOD-ADAPTER.md).
2// No Node here: everything outside the module goes through `$`; the pure core is imported relatively.
3//
4// The mods validator follows `$` only inside the file that holds the hook, and takes one hook per event and
5// matcher, so every `on(...)` lives here, every engine call is spelled `$.noun.event(...)` in `port`, and the
6// layers (hooks/layers/*) are plain functions over that port (`io`) and the module runtime (`rt`):
7// session/config/commands (core), cursor-rules (layer 1), skill-gate + budgets + gates (layer 2),
8// dsl + host (layer 3), trust (Р2), journal, ui.
9
10import { atom, read, update } from 'claude-code'
11import type { EngineInterface, Register } from 'claude-code'
12
13import { type FileCall, type Io, OWN_TOOL_PREFIX, newRuntime, readOptions } from './ctx.ts'
14import { INITIAL, type State, type StateKey } from './state.ts'
15import { ensureSession } from './layers/config.ts'
16import { classicSessionStart, compactAfter, compactAgentAfter, compactInstructions, configFileChanged, recordStepUsage, sessionEnd, sessionStart } from './layers/session.ts'
17import { editSection, gateCommand, rerenderHealth, rerenderSection } from './layers/commands.ts'
18import { bashAfter, bashBefore, gatesAfterFile, gatesAfterWrite, gatesBeforeFile, gatesMentioned, guardsBash, guardsFileCall, promptGates, turnAfter } from './layers/gates.ts'
19import { checkRoot, relPath, ruleCommand, rulesAfterFile, rulesBeforeFile, rulesContextAfter, rulesContextBefore, rulesFileChanged } from './layers/cursor-rules.ts'
20import { describeMcp, gatePromptSubmit, listingAfter, mcpGate, observeStep, offerAgent, recompute, skillCall } from './layers/skill-gate.ts'
21import { checkBudgets } from './layers/budgets.ts'
22import { captureSkillArgs, composeAfter, dslContextBefore, dslFileChanged, serveOwnTool, skillPrompt, trustOnPrompt } from './layers/dsl.ts'
23import { HEALTH_PANE, SECTION_PANE, WHY_PANE, applyProposed, bandProps, buildErrorOf, healthPane, resetAuto, sectionPane, whyPane } from './layers/ui.ts'
24import { indexWatched, writeIndex } from './layers/index.ts'
25import { invalidateSurface } from './layers/trust.ts'
26
27export { bandLine, gateLine } from './layers/ui.ts'
28
29// Session state: one atom per key with literal refs (the validator lists what the module reads and writes).
30const gateAtom = atom({ plugin: 'context-gate', key: 'gate' } as const, INITIAL.gate)
31const gateStateAtom = atom({ plugin: 'context-gate', key: 'gateState' } as const, INITIAL.gateState)
32const logAtom = atom({ plugin: 'context-gate', key: 'log' } as const, INITIAL.log)
33const seenAtom = atom({ plugin: 'context-gate', key: 'seen' } as const, INITIAL.seen)
34const manualAtom = atom({ plugin: 'context-gate', key: 'manual' } as const, INITIAL.manual)
35const healthAtom = atom({ plugin: 'context-gate', key: 'health' } as const, INITIAL.health)
36const budgetsFiredAtom = atom({ plugin: 'context-gate', key: 'budgetsFired' } as const, INITIAL.budgetsFired)
37const trustAtom = atom({ plugin: 'context-gate', key: 'trust' } as const, INITIAL.trust)
38const recentPathsAtom = atom({ plugin: 'context-gate', key: 'recentPaths' } as const, INITIAL.recentPaths)
39const modelAtom = atom({ plugin: 'context-gate', key: 'model' } as const, INITIAL.model)
40const tierAtom = atom({ plugin: 'context-gate', key: 'tier' } as const, INITIAL.tier)
41const agentTiersAtom = atom({ plugin: 'context-gate', key: 'agentTiers' } as const, INITIAL.agentTiers)
42const ctxPercentAtom = atom({ plugin: 'context-gate', key: 'ctxPercent' } as const, INITIAL.ctxPercent)
43const briefAtom = atom({ plugin: 'context-gate', key: 'brief' } as const, INITIAL.brief)
44const configAtom = atom({ plugin: 'context-gate', key: 'config' } as const, INITIAL.config)
45const sectionViewAtom = atom({ plugin: 'context-gate', key: 'sectionView' } as const, INITIAL.sectionView)
46
47function readState($: EngineInterface, key: StateKey): Promise<unknown> {
48 switch (key) {
49 case 'gate': return read($, gateAtom)
50 case 'gateState': return read($, gateStateAtom)
51 case 'log': return read($, logAtom)
52 case 'seen': return read($, seenAtom)
53 case 'manual': return read($, manualAtom)
54 case 'health': return read($, healthAtom)
55 case 'budgetsFired': return read($, budgetsFiredAtom)
56 case 'trust': return read($, trustAtom)
57 case 'recentPaths': return read($, recentPathsAtom)
58 case 'model': return read($, modelAtom)
59 case 'tier': return read($, tierAtom)
60 case 'agentTiers': return read($, agentTiersAtom)
61 case 'ctxPercent': return read($, ctxPercentAtom)
62 case 'brief': return read($, briefAtom)
63 case 'config': return read($, configAtom)
64 case 'sectionView': return read($, sectionViewAtom)
65 }
66}
67
68function updateState($: EngineInterface, key: StateKey, fn: (v: never) => unknown): Promise<unknown> {
69 switch (key) {
70 case 'gate': return update($, gateAtom, fn as (v: State['gate']) => State['gate'])
71 case 'gateState': return update($, gateStateAtom, fn as (v: State['gateState']) => State['gateState'])
72 case 'log': return update($, logAtom, fn as (v: State['log']) => State['log'])
73 case 'seen': return update($, seenAtom, fn as (v: State['seen']) => State['seen'])
74 case 'manual': return update($, manualAtom, fn as (v: State['manual']) => State['manual'])
75 case 'health': return update($, healthAtom, fn as (v: State['health']) => State['health'])
76 case 'budgetsFired': return update($, budgetsFiredAtom, fn as (v: State['budgetsFired']) => State['budgetsFired'])
77 case 'trust': return update($, trustAtom, fn as (v: State['trust']) => State['trust'])
78 case 'recentPaths': return update($, recentPathsAtom, fn as (v: State['recentPaths']) => State['recentPaths'])
79 case 'model': return update($, modelAtom, fn as (v: State['model']) => State['model'])
80 case 'tier': return update($, tierAtom, fn as (v: State['tier']) => State['tier'])
81 case 'agentTiers': return update($, agentTiersAtom, fn as (v: State['agentTiers']) => State['agentTiers'])
82 case 'ctxPercent': return update($, ctxPercentAtom, fn as (v: State['ctxPercent']) => State['ctxPercent'])
83 case 'brief': return update($, briefAtom, fn as (v: State['brief']) => State['brief'])
84 case 'config': return update($, configAtom, fn as (v: State['config']) => State['config'])
85 case 'sectionView': return update($, sectionViewAtom, fn as (v: State['sectionView']) => State['sectionView'])
86 }
87}
88
89/** The engine calls the layers may make, each spelled out on `$`. */
90function port($: EngineInterface): Io {
91 return {
92 read: ((key: StateKey) => readState($, key)) as Io['read'],
93 update: ((key: StateKey, fn: (v: never) => unknown) => updateState($, key, fn)) as Io['update'],
94 fs: {
95 read: (path) => $.fs.read(path),
96 list: (path) => $.fs.list(path),
97 exists: (path) => $.fs.exists(path),
98 write: (path, text) => $.fs.write(path, text),
99 stat: (path, options) => $.fs.stat(path, options),
100 },
101 session: {
102 id: () => $.session.id(),
103 root: () => $.session.root(),
104 model: () => $.session.model(),
105 repo: () => $.session.repo(),
106 usage: () => $.session.usage(),
107 append: (args) => $.session.append(args),
108 compact: (input) => $.session.compact(input),
109 },
110 env: {
111 os: () => $.env.get('OS'),
112 home: () => $.env.get('HOME'),
113 cacheHome: () => $.env.get('XDG_CACHE_HOME'),
114 planProfile: () => $.env.get('CONTEXT_GATE_PROFILE'),
115 ticketType: () => $.env.get('CONTEXT_GATE_TICKET_TYPE'),
116 ticket: () => $.env.get('CONTEXT_GATE_TICKET'),
117 },
118 store: {
119 get: (key) => $.store.get(key),
120 set: (key, value) => $.store.set(key, value),
121 delete: (key) => $.store.delete(key),
122 keys: () => $.store.keys(),
123 },
124 process: { run: (argv, init) => $.process.run(argv, init), spawn: (request) => $.process.spawn(request) },
125 settings: { read: () => $.settings.read() as Promise<Record<string, unknown>> },
126 mcp: { call: (server, tool, args) => $.mcp.call(server, tool, args) },
127 model: {
128 complete: (request, options) => $.model.complete(request, options),
129 classify: (text, labels, options) => $.model.classify(text, labels, options),
130 },
131 tool: { register: (spec) => $.tool.register(spec), list: () => $.tool.list() },
132 command: { register: (spec) => $.command.register(spec) },
133 ui: {
134 ask: (question, options) => $.ui.ask(question, options),
135 toast: (text, options) => $.ui.toast(text, options),
136 status: (text) => $.ui.status(text),
137 log: (text, options) => $.ui.log(text, options),
138 invalidate: (event) => $.ui.invalidate(event),
139 open: (pane) => $.ui.open(pane),
140 close: (pane) => $.ui.close(pane),
141 },
142 clock: { after: (ms, fn) => $.clock.after(ms, fn) },
143 plugin: { root: $.plugin.root, name: $.plugin.name },
144 }
145}
146
147/** `.catch` for every hook: a failed layer degrades to the engine's own behaviour. */
148function pass<E, R>(_$: unknown, e: E, next: (e: E) => R): R {
149 return next(e)
150}
151
152/** What an enforcement hook answers when it fails before `next` and the repo enforces something on the call (R7):
153 * a refusal, not the engine's fail-open. */
154const GUARD_DENY = 'context-gate: перевірка гейтів не вдалася — дію не виконано. Повтори її; якщо збій повторюється, перевір .claude/gate.json або /gate health.'
155
156/** userConfig fields that widen what repo code may run: only the person in /config may turn them on (S14). Keyed
157 * `<plugin>.<field>`; the plugin part may carry a suffix (`context-gate@inline`). */
158const WIDENING: Record<string, (v: unknown) => boolean> = {
159 trustBuild: (v) => v === 'always',
160 allowScripts: (v) => v === true,
161}
162const widening = (key: string): ((v: unknown) => boolean) | undefined => {
163 const m = /^context-gate(?:@[^.]*)?\.(\w+)$/.exec(key)
164 return m ? WIDENING[m[1]] : undefined
165}
166
167export const register: Register = (on, options) => {
168 const rt = newRuntime(readOptions(options))
169
170 // ───────── core: lifecycle, commands ─────────
171
172 on('session.start', async ($, e, next) => {
173 const io = port($)
174 await sessionStart(io, rt, e)
175 await writeIndex(io, rt, 'session.start')
176 return next(e)
177 }).catch(pass)
178
179 on('classic.SessionStart', async ($, e, next) => {
180 const r = await next(e)
181 const watch = await classicSessionStart(port($), rt, e.source)
182 return watch.length ? { ...r, watchPaths: [...(r.watchPaths ?? []), ...watch] } : r
183 }).catch(pass)
184
185 on('session.end', async ($, e, next) => {
186 await sessionEnd(port($), rt, e.reason)
187 return next(e)
188 }).catch(pass)
189
190 on('session.compact', async ($, e, next) => {
191 if (e.trigger === 'precompute') return next(e)
192 const io = port($)
193 if (e.agentId !== undefined) {
194 // A subagent's own transcript: the main loop keeps its profile, recheck and static cache (M26).
195 const r = await next(e)
196 if (!('skip' in r && r.skip)) await compactAgentAfter(io, rt, e.agentId)
197 return r
198 }
199 const instructions = await compactInstructions(io, rt, e.instructions)
200 const r = await next({ ...e, instructions })
201 if (!('skip' in r && r.skip)) await compactAfter(io, rt) // a vetoed compaction changed nothing (L10)
202 return r
203 }).catch(pass)
204
205 on('classic.FileChanged', async ($, e, next) => {
206 const io = port($)
207 invalidateSurface(rt)
208 rulesFileChanged(rt, e.file_path)
209 await dslFileChanged(io, rt, e.file_path)
210 await configFileChanged(io, rt, e.file_path)
211 if (indexWatched(rt, e.file_path)) await writeIndex(io, rt, 'file-changed')
212 return next(e)
213 }).catch(pass)
214
215 on('command.run', { command: 'gate' }, async ($, e) => {
216 const io = port($)
217 const r = await gateCommand(io, rt, e.args)
218 await writeIndex(io, rt, 'gate')
219 return r
220 })
221
222 on('command.run', { command: 'rule' }, async ($, e) => {
223 const io = port($)
224 await ensureSession(io, rt)
225 return ruleCommand(io, rt, e.args)
226 })
227
228 on('command.run', async ($, e, next) => {
229 captureSkillArgs(rt, e.command, e.args, e.origin)
230 return next(e)
231 }).catch(pass)
232
233 // S14: another plugin's `$.config.set` (or a bridge) may not widen trust; the person's own /config change may.
234 on('config.set', async ($, e, next) => {
235 const widens = widening(e.key)
236 if (widens && widens(e.value) && e.origin.kind !== 'composer') return { deny: 'context-gate: розширити довіру може лише користувач у /config' }
237 return next(e)
238 }).catch(($, e, next) => (next.called || !widening(e.key) ? next(e) : { deny: 'context-gate: зміну не перевірено' }))
239
240 // ───────── layer 1 + 2: prompt ─────────
241
242 on('prompt.context', async ($, e, next) => {
243 const io = port($)
244 await rulesContextBefore(io, rt)
245 await dslContextBefore(io, rt)
246 return rulesContextAfter(io, rt, e, await next(e))
247 }).catch(pass)
248
249 // trust (first prompt) → `[gate:x]`, @mentions, rules, signals, classifier, brief → prompt gates.
250 on('prompt.submit', async ($, e, next) => {
251 const io = port($)
252 await ensureSession(io, rt)
253 await checkRoot(io, rt)
254 await trustOnPrompt(io, rt, e.text)
255 const g = await gatePromptSubmit(io, rt, { text: e.text })
256 gatesMentioned(rt, g.mentioned)
257 const context = [...(e.context ?? []), ...g.context]
258 if (!g.text.trimStart().startsWith('/')) {
259 const failed = await promptGates(io, rt, e.text)
260 if (failed?.drop) return { drop: failed.message }
261 if (failed) context.push(failed.message)
262 }
263 if (g.text === e.text && context.length === (e.context?.length ?? 0)) return next(e)
264 return next({ ...e, text: g.text, context })
265 }).catch(pass)
266
267 on('prompt.attachment', { type: 'skill_listing' }, async ($, e, next) => {
268 const io = port($)
269 const r = await next(e)
270 const before = rt.listingText
271 const text = await listingAfter(io, rt, e.agentId, r.text)
272 if (rt.listingText !== before) void writeIndex(io, rt, 'listing')
273 return text === r.text ? r : { text }
274 }).catch(pass)
275
276 // ───────── tools ─────────
277
278 // Gates (read-before-write, write gates) and strictWrite deny before; Auto Attached rules after the result.
279 on('tool.call', { tool: ['Read', 'Edit', 'Write', 'NotebookEdit'] }, async ($, e, next) => {
280 const io = port($)
281 await ensureSession(io, rt)
282 await checkRoot(io, rt) // a moved root before the path is made repo-relative
283 const file = e.tool === 'NotebookEdit' ? e.notebook_path : e.file_path
284 if (typeof file !== 'string' || !file) return next(e)
285 const c: FileCall = { tool: e.tool, file, rel: relPath(rt, file), agent: e.agentId ?? 'main', input: e as unknown as Record<string, unknown>, ...(e.agentId !== undefined ? { agentId: e.agentId } : {}) }
286 const denied = (await gatesBeforeFile(io, rt, c)) ?? (await rulesBeforeFile(io, rt, c))
287 if (denied) return denied
288 const r = await next(e)
289 gatesAfterFile(rt, c, r)
290 return rulesAfterFile(io, rt, c, await gatesAfterWrite(io, rt, c, r))
291 }).catch(($, e, next) => (next.called || !guardsFileCall(rt, e.tool) ? next(e) : { deny: GUARD_DENY }))
292
293 on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
294 const io = port($)
295 const cmd = typeof e.command === 'string' ? e.command : ''
296 const deny = await bashBefore(io, rt, cmd, e.agentId)
297 if (deny) return { deny }
298 const r = await next(e)
299 await bashAfter(io, rt, cmd, r)
300 return r
301 }).catch(($, e, next) => (next.called || !guardsBash(rt, typeof e.command === 'string' ? e.command : '') ? next(e) : { deny: GUARD_DENY }))
302
303 on('tool.call', { tool: 'Skill' }, async ($, e, next) => {
304 // Without the engine's tool table laid beside the d.ts, Skill's arguments are `unknown`: narrow them.
305 if (typeof e.skill !== 'string' || !e.skill) return next(e)
306 const deny = await skillCall(port($), rt, e.skill, typeof e.args === 'string' ? e.args : undefined, e.agentId)
307 return deny ? { deny } : next(e)
308 }).catch(pass)
309
310 // MCP: our own tools are served here; others outside the applied profile are denied.
311 on('tool.call', { tool: /^mcp__/ }, async ($, e, next) => {
312 const io = port($)
313 if (e.tool.startsWith(OWN_TOOL_PREFIX)) return (await serveOwnTool(io, rt, e as unknown as { tool: string } & Record<string, unknown>)) ?? next(e)
314 const deny = await mcpGate(io, rt, e.tool, e.agentId)
315 return deny ? { deny } : next(e)
316 }).catch(pass)
317
318 on('tool.describe', { tool: /^mcp__/ }, async ($, e, next) => (await describeMcp(port($), rt, e.tool)) ?? next(e)).catch(pass)
319
320 on('agent.offer', async ($, e, next) => ((await offerAgent(port($), rt, e.agent)) ? next(e) : { isOffered: false })).catch(pass)
321
322 on('skill.prompt', async ($, e, next) => {
323 const text = await skillPrompt(port($), rt, e.skill, e.text)
324 return text === undefined ? next(e) : { text }
325 }).catch(pass)
326
327 // ───────── turns, budgets ─────────
328
329 on('turn.step', async function* ($, e, next) {
330 await observeStep(port($), rt, e.model, e.agentId)
331 const res = yield* next(e)
332 recordStepUsage(rt, res.usage, e.agentId)
333 return res
334 })
335
336 on('turn.complete', async ($, e, next) => {
337 const r = await next(e)
338 await turnAfter(port($), rt, e)
339 return r
340 }).catch(pass)
341
342 on('session.measure', async ($, e, next) => {
343 const io = port($)
344 await ensureSession(io, rt)
345 await checkBudgets(io, rt, e.context.percent)
346 return next(e)
347 }).catch(pass)
348
349 // ───────── layer 3: the system prompt ─────────
350
351 on('prompt.compose', async ($, e, next) => {
352 const r = await next(e)
353 const sections = await composeAfter(port($), rt, e, r.sections)
354 return sections ? { sections } : r
355 }).catch(pass)
356
357 // ───────── UI ─────────
358
359 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
360 if (e.props.hasSurvey) return next(e)
361 const gate = await read($, gateAtom)
362 const tier = await read($, tierAtom)
363 const ctx = await read($, ctxPercentAtom)
364 const { Text } = $.ui.resolve(e)
365 const band = bandProps(rt, gate, tier, ctx)
366 return band.hot ? Text({ color: 'warning', wrap: 'truncate', children: band.text }) : Text({ dimColor: true, wrap: 'truncate', children: band.text })
367 }).catch(pass)
368
369 on('ui.render', { component: 'Pane', requestId: WHY_PANE }, async ($, e) => {
370 const els = $.ui.resolve(e)
371 const status = await read($, configAtom)
372 const tree = whyPane(els as never, {
373 log: await read($, logAtom),
374 gate: await read($, gateAtom),
375 health: await read($, healthAtom),
376 disabled: status.disabled,
377 rows: Math.max(3, (e.viewport?.rows ?? 30) - 12),
378 onApply: () => { const io = port($); void applyProposed(io, rt, (t) => recompute(io, rt, t)) },
379 onAuto: () => { const io = port($); void resetAuto(io, (t) => recompute(io, rt, t)) },
380 })
381 return tree as ReturnType<typeof els.Box>
382 })
383
384 on('ui.render', { component: 'Pane', requestId: HEALTH_PANE }, async ($, e) => {
385 const els = $.ui.resolve(e)
386 await read($, healthAtom) // subscribes: a new render redraws the pane
387 const tree = healthPane(els as never, {
388 report: rt.lastHealth,
389 buildError: buildErrorOf(rt),
390 onRerender: () => { void rerenderHealth(port($), rt) },
391 })
392 return tree as ReturnType<typeof els.Box>
393 })
394
395 on('ui.render', { component: 'Pane', requestId: SECTION_PANE }, async ($, e) => {
396 const els = $.ui.resolve(e)
397 const view = await read($, sectionViewAtom)
398 const tree = sectionPane(els as never, {
399 view,
400 onEdit: () => { if (view) void editSection(port($), rt, view.id) },
401 onRerender: () => { void rerenderSection(port($), rt) },
402 })
403 return tree as ReturnType<typeof els.Box>
404 })
405}
406hooks/ctx.ts 308 lines1// Module caches and per-session runtime (docs/MOD-ADAPTER.md "Ground rules").
2// Everything here is disposable: a hot reload drops it and `ensureSession` rebuilds it lazily.
3// Durable values live in $.state (state.ts) and $.store (trust, cache).
4
5import type { EngineInterface, PluginOptions } from 'claude-code'
6
7import type { CompiledPrompt, Diagnostic, GateConfig, Item, MdcRule, RenderResult, HealthReport } from '../packages/core/src/types.ts'
8import { detectWindows } from '../packages/core/src/glob.ts'
9import type { State, StateKey } from './state.ts'
10
11type E = EngineInterface
12
13/** The engine calls the layers use, bound in register.ts (the mods validator follows `$` only inside
14 * the file that holds the hook, so layers never see `$` itself). */
15export interface Io {
16 read<K extends StateKey>(key: K): Promise<State[K]>
17 update<K extends StateKey>(key: K, fn: (v: State[K]) => State[K]): Promise<State[K]>
18 fs: {
19 read(path: string): Promise<string | { base64: string }>
20 list(path: string): ReturnType<E['fs']['list']>
21 exists(path: string): Promise<boolean>
22 write(path: string, text: string): Promise<void>
23 /** `$.fs.stat` (optional: layers fall back to the parent's `list`). With `resolve`, `realPath` is where the path
24 * lands (absent when it leads nowhere); `isLink` says whether the path itself is a symbolic link. */
25 stat?(path: string, options?: { resolve: boolean }): Promise<{ kind: string; size: number; mtimeMs: number; isLink?: boolean; realPath?: string }>
26 }
27 session: {
28 id(): Promise<string>
29 root(): Promise<string>
30 model(): Promise<string>
31 repo(): ReturnType<E['session']['repo']>
32 usage(): ReturnType<E['session']['usage']>
33 append: E['session']['append']
34 compact(input?: { instructions?: string }): Promise<unknown>
35 }
36 env: {
37 os(): Promise<string | undefined>
38 home(): Promise<string | undefined>
39 /** `XDG_CACHE_HOME` (optional; the cache dir falls back to `<home>/.cache`). */
40 cacheHome?(): Promise<string | undefined>
41 /** Runner plan (shiftwork): `CONTEXT_GATE_PROFILE`, `CONTEXT_GATE_TICKET_TYPE`, `CONTEXT_GATE_TICKET` (M19). */
42 planProfile?(): Promise<string | undefined>
43 ticketType?(): Promise<string | undefined>
44 ticket?(): Promise<string | undefined>
45 }
46 store: {
47 get(key: string): Promise<unknown>
48 set(key: string, value: unknown): Promise<void>
49 delete(key: string): Promise<void>
50 /** `$.store.keys()` (optional for fake ports): the cache sweep drops entries no index knows (R2). */
51 keys?(): Promise<string[]>
52 }
53 process: {
54 run: E['process']['run']
55 /** `$.process.spawn` (WP2: `/gate edit` starts the browser editor; optional for fake ports). */
56 spawn?: E['process']['spawn']
57 }
58 /** `$.settings.read()`: merged settings, `env` block included (G-03 env whitelist; optional for fake ports). */
59 settings?: { read(): Promise<Record<string, unknown>> }
60 mcp: { call: E['mcp']['call'] }
61 model: { complete: E['model']['complete']; classify: E['model']['classify'] }
62 tool: { register: E['tool']['register']; list: E['tool']['list'] }
63 command: { register: E['command']['register'] }
64 ui: {
65 ask: E['ui']['ask']
66 toast: E['ui']['toast']
67 status: E['ui']['status']
68 log: E['ui']['log']
69 invalidate: E['ui']['invalidate']
70 open: E['ui']['open']
71 close: E['ui']['close']
72 }
73 clock: { after(ms: number, fn: () => void): unknown }
74 plugin: { root: string; name: string }
75}
76
77/** Layers name the port `$` in types only; values are always `io`. */
78export type $ = Io
79
80export interface Options {
81 profile: string
82 mode: 'shadow' | 'auto'
83 trustBuild: 'ask' | 'always' | 'never'
84 allowScripts: boolean
85 brief: boolean
86}
87
88export function readOptions(o: PluginOptions | undefined): Options {
89 const s = (k: string): string => (typeof o?.[k] === 'string' ? (o[k] as string) : '')
90 const mode = s('mode') === 'auto' ? 'auto' : 'shadow'
91 const tb = s('trustBuild')
92 return {
93 profile: s('profile').trim(),
94 mode,
95 trustBuild: tb === 'always' || tb === 'never' ? tb : 'ask',
96 allowScripts: o?.allowScripts === true,
97 brief: o?.brief === true,
98 }
99}
100
101export interface PromptSet {
102 key: string
103 /** Compiled TSX prompts (`.compiled/*.json`), skill prompts included. */
104 compiled: CompiledPrompt[]
105 /** Markdown prompt files (`<dir>/*.md`, tier variants `<id>.<tier>.md` included), for core `assemblePrompts`. */
106 markdown: { path: string; text: string }[]
107 /** Prompt sources whose `.compiled` is missing or older (repo-relative). */
108 stale: string[]
109 diagnostics: Diagnostic[]
110 /** Absolute paths worth watching. */
111 watch: string[]
112 /** Every source (entry and imports) of the compiled prompts, repo-relative: their mtimes feed staleness. */
113 sources?: string[]
114 /** Where the compiled prompts came from: the repo `.compiled`, the CLI's per-repo cache (Р3), or nowhere. */
115 compiledFrom?: 'repo' | 'cache' | 'none'
116}
117
118export interface ScriptTool {
119 name: string
120 description: string
121 path: string
122 /** JSON Schema from the `# input:` header (core `parseToolHeader`). */
123 inputSchema: Record<string, unknown>
124 tiers?: string[]
125 /** Function-level tool (`# gate-tool: <fn>` in a module): call this export through the shim. */
126 fn?: string
127}
128
129export interface Runtime {
130 options: Options
131 ready: boolean
132 /** The bootstrap in flight: concurrent hooks on a fresh runtime await it instead of running on an empty config. */
133 boot?: Promise<void>
134 root: string
135 windows: boolean
136 interactive: boolean
137 surface: string | null
138 /** Valid config, or undefined when gate.json failed validation (layer 2 off). */
139 config?: GateConfig
140 /** Config used by layer 1 and budgets: the valid one or defaults. */
141 cfg: GateConfig
142 configDiagnostics: Diagnostic[]
143 disabled: Record<string, string>
144 rules?: { key: string; list: MdcRule[]; diagnostics: Diagnostic[]; checkedAt: number }
145 rulesDirty: boolean
146 /** Last skill listing text (main loop) and the names it held. */
147 listingText?: string
148 listingNames: string
149 mcpTools?: string[]
150 agentNames: Set<string>
151 itemsDirty: boolean
152 items?: Item[]
153 prompts?: PromptSet
154 promptsDirty: boolean
155 lastRender?: RenderResult
156 lastHealth?: HealthReport
157 /** Static section cache: id → node hash + rendered text. */
158 staticCache: Map<string, { hash: string; text: string; chars: number; tokens: number }>
159 /** Last sections added in prompt.compose (reused for an `analysis` render). */
160 lastSections?: { id: string; text: string; scope: 'session' }[]
161 skillArgs: Map<string, string>
162 /** Our registered tools: full tool name → what serves it. */
163 tools: Map<string, { kind: 'skill'; prompt: CompiledPrompt } | { kind: 'lazy'; ref: string; description: string } | { kind: 'script'; tool: ScriptTool }>
164 /** Files read (any part) per agent, for read-before-write. */
165 readFiles: Map<string, Set<string>>
166 /** Files written this session (repo-relative). */
167 changedPaths: Set<string>
168 denies: Record<string, number>
169 verifyFailed: number
170 stallTurns: number
171 editedThisTurn: boolean
172 escalated: Set<string>
173 journalBuffer: string[]
174 /** Flushes run one after another (each re-reads the file, so other writers' lines survive). */
175 journalFlush?: Promise<void>
176 /** The journal file exists but cannot be read: writing it whole would erase other writers' history. */
177 journalBlocked?: boolean
178 /** Hash of the last written journal snapshot (dedup). */
179 lastSnapshot?: string
180 trustAsked: boolean
181 recheckReason?: 'new' | 'compact' | 'auto'
182 trustCache?: { key: string; hash: string; decision: 'unknown' | 'trusted' | 'denied' }
183 /** Hash of the code the repo can execute beyond gate.json (S1): file fingerprint, content hash, when computed. */
184 trustSurface?: { root: string; fingerprint: string; hash: string; at: number }
185 /** Real path of `root` (symlinks resolved), for the read containment check (H02). */
186 realRoot?: { root: string; real: string }
187 building: boolean
188 buildAttempted: Set<string>
189 whitelist?: string[]
190 unknownListingLogged: boolean
191 /** `turn.step` usage of the main loop (G-43, health H002/H012): totals and the last step. */
192 /** Compactions this conversation (session.compact), health «Компакції за сесію» (G-43). */
193 compactions: number
194 stepUsage?: { steps: number; input: number; cacheRead: number; cacheCreation: number; output: number; last?: { input: number; cacheRead: number; cacheCreation: number; output: number; model: string } }
195 /** Last failed prompt build (H013 / G*), for the `prompt ⚠ build` status marker; cleared by a good build. */
196 buildError?: { code: string; message: string; at: number }
197 /** Model calls of each lazy include (`get_<name>`), by ref. */
198 lazyCalls: Map<string, number>
199 /** Module exports asked once per session (G158): module path → names (null when the shim can't tell). */
200 moduleExports: Map<string, string[] | null>
201 /** `.trace/last.json` throttle: last write time and content hash. */
202 traceWrite?: { at: number; hash: string }
203 /** Hash of the last journaled debug/assert/log batch (dedup across renders). */
204 lastDebug?: string
205 /** `.claude/gate.debug.log` text kept in memory (no append API). */
206 debugLogText?: string
207}
208
209export function newRuntime(options: Options): Runtime {
210 return {
211 options,
212 ready: false,
213 root: '',
214 windows: false,
215 interactive: true,
216 surface: 'terminal',
217 cfg: undefined as unknown as GateConfig,
218 configDiagnostics: [],
219 disabled: {},
220 rulesDirty: true,
221 listingNames: '',
222 agentNames: new Set(),
223 itemsDirty: true,
224 promptsDirty: true,
225 staticCache: new Map(),
226 compactions: 0,
227 skillArgs: new Map(),
228 tools: new Map(),
229 readFiles: new Map(),
230 changedPaths: new Set(),
231 denies: {},
232 verifyFailed: 0,
233 stallTurns: 0,
234 editedThisTurn: false,
235 escalated: new Set(),
236 journalBuffer: [],
237 trustAsked: false,
238 building: false,
239 buildAttempted: new Set(),
240 unknownListingLogged: false,
241 lazyCalls: new Map(),
242 moduleExports: new Map(),
243 }
244}
245
246/** One Read/Edit/Write/NotebookEdit call, as the layers see it. */
247export interface FileCall {
248 tool: 'Read' | 'Edit' | 'Write' | 'NotebookEdit'
249 /** Path as given (absolute). */
250 file: string
251 /** Repo-relative POSIX. */
252 rel: string
253 /** `agentId` or `main`. */
254 agent: string
255 agentId?: string
256 input: Record<string, unknown>
257}
258
259export type ToolResultLike = { deny?: unknown; isError?: unknown; context?: readonly string[] }
260
261export const OWN_TOOL_PREFIX = 'mcp__context-gate__'
262
263/** Join a repo-relative POSIX path onto the root (absolute paths pass through). */
264export function join(root: string, rel: string): string {
265 if (/^([A-Za-z]:[\\/]|[\\/])/.test(rel)) return rel
266 const r = root.replace(/[\\/]+$/, '')
267 const p = rel.replace(/^\.\//, '')
268 return r ? `${r}/${p}` : p
269}
270
271/** Repo-relative path stays inside the root (no `..`, not absolute). */
272export function insideRoot(rel: string): boolean {
273 if (/^([A-Za-z]:|[\\/])/.test(rel)) return false
274 return !rel.split(/[\\/]/).includes('..')
275}
276
277export async function initRoot(io: Io, rt: Runtime): Promise<void> {
278 rt.root = await io.session.root()
279 let os: string | undefined
280 try { os = await io.env.os() } catch { os = undefined }
281 rt.windows = detectWindows(rt.root, os)
282}
283
284export function debug(io: Io, text: string): void {
285 try { io.ui.log(`context-gate: ${text}`, { to: 'debug' }) } catch { /* logging is best effort */ }
286}
287
288/** Stable JSON (sorted keys) for hashing. */
289export function stableJson(v: unknown): string {
290 if (Array.isArray(v)) return '[' + v.map(stableJson).join(',') + ']'
291 if (v && typeof v === 'object') return '{' + Object.keys(v).sort().map((k) => JSON.stringify(k) + ':' + stableJson((v as Record<string, unknown>)[k])).join(',') + '}'
292 return JSON.stringify(v ?? null)
293}
294
295/** FNV-1a, hex (same as render.ts hashString; kept local so this file needs no render.ts). */
296export function hash(s: string): string {
297 let h = 0x811c9dc5
298 for (let i = 0; i < s.length; i++) {
299 h ^= s.charCodeAt(i)
300 h = Math.imul(h, 0x01000193) >>> 0
301 }
302 return h.toString(16).padStart(8, '0')
303}
304
305export function now(): number {
306 return Date.now()
307}
308hooks/state.ts 52 lines1// Session state helpers (the 'context-gate' contract in types/index.d.ts). Never write from a ui.render hook.
2
3import type { PluginState } from 'claude-code'
4
5import type { ContextGateDecision, ContextGateLogEntry, ContextGateManual } from '../types'
6
7/** The plugin's session state, key by key (types/index.d.ts). The atoms themselves live in register.ts:
8 * the mods validator reads state references only as consts of the hooks file. */
9export type State = PluginState['context-gate']
10export type StateKey = keyof State
11
12export const INITIAL: State = {
13 gate: null,
14 gateState: { turn: 0 },
15 log: [],
16 seen: [],
17 manual: { add: [], remove: [] },
18 health: null,
19 budgetsFired: [],
20 trust: { decision: 'unknown', key: null, commandsHash: null },
21 recentPaths: [],
22 model: null,
23 tier: null,
24 agentTiers: {},
25 ctxPercent: null,
26 brief: null,
27 config: { ok: true, disabled: {}, diagnostics: 0 },
28 sectionView: null,
29}
30
31/** `$.state.set` takes JSON only: drop `undefined` fields. */
32export function json<T>(x: T): T {
33 return JSON.parse(JSON.stringify(x)) as T
34}
35
36/** Manual signals present → the gate applies whatever the classifier mode. */
37export function hasManual(m: ContextGateManual | undefined): boolean {
38 return !!m && (m.profile !== undefined || m.off === true || m.add.length > 0 || m.remove.length > 0)
39}
40
41/** The gate filters only when it is applied (not shadow) and not off. */
42export function isApplied(g: ContextGateDecision | null | undefined): g is ContextGateDecision {
43 return !!g && !g.shadow && !g.off
44}
45
46export function pushRing<T>(buf: readonly T[], entry: T, max: number): T[] {
47 const out = [...buf, entry]
48 return out.length > max ? out.slice(out.length - max) : out
49}
50
51export type LogEntry = ContextGateLogEntry
52hooks/layers/config.ts 127 lines1// Core: .claude/gate.json loading and the lazy session bootstrap shared by every hook.
2// A schema error disables layer 2 (skill-gate) and records why for /gate why; layer 1 keeps
3// running on defaults (SPEC "Конфігурація", MOD-ADAPTER "session.start").
4
5
6import { defaultConfig, envMaskValues, filterEnv, loadConfig, tierForModel } from '../../packages/core/src/config.ts'
7import type { GateConfig } from '../../packages/core/src/types.ts'
8import { json } from '../state.ts'
9import { type Io, type Runtime, debug, initRoot, join } from '../ctx.ts'
10
11export const GATE_JSON = '.claude/gate.json'
12
13export async function loadGateConfig(io: Io, rt: Runtime): Promise<void> {
14 const text = await io.fs.read(join(rt.root, GATE_JSON)).then((t) => (typeof t === 'string' ? t : undefined), () => undefined)
15 const { config, diagnostics } = loadConfig(text)
16 rt.configDiagnostics = diagnostics
17 delete rt.disabled.gate
18 if (config) {
19 rt.config = config
20 rt.cfg = config
21 } else {
22 rt.config = undefined
23 rt.cfg = withLayerSwitches(defaultConfig(), text)
24 const first = diagnostics.find((d) => d.severity === 'error') ?? diagnostics[0]
25 rt.disabled.gate = `${GATE_JSON}: ${first ? `${first.code} ${first.message}` : 'помилка конфігурації'} — skill-gate вимкнено`
26 debug(io, rt.disabled.gate)
27 }
28 if (rt.cfg.cursorRules?.enabled === false) rt.disabled.rules = 'cursorRules.enabled: false'
29 else delete rt.disabled.rules
30 if (await io.fs.exists(join(rt.root, '.claude/rules/cursor')).catch(() => false)) {
31 rt.disabled.rules = '.claude/rules/cursor/ існує (згенеровано context-gate sync) — шар cursor-rules вимкнено, щоб не дублювати контекст'
32 }
33 rt.itemsDirty = true
34 rt.promptsDirty = true
35 rt.whitelist = undefined
36 await writeConfigStatus(io, rt)
37}
38
39/** A schema error disables layer 2 only: a well-typed `cursorRules` switch of the broken file still holds, so a
40 * disabled cursor-rules layer stays off (L02). */
41function withLayerSwitches(base: GateConfig, text: string | undefined): GateConfig {
42 let raw: unknown
43 try { raw = text === undefined ? undefined : JSON.parse(text) } catch { return base }
44 const cr = raw && typeof raw === 'object' ? (raw as { cursorRules?: unknown }).cursorRules : undefined
45 if (!cr || typeof cr !== 'object' || Array.isArray(cr)) return base
46 const out: NonNullable<GateConfig['cursorRules']> = { ...(base.cursorRules ?? {}) }
47 for (const k of ['enabled', 'nested', 'strictWrite'] as const) {
48 const v = (cr as Record<string, unknown>)[k]
49 if (typeof v === 'boolean') out[k] = v
50 }
51 return { ...base, cursorRules: out }
52}
53
54export async function writeConfigStatus(io: Io, rt: Runtime): Promise<void> {
55 const errors = rt.configDiagnostics.filter((d) => d.severity === 'error').length
56 await io.update('config', () => json({ ok: rt.config !== undefined && errors === 0, disabled: { ...rt.disabled }, diagnostics: rt.configDiagnostics.length }))
57}
58
59/** Lazily bootstrap the session (session.start does it eagerly; a hot reload or a test may skip it). Concurrent hooks
60 * on a fresh runtime await the one bootstrap in flight: none of them sees an empty config and skips the gates (L03). */
61export async function ensureSession(io: Io, rt: Runtime): Promise<void> {
62 if (rt.ready) return
63 if (!rt.boot) {
64 const p: Promise<void> = bootstrap(io, rt).finally(() => { if (rt.boot === p) rt.boot = undefined })
65 rt.boot = p
66 }
67 await rt.boot
68}
69
70async function bootstrap(io: Io, rt: Runtime): Promise<void> {
71 try {
72 await initRoot(io, rt)
73 await loadGateConfig(io, rt)
74 const model = await io.session.model().catch(() => undefined)
75 if (model) {
76 const cw = await io.session.usage().then((u) => u.context.window, () => undefined)
77 await io.update('model', () => model)
78 await io.update('tier', () => modelTier(rt, model, cw))
79 }
80 if (rt.options.profile) {
81 await io.update('manual', (m) => (m.profile !== undefined || m.off ? m : json({ ...m, profile: rt.options.profile })))
82 }
83 rt.ready = true
84 } catch (err) {
85 rt.ready = false
86 debug(io, `bootstrap failed: ${String((err as Error)?.message ?? err)}`)
87 if (!rt.cfg) rt.cfg = defaultConfig()
88 }
89}
90
91/** Tier for a model (G-01): `models` globs and attribute entries, then the harness's context window
92 * (`$.session.usage().context.window`, main loop only) through `tiers[*].thresholds`. */
93export function modelTier(rt: Runtime, model: string, contextWindow?: number): string {
94 return tierForModel(rt.cfg ?? defaultConfig(), model, contextWindow ? { contextWindow } : undefined).tier
95}
96
97// ───────────────────────── env whitelist (G-03) ─────────────────────────
98// PROBE #9: `$.env.get` takes literal names only, so the gate.json `env` whitelist reads the settings `env`
99// block (`$.settings.read()`) instead. Only whitelisted names reach the DSL (`env.*`); their values are masked
100// in debug output (`envMask`).
101
102const envCache = new WeakMap<Runtime, { cfg: unknown; env: Record<string, string> }>()
103
104export async function ensureEnv(io: Io, rt: Runtime): Promise<Record<string, string>> {
105 const list = rt.cfg?.env
106 if (!list?.length) return {}
107 const hit = envCache.get(rt)
108 if (hit && hit.cfg === rt.cfg) return hit.env
109 let source: Record<string, unknown> | undefined
110 try {
111 const s = await io.settings?.read()
112 const e = s?.env
113 source = e && typeof e === 'object' && !Array.isArray(e) ? (e as Record<string, unknown>) : undefined
114 } catch (err) {
115 debug(io, `settings.read: ${String((err as Error)?.message ?? err)}`)
116 }
117 const env = filterEnv(source, list)
118 envCache.set(rt, { cfg: rt.cfg, env })
119 return env
120}
121
122/** Values to mask in debug output (trace, `$.ui.log`, `.claude/gate.debug.log`); empty before `ensureEnv`. */
123export function envMask(rt: Runtime): string[] {
124 const hit = envCache.get(rt)
125 return hit && hit.cfg === rt.cfg ? envMaskValues(hit.env) : []
126}
127hooks/layers/session.ts 136 lines1// Core lifecycle (MOD-ADAPTER "Core"): session.start, classic.SessionStart (watchPaths, /clear, compact),
2// session.end {clear}, session.compact, classic.FileChanged for gate.json.
3
4
5import { json } from '../state.ts'
6import { type Io, type Runtime, debug, join } from '../ctx.ts'
7import { GATE_JSON, ensureSession, loadGateConfig } from './config.ts'
8import { ensureRules } from './cursor-rules.ts'
9import { flushJournal, journal } from './journal.ts'
10import { keepText } from './budgets.ts'
11import { recompute } from './skill-gate.ts'
12import { buildStale, loadPrompts, promptDir, registerScriptTools, registerSkillTools } from './dsl.ts'
13import { trustState } from './trust.ts'
14import { refreshStatus } from './ui.ts'
15import { resetGateStats } from './gates.ts'
16import { registerCommands } from './commands.ts'
17
18/** Per-conversation state; manual, gate, trust and the log survive. */
19export async function resetConversation(io: Io, rt: Runtime, trigger: string): Promise<void> {
20 await io.update('seen', () => [])
21 await io.update('budgetsFired', () => [])
22 await io.update('gateState', () => ({ turn: 0 }))
23 await io.update('recentPaths', () => [])
24 await io.update('agentTiers', () => ({}))
25 await io.update('brief', () => null)
26 await io.update('health', () => null)
27 rt.readFiles.clear()
28 rt.changedPaths.clear()
29 rt.verifyFailed = 0
30 rt.stallTurns = 0
31 rt.escalated.clear()
32 rt.staticCache.clear()
33 rt.lastRender = undefined
34 rt.stepUsage = undefined
35 resetGateStats(rt) // H011 counts per conversation
36 rt.compactions = 0
37 await journal(io, rt, { kind: 'debug', trigger })
38}
39
40function recheckOn(rt: Runtime, what: string): boolean {
41 return (rt.config?.classify?.recheckOn ?? []).some((x) => x === what || x.endsWith(what))
42}
43
44async function watchList(io: Io, rt: Runtime): Promise<string[]> {
45 const out = [join(rt.root, GATE_JSON), join(rt.root, '.cursor/rules')]
46 for (const r of await ensureRules(io, rt)) if (!r.source?.startsWith('provider:')) out.push(join(rt.root, r.path))
47 const set = await loadPrompts(io, rt)
48 out.push(...set.watch, join(rt.root, `${promptDir(rt)}/scripts`))
49 return [...new Set(out)]
50}
51
52/** session.start, before `next`: commands, config, rules, prompt-skill tools; trusted → script tools + build. */
53export async function sessionStart(io: Io, rt: Runtime, e: { isInteractive: boolean; surface: string | null }): Promise<void> {
54 rt.interactive = e.isInteractive
55 rt.surface = e.surface
56 rt.ready = false
57 rt.boot = undefined
58 await registerCommands(io)
59 await ensureSession(io, rt)
60 try {
61 await ensureRules(io, rt, { force: true })
62 await registerSkillTools(io, rt)
63 if ((await trustState(io, rt)) === 'trusted') {
64 await registerScriptTools(io, rt)
65 void buildStale(io, rt).catch((err: unknown) => debug(io, `build: ${String(err)}`))
66 }
67 await refreshStatus(io, rt)
68 } catch (err) {
69 debug(io, `session.start: ${String((err as Error)?.message ?? err)}`)
70 }
71}
72
73/** A conversation other than the one this runtime has followed: `/clear`, `/resume` (the process goes on under another
74 * session id) and a fork. Reads, dedup, turn and budget state of the old one must not carry over (M17). */
75const NEW_CONVERSATION = new Set(['clear', 'resume', 'fork'])
76
77/** classic.SessionStart, after `next`: /clear, /resume and fork reset, compaction recheck, and the FileChanged watch list. */
78export async function classicSessionStart(io: Io, rt: Runtime, source: string): Promise<string[]> {
79 try {
80 await ensureSession(io, rt)
81 if (NEW_CONVERSATION.has(source)) await resetConversation(io, rt, source)
82 if (source === 'compact' && recheckOn(rt, 'compact')) {
83 await io.update('manual', (m) => json({ ...m, recheck: true }))
84 rt.recheckReason = 'compact'
85 }
86 return await watchList(io, rt)
87 } catch (err) {
88 debug(io, `classic.SessionStart: ${String((err as Error)?.message ?? err)}`)
89 return []
90 }
91}
92
93export async function sessionEnd(io: Io, rt: Runtime, reason: string): Promise<void> {
94 if (reason === 'clear' || reason === 'resume') await resetConversation(io, rt, reason)
95 await flushJournal(io, rt)
96}
97
98/** session.compact, before `next`: instructions that keep the profile and delivered rules. */
99export async function compactInstructions(io: Io, rt: Runtime, instructions: string | undefined): Promise<string> {
100 await ensureSession(io, rt)
101 return [instructions, await keepText(io)].filter(Boolean).join('\n\n')
102}
103
104/** session.compact of a subagent's transcript, after `next`: the main loop was not compacted (no recheck, the static
105 * cache stays, M26); only that agent's delivered rules are forgotten, so they reach it again. */
106export async function compactAgentAfter(io: Io, rt: Runtime, agentId: string): Promise<void> {
107 await io.update('seen', (s) => s.filter((k) => !k.startsWith(`${agentId}:`)))
108 await journal(io, rt, { kind: 'debug', trigger: 'compact', data: { agentId } })
109}
110
111/** session.compact, after `next` (a real compaction of the main conversation): reclassify on the next prompt when
112 * recheckOn has `compact`. */
113export async function compactAfter(io: Io, rt: Runtime): Promise<void> {
114 if (recheckOn(rt, 'compact')) {
115 await io.update('manual', (m) => json({ ...m, recheck: true }))
116 rt.recheckReason = 'compact'
117 }
118 rt.staticCache.clear()
119 rt.compactions++
120 await journal(io, rt, { kind: 'debug', trigger: 'compact' })
121}
122
123export async function configFileChanged(io: Io, rt: Runtime, path: string): Promise<void> {
124 if (!rt.root || path !== join(rt.root, GATE_JSON)) return
125 await loadGateConfig(io, rt)
126 await recompute(io, rt, 'config').catch(() => null)
127}
128
129/** turn.step, after `next`: the main loop's token usage (prompt-cache hits for health H002/H012, G-43). */
130export function recordStepUsage(rt: Runtime, usage: { input_tokens: number; output_tokens: number; cache_read_input_tokens: number; cache_creation_input_tokens?: number; model?: string } | null | undefined, agentId: string | undefined): void {
131 if (!usage || agentId !== undefined) return
132 const last = { input: usage.input_tokens ?? 0, cacheRead: usage.cache_read_input_tokens ?? 0, cacheCreation: usage.cache_creation_input_tokens ?? 0, output: usage.output_tokens ?? 0, model: usage.model ?? '' }
133 const u = rt.stepUsage ?? { steps: 0, input: 0, cacheRead: 0, cacheCreation: 0, output: 0 }
134 rt.stepUsage = { steps: u.steps + 1, input: u.input + last.input, cacheRead: u.cacheRead + last.cacheRead, cacheCreation: u.cacheCreation + last.cacheCreation, output: u.output + last.output, last }
135}
136hooks/layers/commands.ts 346 lines1// `/gate` and `/rule` (SPEC "Інтерфейс користувача"). Grammar: core gatecmd.parseGateCommand.
2
3
4import { parseGateCommand } from '../../packages/core/src/gatecmd.ts'
5import { formatWhy } from '../../packages/core/src/journal.ts'
6import type { DecisionLogEntry, Gate, Item, ItemDecision, Signals } from '../../packages/core/src/types.ts'
7import { formatHealth } from '../../packages/core/src/health.ts'
8import { renderPrompt } from '../../packages/core/src/render.ts'
9import { skillArgs } from '../../packages/core/src/assemble.ts'
10import { decideGate } from '../../packages/core/src/decide.ts'
11import { evalSource, newBudget } from '../../packages/core/src/expr.ts'
12import { groupsOf, makeItem, mcpServerOf } from '../../packages/core/src/items.ts'
13import { formatPipeText, itemProvenance, runPipeline, type PipeHost, type RenderedRecord } from '../../packages/core/src/pipeline.ts'
14import type { ContextGateManual, ContextGateSectionView } from '../../types'
15import { json } from '../state.ts'
16import { type Io, OWN_TOOL_PREFIX, type Runtime, now } from '../ctx.ts'
17import { ensureSession } from './config.ts'
18import { rulesReport } from './cursor-rules.ts'
19import { effectiveMode, ensureItems, groupedConfig, readBranch, recompute } from './skill-gate.ts'
20import { buildPrompts, buildScope, composeSections, hostFor, loadPrompts, preloadOf, renderOptions, sectionsFor } from './dsl.ts'
21import { revokeTrust } from './trust.ts'
22import { HEALTH_PANE, SECTION_PANE, WHY_PANE, buildErrorOf, gateLine } from './ui.ts'
23import { openEditor } from './editor.ts'
24import { unverifiedLines } from './probe.ts'
25import { formatTierCosts, tierCosts } from '../../packages/core/src/report.ts'
26
27export const GATE_HINT = '[<profile>|+group|-group|off|auto|new|why [off|<item>]|shadow|apply|rules|health|build|render prompt://<id>|edit <id>|trust revoke|<stage> | <stage>…]'
28
29const HELP = [
30 '/gate — стан; /gate <profile> — зафіксувати профіль; /gate +g / -g — групи на сесію;',
31 '/gate off | auto — вимкнути фільтрацію / повернути автоматику; /gate new — перекласифікувати;',
32 '/gate shadow | apply — режим класифікатора; /gate why [off] — журнал рішень; /gate why <елемент> — чому skill/MCP/агент/правило вимкнено;',
33 '/gate rules — доставлені правила;',
34 '/gate health — метрики промпту (pane); /gate build — зібрати промпти; /gate render prompt://<id> — секція (pane);',
35 '/gate edit <id> — браузерний редактор; /gate trust revoke; pipe: /gate collect kind=skill | where group=frontend | off.',
36].join('\n')
37
38async function statusText(io: Io, rt: Runtime): Promise<string> {
39 const gate = await io.read('gate')
40 const manual = await io.read('manual')
41 const status = await io.read('config')
42 const trust = await io.read('trust')
43 const lines = [gateLine(gate, await io.read('tier'), await io.read('ctxPercent'))]
44 const mode = effectiveMode(rt, manual)
45 lines.push(`режим: ${mode === 'auto' ? 'apply (auto)' : 'shadow'}${gate?.shadow ? ' — рішення лише в журнал, нічого не фільтрується' : ''}; довіра: ${trust.decision}`)
46 if (gate) {
47 lines.push(`тригер: ${gate.trigger}${gate.proposed ? `; пропозиція: ${gate.proposed.profile} (${gate.proposed.confidence.toFixed(2)})` : ''}; групи: ${gate.groups.join(', ') || '—'}`)
48 const list = (label: string, xs: string[]) => { if (xs.length) lines.push(`${label}: ${xs.slice(0, 30).join(', ')}${xs.length > 30 ? ` …(+${xs.length - 30})` : ''}`) }
49 // Shadow: nothing is filtered; the lists are what the proposal would do.
50 const would = gate.shadow ? ' (пропозиція, не застосовано)' : ''
51 list('skills on', [...gate.skills.on, ...gate.skills.preload.map((s) => `${s} (preload)`)])
52 list(`skills лише назва${would}`, gate.skills.nameOnly)
53 list(`skills off${would}`, gate.skills.off)
54 list(`mcp off${would}`, gate.mcp.off)
55 list(`агенти off${would}`, gate.agents.off)
56 if (gate.reason.length) lines.push(`чому: ${gate.reason.join('; ')}`)
57 const from = await provenanceLines(io, rt, gate as unknown as Gate, manual)
58 if (from.length) lines.push(`звідки${would}:`, ...from)
59 }
60 if (manual.profile || manual.add.length || manual.remove.length || manual.off) {
61 lines.push(`вручну: ${[manual.off ? 'off' : '', manual.profile ?? '', ...manual.add.map((g) => `+${g}`), ...manual.remove.map((g) => `-${g}`)].filter(Boolean).join(' ')}`)
62 }
63 for (const [k, v] of Object.entries(status.disabled)) lines.push(`вимкнено ${k}: ${v}`)
64 return lines.join('\n')
65}
66
67/** One line per source: `- manual +backend: skill:prisma, tool:mcp__postgres__query` (SPEC scenario 4). */
68async function provenanceLines(io: Io, rt: Runtime, gate: Gate, manual: ContextGateManual): Promise<string[]> {
69 if (!rt.config) return []
70 const items = await ensureItems(io, rt)
71 const state = await io.read('gateState')
72 const src = state.profileSource
73 const profileSource = src === 'classify' && gate.proposed ? `classify ${gate.proposed.confidence.toFixed(2)}` : src
74 const prov = itemProvenance(items, { config: rt.config, gate, ...(profileSource ? { profileSource } : {}), manual })
75 const by = new Map<string, string[]>()
76 for (const [id, label] of prov) by.set(label, [...(by.get(label) ?? []), id])
77 return [...by].map(([label, ids]) => `- ${label}: ${ids.slice(0, 20).join(', ')}${ids.length > 20 ? ` …(+${ids.length - 20})` : ''}`)
78}
79
80const DECISION_LABEL: Record<string, string> = { on: 'увімкнено', nameOnly: 'лише назва (без опису)', off: 'вимкнено', preload: 'preload (тіло в промпті)' }
81
82/** Items a `/gate why <q>` names: an id (`skill:x`, `tool:mcp__a__b`), a name, or an MCP server (`postgres`). */
83function itemsNamed(items: readonly Item[], q: string): Item[] {
84 const bare = q.replace(/^@/, '')
85 const byId = items.filter((it) => it.id === bare)
86 if (byId.length) return byId
87 return items.filter((it) => it.name === bare || it.name.replace(/^[^:]+:/, '') === bare || (it.kind === 'tool' && (mcpServerOf(it.name) === bare || it.name === OWN_TOOL_PREFIX + bare)))
88}
89
90/**
91 * `/gate why <item>` (O7): for each item the query names, the decision in force and the reason: the groups that
92 * mention it, which of them are active (profile, tier, `/gate +g`), the mode, denies so far, and how to turn it
93 * on; an item the session does not hold says why it may be missing.
94 */
95async function explainItem(io: Io, rt: Runtime, q: string): Promise<string> {
96 if (!rt.config) return `skill-gate вимкнено: ${rt.disabled.gate ?? 'немає конфігурації'} — жоден елемент не фільтрується.`
97 const items = await ensureItems(io, rt)
98 const gate = await io.read('gate')
99 const log = (await io.read('log')) as DecisionLogEntry[]
100 const found = itemsNamed(items, q)
101 if (!found.length) {
102 const lines = [`«${q}» немає серед елементів сесії (${items.length}), тож gate його не вимикав.`]
103 if (!rt.listingText) lines.push('- листинг skills ще не надходив (з’явиться з першим запитом до моделі): skill може бути там.')
104 if (!rt.mcpTools?.length) lines.push('- MCP-інструментів сесія ще не показала.')
105 if (rt.disabled.rules) lines.push(`- шар cursor-rules вимкнено: ${rt.disabled.rules}`)
106 if ((rt.config.itemSources ?? []).some((s) => s.kind === 'claude-tools' && s.match)) lines.push('- itemSources `claude-tools` з `match` звужує, які MCP-інструменти стають елементами.')
107 const denied = log.filter((e) => e.kind === 'deny' && JSON.stringify(e.data ?? {}).includes(q)).length
108 if (denied) lines.push(`- у журналі ${denied} deny зі згадкою «${q}»: /gate why`)
109 lines.push('Повний перелік: /gate collect | where name~' + JSON.stringify(q))
110 return lines.join('\n')
111 }
112 const cfg = groupedConfig(rt.config)
113 const active = new Set(gate?.groups ?? [])
114 const mode = !gate ? 'рішення ще не було' : gate.off ? '/gate off: нічого не фільтрується' : gate.shadow ? 'shadow: рішення лише в журнал, нічого не фільтрується' : `застосовано (${gate.profile ? `профіль ${gate.profile}` : `tier ${gate.tier}`})`
115 const lines = [`режим: ${mode}`]
116 for (const it of found.slice(0, 10)) {
117 const d = gate?.items[it.id]
118 const groups = groupsOf(cfg, it)
119 const on = groups.filter((g) => active.has(g))
120 const parts = [`\`${it.id}\`: ${d ? DECISION_LABEL[d] ?? d : 'ще не вирішено'}`]
121 if (groups.length) parts.push(`групи: ${groups.map((g) => `${g}${active.has(g) ? ' (активна)' : ''}`).join(', ')}`)
122 else parts.push(it.kind === 'tool' && it.name.startsWith('mcp__') ? 'жодна група не згадує → не фільтрується' : it.kind === 'skill' ? 'жодна група не згадує → лише назва в apply' : 'жодна група не згадує → доступний')
123 if (d === 'off' && groups.length && !on.length) parts.push(`увімкнути: /gate +${groups[0]}`)
124 const denies = rt.denies[it.name] ?? rt.denies[it.name.replace(/^tool:/, '')]
125 if (denies) parts.push(`deny у сесії: ${denies}`)
126 if (it.kind === 'rule' && d === 'off' && rt.rules?.list.find((r) => r.id === it.name)?.type === 'auto') parts.push('Auto Attached: доставляється за globs, профіль його не вимикає')
127 if (it.kind === 'rule') parts.push(`доставлено main: ${(await io.read('seen')).includes(`main:${it.name}`) ? 'так' : 'ні'}`)
128 lines.push(`- ${parts.join(' · ')}`)
129 }
130 if (found.length > 10) lines.push(`…(+${found.length - 10})`)
131 return lines.join('\n')
132}
133
134async function setManual(io: Io, fn: (m: ContextGateManual) => ContextGateManual): Promise<void> {
135 await io.update('manual', (m) => json(fn(m)))
136}
137
138export async function registerCommands(io: Io): Promise<void> {
139 await io.command.register({ name: 'gate', description: 'context-gate: стан, профіль, why, rules, health', argumentHint: GATE_HINT })
140 await io.command.register({ name: 'rule', description: 'context-gate: застосувати Manual-правило Cursor', argumentHint: '<id>' })
141}
142
143export async function gateCommand(io: Io, rt: Runtime, args: string): Promise<{ text: string }> {
144 await ensureSession(io, rt)
145 // `/gate why <item>` (O7): core's grammar knows only `why [off]`, the item form is the mod's.
146 const whyItem = args.includes('|') ? null : /^\s*why\s+(\S.*?)\s*$/.exec(args)
147 if (whyItem && whyItem[1] !== 'off') return { text: await explainItem(io, rt, whyItem[1]) }
148 const profiles = rt.config ? Object.keys(rt.config.profiles) : undefined
149 const cmd = parseGateCommand(args, profiles ? { profiles } : {})
150 if ('error' in cmd) return { text: `${cmd.error}\n${HELP}` }
151 const needGate = (): string | undefined => (rt.config ? undefined : `skill-gate вимкнено: ${rt.disabled.gate ?? 'немає конфігурації'}`)
152 switch (cmd.cmd) {
153 case 'status':
154 return { text: await statusText(io, rt) }
155 case 'help':
156 return { text: HELP }
157 case 'profile':
158 case 'groups':
159 case 'off':
160 case 'auto': {
161 const off = needGate()
162 if (off) return { text: off }
163 if (cmd.cmd === 'profile') await setManual(io, (m) => ({ ...m, profile: cmd.profile, off: undefined }))
164 else if (cmd.cmd === 'groups') await setManual(io, (m) => ({ ...m, add: [...new Set([...m.add.filter((g) => !cmd.remove.includes(g)), ...cmd.add])], remove: [...new Set([...m.remove.filter((g) => !cmd.add.includes(g)), ...cmd.remove])] }))
165 else if (cmd.cmd === 'off') await setManual(io, (m) => ({ ...m, off: true }))
166 else await setManual(io, (m) => ({ add: [], remove: [], ...(m.mode ? { mode: m.mode } : {}) }))
167 const g = await recompute(io, rt, 'manual', cmd.cmd === 'auto' ? { recheck: true, recheckReason: 'auto' } : {})
168 // In shadow `+g` / `-g` only edit the proposal (M20): say so, the user may expect filtering to start.
169 const note = cmd.cmd === 'groups' && g?.shadow ? 'shadow: групи записано в пропозицію, нічого не фільтрується; застосувати: /gate apply або /gate <профіль>.\n' : ''
170 return { text: note + (await statusText(io, rt)) }
171 }
172 case 'new': {
173 const off = needGate()
174 if (off) return { text: off }
175 await setManual(io, (m) => ({ ...m, recheck: true }))
176 rt.recheckReason = 'new'
177 return { text: 'Перекласифікую задачу з наступного промпту.' }
178 }
179 case 'shadow':
180 case 'apply': {
181 const off = needGate()
182 if (off) return { text: off }
183 await setManual(io, (m) => ({ ...m, mode: cmd.cmd === 'apply' ? 'auto' : 'shadow' }))
184 await recompute(io, rt, 'manual')
185 return { text: await statusText(io, rt) }
186 }
187 case 'why': {
188 if (cmd.close) {
189 await io.ui.close({ id: WHY_PANE })
190 return { text: 'Pane /gate why закрито.' }
191 }
192 const opened = await io.ui.open({ id: WHY_PANE, title: 'gate why', closeOnEscape: true }).catch(() => ({ isPlaced: false as const, reason: 'no surface' }))
193 const status = await io.read('config')
194 const disabled = Object.entries(status.disabled).map(([k, v]) => `- вимкнено ${k}: ${v}`)
195 const log = (await io.read('log')) as DecisionLogEntry[]
196 const costs = tierCosts(log) // SPEC «Ескалація»: attempts and tokens per tier (core report.ts)
197 const probe = unverifiedLines(rt) // G-62: features resting on probe points no live run confirmed
198 const text = [...disabled, formatWhy(log, 50), ...(costs.length ? ['', formatTierCosts(costs)] : []), ...(probe.length ? ['', ...probe] : [])].join('\n')
199 return { text: opened.isPlaced ? `Відкрито pane «gate why».\n\n${text}` : text }
200 }
201 case 'rules':
202 return { text: await rulesReport(io, rt) }
203 case 'health': {
204 const opened = await openPane(io, HEALTH_PANE, 'gate health')
205 const err = buildErrorOf(rt)
206 const body = rt.lastHealth ? formatHealth(rt.lastHealth) : 'Рендера промпту ще не було в цій сесії (секцій DSL немає або prompt.compose ще не спрацював).'
207 const u = rt.stepUsage
208 const cache = u && u.input + u.cacheRead + u.cacheCreation > 0 ? `turn.step: кроків ${u.steps}, кеш промпту ${Math.round((u.cacheRead / (u.input + u.cacheRead + u.cacheCreation)) * 100)}% вхідних токенів (read ${u.cacheRead}, write ${u.cacheCreation}, без кешу ${u.input})\n` : ''
209 const probe = unverifiedLines(rt)
210 return { text: [opened ? 'Відкрито pane «gate health».\n' : '', err ? `prompt ⚠ build: ${err.code} ${err.message}\n` : '', cache, body, probe.length ? `\n\n${probe.join('\n')}` : ''].join('') }
211 }
212 case 'build': {
213 const r = await buildPrompts(io, rt, { timeoutMs: 120_000, ask: true })
214 await loadPrompts(io, rt, { force: true })
215 return { text: r.message }
216 }
217 case 'render': {
218 const v = await renderSectionView(io, rt, cmd.id)
219 if ('error' in v) return { text: v.error }
220 await io.update('sectionView', (prev) => json({ ...v.view, ...(prev?.id === v.id && prev.editorUrl ? { editorUrl: prev.editorUrl } : {}) }))
221 const opened = await openPane(io, SECTION_PANE, `prompt://${cmd.id}`)
222 const s = v.view
223 return { text: [`${opened ? 'Відкрито pane секції.\n' : ''}prompt://${s.id} (${s.scope}, ${s.tokens} ток., ${s.included ? 'увійшла' : `пропущена: ${s.reason ?? ''}`})\n\n${s.text}`, ...s.diagnostics].join('\n') }
224 }
225 case 'edit': {
226 const r = await editSection(io, rt, cmd.id)
227 return { text: 'url' in r ? `Редактор ${cmd.id}: ${r.url}` : r.error }
228 }
229 case 'trust': {
230 const key = await revokeTrust(io, rt)
231 return { text: `Довіру до ${key} скасовано: скрипти, збірка й командні гейти не запускатимуться до нового підтвердження.` }
232 }
233 case 'pipe':
234 return { text: formatPipeText(await runPipeline(cmd.stages, [], modPipeHost(io, rt))) }
235 }
236}
237
238async function openPane(io: Io, id: string, title: string): Promise<boolean> {
239 const r = await io.ui.open({ id, title, closeOnEscape: true }).catch(() => ({ isPlaced: false as const, reason: 'no surface' }))
240 return r.isPlaced
241}
242
243// ───────────────────────── section pane (/gate render, /gate edit) ─────────────────────────
244
245/** Renders one section (or a prompt skill with empty args) for the pane, as `prompt.compose` would. */
246export async function renderSectionView(io: Io, rt: Runtime, id: string): Promise<{ view: ContextGateSectionView; id: string } | { error: string }> {
247 const set = await loadPrompts(io, rt)
248 const host = await hostFor(io, rt)
249 const { scope, tier } = await buildScope(io, rt, host, undefined)
250 const assembled = sectionsFor(rt, set, tier, await preloadOf(io, rt))
251 const skill = set.compiled.find((p) => p.skill?.name === id)
252 if (skill?.skill) {
253 const parsed = skillArgs(skill, '')
254 if (!parsed.ok) return { error: parsed.text }
255 }
256 const res = await renderPrompt(skill ? [...assembled.system, skill] : assembled.system, scope, host, { ...renderOptions(rt, tier), only: id })
257 const s = res.sections.find((x) => x.id === id)
258 if (!s) return { error: [`Секцію ${id} не знайдено`, ...res.diagnostics.map((d) => `- ${d.code} ${d.severity}: ${d.message}`)].join('\n') }
259 const view: ContextGateSectionView = {
260 id, tier, scope: s.scope, text: s.text, chars: s.chars, tokens: s.tokens, included: s.included, status: s.status,
261 ...(s.reason ? { reason: s.reason } : {}),
262 diagnostics: res.diagnostics.map((d) => `- ${d.code} ${d.severity}: ${d.message}`), at: now(),
263 }
264 return { view, id }
265}
266
267/** Health pane button «перерендерити»: a compose render, which stores fresh health (the pane subscribes). */
268export async function rerenderHealth(io: Io, rt: Runtime): Promise<void> {
269 await composeSections(io, rt, undefined)
270}
271
272/** Pane button «перерендерити»: render the shown section again. */
273export async function rerenderSection(io: Io, rt: Runtime): Promise<void> {
274 const cur = await io.read('sectionView')
275 if (!cur) return
276 const v = await renderSectionView(io, rt, cur.id)
277 await io.update('sectionView', (prev) => json('error' in v ? { ...(prev ?? cur), diagnostics: [v.error], at: now() } : { ...v.view, ...(prev?.editorUrl ? { editorUrl: prev.editorUrl } : {}) }))
278}
279
280/** `/gate edit <id>` and the pane button: start the browser editor, keep its URL on the pane's view. */
281export async function editSection(io: Io, rt: Runtime, id: string): Promise<{ url: string } | { error: string }> {
282 await ensureSession(io, rt)
283 const r = await openEditor(io, rt, id)
284 await io.update('sectionView', (prev) => (prev && prev.id === id ? json({ ...prev, ...('url' in r ? { editorUrl: r.url, editorError: undefined } : { editorError: r.error }) }) : prev))
285 return r
286}
287
288// ───────────────────────── /gate a | b: core stages over the session ─────────────────────────
289
290function evalWhen(expr: string, data: Record<string, unknown>): boolean {
291 const v = evalSource(expr, data as never, newBudget())
292 return !!v && v !== 0 && v !== ''
293}
294
295/** The mod's side of the pipe: the session's items (with the decision in force), live gate and journal. */
296export function modPipeHost(io: Io, rt: Runtime): PipeHost {
297 const sectionItems = async (): Promise<Item[]> => {
298 const set = await loadPrompts(io, rt)
299 const tier = (await io.read('tier')) ?? 'standard'
300 const health = await io.read('health')
301 return sectionsFor(rt, set, tier).system.flatMap((cp) => cp.sections.map((s) => makeItem('section', s.id, {
302 attach: { when: s.when ? 'on-demand' : 'always' },
303 cost: { chars: health?.sections[s.id]?.chars ?? 0 },
304 provenance: { source: 'prompt-dir', ...(s.source?.path ? { path: s.source.path } : {}) },
305 })))
306 }
307 return {
308 config: rt.cfg,
309 now: now(),
310 collect: async () => {
311 const items = [...(rt.config ? await ensureItems(io, rt) : []), ...(await sectionItems())]
312 const gate = await io.read('gate')
313 return gate ? items.map((it) => (gate.items[it.id] ? { ...it, decision: gate.items[it.id] as ItemDecision } : it)) : items
314 },
315 decide: async (items, f) => {
316 const gate = await io.read('gate')
317 const fresh = f.profile || f.model || f.tier || f.branch || f.paths.length
318 if (gate && !fresh) return gate.items as Record<string, ItemDecision>
319 const manual = await io.read('manual')
320 const signals: Signals = { paths: f.paths.length ? f.paths : await io.read('recentPaths') }
321 const model = f.model ?? (await io.read('model')) ?? undefined
322 if (model) signals.model = model
323 const branch = f.branch ?? (await readBranch(io, rt))
324 if (branch) signals.branch = branch
325 if (f.profile) signals.manual = { profile: f.profile, add: [], remove: [] }
326 else if (manual.profile || manual.add.length || manual.remove.length || manual.off) signals.manual = { add: manual.add, remove: manual.remove, ...(manual.profile ? { profile: manual.profile } : {}), ...(manual.off ? { off: true } : {}) }
327 return decideGate(rt.cfg, signals, { turn: 0 }, items, { evalExpr: evalWhen, ...(f.tier ? { tier: f.tier } : {}), now: now() }).gate.items
328 },
329 signals: async () => {
330 const gate = await io.read('gate')
331 return { paths: await io.read('recentPaths'), branch: (await readBranch(io, rt)) ?? '', model: (await io.read('model')) ?? '', tier: (await io.read('tier')) ?? gate?.tier ?? 'standard', profile: gate?.profile ?? null, manual: await io.read('manual') }
332 },
333 render: async (ids, a) => {
334 const set = await loadPrompts(io, rt)
335 const host = await hostFor(io, rt)
336 const built = await buildScope(io, rt, host, a.model)
337 const tier = a.tier ?? built.tier
338 const res = await renderPrompt(sectionsFor(rt, set, tier, await preloadOf(io, rt)).system, built.scope, host, renderOptions(rt, tier))
339 const sections = new Map<string, RenderedRecord>()
340 for (const s of res.sections) if (ids.has(s.id)) sections.set(s.id, { text: s.text, tokens: s.tokens, included: s.included, ...(s.reason ? { reason: s.reason } : {}), status: s.status })
341 return { tier, sections }
342 },
343 log: async () => (await io.read('log')) as DecisionLogEntry[],
344 }
345}
346hooks/layers/gates.ts 649 lines1// Gates (`gates[]`, SPEC "Провайдери — Гейти", "Шар 3а — Контракти виходу і гейти").
2// Builtin read-before-write (before Write/Edit); command gates on write (after the Write/Edit landed: they check the
3// new content and a failure reaches the model as context), commit (Bash `git commit`, global git options included),
4// turn (turn.complete), prompt (prompt.submit). Commands run through io.process.run ONLY when the repo is
5// trusted (Р2). `pass` is a core expression over { exitCode, stdout, stderr, result }; `message` a template.
6// A gate that cannot run is reported once per session; `failClosed: true` makes it block instead (S6).
7// Failures are journaled as `gate-failed` (metadata only) and count as failed verifications (escalation).
8
9
10import type { GateCheckConfig, Scope_, Value } from '../../packages/core/src/types.ts'
11import { evalSource, newBudget, parseTemplate, renderTemplate, truthy } from '../../packages/core/src/expr.ts'
12import type { GateStat } from '../../packages/core/src/health.ts'
13
14import { type Io, type FileCall, type Runtime, type ToolResultLike, debug, insideRoot, join, now, stableJson } from '../ctx.ts'
15import { bareBinary, configuredPromptDir, insideRealRoot, loadWhitelist, makeRenderHost, providerConfigs, providerData, writableInsideRoot } from './host.ts'
16import { commandGateDecision } from '../../packages/core/src/config.ts'
17import { ensureSession } from './config.ts'
18import { journal, flushJournal, pushFileEntry } from './journal.ts'
19import { gateAttemptEntry, type GateOutcome as AttemptOutcome } from '../../packages/core/src/journal.ts'
20import { invalidateSurface, repoKey, trustState } from './trust.ts'
21import { budgetsOnTurn } from './budgets.ts'
22import { checkEscalation } from './skill-gate.ts'
23
24const VERIFY_RE = /\b(test|tests|jest|vitest|pytest|mocha|tsc|typecheck|lint|eslint|ruff|mypy|clippy)\b/
25const GATE_TIMEOUT_MS = 120_000
26/** A baseline capture runs inside the pre-edit hook, so it is cut short: a slow gate just keeps no baseline. */
27const CAPTURE_TIMEOUT_MS = 15_000
28const OUTPUT_TAIL = 1500
29const DEFAULT_BASELINE = '.claude/gate.baseline.json'
30
31/** A gate as gate.json may write it: `failClosed` makes a gate that cannot run block instead of pass (S6). */
32export type Gate = GateCheckConfig & { failClosed?: boolean }
33
34export function defaultTiers(rt: Runtime): string[] {
35 return Object.keys(rt.cfg.tiers ?? {}).filter((t) => t !== 'premium')
36}
37
38async function tierOf(io: Io, agentId: string | undefined): Promise<string> {
39 if (agentId !== undefined) {
40 const t = (await io.read('agentTiers'))[agentId]
41 if (t) return t
42 }
43 return (await io.read('gate'))?.tier ?? (await io.read('tier')) ?? 'standard'
44}
45
46function gatesFor(rt: Runtime, on: GateCheckConfig['on'] | undefined, tier: string): Gate[] {
47 return (rt.config?.gates ?? []).filter((g) => (on === undefined || g.on === on) && (g.tiers ?? defaultTiers(rt)).includes(tier))
48}
49
50// ───────────────────────── `git commit` detection (M08) ─────────────────────────
51
52/** git global options that take a separate value (`git -C <dir> commit`). */
53const GIT_VALUE_OPTS = new Set(['-C', '-c', '--git-dir', '--work-tree', '--namespace', '--exec-path', '--super-prefix', '--config-env', '--list-cmds', '--attr-source'])
54
55/** Words of one simple command, quotes removed (no expansion: enough to find the program and its subcommand). */
56function words(segment: string): string[] {
57 const out: string[] = []
58 const re = /'([^']*)'|"((?:[^"\\]|\\.)*)"|(\S+)/g
59 let m: RegExpExecArray | null
60 while ((m = re.exec(segment))) out.push(m[1] ?? (m[2] !== undefined ? m[2].replace(/\\(.)/g, '$1') : m[3].replace(/['"]/g, '')))
61 return out
62}
63
64/** Shells whose `-c` takes a script (`/bin/bash`, `sh`, `zsh`…). */
65const SHELLS = /^(?:.*[\\/])?(?:ba|z|da|k|fi)?sh(\.exe)?$/i
66
67/** What a Bash command does that a gate can be bound to. */
68export type BashTrigger = 'commit' | 'push' | 'publish'
69
70/** Options of package managers that take a separate value (`npm --workspace pkg publish`, `pnpm -C dir publish`). */
71const PUBLISH_VALUE_OPTS = new Set(['-w', '--workspace', '--prefix', '--registry', '--cwd', '-C', '--dir', '--filter', '-F', '--manifest-path', '--package', '-p', '--repository', '--userconfig'])
72
73/** Package managers whose `publish` (or `npm publish` under yarn berry) uploads a package, plus twine's `upload`. */
74const PUBLISHERS: Record<string, readonly string[]> = { npm: ['publish'], pnpm: ['publish'], yarn: ['publish', 'npm'], bun: ['publish'], cargo: ['publish'], poetry: ['publish'], twine: ['upload'] }
75
76/**
77 * What a Bash command runs: `git commit`, `git push`, a package publish. Every command of a list or pipeline (`;`,
78 * `&&`, `||`, `|`, `&`, newlines, `$(…)`, backticks, subshells) counts, and any program word in it (after `sudo`,
79 * `env X=1`, `time`…); git's global options are skipped with their values (`-C <dir>`, `-c k=v`, `--git-dir=…`), and
80 * the subcommand must match exactly (`git commit-tree` is not a commit). A quoted script given to `sh -c` or `eval` is
81 * scanned as a command line. A false positive (`echo git commit`) only runs the gates.
82 */
83export function bashTriggers(cmd: string, depth = 0, out: Set<BashTrigger> = new Set()): Set<BashTrigger> {
84 for (const segment of cmd.split(/\|\||&&|[;|&\n`(){}]|\$\(/)) {
85 const w = words(segment)
86 for (let j = 0; j < w.length; j++) {
87 // A quoted script is one word: the argument of a shell's `-c` (`bash -lc "git commit"`, `xargs sh -c '…'`)
88 // or of `eval` is a command line of its own. (`grep "git commit"` is not: its argument is only text.)
89 const script = j > 0 && (/^-[A-Za-z]*c$/.test(w[j - 1]) && w.slice(0, j - 1).some((x) => SHELLS.test(x)) || w.slice(0, j).some((x) => x === 'eval'))
90 if (script && depth < 4) bashTriggers(w[j], depth + 1, out)
91 const prog = (w[j].split(/[\\/]/).pop() ?? '').replace(/\.(exe|cmd)$/i, '').toLowerCase()
92 if (prog === 'git') {
93 let i = j + 1
94 while (i < w.length && w[i].startsWith('-')) i += GIT_VALUE_OPTS.has(w[i]) ? 2 : 1
95 if (w[i] === 'commit') out.add('commit')
96 if (w[i] === 'push') out.add('push')
97 continue
98 }
99 const subs = PUBLISHERS[prog]
100 if (!subs) continue
101 // The subcommand is the first word that is not an option or an option's value (`npm --workspace x publish`).
102 let i = j + 1
103 while (i < w.length && w[i].startsWith('-')) i += PUBLISH_VALUE_OPTS.has(w[i]) ? 2 : 1
104 const sub = w[i]
105 if (sub && subs.includes(sub) && (prog !== 'yarn' || sub === 'publish' || w.slice(j + 1).includes('publish'))) out.add('publish')
106 }
107 }
108 return out
109}
110
111/** Does a Bash command run `git commit`? (M08; see `bashTriggers`.) */
112export function isGitCommit(cmd: string): boolean {
113 return bashTriggers(cmd).has('commit')
114}
115
116// ───────────────────────── argv placeholders (M09/S13) ─────────────────────────
117
118/** A path argument that cannot be read as an option (`-rf.ts` → `./-rf.ts`). */
119const asArg = (p: string): string => (p.startsWith('-') ? `./${p}` : p)
120/** What a path may hold inside a larger argv element (`--file={file}`, `sh -c "lint {file}"`): no shell syntax. */
121const SAFE_IN_ARG = /^[\w./@+,=:%~-]+$/
122
123/**
124 * `{file}` and `{changedPaths}` in a gate's argv. A whole element becomes one argv word, never shell text. Inside a
125 * larger element (a `sh -c` script) a path is substituted only when it holds no shell syntax; otherwise the gate
126 * refuses. Paths outside the repo are refused or dropped, a leading `-` is defused, and the replacer is a function,
127 * so `$&` in a name stays literal.
128 */
129export function expandArgv(argv: readonly string[], vars: { file?: string; changed: readonly string[] }): { argv: string[] } | { error: string } {
130 const out: string[] = []
131 const file = vars.file
132 const bad = (p: string): boolean => !insideRoot(p) || /[\0\r\n]/.test(p)
133 for (const a of argv) {
134 if (a === '{changedPaths}') { out.push(...vars.changed.filter((p) => !bad(p)).map(asArg)); continue }
135 if (file === undefined || !a.includes('{file}')) { out.push(a); continue }
136 if (bad(file)) return { error: `шлях ${JSON.stringify(file)} поза репозиторієм` }
137 if (a === '{file}') { out.push(asArg(file)); continue }
138 if (!SAFE_IN_ARG.test(file)) return { error: `шлях ${JSON.stringify(file)} містить символи, які не можна безпечно підставити в «${a}»` }
139 out.push(a.replace(/\{file\}/g, () => asArg(file)))
140 }
141 return { argv: out }
142}
143
144// ───────────────────────── violations and baselines (onlyNew, M10/M12, H01) ─────────────────────────
145
146function violationsOf(result: Value | undefined, stdout: string): string[] {
147 const pickArr = (v: Value | undefined): Value[] | undefined => {
148 if (Array.isArray(v)) return v
149 if (v && typeof v === 'object') for (const k of ['violations', 'errors', 'problems', 'issues']) if (Array.isArray((v as Record<string, Value>)[k])) return (v as Record<string, Value[]>)[k]
150 return undefined
151 }
152 const arr = pickArr(result)
153 if (arr) return arr.map((x) => (typeof x === 'string' ? x : JSON.stringify(x)))
154 return stdout.split('\n').map((l) => l.trim()).filter(Boolean)
155}
156
157const POSITION_KEYS = new Set(['line', 'column', 'col', 'endLine', 'endColumn', 'endCol', 'offset', 'range', 'start', 'end', 'position', 'pos', 'loc', 'location', 'lineNumber', 'columnNumber', 'startLine', 'startColumn'])
158
159function stripPositions(v: unknown): unknown {
160 if (Array.isArray(v)) return v.map(stripPositions)
161 if (v && typeof v === 'object') return Object.fromEntries(Object.entries(v as Record<string, unknown>).filter(([k]) => !POSITION_KEYS.has(k)).map(([k, x]) => [k, stripPositions(x)]))
162 return v
163}
164
165/** A violation's identity without its position: an edit above it shifts its line, not the violation. */
166export function violationKey(v: string): string {
167 const t = v.trim()
168 if (t.startsWith('{') || t.startsWith('[')) {
169 try { return stableJson(stripPositions(JSON.parse(t))) } catch { /* plain text */ }
170 }
171 return t
172 .replace(/\(\d+\s*,\s*\d+\)/g, '')
173 .replace(/^\d+:\d+(?=\s)/, '')
174 .replace(/:\d+(?::\d+)?(?=[:\s)\]]|$)/g, '')
175 .replace(/\b(line|col|column)\s*\d+/gi, '$1')
176 .replace(/\s+/g, ' ')
177 .trim()
178}
179
180/** Violations beyond the baseline, by position-free key and count (two equal errors where one was known: one new). */
181export function freshViolations(current: readonly string[], known: readonly string[]): string[] {
182 const left = new Map<string, number>()
183 for (const k of known) { const key = violationKey(k); left.set(key, (left.get(key) ?? 0) + 1) }
184 const out: string[] = []
185 for (const v of current) {
186 const key = violationKey(v)
187 const n = left.get(key) ?? 0
188 if (n > 0) left.set(key, n - 1)
189 else out.push(v)
190 }
191 return out
192}
193
194/** Baselines kept in memory: an untrusted repo (nothing written to it), a `baseline` path outside the repo, and the
195 * per-file state a `{file}` write gate saw just before the edit. */
196const MEM_BASELINES = new WeakMap<Runtime, Map<string, string[]>>()
197function memBaselines(rt: Runtime): Map<string, string[]> {
198 let m = MEM_BASELINES.get(rt)
199 if (!m) { m = new Map(); MEM_BASELINES.set(rt, m) }
200 return m
201}
202
203/** A `{file}` gate keeps one baseline per file (memory only); every other gate one per gate name. */
204function baselineKey(g: Gate, file: string | undefined): string {
205 return file !== undefined && (g.run ?? []).some((a) => a.includes('{file}')) ? `${g.name}\0${file}` : g.name
206}
207
208/** The baseline file, when `gates[].baseline` is repo-relative and stays inside the repo (H01). */
209function baselinePath(io: Io, rt: Runtime, g: Gate): string | undefined {
210 const rel = g.baseline ?? DEFAULT_BASELINE
211 if (insideRoot(rel)) return join(rt.root, rel)
212 debug(io, `gate ${g.name}: baseline ${JSON.stringify(rel)} поза репозиторієм — знімок лише в пам'яті`)
213 return undefined
214}
215
216async function readBaseline(io: Io, rt: Runtime, g: Gate, key: string): Promise<string[] | undefined> {
217 const mem = memBaselines(rt).get(key)
218 if (mem || key !== g.name) return mem
219 const path = baselinePath(io, rt, g)
220 if (!path || !(await insideRealRoot(io, rt, path))) return undefined
221 const raw = await io.fs.read(path).catch(() => undefined)
222 try {
223 const list = typeof raw === 'string' ? (JSON.parse(raw) as Record<string, unknown>)[g.name] : undefined
224 return Array.isArray(list) ? list.map(String) : undefined
225 } catch {
226 return undefined
227 }
228}
229
230/** Record a baseline: into the repo file only for a trusted repo and a path whose real target stays inside the repo
231 * (no symlink out, H01); otherwise in memory for this session. A clean state (`[]`) stays in memory too: written to
232 * the file it would pin every later session to this one's starting point and add a file to `git status`. */
233async function writeBaseline(io: Io, rt: Runtime, g: Gate, key: string, current: string[], trusted: boolean): Promise<void> {
234 const path = key === g.name && current.length ? baselinePath(io, rt, g) : undefined
235 if (!trusted || !path || !(await writableInsideRoot(io, rt, path))) {
236 if (trusted && path) debug(io, `gate ${g.name}: baseline веде за межі репозиторію — знімок лише в пам'яті`)
237 memBaselines(rt).set(key, current)
238 return
239 }
240 const raw = await io.fs.read(path).catch(() => undefined)
241 let base: Record<string, unknown> = {}
242 try { base = typeof raw === 'string' ? (JSON.parse(raw) as Record<string, unknown>) : {} } catch { base = {} }
243 if (!base || typeof base !== 'object' || Array.isArray(base)) base = {}
244 await io.fs.write(path, JSON.stringify({ ...base, [g.name]: current }, null, 2) + '\n').catch((err: unknown) => {
245 debug(io, `gate ${g.name}: baseline write failed: ${String(err)}`)
246 memBaselines(rt).set(key, current)
247 })
248}
249
250export interface GateOutcome { pass: boolean; message?: string; skipped?: string; exitCode?: number }
251
252// ───────────────────────── gate statistics (health H011) ─────────────────────────
253
254/** Per-session counters by gate name. Kept beside the Runtime (not in it) so `ctx.ts` stays untouched. */
255const STATS = new WeakMap<Runtime, { gates: Map<string, GateStat>; lastBlocked?: { name: string; at: number } }>()
256
257function statsOf(rt: Runtime): { gates: Map<string, GateStat>; lastBlocked?: { name: string; at: number } } {
258 let s = STATS.get(rt)
259 if (!s) { s = { gates: new Map() }; STATS.set(rt, s) }
260 return s
261}
262
263function recordGate(rt: Runtime, name: string, blocked: boolean, ms: number): void {
264 const s = statsOf(rt)
265 const g = s.gates.get(name) ?? { attempts: 0, blocks: 0, ms: 0, overrides: 0 }
266 g.attempts++
267 g.ms = (g.ms ?? 0) + ms
268 if (blocked) { g.blocks++; s.lastBlocked = { name, at: now() } }
269 s.gates.set(name, g)
270}
271
272/**
273 * One evaluation of a gate: the in-memory counters (this conversation's H011) plus a `gate-attempt` entry in
274 * `.claude/gate.log.jsonl` (core journal contract; file only, buffered, so the 200-entry ring keeps the decisions),
275 * which CLI `health` / `report` read with `gateStatsFromJournal`. `skip` is journaled but counts nothing.
276 */
277async function noteAttempt(io: Io, rt: Runtime, g: { name: string; on: string }, outcome: AttemptOutcome, ms: number, tier: string, skipped?: string): Promise<void> {
278 if (outcome === 'pass' || outcome === 'block') recordGate(rt, g.name, outcome === 'block', ms)
279 if (!rt.cfg?.log?.file) return
280 try {
281 const sessionId = await io.session.id().catch(() => undefined)
282 const turn = await io.read('gateState').then((s) => s.turn, () => 0)
283 const profile = (await io.read('gate'))?.profile ?? undefined
284 await pushFileEntry(io, rt, gateAttemptEntry({ gate: g.name, on: g.on, outcome, ms, ...(sessionId ? { sessionId } : {}), ...(skipped ? { skipped } : {}) }, { ts: now(), turn, tier, ...(profile ? { profile } : {}) }), { buffered: true })
285 } catch (err) {
286 debug(io, `gate-attempt journal: ${String((err as Error)?.message ?? err)}`)
287 }
288}
289
290/** Gate counters of this session for `computeHealth(…, { gates })` (H011). */
291export function gateStats(rt: Runtime): Record<string, GateStat> {
292 return Object.fromEntries([...statsOf(rt).gates].map(([k, v]) => [k, { ...v }]))
293}
294
295/** Reset on `/clear` and a fresh session. */
296export function resetGateStats(rt: Runtime): void { STATS.delete(rt) }
297
298/** SPEC «Гейти … false positives за ручними «все одно»»: a prompt that insists after a block. */
299const OVERRIDE_RE = /(все\s*одно|всеодно|anyway|ignore (?:the )?gate|пропусти гейт|без гейт)/i
300const OVERRIDE_WINDOW_MS = 10 * 60_000
301
302/** A manual «все одно» right after a block counts as an override (a likely false positive) of that gate. */
303export function noteGateOverride(rt: Runtime, text: string | undefined): string | undefined {
304 const s = statsOf(rt)
305 if (!text || !s.lastBlocked || now() - s.lastBlocked.at > OVERRIDE_WINDOW_MS || !OVERRIDE_RE.test(text)) return undefined
306 const g = s.gates.get(s.lastBlocked.name)
307 if (g) g.overrides = (g.overrides ?? 0) + 1
308 const name = s.lastBlocked.name
309 s.lastBlocked = undefined
310 return name
311}
312
313// ───────────────────────── provider data for gates (gates[].provider) ─────────────────────────
314
315/** Data of `gates[].provider` (SPEC «Гейти … кожна — команда або провайдер плюс умова проходження»), through the same
316 * RenderHost as a render: gate.json providers and `<prompt dir>/lib` modules alike (M11). */
317async function gateProvider(io: Io, rt: Runtime, name: string, trusted: boolean): Promise<Value | undefined> {
318 const dir = configuredPromptDir(rt.cfg)
319 const providers = await providerConfigs(io, rt, dir)
320 if (!providers[name]) return undefined
321 const host = makeRenderHost(io, rt, { trusted, repoKey: await repoKey(io, rt), itemBody: async () => undefined, rules: async () => rt.rules?.list ?? [], promptDir: dir, providers })
322 try {
323 const data = await providerData(io, rt, host, name)
324 return data[name]
325 } catch (err) {
326 debug(io, `gate provider ${name}: ${String((err as Error)?.message ?? err)}`)
327 return undefined
328 }
329}
330
331const isUnverified = (v: Value | undefined): boolean => v === undefined || v === null || (typeof v === 'object' && !Array.isArray(v) && (v as Record<string, Value>).unverified === true)
332
333/** What the gate's expressions see of its trigger: the prompt text (prompt gates), the Bash command (Bash gates). */
334function inputsOf(vars: GateVars): Scope_ {
335 return { ...(vars.prompt !== undefined ? { prompt: vars.prompt } : {}), ...(vars.command !== undefined ? { command: vars.command } : {}) }
336}
337
338/**
339 * A gate with `pass` and neither `run` nor `provider`: only the expression decides, over `prompt` / `command`. Runs no
340 * process, so it needs no trust: `{ "on": "publish", "pass": "false", "message": "…" }` keeps publishing for people.
341 */
342function expressionGate(io: Io, g: Gate, vars: GateVars): GateOutcome {
343 const scope: Scope_ = { exitCode: 0, stdout: '', stderr: '', result: null, ...inputsOf(vars) }
344 let pass: boolean
345 try {
346 pass = truthy(evalSource(g.pass!, scope, newBudget()))
347 } catch (err) {
348 debug(io, `gate ${g.name}: pass expression failed: ${String(err)}`)
349 return { pass: true, skipped: 'вираз pass не обчислено' }
350 }
351 if (pass) return { pass }
352 let message = `Гейт ${g.name} не пройдено.`
353 if (g.message) {
354 const t = parseTemplate(g.message)
355 try { message = renderTemplate(t.parts, scope, newBudget()) } catch { message = g.message }
356 }
357 return { pass, message }
358}
359
360/**
361 * Run one command gate (trusted repos only), or a provider gate (`provider` without `run`). `capture` records the
362 * baseline of an `onlyNew` gate (before the first edit, or a `{file}` gate just before its edit) and always passes.
363 */
364export interface GateVars { file?: string; prompt?: string; command?: string }
365
366export async function runCommandGate(io: Io, rt: Runtime, g: Gate, vars: GateVars, opts: { capture?: boolean } = {}): Promise<GateOutcome> {
367 if (!g.run?.length && !g.provider) return g.pass && !opts.capture ? expressionGate(io, g, vars) : { pass: true, skipped: 'немає run' }
368 const trust = await trustState(io, rt)
369 const trusted = trust === 'trusted'
370 if (g.run?.length) {
371 // Р2 (core): trusted repo, scripts allowed (interactive, or userConfig allowScripts under -p), binary on the whitelist.
372 const d = commandGateDecision({ trusted, whitelist: await loadWhitelist(io, rt), scriptsAllowed: rt.interactive || rt.options.allowScripts }, g.run)
373 if (!d.run) {
374 debug(io, `gate ${g.name} skipped: ${d.skipped}`)
375 return { pass: true, skipped: d.skipped }
376 }
377 if (!bareBinary(g.run)) return { pass: true, skipped: `${g.run[0]}: шлях замість імені бінарника (білий список приймає лише імена з PATH)` }
378 }
379 const prov = g.provider ? await gateProvider(io, rt, g.provider, trusted) : undefined
380 let r: { exitCode: number; stdout: string; stderr: string }
381 let result: Value = null
382 if (g.run?.length) {
383 const ex = expandArgv(g.run, { file: vars.file, changed: [...rt.changedPaths] })
384 if ('error' in ex) {
385 debug(io, `gate ${g.name}: ${ex.error}`)
386 return opts.capture ? { pass: true, skipped: ex.error } : { pass: false, message: `Гейт ${g.name}: ${ex.error} — перевірку не запущено.` }
387 }
388 try {
389 // A prompt gate reads the prompt on stdin (never in argv: the text is the user's, not a command).
390 r = await io.process.run(ex.argv, { cwd: rt.root, timeoutMs: opts.capture ? CAPTURE_TIMEOUT_MS : GATE_TIMEOUT_MS, ...(vars.prompt !== undefined ? { stdin: vars.prompt } : {}) })
391 } catch (err) {
392 r = { exitCode: -1, stdout: '', stderr: String((err as Error)?.message ?? err) }
393 }
394 // A capture that could not run (timeout, spawn error) records no baseline rather than an empty one.
395 if (opts.capture && r.exitCode < 0) return { pass: true, skipped: 'знімок не знято' }
396 try { result = JSON.parse(r.stdout) as Value } catch { /* not JSON */ }
397 } else {
398 // Provider-only gate: `result` is the provider's data; unavailable data is a skip (reported, or a block with failClosed).
399 if (isUnverified(prov)) {
400 debug(io, `gate ${g.name} skipped: provider ${g.provider} unavailable`)
401 return { pass: true, skipped: `провайдер ${g.provider} недоступний` }
402 }
403 result = prov ?? null
404 r = { exitCode: 0, stdout: typeof prov === 'string' ? prov : JSON.stringify(prov), stderr: '' }
405 }
406 const scope: Scope_ = { exitCode: r.exitCode, stdout: r.stdout, stderr: r.stderr, result, ...inputsOf(vars) }
407 if (g.provider) {
408 scope.provider = prov ?? null
409 if (!(g.provider in scope)) scope[g.provider] = prov ?? null
410 }
411 let pass: boolean
412 try {
413 pass = truthy(evalSource(g.pass ?? 'exitCode == 0', scope, newBudget()))
414 } catch (err) {
415 debug(io, `gate ${g.name}: pass expression failed: ${String(err)}`)
416 pass = r.exitCode === 0
417 }
418 let fresh: string[] | undefined
419 let noBaseline = false
420 if (g.onlyNew) {
421 // The baseline is the state before this session's edits: captured on a pass too (empty), never from a failure
422 // the session itself may have caused (M12). Compared by position-free keys (M10).
423 const key = baselineKey(g, vars.file)
424 const current = pass ? [] : violationsOf(result, r.stdout)
425 const known = opts.capture ? undefined : await readBaseline(io, rt, g, key)
426 if (known === undefined && (opts.capture || rt.changedPaths.size === 0)) {
427 await writeBaseline(io, rt, g, key, current, trusted)
428 pass = true
429 } else if (known === undefined) {
430 if (pass) await writeBaseline(io, rt, g, key, [], trusted)
431 else { fresh = current; noBaseline = true }
432 } else if (!pass) {
433 fresh = freshViolations(current, known)
434 pass = fresh.length === 0
435 }
436 }
437 if (pass) return { pass, exitCode: r.exitCode }
438 let message: string
439 if (g.message) {
440 const t = parseTemplate(g.message)
441 try { message = renderTemplate(t.parts, scope, newBudget()) } catch { message = g.message }
442 } else {
443 const tail = (fresh ? fresh.join('\n') : `${r.stdout}\n${r.stderr}`).trim()
444 message = `Гейт ${g.name} не пройдено (exit ${r.exitCode}).${tail ? `\n${tail.slice(-OUTPUT_TAIL)}` : ''}`
445 }
446 if (fresh?.length && g.message) message += `\nНові порушення:\n${fresh.slice(0, 20).join('\n')}`
447 if (noBaseline) message += '\n(Базового знімка для onlyNew немає: усі порушення вважаються новими.)'
448 return { pass, message, exitCode: r.exitCode }
449}
450
451async function failed(io: Io, rt: Runtime, g: GateCheckConfig, out: GateOutcome, tier: string): Promise<void> {
452 rt.verifyFailed++
453 await journal(io, rt, { kind: 'gate-failed', trigger: `gate:${g.on}`, tier, data: { gate: g.name, exitCode: out.exitCode ?? null } })
454 await checkEscalation(io, rt)
455}
456
457/** Skips already reported this session (gate + reason). */
458const SKIP_NOTED = new WeakMap<Runtime, Set<string>>()
459
460/** A gate that could not run (S6, R7): a block when gate.json marks it `failClosed`, otherwise a pass that is shown
461 * once per session (toast + debug), never a silent one. */
462function skipOutcome(io: Io, rt: Runtime, g: Gate, out: GateOutcome): GateOutcome {
463 if (g.failClosed) return { pass: false, message: `Гейт ${g.name} не виконано (${out.skipped}), а він обов'язковий (failClosed) — дію заблоковано.` }
464 let noted = SKIP_NOTED.get(rt)
465 if (!noted) { noted = new Set(); SKIP_NOTED.set(rt, noted) }
466 const k = `${g.name}\0${out.skipped}`
467 if (!noted.has(k)) {
468 noted.add(k)
469 debug(io, `gate ${g.name} пропущено: ${out.skipped}`)
470 try { io.ui.toast(`context-gate: гейт ${g.name} пропущено — ${out.skipped}`, { timeoutMs: 6000 }) } catch { /* no surface */ }
471 }
472 return out
473}
474
475/** Gates of one kind; returns the first failure's message. */
476async function runGates(io: Io, rt: Runtime, on: GateCheckConfig['on'], tier: string, vars: GateVars, failedGate?: (g: Gate) => void): Promise<string | undefined> {
477 for (const g of gatesFor(rt, on, tier)) {
478 if (g.builtin) continue
479 const t0 = now()
480 let out = await runCommandGate(io, rt, g, vars)
481 if (out.skipped) out = skipOutcome(io, rt, g, out)
482 await noteAttempt(io, rt, g, out.skipped ? 'skip' : out.pass ? 'pass' : 'block', now() - t0, tier, out.skipped)
483 if (!out.pass) {
484 await failed(io, rt, g, out, tier)
485 failedGate?.(g)
486 return out.message
487 }
488 }
489 return undefined
490}
491
492/** Sessions whose pre-edit baselines were taken. */
493const CAPTURED = new WeakSet<Runtime>()
494
495/**
496 * Before an edit (M12): the first edit of the session records the baseline of every `onlyNew` gate that has none,
497 * so the session's own regressions never become «known»; a `{file}` write gate records the file as it is before its
498 * first edit in the session, so later only what the session introduced counts. The first edit waits for these
499 * captures, so they run in parallel and each is cut at CAPTURE_TIMEOUT_MS (a gate slower than that keeps no
500 * baseline: after edits every violation of it counts as new, as the message says). A gate that already has a
501 * baseline (`gates[].baseline` file with violations) is not run. Best effort: never blocks the edit.
502 */
503async function captureBaselines(io: Io, rt: Runtime, tier: string, rel: string): Promise<void> {
504 const fileGate = (g: Gate): boolean => (g.run ?? []).some((a) => a.includes('{file}'))
505 try {
506 if (rt.changedPaths.size === 0 && !CAPTURED.has(rt)) {
507 CAPTURED.add(rt)
508 await Promise.all(gatesFor(rt, undefined, tier).map(async (g) => {
509 if (!g.onlyNew || g.builtin || fileGate(g)) return
510 if ((await readBaseline(io, rt, g, g.name)) === undefined) await runCommandGate(io, rt, g, {}, { capture: true })
511 }))
512 }
513 if (!insideRoot(rel)) return
514 for (const g of gatesFor(rt, 'write', tier)) {
515 if (!g.onlyNew || g.builtin || !fileGate(g)) continue
516 // Re-recording before every edit would turn edit 1's new violation into «known» at edit 2.
517 if (memBaselines(rt).has(baselineKey(g, rel))) continue
518 if (await io.fs.exists(join(rt.root, rel)).catch(() => false)) await runCommandGate(io, rt, g, { file: rel }, { capture: true })
519 else memBaselines(rt).set(baselineKey(g, rel), [])
520 }
521 } catch (err) {
522 debug(io, `baseline capture: ${String((err as Error)?.message ?? err)}`)
523 }
524}
525
526/** Before Write/Edit/NotebookEdit: builtin read-before-write; the pre-edit baselines of `onlyNew` gates. */
527export async function gatesBeforeFile(io: Io, rt: Runtime, c: FileCall): Promise<{ deny: string } | undefined> {
528 if (c.tool === 'Read' || !rt.config) return undefined
529 const tier = await tierOf(io, c.agentId)
530 const reads = rt.readFiles.get(c.agent)
531 const rbw = gatesFor(rt, 'write', tier).find((g) => g.builtin && g.name === 'read-before-write')
532 if (rbw) {
533 const blocked = !reads?.has(c.rel) && (c.tool === 'Write' ? await io.fs.exists(c.file).catch(() => false) : true)
534 await noteAttempt(io, rt, rbw, blocked ? 'block' : 'pass', 0, tier)
535 if (blocked) {
536 await failed(io, rt, rbw, { pass: false }, tier)
537 return { deny: `Гейт read-before-write: спочатку прочитай ${c.rel} інструментом Read, потім змінюй файл.` }
538 }
539 }
540 await captureBaselines(io, rt, tier, c.rel)
541 return undefined
542}
543
544/**
545 * After a Write/Edit/NotebookEdit landed: `write` command gates check the new content (M13: before the edit they saw
546 * the old file and inverted the verdict). The edit stays; a failure reaches the model as context of the result.
547 */
548export async function gatesAfterWrite<R extends ToolResultLike>(io: Io, rt: Runtime, c: FileCall, r: R): Promise<R> {
549 if (c.tool === 'Read' || !rt.config || r.deny !== undefined || r.isError || !insideRoot(c.rel)) return r
550 const msg = await runGates(io, rt, 'write', await tierOf(io, c.agentId), { file: c.rel })
551 return msg ? { ...r, context: [...(r.context ?? []), `${msg}\n(Правку ${c.rel} уже застосовано: виправ порушення наступною правкою.)`] } : r
552}
553
554/** After a file tool: what this agent has read, what changed this session and turn. */
555export function gatesAfterFile(rt: Runtime, c: FileCall, r: ToolResultLike): void {
556 if (r.deny !== undefined || r.isError) return
557 const reads = rt.readFiles.get(c.agent) ?? new Set<string>()
558 rt.readFiles.set(c.agent, reads)
559 reads.add(c.rel)
560 if (c.tool !== 'Read') {
561 rt.changedPaths.add(c.rel)
562 rt.editedThisTurn = true
563 invalidateSurface(rt) // an edit may touch code the trust decision covers (S1)
564 }
565}
566
567/** `@file` mentions arrive with their content: they count as read for read-before-write. */
568export function gatesMentioned(rt: Runtime, rels: string[]): void {
569 const reads = rt.readFiles.get('main') ?? new Set<string>()
570 rt.readFiles.set('main', reads)
571 for (const r of rels) reads.add(r)
572}
573
574/**
575 * `prompt` gates over the prompt text (stdin of `run`, `prompt` in `pass`/`message`). A failure becomes context of the
576 * prompt, or, for a gate with `drop: true`, stops the prompt before the model sees it. `text` also feeds the «все одно»
577 * override counter.
578 */
579export async function promptGates(io: Io, rt: Runtime, text?: string): Promise<{ message: string; drop: boolean } | undefined> {
580 const overridden = noteGateOverride(rt, text)
581 if (overridden) {
582 const g = rt.config?.gates?.find((x) => x.name === overridden)
583 await noteAttempt(io, rt, { name: overridden, on: g?.on ?? 'gate' }, 'override', 0, await tierOf(io, undefined))
584 }
585 if (!rt.config) return undefined
586 let drop = false
587 const message = await runGates(io, rt, 'prompt', await tierOf(io, undefined), { prompt: text ?? '' }, (g) => { drop = g.drop === true })
588 return message === undefined ? undefined : { message, drop }
589}
590
591/** Bash before: `commit`, `push` and `publish` gates on the commands that trigger them (any spelling git accepts, M08). */
592export async function bashBefore(io: Io, rt: Runtime, cmd: string, agentId: string | undefined): Promise<string | undefined> {
593 await ensureSession(io, rt)
594 if (!rt.config) return undefined
595 const triggers = bashTriggers(cmd)
596 if (!triggers.size) return undefined
597 const tier = await tierOf(io, agentId)
598 for (const on of ['commit', 'push', 'publish'] as const) {
599 if (!triggers.has(on)) continue
600 const msg = await runGates(io, rt, on, tier, { command: cmd })
601 if (msg) return msg
602 }
603 return undefined
604}
605
606/** Whether a failing guard must refuse instead of letting the call through (R7): the repo enforces something on it. */
607export function guardsFileCall(rt: Runtime, tool: string): boolean {
608 if (tool === 'Read' || !rt.config) return false
609 return (rt.config.gates ?? []).some((g) => g.on === 'write' && g.builtin) || rt.cfg?.cursorRules?.strictWrite === true
610}
611
612export function guardsBash(rt: Runtime, cmd: string): boolean {
613 if (!rt.config) return false
614 const triggers = bashTriggers(cmd)
615 return (rt.config.gates ?? []).some((g) => (triggers as Set<string>).has(g.on))
616}
617
618/** Bash after: a failed test / typecheck / lint command counts as a failed verification. */
619export async function bashAfter(io: Io, rt: Runtime, cmd: string, r: ToolResultLike): Promise<void> {
620 invalidateSurface(rt) // a command may have changed code the trust decision covers (S1)
621 if (r.deny !== undefined || r.isError !== true || !VERIFY_RE.test(cmd)) return
622 rt.verifyFailed++
623 await journal(io, rt, { kind: 'debug', trigger: 'verify-failed', data: { tool: 'Bash' } })
624 await checkEscalation(io, rt)
625}
626
627/** turn.complete (main loop), after `next`: stall counter, `turn` gates, budgets, escalation, journal flush. */
628export async function turnAfter(io: Io, rt: Runtime, e: { agentId?: string; isAborted: boolean }): Promise<void> {
629 if (e.agentId !== undefined) return
630 try {
631 await ensureSession(io, rt)
632 if (rt.editedThisTurn) rt.stallTurns = 0
633 else rt.stallTurns++
634 rt.editedThisTurn = false
635 if (rt.config && !e.isAborted) {
636 const msg = await runGates(io, rt, 'turn', await tierOf(io, undefined), {})
637 if (msg) {
638 try { io.ui.toast(`context-gate: ${msg.split('\n')[0]}`, { timeoutMs: 8000 }) } catch { /* no surface */ }
639 await io.session.append({ message: { type: 'user', content: [{ type: 'text', text: msg }] } }).catch((err: unknown) => debug(io, `gate append failed: ${String(err)}`))
640 }
641 }
642 await budgetsOnTurn(io, rt)
643 await checkEscalation(io, rt)
644 await flushJournal(io, rt)
645 } catch (err) {
646 debug(io, `turn.complete: ${String((err as Error)?.message ?? err)}`)
647 }
648}
649hooks/layers/cursor-rules.ts 478 lines1// Layer 1: Cursor `.mdc` rules (SPEC "Шар 1 — cursor-rules", PROBE prompt.context / tool.call).
2// Always → prompt.context instruction files (or a `cursorRules` block after claudeMd);
3// Auto Attached → `context` after Read/Edit/Write/NotebookEdit results and for `@file` mentions;
4// Manual → `@id` mentions and `/rule <id>`; Agent Requested → listed only (`context-gate sync` makes skills).
5// Dedup per agent in io.state `seen` (`<agentId|main>:<ruleId>`), reset on prompt.context (the latest prompt's own
6// deliveries kept: they ride the message prompt.context precedes), subagent keys capped.
7// Auto Attached rules are not gated by the profile: the glob is their gate (risk M1).
8// Sources (G-04, G-51): `.cursor/rules` plus every `cursor-mdc` `dir`, `markdown-dir` sources and `provider`
9// sources from `itemSources`; all yield MdcRule and share delivery, dedup and journaling (`rule-delivered`).
10
11
12import type { Diagnostic, MdcRule } from '../../packages/core/src/types.ts'
13import { detectWindows, normalizePath } from '../../packages/core/src/glob.ts'
14import { profileParts } from '../../packages/core/src/decide.ts'
15import { cursorRuleDirs, frameRule, isFileRule, markdownRuleId, packInjections, parseMarkdownRule, parseMdc, providerRules, ruleIdFromPath, ruleMatches, ruleSourcesOf } from '../../packages/core/src/mdc.ts'
16import { INITIAL, isApplied, json } from '../state.ts'
17import type { ContextGateDecision } from '../../types'
18import { type Io, type FileCall, type Runtime, type ToolResultLike, debug, insideRoot, join, now } from '../ctx.ts'
19import { ensureSession, loadGateConfig } from './config.ts'
20import { flushJournal, journal } from './journal.ts'
21import { configuredPromptDir, makeRenderHost, providerConfigs, providerData, readRepoFile } from './host.ts'
22import { repoKey, trustState } from './trust.ts'
23import { refreshStatus } from './ui.ts'
24
25const TYPE_LABEL: Record<string, string> = { always: 'Always', auto: 'Auto Attached', agent: 'Agent Requested', manual: 'Manual' }
26const SKIP_DIRS = new Set(['node_modules', '.git', 'dist', 'build', '.next', 'target', 'vendor', '.venv'])
27const MAX_DEPTH = 6
28const MAX_DIRS = 400
29const RECHECK_MS = 2000
30const RECENT_MAX = 50
31
32interface Found { rel: string; mtimeMs: number }
33
34async function listFiles(io: Io, rt: Runtime, dirRel: string, out: Found[], depth: number, ext: RegExp = /\.mdc$/): Promise<void> {
35 if (depth > MAX_DEPTH) return
36 const entries = await io.fs.list(join(rt.root, dirRel)).catch(() => [])
37 for (const e of entries) {
38 const rel = `${dirRel}/${e.name}`
39 if (e.kind === 'file' && ext.test(e.name)) out.push({ rel, mtimeMs: e.mtimeMs })
40 else if (e.kind === 'dir' && !SKIP_DIRS.has(e.name)) await listFiles(io, rt, rel, out, depth + 1, ext)
41 }
42}
43
44const MD_EXT = /^(?!readme\.md$).+\.(md|markdown)$/i
45
46function trimDir(d: string): string {
47 return d.replace(/\\/g, '/').replace(/^\.\//, '').replace(/\/+$/, '')
48}
49
50/** Provider rules (G-51) per runtime: recomputed when the config changes or on a forced re-read (prompt.context). */
51const providerCache = new WeakMap<Runtime, { cfg: unknown; rules: MdcRule[]; diagnostics: Diagnostic[] }>()
52
53async function loadProviderRules(io: Io, rt: Runtime, force: boolean): Promise<{ rules: MdcRule[]; diagnostics: Diagnostic[] }> {
54 const sources = ruleSourcesOf(rt.cfg).filter((s) => s.kind === 'provider' && s.name)
55 if (!sources.length) return { rules: [], diagnostics: [] }
56 const hit = providerCache.get(rt)
57 if (hit && hit.cfg === rt.cfg && !force) return hit
58 const trusted = (await trustState(io, rt).catch(() => 'unknown')) === 'trusted'
59 // With the prompt dir's `lib/` module providers, as dsl.ts and gates.ts build their hosts (M11).
60 const promptDir = configuredPromptDir(rt.cfg)
61 const host = makeRenderHost(io, rt, { trusted, repoKey: await repoKey(io, rt), itemBody: async () => undefined, rules: async () => rt.rules?.list ?? [], promptDir, providers: await providerConfigs(io, rt, promptDir) })
62 const rules: MdcRule[] = []
63 const diagnostics: Diagnostic[] = []
64 for (const src of sources) {
65 const data = await providerData(io, rt, host, src.name).catch(() => ({} as Record<string, never>))
66 const r = providerRules(data[src.name!], src)
67 rules.push(...r.rules)
68 diagnostics.push(...r.diagnostics)
69 }
70 const entry = { cfg: rt.cfg, rules, diagnostics }
71 providerCache.set(rt, entry)
72 return entry
73}
74
75/** Edge case 6: the session root moved (a `cd` into another worktree): drop the caches built for the old root. */
76/** The new root's gate.json replaces the old one's (which also marks items and prompts dirty). Checked on every
77 * `ensureRules`, before the 2 s re-list throttle: one engine call, and a move is seen on the very next tool call.
78 * The old root's pending journal lines are written to the old root first; per-repo session evidence (files read
79 * for read-before-write, changed paths, recent paths, rule dedup, the journal and debug-log text) starts empty, so
80 * nothing of repo A is written into, or vouches for, repo B (M01). */
81export async function checkRoot(io: Io, rt: Runtime): Promise<void> {
82 const root = await io.session.root().catch(() => rt.root)
83 if (!root || root === rt.root) return
84 debug(io, `session root moved: ${rt.root} → ${root}; caches dropped`)
85 if (rt.root) await flushJournal(io, rt).catch(() => undefined)
86 rt.root = root
87 rt.windows = detectWindows(root, await io.env.os().catch(() => undefined))
88 rt.rules = undefined
89 rt.rulesDirty = true
90 rt.itemsDirty = true
91 rt.promptsDirty = true
92 rt.prompts = undefined
93 rt.staticCache.clear()
94 providerCache.delete(rt)
95 freshSeen.delete(rt)
96 const r = rt as Runtime & { journalText?: string; journalBlocked?: boolean }
97 r.journalText = undefined
98 r.journalBlocked = undefined
99 rt.journalBuffer = []
100 rt.debugLogText = undefined
101 rt.lastSnapshot = undefined
102 rt.lastDebug = undefined
103 rt.traceWrite = undefined
104 rt.readFiles.clear()
105 rt.changedPaths.clear()
106 rt.trustAsked = false
107 rt.trustCache = undefined
108 rt.buildAttempted.clear()
109 rt.buildError = undefined
110 rt.lastRender = undefined
111 rt.lastHealth = undefined
112 await io.update('recentPaths', () => [])
113 await io.update('seen', () => [])
114 await loadGateConfig(io, rt)
115 // The old repo's decision is not the new one's: decided afresh on the next prompt, which is a new task there
116 // (turn 0: classifier, brief), so the old profile, its source and pending `when` candidate go with it. A pinned
117 // profile the new gate.json does not declare would apply as an empty profile there (denying nearly everything):
118 // it goes too.
119 const wasApplied = isApplied(await io.read('gate').catch(() => null))
120 await io.update('gate', () => null)
121 await io.update('gateState', () => json(INITIAL.gateState))
122 const profiles = rt.config?.profiles ?? {}
123 await io.update('manual', (m) => (m.profile !== undefined && !profileParts(m.profile, rt.config).every((p) => Object.prototype.hasOwnProperty.call(profiles, p)) ? json({ ...m, profile: undefined }) : m))
124 if (wasApplied) {
125 // MCP descriptions and the attachment the engine cached still show the old repo's gate.
126 try {
127 io.ui.invalidate('prompt.attachment')
128 io.ui.invalidate('tool.describe')
129 } catch { /* no surface */ }
130 }
131 await refreshStatus(io, rt).catch(() => undefined)
132}
133
134/** Directories holding `.cursor/rules` below the root (nested option), breadth-first with caps. */
135async function nestedRuleDirs(io: Io, rt: Runtime): Promise<string[]> {
136 const found: string[] = []
137 const queue: { rel: string; depth: number }[] = [{ rel: '', depth: 0 }]
138 let visited = 0
139 while (queue.length && visited < MAX_DIRS) {
140 const { rel, depth } = queue.shift()!
141 visited++
142 const entries = await io.fs.list(rel ? join(rt.root, rel) : rt.root).catch(() => [])
143 for (const e of entries) {
144 if (e.kind !== 'dir' || e.isLink) continue
145 if (e.name === '.cursor' && rel) found.push(`${rel}/.cursor/rules`)
146 if (e.name.startsWith('.') || SKIP_DIRS.has(e.name) || depth + 1 > MAX_DEPTH) continue
147 queue.push({ rel: rel ? `${rel}/${e.name}` : e.name, depth: depth + 1 })
148 }
149 }
150 return found
151}
152
153export function rulesActive(rt: Runtime): boolean {
154 return rt.disabled.rules === undefined
155}
156
157/** Parse rules once; re-list when dirty (FileChanged), on force, or at most every 2 s. */
158export async function ensureRules(io: Io, rt: Runtime, opts: { force?: boolean } = {}): Promise<MdcRule[]> {
159 await ensureSession(io, rt)
160 await checkRoot(io, rt)
161 if (!rulesActive(rt)) return []
162 const t = now()
163 if (rt.rules && !rt.rulesDirty && !opts.force && t - rt.rules.checkedAt < RECHECK_MS) return rt.rules.list
164 const { dirs, nested } = cursorRuleDirs(rt.cfg)
165 const files: Found[] = []
166 // Source dirs come from the repo's gate.json: one outside the root (absolute, `..`) is never read (M05).
167 for (const d of dirs) if (insideRoot(trimDir(d))) await listFiles(io, rt, trimDir(d), files, 0)
168 if (nested) for (const d of await nestedRuleDirs(io, rt)) await listFiles(io, rt, d, files, 0)
169 const mdSources = ruleSourcesOf(rt.cfg).filter((s) => s.kind === 'markdown-dir' && s.dir && insideRoot(trimDir(s.dir)))
170 const mdFiles: { rel: string; mtimeMs: number; src: (typeof mdSources)[number] }[] = []
171 for (const src of mdSources) {
172 const found: Found[] = []
173 await listFiles(io, rt, trimDir(src.dir!), found, 0, MD_EXT)
174 for (const f of found) mdFiles.push({ ...f, src })
175 }
176 const prov = await loadProviderRules(io, rt, !!opts.force)
177 const uniqueFiles = [...new Map(files.map((f) => [f.rel, f])).values()]
178 const key = [...uniqueFiles, ...mdFiles].map((f) => `${f.rel}:${f.mtimeMs}`).sort().join('|') + `#${prov.rules.map((r) => `${r.id}:${r.body.length}`).join(',')}`
179 if (rt.rules && rt.rules.key === key) {
180 rt.rules.checkedAt = t
181 rt.rulesDirty = false
182 return rt.rules.list
183 }
184 const list: MdcRule[] = []
185 const diagnostics: Diagnostic[] = []
186 for (const f of uniqueFiles.sort((a, b) => (a.rel < b.rel ? -1 : 1))) {
187 // Symlinks out of the repo are not read (H02).
188 const text = await readRepoFile(io, rt, f.rel)
189 if (typeof text !== 'string') continue
190 // Ids below a custom cursor-mdc dir, and one rule per id (core loadRuleSources, M41).
191 const { id, dirPrefix } = ruleIdFromPath(f.rel, dirs)
192 const r = parseMdc(text, { path: f.rel, id, dirPrefix })
193 diagnostics.push(...r.diagnostics)
194 const dup = list.find((x) => x.id === r.rule.id)
195 if (dup) { diagnostics.push({ code: 'G001', severity: 'warning', message: `Правило ${r.rule.id}: id уже має ${dup.path}; ${f.rel} пропущено`, path: f.rel }); continue }
196 list.push(r.rule)
197 }
198 for (const f of mdFiles.sort((a, b) => (a.rel < b.rel ? -1 : 1))) {
199 const text = await readRepoFile(io, rt, f.rel)
200 if (typeof text !== 'string') continue
201 const r = parseMarkdownRule(text, { path: f.rel, id: markdownRuleId(f.rel, trimDir(f.src.dir!)), ...(f.src.frontmatter ? { frontmatter: f.src.frontmatter } : {}), ...(f.src.as ? { as: f.src.as } : {}) })
202 if (!list.some((x) => x.id === r.rule.id)) list.push(r.rule)
203 diagnostics.push(...r.diagnostics)
204 }
205 for (const r of prov.rules) if (!list.some((x) => x.id === r.id)) list.push(r)
206 diagnostics.push(...prov.diagnostics)
207 rt.rules = { key, list, diagnostics, checkedAt: t }
208 rt.rulesDirty = false
209 rt.itemsDirty = true
210 if (diagnostics.length) debug(io, `${diagnostics.length} .mdc diagnostics: ${diagnostics.slice(0, 3).map((d) => `${d.code} ${d.path ?? ''}`).join(', ')}`)
211 return list
212}
213
214/** A rule the applied gate switched off is not delivered. */
215export function ruleOn(gate: ContextGateDecision | null, id: string): boolean {
216 if (!isApplied(gate)) return true
217 return gate.items[`rule:${id}`] !== 'off'
218}
219
220/** The gate rules obey: none while gate.json is invalid (layer 2 off, a stored gate must not outlive it, M18). */
221async function readGate(io: Io, rt: Runtime): Promise<ContextGateDecision | null> {
222 return rt.config ? io.read('gate') : null
223}
224
225export function relPath(rt: Runtime, file: string): string {
226 return normalizePath(file, rt.root, { windows: rt.windows })
227}
228
229export async function pushRecent(io: Io, paths: string[]): Promise<void> {
230 if (!paths.length) return
231 await io.update('recentPaths', (list) => {
232 const out = list.filter((p) => !paths.includes(p))
233 out.push(...paths)
234 return out.slice(-RECENT_MAX)
235 })
236}
237
238/** Auto rules matching `rel`, not yet seen by `agent`. The profile does not gate them: a task that started under
239 * one profile and then touches another area still gets that area's rules (risk M1). */
240async function autoHits(io: Io, rt: Runtime, rels: string[], agent: string): Promise<MdcRule[]> {
241 const rules = await ensureRules(io, rt)
242 if (!rules.length) return []
243 const seen = new Set(await io.read('seen'))
244 const opts = { nocase: rt.windows }
245 return rules.filter((r) => r.type === 'auto' && !seen.has(`${agent}:${r.id}`) && rels.some((p) => ruleMatches(r, p, opts)))
246}
247
248/** Subagents whose deliveries `seen` keeps (the most recent ones): ids are ephemeral (L04). */
249const SEEN_AGENTS_MAX = 16
250
251/** `seen` with `keys` added; subagent keys of all but the SEEN_AGENTS_MAX most recently added agents dropped. */
252export function addSeen(seen: readonly string[], keys: readonly string[]): string[] {
253 const all = [...new Set([...seen, ...keys])]
254 const agents: string[] = []
255 for (const k of all) {
256 const a = splitSeen(k).agent
257 if (a === 'main') continue
258 const i = agents.indexOf(a)
259 if (i >= 0) agents.splice(i, 1)
260 agents.push(a)
261 }
262 if (agents.length <= SEEN_AGENTS_MAX) return all
263 const keep = new Set(agents.slice(-SEEN_AGENTS_MAX))
264 return all.filter((k) => { const a = splitSeen(k).agent; return a === 'main' || keep.has(a) })
265}
266
267async function markSeen(io: Io, keys: string[]): Promise<void> {
268 if (!keys.length) return
269 await io.update('seen', (s) => addSeen(s, keys))
270}
271
272/** `main:` keys the latest prompt delivered (its @file / @id context): prompt.context, computed for the message that
273 * prompt starts, must not forget them (L06). Reset by the next prompt. */
274const freshSeen = new WeakMap<Runtime, Set<string>>()
275
276/** Journal one `rule-delivered` entry per rule (G-05): `report` and `observe --status never` count these. */
277async function journalDelivered(io: Io, rt: Runtime, ids: readonly string[], agent: string, via: string, extra: Record<string, unknown> = {}): Promise<void> {
278 for (const id of ids) {
279 await journal(io, rt, { kind: 'rule-delivered', trigger: via, enabled: [`rule:${id}`], reason: [`${via}: ${id} → ${agent}`], data: { rule: id, ruleId: id, agent, via, ...extra } })
280 }
281}
282
283function maxChars(rt: Runtime): number {
284 return rt.cfg.cursorRules?.maxCharsPerInjection ?? 30000
285}
286
287/** A mentioned rule id: exact, else the one rule whose id ends in `/<id>` (a Manual rule in a `.cursor/rules`
288 * subfolder, `db/migrations` for `@migrations`, as the hooks adapter resolves it; L05). */
289export function findRule(rules: readonly MdcRule[], id: string): MdcRule | undefined {
290 const exact = rules.find((r) => r.id === id)
291 if (exact) return exact
292 const tail = rules.filter((r) => r.id.endsWith(`/${id}`))
293 return tail.length === 1 ? tail[0] : undefined
294}
295
296/** Layer-1 part of prompt.submit: `@file` → Auto Attached, `@id` → Manual/any rule. Returns context blocks.
297 * A slash mention that names a rule id and no extension (`@db/migrations`) is that rule, not a file. */
298export async function rulesForPrompt(io: Io, rt: Runtime, files: string[], ruleIds: string[]): Promise<string[]> {
299 const fresh = new Set<string>()
300 freshSeen.set(rt, fresh)
301 const all = rulesActive(rt) ? await ensureRules(io, rt) : []
302 const asRule = (f: string): boolean => !/\.[A-Za-z0-9]+$/.test(f) && all.some((r) => r.id === f.replace(/^\.\//, ''))
303 const rels = files.filter((f) => !asRule(f)).map((f) => relPath(rt, f))
304 const ids = [...ruleIds, ...files.filter(asRule).map((f) => f.replace(/^\.\//, ''))]
305 await pushRecent(io, rels)
306 if (!rulesActive(rt)) return []
307 const blocks: string[] = []
308 const hits = rels.length ? await autoHits(io, rt, rels, 'main') : []
309 const seen = new Set(await io.read('seen'))
310 const mentioned = [...new Set(ids.map((id) => findRule(all, id)).filter((r): r is MdcRule => !!r && !seen.has(`main:${r.id}`) && !hits.includes(r)))]
311 const packed = packInjections([...hits, ...mentioned], maxChars(rt))
312 if (packed.text) blocks.push(packed.text)
313 const keys = packed.included.map((id) => `main:${id}`)
314 await markSeen(io, keys)
315 for (const k of keys) fresh.add(k)
316 const byFile = hits.filter((r) => packed.included.includes(r.id)).map((r) => r.id)
317 const byId = mentioned.filter((r) => packed.included.includes(r.id)).map((r) => r.id)
318 await journalDelivered(io, rt, byFile, 'main', '@file', { paths: rels })
319 await journalDelivered(io, rt, byId, 'main', '@mention')
320 return blocks
321}
322
323/** `/rule <id>`. */
324export async function ruleCommand(io: Io, rt: Runtime, args: string): Promise<{ text: string; context?: string[] }> {
325 const id = args.trim().replace(/^@/, '')
326 const rules = await ensureRules(io, rt)
327 const manualIds = rules.filter((r) => r.type === 'manual' || r.type === 'agent').map((r) => r.id)
328 if (!rulesActive(rt)) return { text: `Шар cursor-rules вимкнено: ${rt.disabled.rules}` }
329 const usage = `Використання: /rule <id>${manualIds.length ? `. Manual/Agent-правила: ${manualIds.join(', ')}` : ''}`
330 if (!id) return { text: usage }
331 const rule = findRule(rules, id)
332 if (!rule) return { text: `Правило «${id}» не знайдено. ${usage}` }
333 await markSeen(io, [`main:${rule.id}`])
334 await journalDelivered(io, rt, [rule.id], 'main', '/rule')
335 return { text: `Застосовано правило ${rule.id}`, context: [frameRule(rule)] }
336}
337
338/** Split a `seen` key `<agent>:<ruleId>` (rule ids never contain `:`; agent ids may). */
339function splitSeen(k: string): { agent: string; id: string } {
340 const i = k.lastIndexOf(':')
341 return { agent: k.slice(0, i), id: k.slice(i + 1) }
342}
343
344/** `/gate rules` (G-06, SPEC scenario 5): one row per rule with its type, globs, source and gate decision,
345 * and «доставлено: так/ні» per agent (main plus every subagent seen in this conversation). */
346export async function rulesReport(io: Io, rt: Runtime): Promise<string> {
347 const rules = await ensureRules(io, rt)
348 if (!rulesActive(rt)) return `Шар cursor-rules вимкнено: ${rt.disabled.rules}`
349 const seen = new Set(await io.read('seen'))
350 // main plus the most recent subagents (ids are ephemeral; a long session would add one column per subagent).
351 const subagents: string[] = []
352 for (const a of [...[...seen].map((k) => splitSeen(k).agent), ...Object.keys(await io.read('agentTiers').catch(() => ({})))]) {
353 if (a === 'main') continue
354 const i = subagents.indexOf(a)
355 if (i >= 0) subagents.splice(i, 1)
356 subagents.push(a)
357 }
358 const agents = new Set<string>(['main', ...subagents.slice(-REPORT_AGENTS_MAX)])
359 const gate = await readGate(io, rt)
360 const lines = [`**Правила** (${rules.length})`]
361 if (!rules.length) lines.push('- (немає: .cursor/rules порожній, itemSources без правил)')
362 for (const t of ['always', 'auto', 'agent', 'manual']) {
363 for (const r of rules.filter((x) => x.type === t)) {
364 const parts = [`\`${r.id}\``, TYPE_LABEL[r.type] ?? r.type]
365 if (r.globs.length || r.negGlobs.length) parts.push(`globs ${[...r.globs, ...r.negGlobs.map((g) => `!${g}`)].join(', ')}`)
366 if (r.source && r.source !== 'cursor-mdc') parts.push(`джерело ${r.source}`)
367 // Auto Attached rules follow their globs whatever the profile (autoHits): only the other types are gated.
368 if (!ruleOn(gate, r.id)) parts.push(r.type === 'auto' ? 'профіль вимикає, але Auto Attached доставляється за globs' : 'вимкнено профілем')
369 const delivered = [...agents].map((a) => `${a} — ${seen.has(`${a}:${r.id}`) ? 'так' : 'ні'}`).join(', ')
370 parts.push(r.type === 'agent' ? `доставлено: ${delivered} (Agent Requested: через skill cursor-*, \`context-gate sync\`)` : `доставлено: ${delivered}`)
371 lines.push(`- ${parts.join(' · ')}`)
372 }
373 }
374 const manual = rules.filter((r) => r.type === 'manual').map((r) => r.id)
375 if (manual.length) lines.push('', `Manual: @id або /rule <id> (${manual.join(', ')})`)
376 if (rt.rules?.diagnostics.length) lines.push('', `Діагностика правил: ${rt.rules.diagnostics.map((d) => `${d.code} ${d.path ?? ''}${d.line ? `:${d.line}` : ''}`).join(', ')}`)
377 return lines.join('\n')
378}
379
380const REPORT_AGENTS_MAX = 8
381
382type ReadLike = { offset?: unknown; limit?: unknown; pages?: unknown }
383
384function isPartial(e: ReadLike, r: unknown): boolean {
385 if ([e.offset, e.limit, e.pages].some((v) => v !== undefined && v !== null && v !== '')) return true
386 const res = (r as { result?: { type?: string; file?: { truncatedByTokenCap?: boolean } } }).result
387 return res?.type === 'file_unchanged' || res?.file?.truncatedByTokenCap === true
388}
389
390/** prompt.context, before `next`: reset dedup (re-delivery after compaction and /clear), re-read rules. The latest
391 * prompt's own deliveries stay: its context rides the very message this prompt.context precedes (L06). */
392export async function rulesContextBefore(io: Io, rt: Runtime): Promise<void> {
393 await ensureSession(io, rt)
394 const keep = [...(freshSeen.get(rt) ?? [])]
395 await io.update('seen', () => keep)
396 await ensureRules(io, rt, { force: true })
397}
398
399type ContextBlock = { name: string; text: string }
400type InstructionFile = { path: string; kind: 'managed' | 'user' | 'project' | 'local' | 'memory'; content: string; parent?: string }
401
402/** prompt.context, after `next`: Always rules as instruction files after CLAUDE.md (or a `cursorRules` block). */
403export async function rulesContextAfter<R extends { blocks: readonly ContextBlock[]; instructionFiles?: readonly InstructionFile[] }>(
404 io: Io, rt: Runtime, input: { instructionFiles?: readonly InstructionFile[] }, r: R,
405): Promise<R> {
406 const rules = rt.rules?.list ?? []
407 if (!rulesActive(rt) || !rules.length) return r
408 const gate = await readGate(io, rt)
409 const always = rules.filter((x) => x.type === 'always' && ruleOn(gate, x.id))
410 if (!always.length) return r
411 const packed = packInjections(always, maxChars(rt))
412 await markSeen(io, packed.included.map((id) => `main:${id}`))
413 await journalDelivered(io, rt, packed.included, 'main', 'prompt.context')
414 const files = r.instructionFiles ?? input.instructionFiles
415 if (files !== undefined) {
416 // File rules become instruction files; provider rules (no file) and pointer lines go in the block.
417 const included = always.filter((x) => packed.included.includes(x.id))
418 const added: InstructionFile[] = included.filter(isFileRule).map((x) => ({ path: join(rt.root, x.path), kind: 'project', content: x.body }))
419 const inBlock = included.filter((x) => !isFileRule(x))
420 const pointers = packed.deferred.length ? packInjections(always.filter((x) => packed.deferred.includes(x.id)), 0).text : ''
421 const text = [inBlock.length ? packInjections(inBlock, Number.MAX_SAFE_INTEGER).text : '', pointers].filter(Boolean).join('\n\n')
422 const blocks = text ? insertAfterClaudeMd(r.blocks, { name: 'cursorRules', text }) : r.blocks
423 return { ...r, blocks, instructionFiles: [...files, ...added] }
424 }
425 return { ...r, blocks: insertAfterClaudeMd(r.blocks, { name: 'cursorRules', text: packed.text }) }
426}
427
428/** Before a file tool runs: recent paths; strictWrite deny for a new file with an undelivered rule. */
429export async function rulesBeforeFile(io: Io, rt: Runtime, c: FileCall): Promise<{ deny: string } | undefined> {
430 await pushRecent(io, [c.rel])
431 if (!rulesActive(rt) || c.tool !== 'Write' || !rt.cfg.cursorRules?.strictWrite) return undefined
432 const exists = await io.fs.exists(c.file).catch(() => true)
433 if (exists) return undefined
434 const hits = await autoHits(io, rt, [c.rel], c.agent)
435 if (!hits.length) return undefined
436 const packed = packInjections(hits, maxChars(rt))
437 await markSeen(io, packed.included.map((id) => `${c.agent}:${id}`))
438 await journal(io, rt, { kind: 'deny', trigger: 'strictWrite', data: { rules: packed.included, path: c.rel } })
439 await journalDelivered(io, rt, packed.included, c.agent, 'strictWrite', { path: c.rel, tool: c.tool })
440 return { deny: `${packed.text}\n\nДо цього файлу діє правило Cursor, яке ще не було застосоване. Повтори запис з урахуванням правила.` }
441}
442
443/** After a successful file tool: Auto Attached rules as `context` after the result (per-agent dedup). */
444export async function rulesAfterFile<R extends ToolResultLike>(io: Io, rt: Runtime, c: FileCall, r: R): Promise<R> {
445 if (!rulesActive(rt) || r.deny !== undefined || r.isError) return r
446 // A full Read of the .mdc itself counts as delivering that rule; a partial or token-capped one does not.
447 if (c.tool === 'Read' && c.rel.endsWith('.mdc') && !isPartial(c.input as ReadLike, r)) {
448 const own = (await ensureRules(io, rt)).find((x) => x.path === c.rel)
449 if (own && !(await io.read('seen')).includes(`${c.agent}:${own.id}`)) {
450 await markSeen(io, [`${c.agent}:${own.id}`])
451 await journalDelivered(io, rt, [own.id], c.agent, 'read-mdc', { path: c.rel })
452 }
453 }
454 const hits = await autoHits(io, rt, [c.rel], c.agent)
455 if (!hits.length) return r
456 const packed = packInjections(hits, maxChars(rt))
457 await markSeen(io, packed.included.map((id) => `${c.agent}:${id}`))
458 await journalDelivered(io, rt, packed.included, c.agent, 'tool.call', { path: c.rel, tool: c.tool })
459 return { ...r, context: [...(r.context ?? []), packed.text] }
460}
461
462export function rulesFileChanged(rt: Runtime, path: string): void {
463 const p = path.replace(/\\/g, '/')
464 const dirs = rt.cfg ? [...cursorRuleDirs(rt.cfg).dirs, ...ruleSourcesOf(rt.cfg).filter((s) => s.kind === 'markdown-dir' && s.dir).map((s) => s.dir!)] : []
465 if (/\/\.cursor\/rules\//.test(p) || dirs.some((d) => p.includes(`/${trimDir(d)}/`) || p.startsWith(`${trimDir(d)}/`))) {
466 rt.rulesDirty = true
467 rt.itemsDirty = true
468 }
469}
470
471function insertAfterClaudeMd<B extends { name: string; text: string }>(blocks: readonly B[], block: B): B[] {
472 const out = [...blocks]
473 const i = out.findIndex((b) => b.name === 'claudeMd')
474 out.splice(i < 0 ? out.length : i + 1, 0, block)
475 return out
476}
477
478hooks/layers/skill-gate.ts 896 lines1// Layer 2: skill-gate (SPEC "Шар 2", MOD-ADAPTER "Layer 2"). Signals → decideGate (pure, core) → application.
2// Shadow mode (default): the gate is computed and journaled but NOT applied: nothing is filtered and the
3// band shows `gate (frontend?) …`. `/gate apply` (mode auto) applies it; manual `/gate <p>` and `off` apply
4// regardless of the mode, while `+g` / `-g` in shadow only edit the proposal. Application points: skill listing
5// rewrite, skill.prompt off text (dsl.ts), tool.describe + tool.call deny for MCP, agent.offer, preload section
6// (dsl.ts). Only a user prompt is a turn: other recomputes (`/gate`, model change, gate.json) move neither the
7// turn counter nor the hysteresis. MCP servers no group in gate.json mentions are never denied (O1).
8
9import type { Gate, GateConfig, GateState, Item, ItemDecision, Signals } from '../../packages/core/src/types.ts'
10import { briefRequest, classifyRequest, decideGate, denyText, parseBrief, parseClassify, profileParts, skillOffText } from '../../packages/core/src/decide.ts'
11import { modelForTier, normalizeConfig } from '../../packages/core/src/config.ts'
12import { parseDuration } from '../../packages/core/src/duration.ts'
13import { extractMentions, extractPromptFlag } from '../../packages/core/src/gatecmd.ts'
14import { groupsOf, isMcpTool, makeItem, mcpServerOf, mentionedInGroups, normalizeItems, parseSkillListing, renderSkillListing, skillListingItems } from '../../packages/core/src/items.ts'
15import { ruleToItem } from '../../packages/core/src/mdc.ts'
16import { evalSource, newBudget, regexTest, truthy } from '../../packages/core/src/expr.ts'
17import type { ContextGateDecision, ContextGateManual } from '../../types'
18import { hasManual, isApplied, json } from '../state.ts'
19import { type Io, OWN_TOOL_PREFIX, type Runtime, debug, hash, join, now } from '../ctx.ts'
20import { ensureSession, modelTier } from './config.ts'
21import { allowedBinary, configuredPromptDir, makeRenderHost, providerConfigs, providerData, runArgv } from './host.ts'
22import { repoKey, trustState } from './trust.ts'
23import { ensureRules, relPath, rulesForPrompt } from './cursor-rules.ts'
24import { journal, pushEntry } from './journal.ts'
25import { refreshStatus } from './ui.ts'
26
27// ───────────────────────── items ─────────────────────────
28
29/** Script tools (`# gate-tool:` in `<prompt>/scripts`) registered by dsl.ts, as items (G-35). */
30function scriptTools(rt: Runtime): { name: string; path: string; description: string; tiers?: string[] }[] {
31 const out: { name: string; path: string; description: string; tiers?: string[] }[] = []
32 for (const t of rt.tools.values()) if (t.kind === 'script') out.push(t.tool)
33 return out
34}
35
36/** `claude-tools` sources with `match` (a regex on the tool name) narrow which MCP tools become items. */
37function toolFilter(rt: Runtime): (name: string) => boolean {
38 const pats = (rt.config?.itemSources ?? []).filter((s) => s.kind === 'claude-tools' && s.match).map((s) => s.match!)
39 if (!pats.length) return () => true
40 // Repo patterns run on the core's linear-time engine (M51); an invalid or unsupported pattern is ignored.
41 const valid = pats.filter((p) => regexTest(p, '') !== null)
42 if (!valid.length) return () => false
43 const test = (p: string, name: string): boolean => { try { return regexTest(p, name, newBudget()) === true } catch { return false } }
44 return (name) => valid.some((p) => test(p, name))
45}
46
47const itemKeys = new WeakMap<Runtime, string>()
48
49export async function ensureItems(io: Io, rt: Runtime): Promise<Item[]> {
50 await ensureSession(io, rt)
51 const rules = await ensureRules(io, rt)
52 const scripts = scriptTools(rt)
53 const scriptKey = scripts.map((t) => t.name).sort().join(',')
54 if (rt.items && !rt.itemsDirty && itemKeys.get(rt) === scriptKey) return rt.items
55 const items: Item[] = []
56 if (rt.listingText) items.push(...skillListingItems(parseSkillListing(rt.listingText)))
57 if (rt.mcpTools === undefined) {
58 const list = await io.tool.list().catch(() => [])
59 rt.mcpTools = list.filter((t) => t.mcp && !t.name.startsWith(OWN_TOOL_PREFIX)).map((t) => t.name)
60 }
61 const keep = toolFilter(rt)
62 for (const name of rt.mcpTools) if (keep(name)) items.push(makeItem('tool', name, { provenance: { source: 'claude-tools' } }))
63 for (const t of scripts) items.push(makeItem('tool', t.name, { description: t.description, provenance: { source: 'gate-tool', path: t.path }, ...(t.tiers ? { tags: t.tiers.map((x) => `tier:${x}`) } : {}) }))
64 for (const name of rt.agentNames) items.push(makeItem('agent', name, { provenance: { source: 'claude-agents' } }))
65 for (const r of rules) items.push(ruleToItem(r))
66 rt.items = normalizeItems(items)
67 rt.itemsDirty = false
68 itemKeys.set(rt, scriptKey)
69 return rt.items
70}
71
72// ───────────────────────── per-agent gates (G-08) ─────────────────────────
73
74const agentGates = new WeakMap<Runtime, Map<string, { key: string; gate: ContextGateDecision }>>()
75
76/** The gate a subagent sees: the main decision's profile and manual groups on the agent's own tier
77 * (`agentTiers`, recorded on `turn.step`). Main loop, an unknown agent, a same-tier agent, shadow or off → the main gate. */
78export async function gateFor(io: Io, rt: Runtime, agentId: string | undefined): Promise<ContextGateDecision | null> {
79 // An invalid gate.json switches layer 2 off: a gate stored before that must not keep filtering (M18).
80 if (!rt.config) return null
81 const gate = await io.read('gate')
82 if (agentId === undefined || !isApplied(gate)) return gate
83 const tier = (await io.read('agentTiers'))[agentId]
84 if (!tier || tier === gate.tier) return gate
85 const items = await ensureItems(io, rt)
86 const manual = await io.read('manual')
87 const key = `${tier}|${gate.profile ?? ''}|${manual.add.join(',')}|${manual.remove.join(',')}|${effectiveItems(gate)}|${items.length}`
88 let cache = agentGates.get(rt)
89 if (!cache) { cache = new Map(); agentGates.set(rt, cache) }
90 const hit = cache.get(agentId)
91 if (hit && hit.key === key) return hit.gate
92 const signals: Signals = { paths: [], agentId }
93 if (gate.profile || manual.add.length || manual.remove.length) signals.manual = { add: manual.add, remove: manual.remove, ...(gate.profile ? { profile: gate.profile } : {}) }
94 const res = decideGate(autoConfig(rt.config), signals, { turn: 0 }, items, { tier })
95 const refined = refineGate(rt.config, items, res.gate).gate
96 const g: ContextGateDecision = json({ ...refined, trigger: gate.trigger, reason: [`субагент ${agentId}: tier ${tier} (основний цикл: ${gate.tier})`, ...refined.reason] })
97 cache.set(agentId, { key, gate: g })
98 return g
99}
100
101// ───────────────────────── mod refinements of the core decision ─────────────────────────
102
103/** `mcp__ide__*` (the editor bridge) is the session's own plumbing, never a repo's profile choice. */
104const IDE_TOOL = /^mcp__ide__/
105
106export function groupedConfig(cfg: GateConfig): GateConfig {
107 const legacy = !!(cfg.skillGroups || cfg.mcpGroups || Object.values(cfg.profiles ?? {}).some((p) => p.skills || p.mcp || p.agents) || Object.values(cfg.tiers ?? {}).some((t) => t.skills))
108 return legacy ? normalizeConfig(cfg).config : cfg
109}
110
111/**
112 * Two corrections over core `decideGate` for what the session really holds (O1, M10):
113 * - an MCP tool that no group of gate.json mentions (a personal server, a claude.ai connector, `mcp__ide__*`) is
114 * not the repo's to deny: it passes through, and the reason names the servers (an explicit group still governs);
115 * - a plugin skill `ns:name` that no group names in full follows the groups that name its bare `name`.
116 */
117export function refineGate(config: GateConfig, items: readonly Item[], gate: Gate): { gate: Gate; passthrough: string[] } {
118 if (gate.off) return { gate, passthrough: [] }
119 const cfg = groupedConfig(config)
120 if (!Object.keys(cfg.groups ?? {}).length) return { gate, passthrough: [] }
121 const active = new Set(gate.groups)
122 const decisions: Record<string, ItemDecision> = { ...gate.items }
123 const servers = new Set<string>()
124 let changed = false
125 for (const it of items) {
126 const d = decisions[it.id]
127 if (d === undefined) continue
128 if (isMcpTool(it) && (IDE_TOOL.test(it.name) || !mentionedInGroups(cfg, it))) {
129 // Core decideGate already passes these (O1); the mod still names them in the notice.
130 servers.add(mcpServerOf(it.name) ?? it.name)
131 if (d === 'off') { decisions[it.id] = 'on'; changed = true }
132 } else if (it.kind === 'skill' && it.name.includes(':') && (d === 'nameOnly' || d === 'off') && !mentionedInGroups(cfg, it)) {
133 const bare = { kind: 'skill' as const, name: it.name.replace(/^[^:]+:/, '') }
134 if (!mentionedInGroups(cfg, bare)) continue
135 const next: ItemDecision = groupsOf(cfg, bare).some((g) => active.has(g)) ? 'on' : 'off'
136 if (next !== d) { decisions[it.id] = next; changed = true }
137 }
138 }
139 if (!changed) return { gate, passthrough: [...servers].sort() }
140 const skills: Gate['skills'] = { on: [], nameOnly: [], off: [], preload: [] }
141 const mcp: Gate['mcp'] = { on: [], off: [] }
142 for (const it of items) {
143 const d = decisions[it.id]
144 if (d === undefined) continue
145 if (it.kind === 'skill') skills[d === 'preload' ? 'preload' : d === 'nameOnly' ? 'nameOnly' : d === 'off' ? 'off' : 'on'].push(it.name)
146 else if (isMcpTool(it)) mcp[d === 'off' ? 'off' : 'on'].push(it.name)
147 }
148 const passthrough = [...servers].sort()
149 const offServers = [...new Set(mcp.off.map((n) => mcpServerOf(n) ?? n))]
150 const reason = gate.reason.filter((r) => !r.startsWith('MCP поза профілем вимкнено:'))
151 if (offServers.length) reason.push(`MCP поза профілем вимкнено: ${offServers.join(', ')}`)
152 if (passthrough.length) reason.push(`MCP без групи в gate.json не фільтрується: ${passthrough.join(', ')}`)
153 return { gate: { ...gate, items: decisions, skills, mcp, reason }, passthrough }
154}
155
156const passthroughNoticed = new WeakMap<Runtime, string>()
157
158/** One toast per session and server set: the applied gate leaves these MCP servers alone (O1). */
159async function noticePassthrough(io: Io, rt: Runtime, servers: readonly string[]): Promise<void> {
160 const key = servers.join(',')
161 if (!key || passthroughNoticed.get(rt) === key) return
162 passthroughNoticed.set(rt, key)
163 await journal(io, rt, { kind: 'debug', trigger: 'mcp-passthrough', data: { servers: [...servers] } })
164 try { io.ui.toast(`context-gate: MCP без групи в gate.json не фільтруються: ${servers.join(', ')}. Щоб керувати ними профілем, додай їх у groups.`, { timeoutMs: 8000 }) } catch { /* no surface */ }
165}
166
167// ───────────────────────── signals ─────────────────────────
168
169/** Branch from `.git/HEAD` (a worktree's `.git` file → its gitdir), host git as the fallback. */
170export async function readBranch(io: Io, rt: Runtime): Promise<string | undefined> {
171 const repo = await io.session.repo().catch(() => null)
172 const root = repo?.root ?? rt.root
173 const head = async (gitDir: string): Promise<string | undefined> => {
174 const t = await io.fs.read(`${gitDir}/HEAD`).catch(() => undefined)
175 if (typeof t !== 'string') return undefined
176 const m = /^ref:\s*refs\/heads\/(.+)$/m.exec(t)
177 return m ? m[1].trim() : undefined
178 }
179 let b = await head(join(root, '.git'))
180 if (b === undefined) {
181 const dotgit = await io.fs.read(join(root, '.git')).catch(() => undefined)
182 const m = typeof dotgit === 'string' ? /^gitdir:\s*(.+)$/m.exec(dotgit) : null
183 if (m) b = await head(join(root, m[1].trim()))
184 }
185 return b
186}
187
188export function effectiveMode(rt: Runtime, manual: ContextGateManual): 'shadow' | 'auto' {
189 if (manual.mode) return manual.mode
190 if (rt.options.mode === 'auto') return 'auto'
191 return rt.config?.classify?.mode === 'auto' ? 'auto' : 'shadow'
192}
193
194function autoConfig(cfg: GateConfig): GateConfig {
195 return { ...cfg, classify: { minConfidence: 0.7, ...cfg.classify, mode: 'auto' } }
196}
197
198function evalWhen(expr: string, data: Record<string, unknown>): boolean {
199 return truthy(evalSource(expr, data as never, newBudget()))
200}
201
202function manualSignal(m: ContextGateManual): Signals['manual'] | undefined {
203 if (!hasManual(m)) return undefined
204 const out: NonNullable<Signals['manual']> = { add: m.add, remove: m.remove }
205 if (m.profile !== undefined) out.profile = m.profile
206 if (m.off) out.off = true
207 return out
208}
209
210function effectiveItems(g: ContextGateDecision | null): string {
211 if (!isApplied(g)) return ''
212 return Object.keys(g.items).sort().map((k) => `${k}=${g.items[k]}`).join(',')
213}
214
215/** Session env the shiftwork runner sets (`CONTEXT_GATE_PROFILE`, `CONTEXT_GATE_TICKET_TYPE`, `CONTEXT_GATE_TICKET`).
216 * `$.env.get` takes literal names only, so the port reads them; a port without them yields nothing. */
217type PlanEnv = { planProfile?(): Promise<string | undefined>; ticketType?(): Promise<string | undefined>; ticket?(): Promise<string | undefined> }
218
219const planEnvs = new WeakMap<Runtime, { profile?: string; ticketType?: string; ticket?: string }>()
220
221async function planEnv(io: Io, rt: Runtime): Promise<{ profile?: string; ticketType?: string; ticket?: string }> {
222 const hit = planEnvs.get(rt)
223 if (hit) return hit
224 const env = io.env as Io['env'] & PlanEnv
225 const get = async (f: (() => Promise<string | undefined>) | undefined): Promise<string | undefined> => {
226 const v = f ? await f.call(env).catch(() => undefined) : undefined
227 return typeof v === 'string' && v.trim() ? v.trim() : undefined
228 }
229 const out: { profile?: string; ticketType?: string; ticket?: string } = {}
230 const profile = await get(env.planProfile)
231 const ticketType = await get(env.ticketType)
232 const ticket = await get(env.ticket)
233 if (profile) out.profile = profile
234 if (ticketType) out.ticketType = ticketType
235 if (ticket) out.ticket = ticket
236 planEnvs.set(rt, out)
237 return out
238}
239
240/** The runner's planned profile, when gate.json declares it: a manual signal for this session (not persisted). */
241async function planProfile(io: Io, rt: Runtime, cfg: GateConfig): Promise<string | undefined> {
242 const p = (await planEnv(io, rt)).profile
243 return p && profileParts(p, cfg).every((x) => Object.prototype.hasOwnProperty.call(cfg.profiles ?? {}, x)) ? p : undefined
244}
245
246/** Data a `when.expr` reads (SPEC «Сигнали профілю»): `git.branch`, `session`, `tier` and the providers, as the
247 * render scope has them. Only computed when some profile has an `expr`. */
248/** Provider data of one prompt: the dry `when` decision and the recompute right after it read the same snapshot,
249 * so the providers run once per prompt, not twice. */
250const WHEN_DATA_TTL_MS = 3000
251const whenDataMemo = new WeakMap<Runtime, { key: string; at: number; data: Record<string, unknown> }>()
252
253async function whenData(io: Io, rt: Runtime, cfg: GateConfig, branch: string | undefined, model: string | undefined, tier: string | null): Promise<Record<string, unknown> | undefined> {
254 if (!Object.values(cfg.profiles ?? {}).some((p) => p.when?.expr)) return undefined
255 const key = JSON.stringify([rt.root, branch ?? '', model ?? '', tier ?? ''])
256 const memo = whenDataMemo.get(rt)
257 if (memo && memo.key === key && now() - memo.at < WHEN_DATA_TTL_MS) return memo.data
258 const data: Record<string, unknown> = { git: { branch: branch ?? '' }, session: { model: model ?? '', root: rt.root, interactive: rt.interactive }, tier: tier ?? 'standard' }
259 try {
260 const trusted = (await trustState(io, rt).catch(() => 'unknown')) === 'trusted'
261 // The same providers as the render scope: gate.json's plus the `<prompt dir>/lib` modules (M11, M19).
262 const dir = configuredPromptDir(rt.cfg)
263 const providers = await providerConfigs(io, rt, dir)
264 const host = makeRenderHost(io, rt, { trusted, repoKey: await repoKey(io, rt), itemBody: async () => undefined, rules: async () => rt.rules?.list ?? [], promptDir: dir, providers })
265 for (const [k, v] of Object.entries(await providerData(io, rt, host))) if (!(k in data)) data[k] = v
266 } catch (err) {
267 debug(io, `when.expr data: ${String((err as Error)?.message ?? err)}`)
268 }
269 whenDataMemo.set(rt, { key, at: now(), data })
270 return data
271}
272
273/** Applied: mode auto, or a profile / off the user (or the runner's plan) fixed. `+g`/`-g` alone keep shadow (M20). */
274function appliedBy(rt: Runtime, manual: ContextGateManual, plan: string | undefined): boolean {
275 return effectiveMode(rt, manual) === 'auto' || manual.profile !== undefined || manual.off === true || plan !== undefined
276}
277
278export interface RecomputeOptions {
279 classified?: { profile: string; confidence: number }
280 recheck?: boolean
281 recheckReason?: 'new' | 'compact' | 'auto'
282 prevModel?: string
283 /** A user turn (default: trigger `prompt`): moves `turn` and the hysteresis. Others re-derive in place (M21). */
284 advance?: boolean
285 /** Decide as a turn (the classifier may commit on the first prompt) but keep the stored turn: the late answer
286 * of a classifier that ran past the prompt (shadow, P5). */
287 keepTurn?: boolean
288}
289
290/** Recomputes run one at a time: a background classifier answer must not interleave with a prompt's. */
291const recomputing = new WeakMap<Runtime, Promise<unknown>>()
292
293/** The one place that calls decideGate. Returns the stored gate (null when layer 2 is off). */
294export function recompute(io: Io, rt: Runtime, trigger: string, opts: RecomputeOptions = {}): Promise<ContextGateDecision | null> {
295 const prev = recomputing.get(rt) ?? Promise.resolve()
296 const run = prev.then(() => recomputeNow(io, rt, trigger, opts), () => recomputeNow(io, rt, trigger, opts))
297 recomputing.set(rt, run.catch(() => undefined))
298 return run
299}
300
301async function recomputeNow(io: Io, rt: Runtime, trigger: string, opts: RecomputeOptions): Promise<ContextGateDecision | null> {
302 await ensureSession(io, rt)
303 const cfg = rt.config
304 if (!cfg) {
305 // gate.json turned invalid: drop the stored decision, so nothing keeps filtering on it (M18).
306 const stale = await io.read('gate')
307 if (stale) {
308 await io.update('gate', () => null)
309 if (isApplied(stale)) {
310 io.ui.invalidate('prompt.attachment')
311 io.ui.invalidate('tool.describe')
312 }
313 }
314 await refreshStatus(io, rt)
315 return null
316 }
317 const items = await ensureItems(io, rt)
318 const manual = await io.read('manual')
319 const model = (await io.read('model')) ?? undefined
320 const signals: Signals = { paths: await io.read('recentPaths'), model }
321 const plan = await planProfile(io, rt, cfg)
322 const ms = manualSignal(manual)
323 if (ms) signals.manual = ms
324 else if (plan) signals.manual = { profile: plan, add: [], remove: [] }
325 const env = await planEnv(io, rt)
326 if (env.ticketType) signals.ticketType = env.ticketType
327 if (env.ticket) signals.ticketId = env.ticket
328 const branch = await readBranch(io, rt)
329 if (branch) signals.branch = branch
330 if (opts.classified) signals.classified = opts.classified
331 const data = await whenData(io, rt, cfg, branch, model, await io.read('tier'))
332 if (data) signals.data = data
333 const state = (await io.read('gateState')) as GateState
334 const advance = opts.advance ?? trigger === 'prompt'
335 // G-01: the harness's context window infers the tier of a model no `models` entry names (M22).
336 const cw = model ? await io.session.usage().then((u) => u.context.window, () => undefined) : undefined
337 const res = decideGate(autoConfig(cfg), signals, state, items, {
338 recheck: opts.recheck, recheckReason: opts.recheckReason, prevModel: opts.prevModel, evalExpr: evalWhen, now: now(),
339 advance, nocase: rt.windows, ...(cw ? { modelAttrs: { contextWindow: cw } } : {}),
340 })
341 const refined = refineGate(cfg, items, res.gate)
342 const applied = appliedBy(rt, manual, plan)
343 let gate: ContextGateDecision = refined.gate
344 const log = { ...res.log, kind: 'decision', data: { ...(res.log.data ?? {}), source: trigger } as Record<string, unknown> }
345 if (opts.keepTurn) log.turn = state.turn
346 if (refined.passthrough.length) log.data = { ...log.data, passthrough: refined.passthrough }
347 if (!applied) {
348 const proposedProfile = res.gate.profile ?? opts.classified?.profile
349 const confidence = res.gate.trigger === 'classify' || !res.gate.profile ? (opts.classified?.confidence ?? 0) : 1
350 const { profile: _drop, ...rest } = refined.gate
351 gate = { ...rest, shadow: true, reason: [...refined.gate.reason, 'shadow: рішення лише в журнал, нічого не фільтрується (/gate apply)'] }
352 if (proposedProfile) gate.proposed = { profile: proposedProfile, confidence }
353 delete log.profile
354 log.data = { ...log.data, shadow: true, ...(gate.proposed ? { proposed: gate.proposed } : {}) }
355 }
356 log.reason = gate.reason
357 const prev = await io.read('gate')
358 const nextState: GateState = opts.keepTurn ? { ...res.state, turn: state.turn } : res.state
359 await io.update('gate', () => json(gate))
360 await io.update('gateState', () => json(nextState))
361 await io.update('tier', () => res.gate.tier)
362 await pushEntry(io, rt, json(log))
363 if (effectiveItems(prev) !== effectiveItems(gate)) {
364 io.ui.invalidate('prompt.attachment')
365 io.ui.invalidate('tool.describe')
366 }
367 // Always rules ride prompt.context, computed once per conversation: re-read it only when the gate turns one of
368 // them on or off, which costs the cached first message (M12).
369 if (alwaysOff(rt, prev) !== alwaysOff(rt, gate)) io.ui.invalidate('prompt.context')
370 if (applied) await noticePassthrough(io, rt, refined.passthrough)
371 await refreshStatus(io, rt)
372 return gate
373}
374
375/** Always rules the gate switches off, as a comparable key. */
376function alwaysOff(rt: Runtime, g: ContextGateDecision | null): string {
377 if (!isApplied(g)) return ''
378 return (rt.rules?.list ?? []).filter((r) => r.type === 'always' && g.items[`rule:${r.id}`] === 'off').map((r) => r.id).sort().join(',')
379}
380
381/** Re-derive per-item decisions for the current profile after the item set grew (no hysteresis step, no log). */
382async function materialize(io: Io, rt: Runtime): Promise<void> {
383 const gate = await io.read('gate')
384 if (!gate || !rt.config || gate.off) return
385 const items = await ensureItems(io, rt)
386 const manual = await io.read('manual')
387 const profile = gate.profile ?? (gate.shadow ? gate.proposed?.profile : undefined)
388 const signals: Signals = { paths: [], model: (await io.read('model')) ?? undefined }
389 if (profile || manual.add.length || manual.remove.length) signals.manual = { add: manual.add, remove: manual.remove, ...(profile ? { profile } : {}) }
390 // The stored tier stands (it may come from the context window, G-01), not the model id's fallback.
391 const res = decideGate(autoConfig(rt.config), signals, { turn: 0 }, items, { tier: gate.tier, nocase: rt.windows })
392 const d = refineGate(rt.config, items, res.gate).gate
393 const next: ContextGateDecision = { ...gate, items: d.items, skills: d.skills, mcp: d.mcp, agents: d.agents, rules: d.rules }
394 await io.update('gate', () => json(next))
395}
396
397// ───────────────────────── classifier, brief ─────────────────────────
398
399export { parseClassify }
400
401/** A `{ kind: 'cli' }` classify/brief provider: trusted repo + whitelisted binary, JSON on stdin (G-02). */
402async function runProvider(io: Io, rt: Runtime, what: string, p: { command: string[]; timeout?: string }, stdin: string, defaultMs: number): Promise<string | undefined> {
403 if (!p.command.length) return undefined
404 if ((await trustState(io, rt).catch(() => 'unknown')) !== 'trusted') {
405 debug(io, `${what} provider ${p.command[0]}: репозиторій не довірений — пропущено`)
406 return undefined
407 }
408 if (!(await allowedBinary(io, rt, p.command))) {
409 debug(io, `${what} provider ${p.command[0]}: бінарник поза білим списком (G201)`)
410 return undefined
411 }
412 const r = await runArgv(io, rt, p.command, { stdin, timeoutMs: parseDuration(p.timeout) ?? defaultMs })
413 if (r.exitCode !== 0) {
414 debug(io, `${what} provider ${p.command[0]}: exit ${r.exitCode} (G203) ${r.stderr.slice(0, 200)}`)
415 await journal(io, rt, { kind: 'debug', trigger: `${what}-provider`, data: { code: 'G203', exitCode: r.exitCode, command: p.command[0] } })
416 return undefined
417 }
418 return r.stdout
419}
420
421function classifySystem(cfg: GateConfig): string {
422 const lines = Object.entries(cfg.profiles).map(([name, p]) => `- ${name}${p.groups?.length ? ` (групи: ${p.groups.join(', ')})` : ''}`)
423 return [
424 'Ти класифікатор задач для розробника. Обери один профіль задачі з переліку:',
425 ...lines,
426 'Відповідай лише одним рядком JSON без пояснень: {"profile": "<назва з переліку>", "confidence": <число від 0 до 1>}.',
427 ].join('\n')
428}
429
430const CLASSIFY_MS = 8000
431/** How long a shadow-mode prompt waits for the classifier or the brief before it goes on without them (P5). */
432export const SHADOW_GRACE_MS = 1500
433
434/** A promise and whether it settled, for a wait that may give up before it does. */
435interface Tracked<T> { done: boolean; value?: T; promise: Promise<T | undefined> }
436
437function track<T>(p: Promise<T>): Tracked<T> {
438 const t: Tracked<T> = { done: false, promise: Promise.resolve(undefined) }
439 t.promise = p.then((v) => { t.done = true; t.value = v; return v }, () => { t.done = true; return undefined })
440 return t
441}
442
443/** `p`, or undefined once `ms` passed on the session clock. A clock that fires early (a test kit) or no clock at
444 * all leaves `p` to decide. */
445function within<T>(io: Io, p: Promise<T>, ms: number): Promise<T | undefined> {
446 const started = now()
447 return new Promise((resolve) => {
448 let done = false
449 const finish = (v: T | undefined): void => { if (!done) { done = true; resolve(v) } }
450 p.then(finish, () => finish(undefined))
451 try { io.clock.after(ms, () => { if (now() - started >= ms - 50) finish(undefined) }) } catch { /* no clock: wait for p */ }
452 })
453}
454
455/** What a classifier or brief call cost (P5): `report` and `/gate why` count `data.usage` per tier. */
456async function journalModelCall(io: Io, rt: Runtime, what: 'classify' | 'brief', model: string, r: unknown, ms: number): Promise<void> {
457 const v = r && typeof r === 'object' ? (r as { isAnswered?: unknown; usage?: unknown }) : undefined
458 await journal(io, rt, { kind: 'model-call', trigger: what, tier: (await io.read('tier')) ?? '', data: { model, ms, answered: v?.isAnswered === true, ...(v?.usage && typeof v.usage === 'object' ? { usage: v.usage as Record<string, unknown> } : {}) } })
459}
460
461/** Classifier (G-02 `classify.provider`): `builtin` (default) asks `io.model.complete` for JSON, falling back
462 * to `io.model.classify` (label only → below minConfidence, never auto-applies); `jev` is the engine's label
463 * classifier on its own (the user chose it, so its label counts as minConfidence); `{ kind: 'cli' }` runs the
464 * command with `classifyRequest` JSON on stdin and reads `{ profile, confidence }` (trusted repos only).
465 * Every engine call is bounded by 8 s and journaled with its usage. */
466export async function classify(io: Io, rt: Runtime, text: string, paths: string[]): Promise<{ profile: string; confidence: number } | undefined> {
467 const cfg = rt.config
468 if (!cfg) return undefined
469 const profiles = Object.keys(cfg.profiles)
470 if (!profiles.length) return undefined
471 const prompt = `${text.slice(0, 4000)}${paths.length ? `\n\nФайли: ${paths.slice(-20).join(', ')}` : ''}`
472 const model = cfg.classify?.model ?? 'haiku'
473 const provider = cfg.classify?.provider ?? 'builtin'
474 const minConf = cfg.classify?.minConfidence ?? 0.7
475 if (typeof provider === 'object') {
476 const out = await runProvider(io, rt, 'classify', provider, classifyRequest(cfg, text, paths, (await io.read('model')) ?? undefined), CLASSIFY_MS)
477 return out === undefined ? undefined : parseClassify(out, profiles)
478 }
479 const label = async (): Promise<string | undefined> => {
480 const t0 = now()
481 const l = await within(io, io.model.classify(prompt, profiles, { model }), CLASSIFY_MS)
482 await journalModelCall(io, rt, 'classify', model, undefined, now() - t0)
483 return l ?? undefined
484 }
485 if (provider === 'jev') {
486 try {
487 const l = await label()
488 return l && profiles.includes(l) ? { profile: l, confidence: minConf } : undefined
489 } catch (err) {
490 debug(io, `jev classifier failed: ${String((err as Error)?.message ?? err)}`)
491 return undefined
492 }
493 }
494 try {
495 const t0 = now()
496 const r = await io.model.complete({ model, system: classifySystem(cfg), prompt, maxTokens: 64, timeoutMs: CLASSIFY_MS })
497 await journalModelCall(io, rt, 'classify', model, r, now() - t0)
498 if (r.isAnswered) {
499 const parsed = parseClassify(r.text, profiles)
500 if (parsed) return parsed
501 }
502 } catch (err) {
503 debug(io, `classifier complete failed: ${String((err as Error)?.message ?? err)}`)
504 }
505 try {
506 const l = await label()
507 if (l && profiles.includes(l)) return { profile: l, confidence: Math.max(0, minConf - 0.01) }
508 } catch (err) {
509 debug(io, `classifier fallback failed: ${String((err as Error)?.message ?? err)}`)
510 }
511 return undefined
512}
513
514const BRIEF_SYSTEM = [
515 'Ти досвідчений інженер. Напиши бриф задачі для слабшої моделі, яка її виконуватиме.',
516 'Розділи Markdown: Мета; Обмеження; Релевантні файли; Кроки; Критерії прийняття; Відомі пастки.',
517 'Стисло й конкретно, без вступу.',
518].join('\n')
519
520/** Task brief on a strong model for non-premium tiers, cached in state by text hash. `brief.provider`
521 * `{ kind: 'cli' }` (G-02) writes it with an external command instead (`briefRequest` JSON on stdin). */
522async function brief(io: Io, rt: Runtime, text: string, tier: string): Promise<string | undefined> {
523 const b = rt.config?.brief
524 const enabled = b?.enabled === true || rt.options.brief
525 if (!enabled || tier === 'premium') return undefined
526 const tiers = b?.tiers ?? ['quick', 'standard']
527 if (!tiers.includes(tier)) return undefined
528 const key = hash(text)
529 const cached = await io.read('brief')
530 if (cached?.key === key) return cached.text
531 const maxChars = b?.maxChars ?? 2000
532 if (b?.provider && typeof b.provider === 'object') {
533 const stdout = await runProvider(io, rt, 'brief', b.provider, briefRequest(text, tier, maxChars, await io.read('recentPaths'), (await io.read('model')) ?? undefined), 60_000)
534 const out = stdout === undefined ? undefined : parseBrief(stdout, maxChars)
535 if (!out) return undefined
536 await io.update('brief', () => ({ key, text: out, at: now() }))
537 await journal(io, rt, { kind: 'debug', trigger: 'brief', tier, data: { chars: out.length, provider: 'cli' } })
538 return out
539 }
540 try {
541 const model = b?.model ?? 'opus'
542 const t0 = now()
543 const r = await io.model.complete({ model, system: BRIEF_SYSTEM, prompt: text.slice(0, 8000), maxTokens: Math.ceil(maxChars / 2), timeoutMs: 30000 })
544 await journalModelCall(io, rt, 'brief', model, r, now() - t0)
545 if (!r.isAnswered) return undefined
546 const out = r.text.trim().slice(0, maxChars)
547 await io.update('brief', () => ({ key, text: out, at: now() }))
548 await journal(io, rt, { kind: 'debug', trigger: 'brief', tier, data: { chars: out.length } })
549 return out
550 } catch (err) {
551 debug(io, `brief failed: ${String((err as Error)?.message ?? err)}`)
552 return undefined
553 }
554}
555
556function briefBlock(rt: Runtime, text: string): string {
557 return `Бриф задачі (context-gate, ${rt.config?.brief?.model ?? 'opus'}):\n${text}`
558}
559
560// ───────────────────────── escalation ─────────────────────────
561
562function modelHint(cfg: GateConfig, tier: string): string {
563 const glob = modelForTier(cfg, tier)
564 return glob ? glob.replace(/\*/g, '').replace(/^-+|-+$/g, '').replace(/^claude-/, '') : tier
565}
566
567/** escalation-suggested once per tier and task when verifyFailed / stallTurns cross `escalation.after`. */
568export async function checkEscalation(io: Io, rt: Runtime): Promise<void> {
569 const esc = rt.config?.escalation
570 if (!esc) return
571 const tier = (await io.read('tier')) ?? 'standard'
572 const i = esc.order.indexOf(tier)
573 if (i < 0 || i >= esc.order.length - 1 || rt.escalated.has(tier)) return
574 const vf = esc.after.verifyFailed
575 const st = esc.after.stallTurns
576 let why: string | undefined
577 if (vf !== undefined && rt.verifyFailed >= vf) why = `${rt.verifyFailed} невдалі перевірки на ${tier}`
578 else if (st !== undefined && rt.stallTurns >= st) why = `${rt.stallTurns} ходів без змін на ${tier}`
579 if (!why) return
580 rt.escalated.add(tier)
581 const next = esc.order[i + 1]
582 const text = `${why} — перейди на ${next}: /model ${modelHint(rt.config!, next)}`
583 await journal(io, rt, { kind: 'escalation-suggested', trigger: 'escalation', tier, data: { to: next, verifyFailed: rt.verifyFailed, stallTurns: rt.stallTurns } })
584 try { io.ui.toast(`context-gate: ${text}`, { timeoutMs: 10000 }) } catch { /* no surface */ }
585}
586
587function resetTask(rt: Runtime): void {
588 rt.verifyFailed = 0
589 rt.stallTurns = 0
590 rt.escalated.clear()
591}
592
593// ───────────────────────── hooks ─────────────────────────
594
595function asGate(g: ContextGateDecision): Gate {
596 return g as unknown as Gate
597}
598
599export function skillOff(gate: ContextGateDecision | null, name: string): boolean {
600 if (!isApplied(gate)) return false
601 const d = gate.items[`skill:${name}`] ?? gate.items[`skill:${name.replace(/^[^:]+:/, '')}`]
602 return d === 'off'
603}
604
605export async function skillOffMessage(io: Io, rt: Runtime, name: string, agentId?: string): Promise<string | undefined> {
606 const gate = await gateFor(io, rt, agentId)
607 if (!rt.config || !skillOff(gate, name)) return undefined
608 return skillOffText(name, asGate(gate!), rt.config)
609}
610
611/** Layer 3's per-prompt block (`prompt.volatile: "context"`, P1). Set by dsl.ts, which imports this module (a
612 * direct import back would make the two modules a cycle). */
613let promptContextSource: ((io: Io, rt: Runtime) => Promise<string | undefined>) | undefined
614
615export function setPromptContextSource(fn: (io: Io, rt: Runtime) => Promise<string | undefined>): void {
616 promptContextSource = fn
617}
618
619async function layer3Context(io: Io, rt: Runtime): Promise<string[]> {
620 const t = await promptContextSource?.(io, rt).catch(() => undefined)
621 return t ? [t] : []
622}
623
624/** A brief that ran past a shadow-mode prompt: injected into the next prompt once it is ready (P5). */
625const lateBriefs = new WeakMap<Runtime, Tracked<string | undefined>>()
626
627/**
628 * `[gate:off]`, `[gate:auto]`, `[gate:new]` are the `/gate` commands, as in the other adapters (M23); any other
629 * word is a profile, applied only when gate.json declares it (an unknown one is G502, ignored). Returns a notice.
630 */
631async function applyPromptFlag(io: Io, rt: Runtime, word: string): Promise<string | undefined> {
632 if (word === 'off') {
633 await io.update('manual', (m) => json({ ...m, off: true }))
634 return undefined
635 }
636 if (word === 'auto') {
637 await io.update('manual', (m) => json({ add: [], remove: [], ...(m.mode ? { mode: m.mode } : {}), recheck: true }))
638 rt.recheckReason = 'auto'
639 return undefined
640 }
641 if (word === 'new') {
642 await io.update('manual', (m) => json({ ...m, recheck: true }))
643 rt.recheckReason = 'new'
644 return undefined
645 }
646 const cfg = rt.config
647 if (!cfg) return undefined
648 const declared = (x: string): boolean => Object.prototype.hasOwnProperty.call(cfg.profiles ?? {}, x)
649 if (!profileParts(word, cfg).every(declared)) {
650 const known = Object.keys(cfg.profiles ?? {}).join(', ') || '—'
651 await journal(io, rt, { kind: 'debug', trigger: 'prompt-flag', data: { code: 'G502', profile: word } })
652 return `G502 [gate:${word}]: профіль не оголошено в gate.json, прапорець проігноровано. Відомі: ${known}`
653 }
654 await io.update('manual', (m) => json({ ...m, profile: word, off: undefined }))
655 return undefined
656}
657
658/** prompt.submit for layers 1 and 2: `[gate:x]` flag, `@` mentions, signals, classifier, brief.
659 * Context is attached on the way down by the caller. A new task (the first prompt, `/gate new`, `[gate:new]`)
660 * resets the escalation counters and gets the brief; a compaction only re-derives the profile (M24). In shadow
661 * mode the classifier and the brief wait at most SHADOW_GRACE_MS: a late classifier answer lands as its own
662 * decision, a late brief rides the next prompt (P5). */
663export async function gatePromptSubmit(io: Io, rt: Runtime, input: { text: string }): Promise<{ text: string; context: string[]; mentioned: string[] }> {
664 await ensureSession(io, rt)
665 let text = input.text
666 const flag = extractPromptFlag(text)
667 if (flag.profile) {
668 text = flag.text
669 const notice = await applyPromptFlag(io, rt, flag.profile)
670 if (notice) try { io.ui.toast(`context-gate: ${notice}`, { timeoutMs: 8000 }) } catch { /* no surface */ }
671 }
672 const context: string[] = []
673 const mentioned: string[] = []
674 if (text.trimStart().startsWith('/')) return { text, context, mentioned }
675 const mentions = extractMentions(text)
676 mentioned.push(...mentions.files.map((f) => relPath(rt, f)))
677 context.push(...(await rulesForPrompt(io, rt, mentions.files, mentions.rules)))
678 if (!rt.config) return { text, context: [...context, ...(await layer3Context(io, rt))], mentioned }
679 const gs = await io.read('gateState')
680 const manual = await io.read('manual')
681 const why = manual.recheck === true ? (rt.recheckReason ?? 'new') : undefined
682 const newTask = gs.turn === 0 || (why !== undefined && why !== 'compact')
683 const reclassify = gs.turn === 0 || why !== undefined
684 const late = lateBriefs.get(rt)
685 if (late && (late.done || newTask)) {
686 lateBriefs.delete(rt)
687 if (!newTask && late.value) context.push(briefBlock(rt, late.value))
688 }
689 const plan = await planProfile(io, rt, rt.config)
690 const shadow = !appliedBy(rt, manual, plan)
691 let classified: RecomputeOptions['classified']
692 let lateClassify: Tracked<RecomputeOptions['classified']> | undefined
693 if (newTask) resetTask(rt)
694 if (reclassify && !manual.profile && !manual.off && !plan && Object.keys(rt.config.profiles).length) {
695 // A deterministic `when` match decides without the classifier.
696 const items = await ensureItems(io, rt)
697 const model = (await io.read('model')) ?? undefined
698 const branch = await readBranch(io, rt)
699 const signals: Signals = { paths: await io.read('recentPaths'), model, branch }
700 const env = await planEnv(io, rt)
701 if (env.ticketType) signals.ticketType = env.ticketType
702 const data = await whenData(io, rt, rt.config, branch, model, await io.read('tier'))
703 if (data) signals.data = data
704 const dry = decideGate(autoConfig(rt.config), signals, gs as GateState, items, { evalExpr: evalWhen, recheck: manual.recheck, nocase: rt.windows })
705 if (!dry.gate.trigger.startsWith('when:')) {
706 const call = classify(io, rt, text, await io.read('recentPaths'))
707 if (!shadow) classified = await call
708 else {
709 const t = track(call)
710 await within(io, t.promise, SHADOW_GRACE_MS)
711 if (t.done) classified = t.value
712 else lateClassify = t
713 }
714 }
715 }
716 const recheck = manual.recheck === true && gs.turn > 0
717 const gate = await recompute(io, rt, 'prompt', { classified, recheck, recheckReason: recheck ? why : undefined })
718 if (manual.recheck) {
719 await io.update('manual', (m) => json({ ...m, recheck: undefined }))
720 rt.recheckReason = undefined
721 }
722 if (lateClassify) {
723 // The answer lands as its own decision of this turn (the proposal, or the profile of a fresh task). Decided as
724 // that turn (advance) only while it is still the current one: after a later prompt it would count one more
725 // hysteresis turn for a pending `when` candidate (M21), and that prompt already decided without it.
726 const turnAt = ((await io.read('gateState')) as GateState).turn
727 void lateClassify.promise.then(async (c) => {
728 if (!c) return null
729 if (((await io.read('gateState')) as GateState).turn !== turnAt) {
730 debug(io, 'late classifier: відповідь прийшла після наступного промпту — відкинуто')
731 return null
732 }
733 return recompute(io, rt, 'classify', { classified: c, advance: true, keepTurn: true })
734 })
735 .catch((err: unknown) => debug(io, `late classifier: ${String((err as Error)?.message ?? err)}`))
736 }
737 if (newTask && gate) {
738 const call = brief(io, rt, text, gate.tier)
739 let b: string | undefined
740 if (!gate.shadow) b = await call
741 else {
742 const t = track(call)
743 await within(io, t.promise, SHADOW_GRACE_MS)
744 if (t.done) b = t.value
745 else lateBriefs.set(rt, t)
746 }
747 if (b) context.push(briefBlock(rt, b))
748 }
749 context.push(...(await layer3Context(io, rt)))
750 return { text, context, mentioned }
751}
752
753/** MCP tool outside the applied profile → the deny text (counted for H010). */
754export async function mcpGate(io: Io, rt: Runtime, tool: string, agentId?: string): Promise<string | undefined> {
755 await ensureSession(io, rt)
756 const gate = await gateFor(io, rt, agentId)
757 if (!rt.config || !isApplied(gate) || gate.items[`tool:${tool}`] !== 'off') return undefined
758 rt.denies[tool] = (rt.denies[tool] ?? 0) + 1
759 await journal(io, rt, { kind: 'deny', trigger: 'mcp', tier: gate.tier, data: { tool, count: rt.denies[tool], ...(agentId !== undefined ? { agent: agentId } : {}) } })
760 return denyText('tool', tool, asGate(gate), rt.config)
761}
762
763/** prompt.attachment {skill_listing}, after `next`: capture the listing (item source), rewrite it for the applied gate. */
764export async function listingAfter(io: Io, rt: Runtime, agentId: string | undefined, text: string | null): Promise<string | null> {
765 await ensureSession(io, rt)
766 if (text === null) return null
767 const listing = parseSkillListing(text)
768 const names = listing.lines.filter((l) => l.type === 'skill').map((l) => (l as { name: string }).name)
769 if (agentId === undefined) {
770 rt.listingText = text
771 const key = names.slice().sort().join(',')
772 if (key !== rt.listingNames) {
773 rt.listingNames = key
774 rt.itemsDirty = true
775 await materialize(io, rt)
776 }
777 }
778 const gate = await gateFor(io, rt, agentId)
779 if (!isApplied(gate)) return text
780 if (!names.length) {
781 if (!rt.unknownListingLogged) {
782 rt.unknownListingLogged = true
783 await journal(io, rt, { kind: 'health', trigger: 'H009', data: { note: 'формат skill_listing не розпізнано' } })
784 }
785 return text
786 }
787 return renderSkillListing(listing, gate.items as Record<string, ItemDecision>)
788}
789
790/** tool.describe for an MCP tool: one line «вимкнено профілем …» and deferred when gated off. */
791export async function describeMcp(io: Io, rt: Runtime, tool: string): Promise<{ description: string; isDeferred: true } | undefined> {
792 if (tool.startsWith(OWN_TOOL_PREFIX)) return undefined
793 await ensureSession(io, rt)
794 if (rt.mcpTools && !rt.mcpTools.includes(tool)) {
795 rt.mcpTools.push(tool)
796 rt.itemsDirty = true
797 }
798 const gate = await io.read('gate')
799 if (rt.config && isApplied(gate) && gate.items[`tool:${tool}`] === 'off') return { description: denyText('tool', tool, asGate(gate), rt.config), isDeferred: true }
800 return undefined
801}
802
803// ───────────────────────── skill invocations (tool.call Skill / typed `/name` → skill.prompt) ─────────────────────────
804
805/** One expansion the engine is about to ask `skill.prompt` for: the model's Skill call the gate let through (and
806 * for which agent), or a `/name` the user typed. `skill.prompt` carries neither the agent nor the call, so the
807 * entries queue per skill and the oldest (or the one whose args the text names) is taken (M06, M07, M13). */
808export interface SkillInvocation { args: string; agentId?: string; user: boolean; at: number }
809
810const SKILL_QUEUE_MAX = 8
811const SKILL_QUEUE_TTL_MS = 10 * 60_000
812const USER_INVOCATION_TTL_MS = 60_000
813const skillQueues = new WeakMap<Runtime, Map<string, SkillInvocation[]>>()
814
815const bareSkill = (name: string): string => name.replace(/^[^:]+:/, '')
816
817function skillQueue(rt: Runtime, skill: string): SkillInvocation[] {
818 let m = skillQueues.get(rt)
819 if (!m) { m = new Map(); skillQueues.set(rt, m) }
820 const key = bareSkill(skill)
821 const t = now()
822 // A call the engine refused after us never reaches skill.prompt: old entries age out. A typed `/name` expands
823 // right away, so its entry lives a minute: a stale one must not exempt a later unannounced expansion (M13).
824 const q = (m.get(key) ?? []).filter((x) => t - x.at < (x.user ? USER_INVOCATION_TTL_MS : SKILL_QUEUE_TTL_MS))
825 m.set(key, q)
826 return q
827}
828
829export function noteSkillInvocation(rt: Runtime, skill: string, inv: { args: string; agentId?: string; user: boolean }): void {
830 const q = skillQueue(rt, skill)
831 q.push({ ...inv, at: now() })
832 if (q.length > SKILL_QUEUE_MAX) q.splice(0, q.length - SKILL_QUEUE_MAX)
833}
834
835/** The invocation a `skill.prompt` expands: the one whose args equal the text's `--args`, else the oldest. */
836export function takeSkillInvocation(rt: Runtime, skill: string, textArgs?: string): SkillInvocation | undefined {
837 const q = skillQueue(rt, skill)
838 if (!q.length) return undefined
839 const i = textArgs !== undefined ? q.findIndex((x) => x.args === textArgs) : -1
840 return q.splice(i < 0 ? 0 : i, 1)[0]
841}
842
843/** tool.call Skill: a gated-off skill is denied before it loads (for the calling agent's gate, G-08); an allowed
844 * call is remembered with its args and agent for skill.prompt. */
845export async function skillCall(io: Io, rt: Runtime, skill: string, args: string | undefined, agentId?: string): Promise<string | undefined> {
846 await ensureSession(io, rt)
847 const off = await skillOffMessage(io, rt, skill, agentId)
848 if (off) {
849 await journal(io, rt, { kind: 'deny', trigger: 'skill', data: { skill, ...(agentId !== undefined ? { agent: agentId } : {}) } })
850 return off
851 }
852 noteSkillInvocation(rt, skill, { args: args ?? '', user: false, ...(agentId !== undefined ? { agentId } : {}) })
853 return undefined
854}
855
856/** agent.offer: false for agent types outside the applied profile. */
857export async function offerAgent(io: Io, rt: Runtime, agent: string): Promise<boolean> {
858 await ensureSession(io, rt)
859 if (!rt.config) return true
860 if (!rt.agentNames.has(agent)) {
861 rt.agentNames.add(agent)
862 rt.itemsDirty = true
863 await materialize(io, rt)
864 }
865 const gate = await io.read('gate')
866 return !(isApplied(gate) && (gate.agents.off.includes(agent) || gate.items[`agent:${agent}`] === 'off'))
867}
868
869const AGENT_TIERS_MAX = 64
870
871/** turn.step observer: main-loop model change → tier + recompute; subagent → its own tier. */
872export async function observeStep(io: Io, rt: Runtime, model: string, agentId: string | undefined): Promise<void> {
873 try {
874 await ensureSession(io, rt)
875 if (!rt.config || !model) return
876 if (agentId === undefined) {
877 const prev = await io.read('model')
878 if (model !== prev) {
879 await io.update('model', () => model)
880 const cw = await io.session.usage().then((u) => u.context.window, () => undefined)
881 await io.update('tier', () => modelTier(rt, model, cw))
882 if (prev) await recompute(io, rt, 'model-change', { prevModel: prev })
883 }
884 } else {
885 const tiers = await io.read('agentTiers')
886 if (!(agentId in tiers)) {
887 const t = modelTier(rt, model)
888 // Subagent ids are ephemeral: keep the most recent ones only (L04).
889 await io.update('agentTiers', (m) => json(Object.fromEntries([...Object.entries(m), [agentId, t]].slice(-AGENT_TIERS_MAX))))
890 }
891 }
892 } catch (err) {
893 debug(io, `turn.step: ${String((err as Error)?.message ?? err)}`)
894 }
895}
896hooks/layers/budgets.ts 78 lines1// Budgets (SPEC "Шар 2 — Бюджети"): context percent from session.measure / turn.complete, thresholds by tier,
2// `onExceed` once per crossing: section (DSL section turns on), notice (toast + transcript notice; not
3// io.ui.notice, which is tool-dialog only), compact (deferred io.session.compact keeping profile + rules).
4
5
6import type { OnExceedAction } from '../../packages/core/src/types.ts'
7import { budgetFor } from '../../packages/core/src/config.ts'
8
9import { type Io, type Runtime, debug } from '../ctx.ts'
10import { journal } from './journal.ts'
11import { refreshStatus } from './ui.ts'
12
13export type BudgetKey = 'softContextPct' | 'hardContextPct'
14const KEYS: BudgetKey[] = ['softContextPct', 'hardContextPct']
15const DEFAULT_HARD_NOTICE = 'Контекст {pct}%: запусти /compact або /handoff'
16
17/** Section ids that onExceed `section` actions own: shown only while their threshold is crossed. */
18export function budgetSections(rt: Runtime): Map<string, BudgetKey> {
19 const out = new Map<string, BudgetKey>()
20 for (const k of KEYS) {
21 const a = rt.cfg?.onExceed?.[k]
22 if (a?.do === 'section') out.set(a.section, k)
23 }
24 return out
25}
26
27/** What compaction must keep: the active profile, tier and delivered rules. */
28export async function keepText(io: Io): Promise<string> {
29 const gate = await io.read('gate')
30 const seen = await io.read('seen')
31 const rules = [...new Set(seen.map((k) => k.slice(k.lastIndexOf(':') + 1)))]
32 const profile = gate?.profile ?? (gate?.proposed ? `${gate.proposed.profile} (запропоновано)` : '—')
33 return `context-gate: збережи в підсумку активний профіль ${profile}, tier ${gate?.tier ?? '—'} і застосовані правила Cursor: ${rules.join(', ') || '—'}.`
34}
35
36async function act(io: Io, rt: Runtime, key: BudgetKey, pct: number): Promise<void> {
37 const configured = rt.cfg?.onExceed?.[key]
38 const action: OnExceedAction | undefined = configured ?? (key === 'hardContextPct' ? { do: 'notice', text: DEFAULT_HARD_NOTICE } : undefined)
39 await journal(io, rt, { kind: 'debug', trigger: `budget:${key}`, data: { pct: Math.round(pct), action: action?.do ?? 'none' } })
40 if (!action) return
41 if (action.do === 'notice') {
42 const text = action.text.replace(/\{pct\}/g, String(Math.round(pct)))
43 try { io.ui.toast(text, { timeoutMs: 10000 }) } catch { /* no surface */ }
44 await io.session.append({ message: { type: 'system', content: [{ type: 'text', text }] } }).catch((err: unknown) => debug(io, `notice append failed: ${String(err)}`))
45 } else if (action.do === 'compact') {
46 const instructions = [action.instructions, await keepText(io)].filter(Boolean).join('\n\n')
47 // io.session.compact rejects while a turn runs: defer it.
48 io.clock.after(0, () => { void io.session.compact({ instructions }).catch((err: unknown) => debug(io, `compact failed: ${String(err)}`)) })
49 }
50 // `section`: budgetsFired drives the DSL section at the next prompt.compose.
51}
52
53export async function checkBudgets(io: Io, rt: Runtime, pct: number | undefined): Promise<void> {
54 if (pct === undefined || !Number.isFinite(pct)) return
55 await io.update('ctxPercent', () => pct)
56 const gate = await io.read('gate')
57 const tier = gate?.tier ?? (await io.read('tier')) ?? 'standard'
58 const th = budgetFor(rt.cfg, tier)
59 const fired = await io.read('budgetsFired')
60 const crossed: BudgetKey[] = []
61 const next: string[] = []
62 for (const k of KEYS) {
63 const over = pct >= th[k]
64 if (over) next.push(k)
65 if (over && !fired.includes(k)) crossed.push(k)
66 }
67 // Dropping below re-arms the threshold (after compaction): once per crossing.
68 if (next.join() !== fired.join()) await io.update('budgetsFired', () => next)
69 for (const k of crossed) await act(io, rt, k, pct)
70 await refreshStatus(io, rt)
71}
72
73/** turn.complete part (registered by gates.ts, which owns that hook). */
74export async function budgetsOnTurn(io: Io, rt: Runtime): Promise<void> {
75 const u = await io.session.usage().catch(() => undefined)
76 await checkBudgets(io, rt, u?.context.percent)
77}
78hooks/layers/dsl.ts 1023 lines1// Layer 3: the prompt DSL (SPEC "Шар 3", "Збірка через mod", "Промпти як skills", "Шар 3а").
2// prompt.compose reads `.claude/prompt/.compiled/*.json` and Markdown sections (`<dir>/*.md`, tier variant
3// files `<id>.<tier>.md`), renders them with core renderPrompt over a RenderHost built from $, and adds them
4// as `session` sections `context-gate:<id>` ordered static → profile → volatile. Stale `.compiled` →
5// `node <plugin>/dist/cli.js build --only <file>` when trusted (2 s in compose, else previous + H013).
6// Prompt skills render at invocation (skill.prompt, or as tools for `invoke.model: 'tool'`).
7
8
9import type { CompiledPrompt, Diagnostic, Gate, RenderedSection, Scope_, SectionNode, Value } from '../../packages/core/src/types.ts'
10import { DEBUG_LOG_FILE, DEBUG_LOG_MAX, capDebugLog, debugLogLines, renderPrompt, materializeData } from '../../packages/core/src/render.ts'
11import type { RenderHostExt, RenderOptionsExt, RenderResultExt } from '../../packages/core/src/render.ts'
12import { argsToJsonSchema } from '../../packages/core/src/argparse.ts'
13import { maskSecrets, maskSecretsDeep } from '../../packages/core/src/config.ts'
14import { denyText } from '../../packages/core/src/decide.ts'
15import { assemblePrompts, buildScope as coreBuildScope, defaultGate, isMarkdownSectionFile, promptSectionDirs, skillArgs, type MarkdownFile, type PromptSet as AssembledSet } from '../../packages/core/src/assemble.ts'
16import { parseToolHeader, parseToolHeaders } from '../../packages/core/src/toolheader.ts'
17import { missingExports, scriptArgv, scriptLang, shimLang, usedFunctions } from '../../packages/core/src/shims.ts'
18import { repoCacheName } from '../../packages/core/src/sha256.ts'
19import type { RunJson } from '../../packages/core/src/runjson.ts'
20import { computeHealth } from '../../packages/core/src/health.ts'
21import { parseSkillListing } from '../../packages/core/src/items.ts'
22import { isApplied, json } from '../state.ts'
23import { type Io, OWN_TOOL_PREFIX, type PromptSet, type Runtime, type ScriptTool, debug, hash, insideRoot, join, now, stableJson } from '../ctx.ts'
24import { ensureEnv, ensureSession, envMask, modelTier } from './config.ts'
25import { ensureRules } from './cursor-rules.ts'
26import { journal, pushFileEntry } from './journal.ts'
27import { snapshotData, snapshotEntry } from '../../packages/core/src/journal.ts'
28import { type ModHost, allowedBinary, makeRenderHost, providerConfigs, providerData, readRepoFile, runArgv, writableInsideRoot } from './host.ts'
29import { ensureTrust, needsTrust, rebindAfterBuild, repoKey, sourcesHash, trustState } from './trust.ts'
30import { budgetSections } from './budgets.ts'
31import { gateFor, noteSkillInvocation, readBranch, setPromptContextSource, skillOffMessage, takeSkillInvocation } from './skill-gate.ts'
32import { refreshStatus } from './ui.ts'
33import { gateStats } from './gates.ts'
34
35// `prompt.volatile: "context"` sections reach the model through prompt.submit, which skill-gate.ts handles.
36setPromptContextSource((io, rt) => volatileContext(io, rt))
37
38const SYNC_BUILD_MS = 2000
39const FULL_BUILD_MS = 30_000
40const SECTION_PREFIX = 'context-gate:'
41
42const DEFAULT_PROMPT_DIR = '.claude/prompt'
43
44/** gate.json `prompt.dir`, repo-relative. An absolute or `..` value would read prompts from, and write `.trace` and
45 * `data/` into, a directory outside the repo before any trust: it falls back to the default (M05). */
46export function promptDir(rt: Runtime): string {
47 const dir = (rt.cfg.prompt?.dir ?? DEFAULT_PROMPT_DIR).replace(/\\/g, '/').replace(/^\.\//, '').replace(/\/+$/, '')
48 return dir && insideRoot(dir) ? dir : DEFAULT_PROMPT_DIR
49}
50
51// ───────────────────────── loading ─────────────────────────
52
53function isCompiledPrompt(v: unknown): v is CompiledPrompt {
54 return !!v && typeof v === 'object' && (v as CompiledPrompt).version === 1 && Array.isArray((v as CompiledPrompt).sections)
55}
56
57type ListEntry = { name: string; kind: string; size: number; mtimeMs: number; isLink?: boolean }
58
59/** mtime of a repo-relative file (undefined when missing): `io.fs.stat`, else the parent dir's listing (memoized per load). */
60async function mtimeOf(io: Io, rt: Runtime, rel: string, lists: Map<string, ListEntry[]>): Promise<number | undefined> {
61 if (!insideRoot(rel)) return undefined
62 if (io.fs.stat) {
63 const st = await io.fs.stat(join(rt.root, rel)).catch(() => undefined)
64 if (st) return st.mtimeMs
65 }
66 const i = rel.lastIndexOf('/')
67 const dir = i < 0 ? '' : rel.slice(0, i)
68 let entries = lists.get(dir)
69 if (!entries) {
70 entries = [...(await io.fs.list(dir ? join(rt.root, dir) : rt.root).catch(() => []))]
71 lists.set(dir, entries)
72 }
73 return entries.find((e) => e.name === rel.slice(i + 1) && e.kind === 'file')?.mtimeMs
74}
75
76/**
77 * Spellings of one git remote (`git@host:o/r.git`, `https://host/o/r`, `ssh://git@host/o/r.git`, …): the engine
78 * may report the remote differently from `git config remote.origin.url`, which keys the CLI's cache dir.
79 */
80export function remoteSpellings(remote: string): string[] {
81 const out = new Set<string>([remote])
82 const m = /^(?:[\w+.-]+:\/\/)?(?:[^@/]+@)?([^/:]+)(?::\d+)?[:/](.+?)(?:\.git)?\/?$/.exec(remote.trim())
83 if (m) {
84 const [, host, path] = m
85 for (const base of [`https://${host}/${path}`, `http://${host}/${path}`, `git@${host}:${path}`, `ssh://git@${host}/${path}`, `git://${host}/${path}`]) {
86 out.add(base)
87 out.add(`${base}.git`)
88 }
89 }
90 out.add('')
91 return [...out]
92}
93
94/**
95 * Per-repo cache dir of the CLI (`~/.cache/context-gate/<name>-<hash12>/`, XDG_CACHE_HOME respected), SPEC Р3.
96 * Only a dir whose name is this root's own hash counts (the remote's spellings tried); never another repo's dir
97 * that merely shares the basename (M02).
98 */
99export async function repoCacheDir(io: Io, rt: Runtime): Promise<string | undefined> {
100 const xdg = await io.env.cacheHome?.().catch(() => undefined)
101 const home = xdg ? undefined : await io.env.home().catch(() => undefined)
102 const base = xdg ? `${xdg.replace(/[\\/]+$/, '')}/context-gate` : home ? `${home.replace(/[\\/]+$/, '')}/.cache/context-gate` : undefined
103 if (!base) return undefined
104 const repo = await io.session.repo().catch(() => null)
105 const exact = `${base}/${repoCacheName(rt.root, repo?.remote ?? '')}`
106 if (await io.fs.exists(`${exact}/compiled`).catch(() => false)) return exact
107 // The CLI keys its dir on the raw `git config --get remote.origin.url`: with no remote from the engine, read it
108 // from the repo's git config and try that exact spelling first.
109 const remote = repo?.remote || (await originUrl(io, rt.root)) || ''
110 const spellings = remote && remote !== repo?.remote ? [remote, ...remoteSpellings(remote)] : remoteSpellings(remote)
111 for (const spelling of spellings) {
112 const dir = `${base}/${repoCacheName(rt.root, spelling)}`
113 if (dir !== exact && (await io.fs.exists(`${dir}/compiled`).catch(() => false))) return dir
114 }
115 return exact
116}
117
118/** `remote.origin.url` from `<root>/.git/config`, or a worktree's common git dir (`.git` file → gitdir → commondir). */
119async function originUrl(io: Io, root: string): Promise<string | undefined> {
120 const read = async (p: string): Promise<string | undefined> => {
121 const t = await io.fs.read(p).catch(() => undefined)
122 return typeof t === 'string' ? t : undefined
123 }
124 let gitDir = join(root, '.git')
125 let config = await read(`${gitDir}/config`)
126 if (config === undefined) {
127 const m = /^gitdir:\s*(.+)$/m.exec((await read(gitDir)) ?? '')
128 if (!m) return undefined
129 gitDir = join(root, m[1].trim())
130 const common = (await read(`${gitDir}/commondir`))?.trim()
131 config = await read(`${common ? join(gitDir, common) : gitDir}/config`)
132 }
133 let inOrigin = false
134 for (const line of (config ?? '').split(/\r?\n/)) {
135 const sec = /^\s*\[(.+)\]\s*$/.exec(line)
136 if (sec) { inOrigin = /^remote\s+"origin"$/.test(sec[1].trim()); continue }
137 const kv = inOrigin ? /^\s*url\s*=\s*(.*?)\s*$/.exec(line) : null
138 if (kv) return kv[1]
139 }
140 return undefined
141}
142
143async function readCompiledDir(io: Io, absDir: string, label: string, entries: readonly ListEntry[], diagnostics: Diagnostic[]): Promise<{ prompt: CompiledPrompt; mtimeMs: number }[]> {
144 const out: { prompt: CompiledPrompt; mtimeMs: number }[] = []
145 for (const e of entries) {
146 if (e.kind !== 'file' || !e.name.endsWith('.json')) continue
147 const t = await io.fs.read(`${absDir}/${e.name}`).catch(() => undefined)
148 if (typeof t !== 'string') continue
149 try {
150 const v = JSON.parse(t) as unknown
151 if (isCompiledPrompt(v)) out.push({ prompt: v, mtimeMs: e.mtimeMs })
152 else diagnostics.push({ code: 'G001', severity: 'warning', message: `${label}/${e.name}: не CompiledPrompt v1`, path: `${label}/${e.name}` })
153 } catch (err) {
154 diagnostics.push({ code: 'G001', severity: 'warning', message: `${label}/${e.name}: ${String((err as Error).message)}`, path: `${label}/${e.name}` })
155 }
156 }
157 return out
158}
159
160/** Prompt ids `<prompt dir>/prompt.lock.json` lists; undefined without a lock (or an empty one). */
161async function lockedIds(io: Io, rt: Runtime, dir: string): Promise<Set<string> | undefined> {
162 const t = await readRepoFile(io, rt, `${dir}/prompt.lock.json`)
163 if (typeof t !== 'string') return undefined
164 try {
165 const prompts = (JSON.parse(t) as { prompts?: unknown }).prompts
166 if (!prompts || typeof prompts !== 'object' || Array.isArray(prompts)) return undefined
167 const ids = Object.keys(prompts)
168 return ids.length ? new Set(ids) : undefined
169 } catch { return undefined }
170}
171
172/**
173 * `.compiled/*.json` (or, when the repo has none, the CLI's per-repo cache, Р3) and the Markdown prompts.
174 * Staleness (SPEC "Життєвий цикл"): a `.prompt.tsx` without a compiled prompt, or one whose `sources[]` (the entry
175 * and every import: `shared/*.prompt.tsx`, `.md`, `.json`) has a file newer than its compiled JSON (H013).
176 */
177export async function loadPrompts(io: Io, rt: Runtime, opts: { force?: boolean } = {}): Promise<PromptSet> {
178 await ensureSession(io, rt)
179 const dir = promptDir(rt)
180 const absDir = join(rt.root, dir)
181 const entries = await io.fs.list(absDir).catch(() => [])
182 let compiledDir = `${absDir}/.compiled`
183 let compiledLabel = `${dir}/.compiled`
184 let compiledFrom: 'repo' | 'cache' | 'none' = 'repo'
185 let compiledEntries: ListEntry[] = [...(await io.fs.list(compiledDir).catch(() => []))]
186 if (!compiledEntries.some((e) => e.kind === 'file' && e.name.endsWith('.json'))) {
187 const cache = await repoCacheDir(io, rt)
188 const cached = cache ? [...(await io.fs.list(`${cache}/compiled`).catch(() => []))] : []
189 if (cache && cached.some((e) => e.kind === 'file' && e.name.endsWith('.json'))) {
190 compiledDir = `${cache}/compiled`
191 compiledLabel = compiledDir
192 compiledEntries = cached
193 compiledFrom = 'cache'
194 } else compiledFrom = 'none'
195 }
196 // Imported sources outside the listed dir change nothing in the listings: their mtimes join the key.
197 const lists = new Map<string, ListEntry[]>()
198 const sourceKey: string[] = []
199 for (const src of rt.prompts?.sources ?? []) {
200 if (!src.includes('/') || src.slice(0, src.lastIndexOf('/')) !== dir) sourceKey.push(`s/${src}:${(await mtimeOf(io, rt, src, lists)) ?? 'missing'}`)
201 }
202 // Markdown section dirs beyond `prompt.dir`: `itemSources` `{ kind: "prompt-dir", as: "section" }` (core, as the CLI).
203 const extraDirs: { rel: string; entries: ListEntry[] }[] = []
204 for (const rel of promptSectionDirs({ ...rt.cfg, prompt: { ...rt.cfg.prompt, dir } }).slice(1).filter((d) => insideRoot(d))) {
205 extraDirs.push({ rel, entries: [...(await io.fs.list(join(rt.root, rel)).catch(() => []))] })
206 }
207 const key = [`from:${compiledFrom}`, ...entries.map((e) => `${e.name}:${e.mtimeMs}`), ...compiledEntries.map((e) => `c/${e.name}:${e.mtimeMs}`), ...sourceKey,
208 ...extraDirs.flatMap((d) => d.entries.map((e) => `d/${d.rel}/${e.name}:${e.mtimeMs}`))].sort().join('|')
209 if (rt.prompts && rt.prompts.key === key && !rt.promptsDirty && !opts.force) return rt.prompts
210 const diagnostics: Diagnostic[] = []
211 // With a lock, only the ids it lists (CLI loadCompiled, M32): an orphan of a removed or renamed prompt never renders.
212 const listed = await lockedIds(io, rt, dir)
213 const loaded = (await readCompiledDir(io, compiledDir, compiledLabel, compiledEntries, diagnostics)).filter((l) => !listed || listed.has(l.prompt.id))
214 const compiled = loaded.map((l) => l.prompt)
215 // Markdown sources as files; tier variants and parsing are core `assemblePrompts` (same as the CLI).
216 const markdown: MarkdownFile[] = []
217 for (const d of [{ rel: dir, entries }, ...extraDirs]) {
218 for (const e of d.entries) {
219 if (e.kind !== 'file' || !isMarkdownSectionFile(e.name)) continue
220 const path = `${d.rel}/${e.name}`
221 if (markdown.some((m) => m.path === path)) continue
222 const t = await readRepoFile(io, rt, path) // symlinks out of the repo are not read (H02)
223 if (typeof t === 'string') markdown.push({ path, text: t })
224 }
225 }
226 markdown.sort((x, y) => x.path.localeCompare(y.path))
227 diagnostics.push(...assemblePrompts([], markdown, '', Object.keys(rt.cfg.tiers ?? {})).diagnostics)
228 // Staleness per entry: missing compiled, or any source newer than the compiled JSON.
229 const stale = new Set<string>()
230 const byEntry = new Map<string, { prompt: CompiledPrompt; mtimeMs: number }>()
231 for (const l of loaded) {
232 const entry = l.prompt.sources?.[0]?.path
233 if (entry) byEntry.set(entry, l)
234 }
235 const sources = new Set<string>()
236 for (const e of entries) {
237 if (e.kind !== 'file' || !e.name.endsWith('.prompt.tsx')) continue
238 const rel = `${dir}/${e.name}`
239 const id = e.name.replace(/\.prompt\.tsx$/, '')
240 const l = byEntry.get(rel) ?? loaded.find((x) => x.prompt.id === id && !x.prompt.sources?.length)
241 if (!l) { stale.add(rel); continue }
242 if (e.mtimeMs > l.mtimeMs) { stale.add(rel); continue }
243 }
244 for (const [entry, l] of byEntry) {
245 for (const s of l.prompt.sources ?? []) {
246 sources.add(s.path)
247 if (stale.has(entry)) continue
248 const m = s.path.startsWith(`${dir}/`) && !s.path.slice(dir.length + 1).includes('/') ? entries.find((e) => e.name === s.path.slice(dir.length + 1))?.mtimeMs : await mtimeOf(io, rt, s.path, lists)
249 // A source gone missing also needs a rebuild (the build reports it).
250 if (m === undefined ? s.path !== entry : m > l.mtimeMs) stale.add(entry)
251 }
252 }
253 const watch = [absDir, `${absDir}/.compiled`, ...entries.filter((e) => e.kind === 'file').map((e) => `${absDir}/${e.name}`), ...[...sources].filter((p) => insideRoot(p)).map((p) => join(rt.root, p)),
254 ...extraDirs.flatMap((d) => [join(rt.root, d.rel), ...d.entries.filter((e) => e.kind === 'file' && isMarkdownSectionFile(e.name)).map((e) => join(rt.root, `${d.rel}/${e.name}`))])]
255 rt.prompts = { key, compiled, markdown, stale: [...stale].sort(), diagnostics, watch: [...new Set(watch)], sources: [...sources].sort(), compiledFrom }
256 rt.promptsDirty = false
257 return rt.prompts
258}
259
260/** prompt.context (after compaction, /clear): the same stat check as prompt.compose; a stale build starts in the background. */
261export async function dslContextBefore(io: Io, rt: Runtime): Promise<void> {
262 try {
263 rt.promptsDirty = true
264 const set = await loadPrompts(io, rt)
265 if (set.stale.length && rt.interactive && rt.cfg.prompt?.build !== 'never' && (await trustState(io, rt)) === 'trusted') {
266 void buildPrompts(io, rt, { timeoutMs: FULL_BUILD_MS }).catch(() => undefined)
267 }
268 } catch (err) {
269 debug(io, `prompt.context: ${String((err as Error)?.message ?? err)}`)
270 }
271}
272
273// ───────────────────────── build ─────────────────────────
274
275export async function buildPrompts(io: Io, rt: Runtime, opts: { only?: string; timeoutMs: number; ask?: boolean }): Promise<{ ok: boolean; message: string }> {
276 if (rt.cfg.prompt?.build === 'never') return { ok: false, message: 'prompt.build: never — збірку вимкнено в gate.json' }
277 const trust = opts.ask ? await ensureTrust(io, rt, { ask: true }) : await trustState(io, rt)
278 if (trust !== 'trusted') return { ok: false, message: 'Репозиторій не довірений: збірку промптів пропущено (довіра — запит при першому промпті, скасування /gate trust revoke)' }
279 const cli = join(io.plugin.root, 'dist/cli.js')
280 if (!(await io.fs.exists(cli).catch(() => false))) return { ok: false, message: `Немає ${cli}: виконай npm run build у теці плагіна` }
281 if (rt.building) return { ok: false, message: 'Збірка вже йде' }
282 rt.building = true
283 try {
284 const sources = await sourcesHash(io, rt).catch(() => undefined)
285 const argv = ['node', cli, 'build', ...(opts.only ? ['--only', opts.only] : [])]
286 const r = await runArgv(io, rt, argv, { timeoutMs: opts.timeoutMs })
287 rt.promptsDirty = true
288 // The `.compiled/` this trusted build wrote is part of the trust surface: keep the decision (S1, Р3).
289 if (sources !== undefined) await rebindAfterBuild(io, rt, sources).catch((err: unknown) => debug(io, `trust rebind: ${String(err)}`))
290 if (r.exitCode === 0) {
291 const hadError = !!rt.buildError
292 rt.buildError = undefined
293 if (hadError) await refreshStatus(io, rt)
294 await journal(io, rt, { kind: 'debug', trigger: 'build', data: { only: opts.only ?? null, ms: r.ms } })
295 return { ok: true, message: `Збірка промптів: ok (${r.ms} мс)` }
296 }
297 const lines = `${r.stdout}\n${r.stderr}`.split('\n').map((l) => l.trim()).filter(Boolean).slice(0, 3)
298 await journal(io, rt, { kind: 'health', trigger: 'H013', data: { only: opts.only ?? null, exitCode: r.exitCode } })
299 const message = `Збірка промптів не вдалася (H013, exit ${r.exitCode}), лишаю попередній .compiled${lines.length ? `:\n${lines.join('\n')}` : ''}${r.exitCode === -1 ? '\nПідказка: перевір, що node є в PATH' : ''}`
300 // The status line shows `prompt ⚠ build` until a good build (ui.ts `buildErrorOf`); the first G* code wins over H013.
301 const code = /\b(G\d{3})\b/.exec(lines.join('\n'))?.[1] ?? 'H013'
302 rt.buildError = { code, message: lines[0] ?? `exit ${r.exitCode}`, at: now() }
303 await refreshStatus(io, rt)
304 try { io.ui.toast(message.split('\n').slice(0, 4).join('\n'), { timeoutMs: 10000 }) } catch { /* no surface */ }
305 return { ok: false, message }
306 } finally {
307 rt.building = false
308 }
309}
310
311/** Background build on session start / after trust: once, all stale files. */
312export async function buildStale(io: Io, rt: Runtime): Promise<void> {
313 if (!rt.interactive) return
314 const set = await loadPrompts(io, rt)
315 if (!set.stale.length) return
316 await buildPrompts(io, rt, { timeoutMs: FULL_BUILD_MS })
317}
318
319// ───────────────────────── scope ─────────────────────────
320
321/** The render scope: core `buildScope` (the CLI's too) over the session's gate, rules, ctx, data and providers. */
322export async function buildScope(io: Io, rt: Runtime, host: RenderHostExt, model: string | undefined): Promise<{ scope: Scope_; tier: string; dataKey: string }> {
323 const stateGate = await io.read('gate')
324 // The main loop's tier is the stored one (it may come from the context window, G-01); another model (a
325 // subagent's compose) gets its own (M22).
326 const main = await io.read('model')
327 const tier = model && model !== main ? modelTier(rt, model) : (await io.read('tier')) ?? stateGate?.tier ?? (model ? modelTier(rt, model) : 'standard')
328 const pct = await io.read('ctxPercent')
329 const fired = await io.read('budgetsFired')
330 const owned = budgetSections(rt)
331 const active = [...owned].filter(([, k]) => fired.includes(k)).map(([id]) => id)
332 const repo = await io.session.repo().catch(() => null)
333 const rules = await ensureRules(io, rt)
334 const key = await repoKey(io, rt)
335 const dataKey = `data:${key}`
336 const stored = ((await io.store.get(dataKey).catch(() => undefined)) ?? {}) as Record<string, Value>
337 const data = materializeData(stored, now()).data
338 const branch = await readBranch(io, rt)
339 const gate: Gate = stateGate ? { ...(stateGate as unknown as Gate), tier } : defaultGate(tier)
340 const providers = await providerData(io, rt, host)
341 const scope = coreBuildScope({
342 config: rt.cfg,
343 gate,
344 git: { branch: branch ?? '' },
345 rules,
346 session: { model: model ?? (await io.read('model')) ?? '', root: rt.root, interactive: rt.interactive },
347 ...(pct !== null && pct !== undefined ? { ctxPercent: pct } : {}),
348 data: data as Value,
349 budgetsFired: fired,
350 budgetsActive: active,
351 providers: { ...providers, tier, repo: { name: repo?.name ?? null, root: rt.root }, env: await ensureEnv(io, rt) },
352 }) as Scope_
353 return { scope, tier, dataKey }
354}
355
356async function itemBodyOf(io: Io, rt: Runtime, kind: 'skill' | 'rule', name: string): Promise<{ description?: string; body?: string; path?: string } | undefined> {
357 if (kind === 'rule') {
358 const r = (await ensureRules(io, rt)).find((x) => x.id === name)
359 return r ? { body: r.body, path: r.path, ...(r.description ? { description: r.description } : {}) } : undefined
360 }
361 if (!/^[\w.:@-]+$/.test(name)) return undefined
362 const rel = `.claude/skills/${name.replace(/^[^:]+:/, '')}/SKILL.md`
363 const t = await readRepoFile(io, rt, rel)
364 if (typeof t !== 'string') return undefined
365 const m = /^---\n([\s\S]*?)\n---\n?/.exec(t.replace(/\r\n?/g, '\n'))
366 const desc = m ? /^description:\s*(.+)$/m.exec(m[1])?.[1]?.replace(/^["']|["']$/g, '') : undefined
367 return { body: (m ? t.slice(m[0].length) : t).trim(), path: rel, ...(desc ? { description: desc } : {}) }
368}
369
370export async function hostFor(io: Io, rt: Runtime): Promise<ModHost> {
371 const trusted = (await trustState(io, rt)) === 'trusted'
372 const dir = promptDir(rt)
373 return makeRenderHost(io, rt, { trusted, repoKey: await repoKey(io, rt), itemBody: (kind, name) => itemBodyOf(io, rt, kind, name), rules: () => ensureRules(io, rt), promptDir: dir, providers: await providerConfigs(io, rt, dir) })
374}
375
376/** `data.*` keys a render may write as files (CLI `validDataKey`). */
377const DATA_KEY = /^[\w][\w.-]{0,127}$/
378
379/** `store=` values: always to `$.store` (`data:<repo>`); with gate.json `prompt.persist` also `<prompt dir>/data/<key>.json`, as the CLI. */
380async function persistData(io: Io, rt: Runtime, dataKey: string, res: RenderResultExt): Promise<void> {
381 if (!Object.keys(res.storedEntries ?? {}).length) return
382 const prev = ((await io.store.get(dataKey).catch(() => undefined)) ?? {}) as Record<string, Value>
383 await io.store.set(dataKey, { ...prev, ...res.storedEntries }).catch(() => undefined)
384 if (!rt.cfg.prompt?.persist) return
385 for (const [k, v] of Object.entries(res.stored ?? {})) {
386 if (!DATA_KEY.test(k) || k.includes('..')) continue
387 await io.fs.write(join(rt.root, `${promptDir(rt)}/data/${k}.json`), JSON.stringify(v, null, 2) + '\n').catch((err: unknown) => debug(io, `data ${k}: ${String(err)}`))
388 }
389}
390
391// ───────────────────────── compose ─────────────────────────
392
393/** Sections to render: core `assemblePrompts` (compiled system prompts, then Markdown resolved for the tier; skills
394 * apart). `preload` (the applied gate's `skills.preload`, see `preloadOf`) adds core's generated `preload` section. */
395export function sectionsFor(rt: Runtime, set: PromptSet, tier: string, preload: readonly string[] = []): AssembledSet {
396 return assemblePrompts(set.compiled, set.markdown, tier, Object.keys(rt.cfg.tiers ?? {}), { preload })
397}
398
399/** Р5: the skills `tiers[*].preload` inlines for the applied gate (none in shadow mode, with the gate off, or
400 * while gate.json is invalid: a stored gate must not outlive the layer, M18). */
401export async function preloadOf(io: Io, rt?: Runtime): Promise<string[]> {
402 if (rt && !rt.config) return []
403 const gate = await io.read('gate')
404 return isApplied(gate) ? gate.skills.preload : []
405}
406
407/** Render options shared with `context-gate run` (`renderWith`), plus the mod's 2 s script budget. */
408export function renderOptions(rt: Runtime, tier: string): RenderOptionsExt {
409 const secrets = envMask(rt) // G-03: whitelisted env values never reach the trace or diagnostics
410 return { tier, runBudgetMs: 2000, ...(secrets.length ? { secrets } : {}), ...(rt.cfg.prompt?.runCacheDefault ? { runCacheDefault: rt.cfg.prompt.runCacheDefault } : {}), ...(rt.cfg.debug ? { debug: true } : {}), ...(rt.cfg.assertFail ? { assertFail: rt.cfg.assertFail } : {}) }
411}
412
413/** Sync rebuild of stale files when it fits in 2 s; once per source mtime. */
414async function syncBuild(io: Io, rt: Runtime, set: PromptSet): Promise<PromptSet> {
415 if (!set.stale.length || !rt.interactive || rt.cfg.prompt?.build === 'never') return set
416 if ((await trustState(io, rt)) !== 'trusted') return set
417 let rebuilt = false
418 for (const file of set.stale) {
419 const k = `${file}@${set.key}`
420 if (rt.buildAttempted.has(k)) continue
421 rt.buildAttempted.add(k)
422 const r = await buildPrompts(io, rt, { only: file, timeoutMs: SYNC_BUILD_MS })
423 if (r.ok) rebuilt = true
424 }
425 return rebuilt ? loadPrompts(io, rt, { force: true }) : set
426}
427
428/**
429 * gate.json `prompt.volatile` (P1): `system` (default, SPEC «volatile — щоходу, останніми») keeps `scope: volatile`
430 * sections at the end of the system prompt; `context` delivers them as the prompt's `context` (prompt.submit)
431 * instead. The system prompt sits ahead of the whole conversation in the API's cache prefix, so a section that
432 * changes every turn there rewrites the cache of everything after it; a block beside the new message does not.
433 */
434export function volatileVia(rt: Runtime): 'system' | 'context' {
435 return (rt.cfg.prompt as { volatile?: unknown } | undefined)?.volatile === 'context' ? 'context' : 'system'
436}
437
438/** The prompts with only their `scope: volatile` sections (or only the others). */
439function byVolatile(prompts: readonly CompiledPrompt[], volatile: boolean): CompiledPrompt[] {
440 return prompts.map((p) => ({ ...p, sections: p.sections.filter((s) => (s.scope === 'volatile') === volatile) })).filter((p) => p.sections.length)
441}
442
443/** prompt.submit with `prompt.volatile: "context"`: the volatile sections rendered for this prompt, as one block. */
444export async function volatileContext(io: Io, rt: Runtime): Promise<string | undefined> {
445 if (volatileVia(rt) !== 'context') return undefined
446 try {
447 const set = await loadPrompts(io, rt)
448 const host = await hostFor(io, rt)
449 const { scope, tier, dataKey } = await buildScope(io, rt, host, undefined)
450 const prompts = byVolatile(sectionsFor(rt, set, tier, await preloadOf(io, rt)).system, true)
451 if (!prompts.length) return undefined
452 const res = await renderPrompt(prompts, scope, host, renderOptions(rt, tier))
453 await persistData(io, rt, dataKey, res)
454 const owned = budgetSections(rt)
455 const fired = await io.read('budgetsFired')
456 const texts = res.sections.filter((s) => s.included && s.text && !(owned.get(s.id) && !fired.includes(owned.get(s.id)!))).map((s) => s.text)
457 return texts.length ? `Поточний стан (context-gate, оновлюється з кожним промптом):\n\n${texts.join('\n\n')}` : undefined
458 } catch (err) {
459 debug(io, `volatile context: ${String((err as Error)?.message ?? err)}`)
460 return undefined
461 }
462}
463
464export async function composeSections(io: Io, rt: Runtime, model: string | undefined): Promise<{ sections: { id: string; text: string; scope: 'session' }[]; result?: RenderResultExt }> {
465 let set = await loadPrompts(io, rt)
466 set = await syncBuild(io, rt, set)
467 const out: { id: string; text: string; scope: 'session' }[] = []
468 const preload = await preloadOf(io, rt)
469 const hasSections = set.compiled.some((p) => !p.skill && p.sections.length) || set.markdown.length > 0 || preload.length > 0
470 if (!hasSections) {
471 rt.lastSections = out
472 return { sections: out }
473 }
474 const host = await hostFor(io, rt)
475 const { scope, tier, dataKey } = await buildScope(io, rt, host, model)
476 // The preload section (Р5) is core's: `assemblePrompts` generates it from the gate's `skills.preload`, as the CLI.
477 const tiered = sectionsFor(rt, set, tier, preload)
478 const g158 = await checkExports(io, rt, host, [...tiered.system, ...Object.values(tiered.skills)])
479 // `prompt.volatile: "context"`: the volatile sections ride prompt.submit (volatileContext), not the system prompt.
480 const system = volatileVia(rt) === 'context' ? byVolatile(tiered.system, false) : tiered.system
481 const res = await renderPrompt(system, scope, host, renderOptions(rt, tier))
482 res.diagnostics.push(...g158)
483 await persistData(io, rt, dataKey, res)
484 // Budget-owned sections appear only while their threshold is crossed.
485 const owned = budgetSections(rt)
486 const fired = await io.read('budgetsFired')
487 for (const s of res.sections) {
488 if (!s.included || !s.text) continue
489 const k = owned.get(s.id)
490 if (k && !fired.includes(k)) continue
491 out.push({ id: SECTION_PREFIX + s.id, text: staticText(rt, s, tier), scope: 'session' })
492 }
493 await recordHealth(io, rt, res, set)
494 await writeSnapshot(io, rt, scope, tier, out)
495 await recordDebug(io, rt, res, tier)
496 await writeLastTrace(io, rt, res, scope, tier, host.trusted)
497 rt.lastSections = out
498 return { sections: out, result: res }
499}
500
501/**
502 * G158 (SPEC «Виклик функцій»): functions the prompts call through `use` that the module does not export. The
503 * shim is asked for `__exports__` once per session per module (`host.listExports`); trusted repos only.
504 */
505async function checkExports(io: Io, rt: Runtime, host: ModHost, prompts: readonly CompiledPrompt[]): Promise<Diagnostic[]> {
506 if (!host.trusted) return []
507 const out: Diagnostic[] = []
508 for (const [path, fns] of usedFunctions(prompts)) {
509 if (!fns.size) continue
510 const known = rt.moduleExports.has(path)
511 const exports = await host.listExports(path)
512 if (!exports) continue
513 const missing = missingExports(path, fns, exports)
514 out.push(...missing)
515 if (!known && missing.length) await journal(io, rt, { kind: 'health', trigger: 'G158', data: { path, missing: missing.map((d) => d.message) } })
516 }
517 return out
518}
519
520/**
521 * `@debug` / `@log` / `@assert` and D001 never reach the prompt (SPEC «Налагодження»): they go to the session debug
522 * log (`$.ui.log`, `to: 'debug'`), the journal (`kind: 'debug'`, `/gate why | where kind=debug`) and, with
523 * `debug: true`, `.claude/gate.debug.log` (cut to 1 MB). A batch identical to the previous render's is skipped.
524 */
525async function recordDebug(io: Io, rt: Runtime, res: RenderResultExt, tier: string): Promise<void> {
526 try {
527 const entries = res.trace.filter((t) => (t.kind === 'debug' || t.kind === 'log' || t.kind === 'assert') && t.source !== 'build-time')
528 const asserts = res.diagnostics.filter((d) => d.code === 'D001')
529 if (!entries.length && !asserts.length) { rt.lastDebug = undefined; return }
530 const key = hash(stableJson([entries.map((t) => [t.section, t.kind, t.detail]), asserts.map((d) => d.message)]))
531 if (rt.lastDebug === key) return
532 rt.lastDebug = key
533 const secrets = envMask(rt)
534 for (const t of entries) debug(io, maskSecrets(`${t.kind} ${t.section}: ${t.detail}`, secrets))
535 const lines = entries.slice(0, 20).map((t) => ({ section: t.section, kind: t.kind, detail: t.detail.slice(0, 500) }))
536 if (lines.length) await journal(io, rt, { kind: 'debug', trigger: 'render', tier, data: { entries: lines, count: entries.length } })
537 if (asserts.length) await journal(io, rt, { kind: 'debug', trigger: 'assert', tier, data: { code: 'D001', assertFail: rt.cfg.assertFail ?? 'skip', messages: asserts.slice(0, 10).map((d) => d.message) } })
538 if (rt.cfg.debug) await writeDebugLog(io, rt, res, tier)
539 } catch (err) {
540 debug(io, `debug entries: ${String((err as Error)?.message ?? err)}`)
541 }
542}
543
544/** `.claude/gate.debug.log` (only with `debug: true`): core `debugLogLines` / `capDebugLog`, as `context-gate run --debug`. */
545async function writeDebugLog(io: Io, rt: Runtime, res: RenderResultExt, tier: string): Promise<void> {
546 const turn = (await io.read('gateState').catch(() => ({ turn: 0 }))).turn
547 const add = debugLogLines(res, now(), { turn, tier, secrets: envMask(rt) })
548 if (!add) return
549 const rel = rt.cfg.debugLog?.path ?? DEBUG_LOG_FILE
550 if (!insideRoot(rel)) return
551 const path = join(rt.root, rel)
552 if (!(await writableInsideRoot(io, rt, path))) { debug(io, `debug log: ${rel} веде за межі репозиторію — не пишу`); return }
553 // Re-read before every write, as flushJournal (M16): another session or `context-gate run --debug` may have
554 // appended since. An existing file that cannot be read is never overwritten.
555 const exists = await io.fs.exists(path).catch(() => false)
556 const t = exists ? await io.fs.read(path).catch(() => undefined) : ''
557 if (typeof t !== 'string') { debug(io, `debug log: ${rel} не прочитано — не перезаписую`); return }
558 rt.debugLogText = capDebugLog(t, add, rt.cfg.debugLog?.maxBytes ?? DEBUG_LOG_MAX)
559 await io.fs.write(path, rt.debugLogText).catch((err: unknown) => debug(io, `debug log: ${String(err)}`))
560}
561
562const TRACE_EVERY_MS = 5000
563
564/** The scope as files keep it: `env.*` values never leave memory (the LSP shows `***`). */
565function scopeForDisk(scope: Scope_): Scope_ {
566 const env = (scope as Record<string, unknown>).env
567 if (!env || typeof env !== 'object' || Array.isArray(env)) return scope
568 return { ...scope, env: Object.fromEntries(Object.keys(env).map((k) => [k, '***'])) } as Scope_
569}
570
571/**
572 * `<prompt dir>/.trace/last.json` after prompt.compose (core RunJson, as `context-gate run --json` writes it), for
573 * the LSP hover «значення з останнього trace». Throttled: changed content, at most once per 5 s.
574 */
575async function writeLastTrace(io: Io, rt: Runtime, res: RenderResultExt, scope: Scope_, tier: string, trusted: boolean): Promise<void> {
576 try {
577 const gate = await io.read('gate')
578 const lazies = [...rt.tools.entries()].filter(([, t]) => t.kind === 'lazy').map(([name, t]) => ({ name: name.slice(OWN_TOOL_PREFIX.length), ref: (t as { ref: string }).ref, description: (t as { description: string }).description }))
579 // Masked structurally, before serialization: a secret with `"` or `\` survives a mask of the JSON text (M04).
580 const body = maskSecretsDeep({ sections: res.sections, text: res.text, trace: res.trace, diagnostics: res.diagnostics, scope: scopeForDisk(scope) }, envMask(rt))
581 const key = hash(stableJson(body))
582 const t = now()
583 if (rt.traceWrite && (rt.traceWrite.hash === key || t - rt.traceWrite.at < TRACE_EVERY_MS)) return
584 rt.traceWrite = { at: t, hash: key }
585 const j: RunJson = {
586 ...body,
587 ms: res.ms,
588 ...(rt.lastHealth ? { health: rt.lastHealth } : {}),
589 meta: {
590 ok: !res.diagnostics.some((d) => d.severity === 'error'),
591 mode: 'prompt',
592 tier,
593 profile: gate?.profile ?? null,
594 source: 'live',
595 trusted,
596 stored: res.stored,
597 lazies,
598 gate: { profile: gate?.profile ?? null, tier: gate?.tier ?? tier, trigger: gate?.trigger ?? 'default', groups: gate?.groups ?? [], reason: gate?.reason ?? [] },
599 at: t,
600 },
601 }
602 await io.fs.write(join(rt.root, `${promptDir(rt)}/.trace/last.json`), JSON.stringify(j, null, 2) + '\n')
603 } catch (err) {
604 debug(io, `trace: ${String((err as Error)?.message ?? err)}`)
605 }
606}
607
608/** Session id, model and ctx percent for journal snapshots (`context-gate run --ctx-from session:…`). */
609async function snapshotMeta(io: Io, rt: Runtime): Promise<{ sessionId: string; model: string; ctxPercent: number }> {
610 const sessionId = await io.session.id().catch(() => '')
611 const model = (await io.read('model')) ?? (await io.session.model().catch(() => '')) ?? ''
612 return { sessionId, model, ctxPercent: (await io.read('ctxPercent')) ?? 0 }
613}
614
615/** With `log.file`: a `snapshot` entry (core contract) per changed compose; file only, never the state ring. */
616async function writeSnapshot(io: Io, rt: Runtime, scope: Scope_, tier: string, sections: readonly { text: string }[]): Promise<void> {
617 if (!rt.cfg?.log?.file) return
618 try {
619 const meta = await snapshotMeta(io, rt)
620 const gate = await io.read('gate')
621 const secrets = envMask(rt)
622 const raw = snapshotData({ ...meta, tier, profile: gate?.profile ?? null, scope: scopeForDisk(scope) as Record<string, Value>, text: sections.map((s) => s.text).join('\n\n') })
623 const data = maskSecretsDeep(raw, secrets)
624 const key = hash(stableJson(data))
625 if (rt.lastSnapshot === key) return
626 rt.lastSnapshot = key
627 const turn = (await io.read('gateState')).turn
628 await pushFileEntry(io, rt, snapshotEntry(data, { ts: now(), turn }))
629 } catch (err) {
630 debug(io, `snapshot: ${String((err as Error)?.message ?? err)}`)
631 }
632}
633
634/** Static sections render once per session per node hash: the first text is kept (prompt cache). */
635function staticText(rt: Runtime, s: RenderedSection, tier: string): string {
636 if (s.scope !== 'static') return s.text
637 const node = rt.prompts ? sectionsFor(rt, rt.prompts, tier).system.flatMap((p) => p.sections).find((x) => x.id === s.id) : undefined
638 const key = hash(stableJson(node ?? s.id) + '|' + tier)
639 const c = rt.staticCache.get(s.id)
640 if (c && c.hash === key) return c.text
641 rt.staticCache.set(s.id, { hash: key, text: s.text, chars: s.chars, tokens: s.tokens })
642 return s.text
643}
644
645async function recordHealth(io: Io, rt: Runtime, res: RenderResultExt, set: PromptSet): Promise<void> {
646 try {
647 const usage = await io.session.usage().catch(() => undefined)
648 // G-43 / G-44: the session's gate decision, compactions and skills listed without a description.
649 const gate = await io.read('gate')
650 const log = await io.read('log')
651 const manualOverrides = log.filter((e) => e.trigger === 'manual').length
652 const noDesc = rt.listingText ? parseSkillListing(rt.listingText).lines.filter((l) => l.type === 'skill' && !l.description.trim()).length : undefined
653 const report = computeHealth(res, rt.lastRender, {
654 compactions: rt.compactions,
655 decision: { profile: gate?.profile ?? null, ...(gate?.proposed ? { confidence: gate.proposed.confidence } : {}), ...(manualOverrides ? { manualOverrides } : {}) },
656 ...(noDesc !== undefined ? { skillsNoDescription: noDesc } : {}),
657 unverified: res.sections.filter((s) => s.included && s.status === 'unverified').length,
658 ...(usage?.context.percent !== undefined ? { contextPct: usage.context.percent } : {}),
659 ...(rt.listingText ? { skillListingChars: rt.listingText.length } : {}),
660 ...(usage?.context.window ? { contextWindow: usage.context.window } : {}),
661 denies: rt.denies,
662 compiledStale: set.stale,
663 gates: gateStats(rt), // H011 (gates.ts)
664 ...(rt.stepUsage?.last ? { usage: { inputTokens: rt.stepUsage.last.input, cacheReadTokens: rt.stepUsage.last.cacheRead, cacheCreationTokens: rt.stepUsage.last.cacheCreation, outputTokens: rt.stepUsage.last.output, sessionInputTokens: rt.stepUsage.input + rt.stepUsage.cacheRead + rt.stepUsage.cacheCreation } } : {}), // H002/H012
665 ...(typeof usage?.cost?.usd === 'number' ? { costUsd: usage.cost.usd } : {}),
666 }, rt.cfg.health ?? {})
667 const prevCodes = (rt.lastHealth?.diagnostics ?? []).map((d) => d.code).sort().join()
668 rt.lastRender = res
669 rt.lastHealth = report
670 const sections: Record<string, { hash: string; chars: number; tokens: number; scope: string; status: string; truncated: boolean }> = {}
671 for (const s of res.sections) if (s.included) sections[s.id] = { hash: s.hash, chars: s.chars, tokens: s.tokens, scope: s.scope, status: s.status, truncated: !!s.truncated }
672 const stable = report.metrics.find((m) => m.code === 'H002')?.value
673 await io.update('health', () => json({ at: now(), ms: res.ms, stablePct: typeof stable === 'number' ? stable : 100, unverified: Object.values(sections).filter((s) => s.status === 'unverified').length, sections }))
674 const codes = report.diagnostics.map((d) => d.code).sort().join()
675 if (codes && codes !== prevCodes) await journal(io, rt, { kind: 'health', trigger: 'health', data: { codes: report.diagnostics.map((d) => d.code) } })
676 await refreshStatus(io, rt)
677 } catch (err) {
678 debug(io, `health: ${String((err as Error)?.message ?? err)}`)
679 }
680}
681
682// ───────────────────────── prompt skills & our tools ─────────────────────────
683
684export function findPromptSkill(rt: Runtime, name: string): CompiledPrompt | undefined {
685 const bare = name.replace(/^[^:]+:/, '')
686 return rt.prompts?.compiled.find((p) => p.skill && (p.skill.name === name || p.skill.name === bare))
687}
688
689/** Raw args from the SKILL.md render line (`run <name> --args '…' --ctx-from live`, or an older `--args "…"`): the
690 * first such span in the text, so the generated comment's `--args "<аргументи>"` placeholder after it never wins.
691 * The engine substitutes `$ARGUMENTS` raw, so a single-quoted value runs to `' --ctx-from` (a `'` inside stays).
692 * An unexpanded `$ARGUMENTS` is no args. */
693export function argsFromText(text: string): string | undefined {
694 const re = /\brun\s+\S+\s+--args\s+(["'])/g
695 for (let m = re.exec(text); m; m = re.exec(text)) {
696 const start = m.index + m[0].length
697 let v: string | undefined
698 if (m[1] === '"') {
699 const d = /^((?:[^"\\]|\\.)*)"/.exec(text.slice(start))
700 v = d ? d[1].replace(/\\(.)/g, '$1') : undefined
701 } else {
702 const end = text.indexOf("' --ctx-from", start)
703 const raw = end >= 0 ? text.slice(start, end) : /^((?:[^']|'\\'')*)'/.exec(text.slice(start))?.[1]
704 v = raw?.replace(/'\\''/g, "'")
705 }
706 if (v === undefined || v === '<аргументи>') continue
707 return v === '$ARGUMENTS' ? undefined : v
708 }
709 return undefined
710}
711
712/**
713 * Core `skillArgs` with `path` args checked against the repo (SPEC: «`path` (перевіряється існування відносно
714 * кореня)»). The parser's check is synchronous, so the path values of a first parse are stat'ed, then it parses again.
715 */
716export async function parseSkillArgs(io: Io, rt: Runtime, prompt: CompiledPrompt, input: string | Record<string, unknown>): Promise<ReturnType<typeof skillArgs>> {
717 await ensureSession(io, rt)
718 const first = skillArgs(prompt, input)
719 if (!first.ok) return first
720 const paths = Object.entries(prompt.skill!.args).filter(([, a]) => a.type === 'path').map(([k]) => first.args[k]).filter((v): v is string => typeof v === 'string' && v !== '')
721 if (!paths.length) return first
722 const existing = new Set<string>()
723 for (const p of paths) if (insideRoot(p) && (await io.fs.exists(join(rt.root, p)).catch(() => false))) existing.add(p)
724 return skillArgs(prompt, input, (p) => existing.has(p))
725}
726
727export async function renderSkill(io: Io, rt: Runtime, prompt: CompiledPrompt, input: string | Record<string, unknown>): Promise<string> {
728 const skill = prompt.skill!
729 const parsed = await parseSkillArgs(io, rt, prompt, input)
730 if (!parsed.ok) return parsed.text
731 const host = await hostFor(io, rt)
732 const { scope, tier } = await buildScope(io, rt, host, undefined)
733 scope.args = parsed.args
734 // `tiers={[…]}` on `<Prompt as="skill">` limits the skill like a section's `tier` (as `context-gate run <skill>`).
735 const section: SectionNode = { id: skill.name, scope: 'volatile', children: skill.body, ...(skill.tiers ? { tier: skill.tiers } : {}) }
736 const res = await renderPrompt([section], scope, host, { ...renderOptions(rt, tier), uses: prompt.uses ?? {} })
737 const s = res.sections[0]
738 await journal(io, rt, { kind: 'skill-render', trigger: 'skill', tier, data: { ...(await snapshotMeta(io, rt)), skill: skill.name, args: parsed.args, ms: res.ms, chars: s?.chars ?? 0, status: s?.status ?? 'fail' } })
739 if (!s || !s.included) return `Skill ${skill.name}: ${s?.reason ?? 'не відрендерено'}${res.diagnostics.length ? ` (${res.diagnostics.slice(0, 3).map((d) => `${d.code} ${d.message}`).join('; ')})` : ''}`
740 return s.text
741}
742
743const toolName = (s: string): string => s.replace(/[^\w-]/g, '_')
744
745/** `invoke.model: 'tool'` skills become tools with a schema from their args. */
746export async function registerSkillTools(io: Io, rt: Runtime): Promise<void> {
747 const set = await loadPrompts(io, rt)
748 for (const p of set.compiled) {
749 if (!p.skill || p.skill.invoke.model !== 'tool') continue
750 const name = toolName(p.skill.name)
751 const full = OWN_TOOL_PREFIX + name
752 const known = rt.tools.get(full)
753 if (known?.kind === 'skill' && known.prompt.sourceHash === p.sourceHash) { rt.tools.set(full, { kind: 'skill', prompt: p }); continue }
754 rt.tools.set(full, { kind: 'skill', prompt: p })
755 await io.tool.register({ name, description: p.skill.description, inputSchema: argsToJsonSchema(p.skill.args) }).catch((err: unknown) => debug(io, `tool ${name}: ${String(err)}`))
756 }
757}
758
759/** `# gate-tool: name` headers in `<dir>/scripts/*` (trusted repos only): core `parseToolHeader`, as the CLI. */
760export function parseScriptHeader(text: string, path: string): ScriptTool | undefined {
761 const { header } = parseToolHeader(text)
762 if (!header) return undefined
763 return { name: header.name, description: header.description ?? header.name, path, inputSchema: header.inputSchema, ...(header.tiers ? { tiers: header.tiers } : {}) }
764}
765
766export async function registerScriptTools(io: Io, rt: Runtime): Promise<void> {
767 if ((await trustState(io, rt)) !== 'trusted') return
768 const dir = `${promptDir(rt)}/scripts`
769 const entries = await io.fs.list(join(rt.root, dir)).catch(() => [])
770 for (const e of entries) {
771 if (e.kind !== 'file') continue
772 const rel = `${dir}/${e.name}`
773 const t = await readRepoFile(io, rt, rel)
774 if (typeof t !== 'string') continue
775 const tool = parseScriptHeader(t, rel)
776 if (tool) await registerOwnScriptTool(io, rt, tool)
777 }
778 await registerFunctionTools(io, rt)
779}
780
781async function registerOwnScriptTool(io: Io, rt: Runtime, tool: ScriptTool): Promise<void> {
782 const full = OWN_TOOL_PREFIX + tool.name
783 if (rt.tools.has(full)) return
784 rt.tools.set(full, { kind: 'script', tool })
785 await io.tool.register({ name: tool.name, description: tool.description, inputSchema: tool.inputSchema }).catch((err: unknown) => debug(io, `script tool ${tool.name}: ${String(err)}`))
786}
787
788/** Modules whose exports may be tools: `<prompt dir>/lib/*`, gate.json `module` providers, and `use` paths of the prompts. */
789async function toolModules(io: Io, rt: Runtime): Promise<string[]> {
790 const dir = promptDir(rt)
791 const out = new Set<string>()
792 for (const e of await io.fs.list(join(rt.root, `${dir}/lib`)).catch(() => [])) if (e.kind === 'file' && shimLang(e.name)) out.add(`${dir}/lib/${e.name}`)
793 for (const p of Object.values(rt.cfg.providers ?? {})) if (p.kind === 'module' && p.path) out.add(p.path.replace(/^\.\//, ''))
794 const set = rt.prompts
795 if (set) for (const path of usedFunctions([...set.compiled, ...sectionsFor(rt, set, 'standard').system]).keys()) out.add(path.replace(/^\.\//, ''))
796 return [...out].filter((p) => insideRoot(p)).sort()
797}
798
799/**
800 * SPEC «Функції як інструменти моделі»: `# gate-tool: next_version` (or `// gate-tool:`) over an export of a module
801 * makes that function a model tool too, served through the language shim with the tool input as kwargs.
802 */
803export async function registerFunctionTools(io: Io, rt: Runtime): Promise<void> {
804 for (const path of await toolModules(io, rt)) {
805 const t = await readRepoFile(io, rt, path)
806 if (typeof t !== 'string' || !t.includes('gate-tool')) continue
807 for (const h of parseToolHeaders(t).headers) {
808 await registerOwnScriptTool(io, rt, { name: h.name, description: h.description ?? h.name, path, inputSchema: h.inputSchema, fn: h.name, ...(h.tiers ? { tiers: h.tiers } : {}) })
809 }
810 }
811}
812
813async function scriptToolArgv(io: Io, rt: Runtime, rel: string): Promise<string[]> {
814 const t = (await readRepoFile(io, rt, rel)) ?? ''
815 return scriptArgv(join(rt.root, rel), scriptLang(rel, typeof t === 'string' ? t : '') ?? 'bash')
816}
817
818async function lazyText(io: Io, rt: Runtime, ref: string): Promise<string> {
819 if (ref.startsWith('prompt://')) {
820 const id = ref.slice('prompt://'.length)
821 const set = await loadPrompts(io, rt)
822 const host = await hostFor(io, rt)
823 const { scope, tier } = await buildScope(io, rt, host, undefined)
824 const res = await renderPrompt(sectionsFor(rt, set, tier, await preloadOf(io, rt)).system, scope, host, { ...renderOptions(rt, tier), only: id })
825 return res.sections.find((s) => s.id === id)?.text || `Секцію ${id} не знайдено`
826 }
827 const m = /^(skill|rule):(.+)$/.exec(ref)
828 if (m) return (await itemBodyOf(io, rt, m[1] as 'skill' | 'rule', m[2]))?.body ?? `${ref} не знайдено`
829 if (ref.startsWith('text:')) return 'Текст цього включення доступний лише в рендері секції.'
830 if (!insideRoot(ref)) return `${ref}: шлях поза репозиторієм`
831 const t = await readRepoFile(io, rt, ref)
832 return typeof t === 'string' ? t : `${ref} не знайдено`
833}
834
835function toolArgs(e: Record<string, unknown>): Record<string, unknown> {
836 const { tool: _t, tool_use_id: _id, agentId: _a, ...rest } = e
837 return rest
838}
839
840type ComposeSection = { id: string; text: string; scope: 'shared' | 'session' }
841
842/** prompt.compose, after `next`: our sections (session scope) appended; a same-id engine `session` section is replaced. */
843export async function composeAfter(io: Io, rt: Runtime, e: { model: string; traits: readonly string[] }, engine: readonly ComposeSection[]): Promise<ComposeSection[] | undefined> {
844 if (e.traits.includes('bare')) return undefined
845 await ensureSession(io, rt)
846 const ours = e.traits.includes('analysis') ? (rt.lastSections ?? []) : (await composeSections(io, rt, e.model)).sections
847 if (!ours.length) return undefined
848 const sections = [...engine]
849 const rest: ComposeSection[] = []
850 for (const s of ours) {
851 const bare = s.id.slice(SECTION_PREFIX.length)
852 const i = sections.findIndex((x) => x.id === bare && x.scope === 'session')
853 if (i >= 0) sections[i] = { ...sections[i], text: s.text }
854 else rest.push(s)
855 }
856 return [...sections, ...rest]
857}
858
859/** command.run: a `/name args` the person typed (origin `composer`). Remembered for skill.prompt: its args (prompt
860 * skills) and that the user, not the model, asked for it, so the gate's off text never replaces it (M13). Another
861 * plugin's `$.command.run`, a bridge or an SDK run is not the person: nothing is queued, so its expansion is checked
862 * against the gate like any unannounced one (its args still come from the render line). */
863export function captureSkillArgs(rt: Runtime, command: string, args: string, origin?: { kind: string }): void {
864 if (origin && origin.kind !== 'composer') return
865 if (command !== 'gate' && command !== 'rule') noteSkillInvocation(rt, command, { args, user: true })
866}
867
868/**
869 * skill.prompt: off text for a gated-off skill; our prompt skill rendered with parsed args; else undefined.
870 * The event names neither the agent nor the call: the invocation queued by tool.call Skill (already checked
871 * against that agent's gate, G-08) or by a typed `/name` is taken, and only an expansion nobody announced (a
872 * subagent's preload) is checked against the main gate here (M06). Args: the queued call's, else the render line's
873 * `--args '…'`, which the engine fills per call (M07).
874 */
875export async function skillPrompt(io: Io, rt: Runtime, skill: string, text: string): Promise<string | undefined> {
876 await ensureSession(io, rt)
877 const fromText = argsFromText(text)
878 const inv = takeSkillInvocation(rt, skill, fromText)
879 if (!inv) {
880 const off = await skillOffMessage(io, rt, skill)
881 if (off) return off
882 }
883 await loadPrompts(io, rt)
884 const prompt = findPromptSkill(rt, skill)
885 if (!prompt) return undefined
886 // The queued call's args are what the user or the model passed; the text only picks the invocation and is the
887 // fallback when nothing was queued (the render line can be garbled by a quote in the args).
888 const args = inv?.args || fromText || ''
889 return renderSkill(io, rt, prompt, args)
890}
891
892type OwnToolResult = { result: string } | { deny: string } | { isError: true; result: string }
893
894/** Serve `mcp__context-gate__*`: prompt-skill tools, lazy includes, script tools. undefined → not ours. */
895export async function serveOwnTool(io: Io, rt: Runtime, e: { tool: string } & Record<string, unknown>): Promise<OwnToolResult | undefined> {
896 await ensureSession(io, rt)
897 let entry = rt.tools.get(e.tool)
898 if (!entry) {
899 await registerSkillTools(io, rt)
900 await registerScriptTools(io, rt)
901 entry = rt.tools.get(e.tool)
902 }
903 if (!entry) return undefined
904 const args = toolArgs(e)
905 if (entry.kind === 'skill') return { result: await renderSkill(io, rt, entry.prompt, args) }
906 if (entry.kind === 'lazy') {
907 // SPEC «Включення»: the journal shows which section the model asked for, and how many times.
908 const count = (rt.lazyCalls.get(entry.ref) ?? 0) + 1
909 rt.lazyCalls.set(entry.ref, count)
910 await journal(io, rt, { kind: 'debug', trigger: 'lazy', data: { ref: entry.ref, tool: e.tool.slice(OWN_TOOL_PREFIX.length), count } })
911 return { result: await lazyText(io, rt, entry.ref) }
912 }
913 const tool = entry.tool
914 // The calling agent's gate and tier (G-08): a subagent on another tier sees its own decision (L07).
915 const agentId = typeof e.agentId === 'string' ? e.agentId : undefined
916 const gate = await gateFor(io, rt, agentId)
917 const agentTier = agentId !== undefined ? (await io.read('agentTiers'))[agentId] : undefined
918 const tier = agentTier ?? gate?.tier ?? (await io.read('tier')) ?? 'standard'
919 // `kind: tool` items obey groups and profiles like MCP tools (SPEC «Скрипти як інструменти моделі»).
920 if (rt.config && isApplied(gate) && (gate.items[`tool:${tool.name}`] === 'off' || gate.items[`tool:${e.tool}`] === 'off')) {
921 rt.denies[e.tool] = (rt.denies[e.tool] ?? 0) + 1
922 await journal(io, rt, { kind: 'deny', trigger: 'script-tool', tier, data: { tool: tool.name, count: rt.denies[e.tool], ...(agentId !== undefined ? { agent: agentId } : {}) } })
923 return { deny: denyText('tool', tool.name, gate as unknown as Gate, rt.config) }
924 }
925 if (tool.tiers && !tool.tiers.includes(tier)) return { deny: `Інструмент ${tool.name} недоступний для tier ${tier} (tiers: ${tool.tiers.join(', ')})` }
926 if ((await trustState(io, rt)) !== 'trusted') return { deny: `Інструмент ${tool.name}: репозиторій не довірений` }
927 if (tool.fn) {
928 const host = await hostFor(io, rt)
929 const t0 = now()
930 const r = await host.shim(tool.path, [{ fn: tool.fn, args: [], kwargs: args as Record<string, Value> }], 30_000)
931 await journal(io, rt, { kind: 'debug', trigger: 'function-tool', tier, data: { tool: tool.name, path: tool.path, ok: !r.errors[0], ms: now() - t0 } })
932 if (r.errors[0]) return { isError: true, result: r.errors[0] }
933 const v = r.results[0]
934 return { result: typeof v === 'string' ? v : JSON.stringify(v ?? null) }
935 }
936 const argv = await scriptToolArgv(io, rt, tool.path)
937 if (!(await allowedBinary(io, rt, argv))) return { deny: `Інструмент ${tool.name}: ${argv[0]} не в білому списку бінарників або allowScripts вимкнено` }
938 const r = await runArgv(io, rt, argv, { stdin: JSON.stringify({ args, ctx: { tier, profile: gate?.profile ?? null } }), timeoutMs: 30_000 })
939 await journal(io, rt, { kind: 'debug', trigger: 'script-tool', tier, data: { tool: tool.name, exitCode: r.exitCode, ms: r.ms } })
940 if (r.exitCode !== 0) return { isError: true, result: `exit ${r.exitCode}\n${r.stderr.slice(-2000)}` }
941 return { result: r.stdout }
942}
943
944/** First prompt: ask trust once (Р2) when repo config holds something runnable; then build / register tools. */
945export async function trustOnPrompt(io: Io, rt: Runtime, text: string): Promise<void> {
946 if (rt.trustAsked || rt.options.trustBuild !== 'ask' || !rt.interactive || text.trimStart().startsWith('/')) return
947 if ((await trustState(io, rt)) !== 'unknown') return
948 const set = await loadPrompts(io, rt)
949 const scripts = await io.fs.exists(join(rt.root, `${promptDir(rt)}/scripts`)).catch(() => false)
950 const hasRunnable = set.stale.length > 0 || set.compiled.length > 0 || set.markdown.length > 0
951 if (!needsTrust(rt.config, hasRunnable, scripts)) return
952 const d = await ensureTrust(io, rt, { ask: true })
953 if (d === 'trusted') {
954 await registerScriptTools(io, rt)
955 void buildStale(io, rt).catch(() => undefined)
956 }
957}
958
959/** What a changed path means for layer 3 (`classic.FileChanged`): pure, so the routing is testable. */
960export type DslChange =
961 | { kind: 'none' }
962 | { kind: 'config' }
963 | { kind: 'compiled' }
964 | { kind: 'entry'; rel: string }
965 | { kind: 'import'; rel: string; entries: string[] }
966 | { kind: 'scripts'; rel: string }
967 | { kind: 'module'; rel: string }
968 | { kind: 'other'; rel: string }
969
970export function classifyChange(rt: Runtime, path: string): DslChange {
971 if (!rt.root) return { kind: 'none' }
972 const root = rt.root.replace(/[\\/]+$/, '')
973 const norm = path.replace(/\\/g, '/')
974 const r = root.replace(/\\/g, '/')
975 if (!norm.startsWith(r + '/')) return { kind: 'none' }
976 const rel = norm.slice(r.length + 1)
977 if (rel === '.claude/gate.json') return { kind: 'config' }
978 const dir = promptDir(rt)
979 // Sources outside the prompt dir count when a compiled prompt imports them.
980 const importers = (rt.prompts?.compiled ?? []).filter((cp) => (cp.sources ?? []).slice(1).some((s) => s.path === rel)).map((cp) => cp.sources[0].path)
981 if (!rel.startsWith(dir + '/')) {
982 if (importers.length) return { kind: 'import', rel, entries: importers }
983 // A Markdown section of a `prompt-dir` item source: re-read on the next compose (no build).
984 const slash = rel.lastIndexOf('/')
985 if (slash > 0 && isMarkdownSectionFile(rel.slice(slash + 1)) && promptSectionDirs(rt.cfg).slice(1).includes(rel.slice(0, slash))) return { kind: 'other', rel }
986 return { kind: 'none' }
987 }
988 const inner = rel.slice(dir.length + 1)
989 if (inner.startsWith('.compiled/')) return { kind: 'compiled' }
990 if (inner.startsWith('.trace/') || inner.startsWith('data/') || inner.startsWith('proposals/') || inner.startsWith('.types/')) return { kind: 'none' }
991 if (inner.startsWith('scripts/')) return { kind: 'scripts', rel }
992 if (inner.startsWith('lib/')) return { kind: 'module', rel }
993 if (/\.prompt\.tsx$/.test(rel) && !inner.includes('/')) return { kind: 'entry', rel }
994 if (importers.length) return { kind: 'import', rel, entries: importers }
995 return { kind: 'other', rel }
996}
997
998/**
999 * classic.FileChanged (SPEC "Життєвий цикл"): `.prompt.tsx` → build that file; an imported source (`shared/*.tsx`,
1000 * `.md`, `.json`) → build its importers; `gate.json` (ctx types, `when`) → build everything; `scripts/**` and
1001 * `lib/**` → re-read tool headers and exports. All in the background, picked up by the next prompt.compose.
1002 */
1003export async function dslFileChanged(io: Io, rt: Runtime, path: string): Promise<void> {
1004 const c = classifyChange(rt, path)
1005 if (c.kind === 'none') return
1006 rt.promptsDirty = true
1007 if (c.kind === 'compiled' || c.kind === 'other') return
1008 const trusted = rt.interactive && (await trustState(io, rt)) === 'trusted'
1009 if (c.kind === 'scripts' || c.kind === 'module') {
1010 for (const [name, t] of [...rt.tools]) if (t.kind === 'script' && t.tool.path === c.rel) rt.tools.delete(name)
1011 rt.moduleExports.delete(c.rel)
1012 if (trusted) await registerScriptTools(io, rt).catch(() => undefined)
1013 return
1014 }
1015 if (!trusted || rt.cfg.prompt?.build === 'never') return
1016 if (c.kind === 'entry') void buildPrompts(io, rt, { only: c.rel, timeoutMs: FULL_BUILD_MS }).catch(() => undefined)
1017 else if (c.kind === 'import') void (async () => { for (const entry of c.entries) await buildPrompts(io, rt, { only: entry, timeoutMs: FULL_BUILD_MS }) })().catch(() => undefined)
1018 else if (c.kind === 'config') {
1019 rt.whitelist = undefined
1020 if ((await loadPrompts(io, rt).catch(() => undefined))?.compiled.length) void buildPrompts(io, rt, { timeoutMs: FULL_BUILD_MS }).catch(() => undefined)
1021 }
1022}
1023hooks/layers/ui.ts 160 lines1// UI: the AbovePrompt band, the pinned health status, and the panes (SPEC "Інтерфейс користувача"):
2// `gate-why` (/gate why), `gate-health` (/gate health) and `gate-section` (/gate render prompt://<id>).
3// Render hooks only read state (reading subscribes); every write happens in handlers or other events.
4
5
6import type { Gate } from '../../packages/core/src/types.ts'
7import { statusLine as gateStatusLine } from '../../packages/core/src/decide.ts'
8import { budgetFor } from '../../packages/core/src/config.ts'
9import { formatWhy } from '../../packages/core/src/journal.ts'
10import { formatHealth } from '../../packages/core/src/health.ts'
11import type { DecisionLogEntry, HealthReport } from '../../packages/core/src/types.ts'
12import type { ContextGateDecision, ContextGateLogEntry, ContextGateRenderHealth, ContextGateSectionView } from '../../types'
13import { json } from '../state.ts'
14import type { Io, Runtime } from '../ctx.ts'
15
16export const WHY_PANE = 'gate-why'
17export const HEALTH_PANE = 'gate-health'
18export const SECTION_PANE = 'gate-section'
19const DASH = '—'
20
21/** The status marker of a failed prompt build (SPEC "Помилки збірки"). */
22export const BUILD_MARK = 'prompt ⚠ build'
23
24/** `rt.buildError` (set by layer 3 on H013/G*, cleared by a good build). */
25export function buildErrorOf(rt: Runtime): { code: string; message: string } | undefined {
26 const e = (rt as { buildError?: { code: string; message: string } }).buildError
27 return e && typeof e === 'object' ? e : undefined
28}
29
30/** `gate — · tier — · ctx —%` with whatever the state holds (no decision yet). */
31export function bandLine(v: { profile: string | null | undefined; proposed?: string | null; tier: string | null | undefined; ctx: number | null | undefined }): string {
32 const profile = v.profile ?? (v.proposed ? `(${v.proposed}?)` : DASH)
33 return `gate ${profile} · tier ${v.tier ?? DASH} · ctx ${v.ctx === null || v.ctx === undefined ? DASH : Math.round(v.ctx)}%`
34}
35
36/** `gate frontend · tier standard · skills 5/23 · mcp 2/6 · rules 3 · ctx 38%`; shadow → `gate (frontend?) …`. */
37export function gateLine(gate: ContextGateDecision | null, tier: string | null, ctx: number | null): string {
38 if (!gate) return bandLine({ profile: null, tier, ctx })
39 const line = gateStatusLine(gate as unknown as Gate, ctx === null ? {} : { ctxPct: ctx })
40 return ctx === null ? `${line} · ctx ${DASH}%` : line
41}
42
43const fmtK = (n: number): string => (n >= 1000 ? `${(n / 1000).toFixed(1)}k` : String(n))
44
45/** `prompt 8.1k (static 76%) · ◌ N` from the stored render health; `prompt ⚠ build` after a failed build. */
46export function healthLine(h: ContextGateRenderHealth | null, buildError?: { code: string } | null): string | undefined {
47 if (!h) return buildError ? BUILD_MARK : undefined
48 if (buildError) return `${BUILD_MARK} · ◌ ${h.unverified}`
49 const secs = Object.values(h.sections)
50 const tokens = secs.reduce((a, s) => a + s.tokens, 0)
51 const stat = secs.filter((s) => s.scope === 'static').reduce((a, s) => a + s.tokens, 0)
52 const pct = tokens ? Math.round((stat / tokens) * 100) : 0
53 return `prompt ${fmtK(tokens)} (static ${pct}%) · ◌ ${h.unverified}`
54}
55
56const drawnMark = new WeakMap<Runtime, string>()
57
58/** Pinned status line: health in interactive sessions; the whole line headless (no AbovePrompt).
59 * A change of the build or config marker also redraws the band (it reads `rt`, which no atom subscribes to). */
60export async function refreshStatus(io: Io, rt: Runtime): Promise<void> {
61 try {
62 const err = buildErrorOf(rt)
63 const mark = `${err ? 'build' : ''}|${configMark(rt) ?? ''}`
64 if ((drawnMark.get(rt) ?? '|') !== mark) {
65 drawnMark.set(rt, mark)
66 try { io.ui.invalidate('ui.render') } catch { /* no surface */ }
67 }
68 const health = healthLine(await io.read('health'), err)
69 if (rt.surface === null) {
70 const line = [gateLine(await io.read('gate'), await io.read('tier'), await io.read('ctxPercent')), health ?? '', configMark(rt) ?? ''].filter(Boolean).join(' · ')
71 io.ui.status(line)
72 } else {
73 io.ui.status(health)
74 }
75 } catch { /* no surface */ }
76}
77
78export async function applyProposed(io: Io, rt: Runtime, recompute: (trigger: string) => Promise<unknown>): Promise<void> {
79 const gate = await io.read('gate')
80 const p = gate?.proposed?.profile
81 if (!p) return
82 await io.update('manual', (m) => json({ ...m, profile: p, off: undefined }))
83 await recompute('manual')
84 void rt
85}
86
87export async function resetAuto(io: Io, recompute: (trigger: string) => Promise<unknown>): Promise<void> {
88 await io.update('manual', (m) => json({ add: [], remove: [], ...(m.mode ? { mode: m.mode } : {}) }))
89 await recompute('auto')
90}
91
92type Els = { Box: (p: Record<string, unknown>) => unknown; Text: (p: Record<string, unknown>) => unknown; Markdown: (p: { text: string }) => unknown; Button: (p: { key: string; label: string; variant?: 'primary'; onPress: () => void }) => unknown }
93
94/** The marker of a layer switched off by an invalid gate.json: otherwise the band reads like «no profile matched» (O7). */
95export const CONFIG_MARK = '⚠ gate.json'
96
97function configMark(rt: Runtime): string | undefined {
98 return rt.disabled?.gate ? CONFIG_MARK : undefined
99}
100
101/** The band's one line: highlighted once the soft context threshold is crossed, a prompt build failed or gate.json
102 * is invalid (`/gate why` says why). */
103export function bandProps(rt: Runtime, gate: ContextGateDecision | null, tier: string | null, ctx: number | null): { text: string; hot: boolean } {
104 const soft = rt.cfg ? budgetFor(rt.cfg, gate?.tier ?? tier ?? 'standard').softContextPct : 70
105 const err = buildErrorOf(rt)
106 const cfgErr = configMark(rt)
107 const line = [gateLine(gate, tier, ctx), err ? BUILD_MARK : '', cfgErr ?? ''].filter(Boolean).join(' · ')
108 return { text: line, hot: (ctx !== null && ctx >= soft) || !!err || !!cfgErr }
109}
110
111/** `/gate why` pane: disabled layers, the last decisions, prompt sections, and the two buttons. */
112export function whyPane(els: Els, v: {
113 log: readonly ContextGateLogEntry[]; gate: ContextGateDecision | null; health: ContextGateRenderHealth | null; disabled: Record<string, string>; rows: number
114 onApply: () => void; onAuto: () => void
115}): unknown {
116 const parts: unknown[] = []
117 const off = Object.entries(v.disabled)
118 if (off.length) parts.push(els.Text({ color: 'warning', children: off.map(([k, d]) => `${k}: ${d}`).join('\n') }))
119 parts.push(els.Markdown({ text: formatWhy(v.log as DecisionLogEntry[], Math.min(50, v.rows)) }))
120 if (v.health) {
121 const secs = Object.entries(v.health.sections).map(([id, s]) => `| ${id} | ${s.scope} | ${s.chars} | ${s.tokens} | ${s.status}${s.truncated ? ', обрізано' : ''} |`)
122 if (secs.length) parts.push(els.Markdown({ text: ['| секція | scope | символи | токени | стан |', '| --- | --- | --- | --- | --- |', ...secs].join('\n') }))
123 }
124 const buttons: unknown[] = []
125 if (v.gate?.shadow && v.gate.proposed) buttons.push(els.Button({ key: 'apply', label: 'Застосувати запропонований профіль', variant: 'primary', onPress: v.onApply }))
126 buttons.push(els.Button({ key: 'auto', label: 'Скинути до auto', onPress: v.onAuto }))
127 parts.push(els.Box({ flexDirection: 'row', gap: 2, children: buttons }))
128 return els.Box({ flexDirection: 'column', gap: 1, children: parts })
129}
130
131/** `/gate health` pane: the full health table with the «що зробити» column, plus a failed build. */
132export function healthPane(els: Els, v: { report: HealthReport | undefined; buildError: { code: string; message: string } | undefined; onRerender: () => void }): unknown {
133 const parts: unknown[] = []
134 if (v.buildError) parts.push(els.Text({ color: 'warning', children: `${BUILD_MARK}: ${v.buildError.code} ${v.buildError.message}` }))
135 parts.push(els.Markdown({ text: v.report ? formatHealth(v.report) : 'Рендера промпту ще не було в цій сесії (секцій DSL немає або prompt.compose ще не спрацював).' }))
136 parts.push(els.Box({ flexDirection: 'row', gap: 2, children: [els.Button({ key: 'rerender', label: 'Перерендерити', onPress: v.onRerender })] }))
137 return els.Box({ flexDirection: 'column', gap: 1, children: parts })
138}
139
140/** The section pane's header line: `prompt://id · scope · tier · N ток. · стан`. */
141export function sectionHeader(s: ContextGateSectionView): string {
142 return `prompt://${s.id} · ${s.scope} · tier ${s.tier} · ${s.tokens} ток. (${s.chars} симв.) · ${s.included ? s.status : `пропущена${s.reason ? `: ${s.reason}` : ''}`}`
143}
144
145/** `/gate render prompt://<id>` pane: the section's render, tokens, and «відкрити в редакторі» / «перерендерити». */
146export function sectionPane(els: Els, v: { view: ContextGateSectionView | null; onEdit: () => void; onRerender: () => void }): unknown {
147 if (!v.view) return els.Text({ dimColor: true, children: 'Секцію не вибрано: /gate render prompt://<id>' })
148 const s = v.view
149 const parts: unknown[] = [els.Text({ bold: true, children: sectionHeader(s) })]
150 if (s.diagnostics.length) parts.push(els.Text({ color: 'warning', children: s.diagnostics.join('\n') }))
151 parts.push(els.Markdown({ text: s.text || '_(порожньо)_' }))
152 if (s.editorUrl) parts.push(els.Text({ children: `Редактор: ${s.editorUrl}` }))
153 if (s.editorError) parts.push(els.Text({ color: 'warning', children: s.editorError }))
154 parts.push(els.Box({ flexDirection: 'row', gap: 2, children: [
155 els.Button({ key: 'edit', label: 'Відкрити в редакторі', variant: 'primary', onPress: v.onEdit }),
156 els.Button({ key: 'rerender', label: 'Перерендерити', onPress: v.onRerender }),
157 ] }))
158 return els.Box({ flexDirection: 'column', gap: 1, children: parts })
159}
160