SLOPSHOPPER

context-gate

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

newpanebandguardcommandtoast
v0.1.0MITupdated 2026-10-07Ivlad003/context-gate
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · context-gate
│ ┃ gate-why ✕ › fix the failing auth test and add an audit log call │ ┃ | хід | тригер | профіль | tier | зміни | │ ┃ причина | ● context-gate: context-gate: .claude/prompt/prompt.lock.json: шлях в │ ┃ | --- | --- | --- | --- | --- | --- | ⏺ Read(src/auth.ts) │ ┃ | 1 | tier | — | premium | 0 увімк., 0 вимк. ⎿ Read 6 lines │ ┃ | модель claude-opus-5-5 ~ opus → premium; ⏺ Update(src/auth.ts) │ ┃ профіль не визначено → набір tier premium; у ⎿ Added 2 lines, removed 1 line │ ┃ gate.json немає груп → усе увімкнено; ⏺ Bash(bun test) │ ┃ shadow: рішення лише в журнал, нічого не ⎿ 3 pass, 1 fail │ ┃ фільтрується (/gate apply) | │ ┃ ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ [ Скинути до auto ] │ ✻ Worked for 42s · done 4:20 PM │ │ › /gate │ ⎿ context-gate: gate — · tier premium · skills 0/0 · mcp 0/0 · rul │ ⎿ context-gate: режим: shadow — рішення лише в журнал, нічого не ф │ ⎿ context-gate: тригер: tier; групи: — │ ⎿ context-gate: чому: модель claude-opus-5-5 ~ *opus* → premium; п │ │ gate — · tier premium · skills 0/0 · mcp 0/0 · rules 0 · ctx 49% ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
gate — · tier premium · skills 0/0 · mcp 0/0 · rules 0 · ctx 49%
Pane · gate-why
| хід | тригер | профіль | tier | зміни | причина | | --- | --- | --- | --- | --- | --- | | 1 | tier | — | premium | 0 увімк., 0 вимк. | модель claude-opus-5-5 ~ opus → premium; профіль не визначено → набір tier premium; у gate.json немає груп → усе увімкнено; shadow: рішення лише в журнал, нічого не фільтрується (/gate apply) | [ Скинути до auto ]
Pane · gate-health
Рендера промпту ще не було в цій сесії (секцій DSL немає або prompt.compose ще не спрацював). [ Перерендерити ]
Pane · gate-section
Секцію не вибрано: /gate render prompt://<id>
README

context-gate

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.

Install

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.

Quick start

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.

The three layers

LayerWhat it doesWhere
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-gatePicks 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 DSLThe 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 reference

JSON 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/.

FieldMeaning
groupsgroup → kind-prefixed globs: skill:react-*, tool:mcp__figma__*, agent:ui-reviewer, rule:api-* (legacy skillGroups/mcpGroups: context-gate migrate)
tierspremium / standard / quick (any names): groups, preload (skill bodies inlined for weaker models), thresholds
modelsmodel id glob → tier, or attributes { match, tier?, contextWindow, costPer1k }
profilesname → groups plus when: paths, branch, ticketType, expr over providers
classify`mode: shadow \auto, model, minConfidence, recheckOn, provider: builtin \jev \{ kind: cli }`
budgets, onExceedsoftContextPct / hardContextPct per tier; actions section, notice, compact
escalationorder of tiers and after: { verifyFailed, stallTurns } → escalation-suggested in the journal
briefa task brief written once per task by a strong model for weaker tiers
providersnamed data sources for the DSL: cli (JSON stdout), file, mcp, module; schema, cache, onError, functions; cli: okExitCodes, parseOnError (eslint -f json exits 1)
executorshow Run/Call start a language (python3, node, bash, deno, …)
itemSourcesitem sources: cursor-mdc, markdown-dir, provider (field, as, template), prompt-dir (extra section dir, as: "section"), claude-skills, claude-tools
gatesdeterministic 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`
cursorRulesenabled, nested (rules in sub-package .cursor/rules), maxCharsPerInjection, strictWrite
promptdir, runCacheDefault, `build: auto \never, commitCompiled, persist`
healththresholds per code (H001: 12000, …)
debug, debugLog, assertFail@debug evaluation and .claude/gate.debug.log (1 MB), a false @assert: skip or fail
envenv vars visible to the DSL as env.*, masked as *** in debug output
allowBinariesnarrows the user's binary whitelist (~/.claude/context-gate.json); never widens it
logfile: true also writes .claude/gate.log.jsonl (shared with the shiftwork runner)

Provider and gate adapters for keylang, tsc and eslint: examples/providers/.

CLI reference

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.

CommandWhat it does
Prompts
buildcompile .claude/prompt/*.prompt.tsx into .compiled/*.json, prompt.lock.json and SKILL.md
runrender 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`
renderrender sections, or one section: render prompt://<id>
healthprompt health metrics H0xx; --json for CI, --strict exits 1 over a threshold
fmtalign @ directives in Markdown prompts
expandgenerate quick/standard variants of canonical sections into proposals/
explain <code>explain a diagnostic code (G0xx…G5xx, H0xx, D0xx)
indexwrite .claude/gate.index.json for the editor
Repository
initcreate .claude/gate.json from the repo structure (classify: shadow) and .gitignore lines
migrateconvert legacy skillGroups/mcpGroups/ruleSources into groups/itemSources
syncthe 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 skillscopy the example skill prompts into .claude/prompt/
trusttrust for the repository (Р2): processes, cli/module providers, @run/@call
datathe script data store data.*
schema infer <provider>draft a provider JSON Schema from a real run
toolsmodel 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, observepipeline stages
where, tokens, on, off, why, take, sort, previewfilters and views
Journal
reportjournal 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
benchprompt and item tokens before/after the gate, unverified, over bench/repos.json

Hooks and calls

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).

HookPurpose
session.startread gate.json, register /gate and /rule, build stale prompts, status line
classic.SessionStartwatchPaths for .cursor/rules, gate.json, prompts; reset after /clear, recheck after compact
session.endstate reset on /clear
session.compactinstructions that keep the active profile and rules
classic.FileChangedrule 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.contextdedup 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.offerhide subagents outside the profile
skill.promptoff text for a disabled skill; prompt-skill render with args
turn.stepmodel and agent → tier recompute; prompt-cache usage
turn.completeturn gates, stall counter, budgets, escalation, journal flush
session.measurecontext percent, budgets, status line
prompt.composerender 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
$ callWhy
$.fs.read, $.fs.list, $.fs.exists, $.fs.statgate.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, .usageroot, journal key, tier, branch fallback, context percent
$.session.append, $.session.compacttranscript notices; onExceed: compact
$.state.get, $.state.setsession state atoms (context-gate.*)
$.store.get, $.store.set, $.store.deleteper-repo trust, render cache, data.*
$.tool.register, $.tool.listlazy-include and script tools; MCP servers of the session
$.command.register/gate, /rule
$.model.classify, $.model.completethe 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.getuser binary whitelist and the env block; literal HOME/OS
$.clock.afterdefer $.session.compact past the running turn
$.ui.*ask (trust), toast, status, log, open/close panes, invalidate, resolve

Run modes

EnvironmentWhat worksReplacement
CLI, Desktop Code tabeverything—
claude -p (shiftwork runner, CI agent)hooks, @run, filtering; no /gate or panesprofile from userConfig or [gate:<profile>] in the prompt; --trust-repo; --append-system-prompt for preload
VS Code extension, cloud sessionshooks without UIas for -p
Claude Code < 2.1.287, --bare, allowManagedModsOnlythe mod does not loadnpx 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).

Editor (VS Code)

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
  • Build on save of .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.
  • TSX hints: 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).
  • Markdown sections: diagnostics, completion after @ 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.

Security model

  • The mod is not sandboxed and runs with the user's rights, so code from a repository runs only after trust-on-first-use (Р2): one prompt per repository (path + remote), covering every 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.
  • Binary whitelist lives only in user settings (~/.claude/context-gate.json); a repository can narrow it (allowBinaries), never widen it.
  • No network from the DSL: $.http is never called. Data reaches scripts through stdin as JSON.
  • Repository text is data. .mdc and DSL files never become commands; provider results are data.
  • The classifier gets only the prompt text and paths, never file contents.
  • The journal holds metadata only (no prompt text, file contents or command output). Debug output masks the values of whitelisted env variables.
  • Organisation rules win: every deny is answered in tool.call, never in tool.check, so sec-default and managed PreToolUse hooks go first.

Development

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/.

License

MIT

Source 45 files
hooks/register.ts 406 lines
1// 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}
406
hooks/ctx.ts 308 lines
1// 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}
308
hooks/state.ts 52 lines
1// 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
52
hooks/layers/config.ts 127 lines
1// 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}
127
hooks/layers/session.ts 136 lines
1// 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}
136
hooks/layers/commands.ts 346 lines
1// `/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}
346
hooks/layers/gates.ts 649 lines
1// 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}
649
hooks/layers/cursor-rules.ts 478 lines
1// 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
478
hooks/layers/skill-gate.ts 896 lines
1// 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}
896
hooks/layers/budgets.ts 78 lines
1// 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}
78
hooks/layers/dsl.ts 1023 lines
1// 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}
1023
hooks/layers/ui.ts 160 lines
1// 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