Live probe for context-gate (SPEC Р6 stage 0): records which unverified mods API points fire, into .claude/probe.json

A runtime and DSL for Claude Code context.
context-gate is one Claude Code mod plugin (Claude Code ≥ 2.1.287) that controls what reaches the model's context: Cursor .mdc rules, skills, MCP tools, subagents and the system prompt itself. Configuration lives in .claude/ and is committed, and the session state lives in the mod. The full design is in docs/SPEC.md (Ukrainian); docs/ARCHITECTURE.md is the consolidated layer-3 map.
You need Claude Code 2.1.287 or newer and Node.js 22.18 or newer on PATH. Installation has two parts: the plugin runs inside Claude Code, and the npm package gives your project the context-gate command.
1. Add the plugin to Claude Code. Run these two commands inside a Claude Code session:
/plugin marketplace add Ivlad003/context-gate
/plugin install context-gate@context-gate
The first command registers this GitHub repository as a plugin marketplace, and the second one installs the context-gate plugin from it. The same works from a terminal: claude plugin marketplace add Ivlad003/context-gate and then claude plugin install context-gate@context-gate. Add --scope project to the install command if the whole team should get the plugin through the repository's .claude/settings.json. Start a new claude session afterwards, so that the plugin loads. To get a newer version later, run claude plugin marketplace update context-gate and then claude plugin update context-gate@context-gate.
The plugin ships its built CLI (dist/cli.js), so nothing has to be built after install. Compiling TSX prompts needs esbuild, which the npm package below brings along. Markdown prompts need no build at all.
2. Add the CLI to your project. In the root of your repository:
npm i -D context-gate # the CLI from npm: https://www.npmjs.com/package/context-gate
npx context-gate init # .claude/gate.json with profiles guessed from the repo, classify: shadow
The local install matters for two reasons. Skills compiled from TSX prompts call npx --no-install context-gate when the plugin is absent (a teammate without the plugin, CI), and the editor integrations call npx --no context-gate. Neither of them downloads anything, so the package has to be in node_modules. To try the CLI once without installing it, run npx context-gate@latest init. You do not need @context-gate/jsx from npm: init and build write its type declarations into .claude/prompt/.types/jsx/, so TSX prompts get autocomplete without it.
3. Check that it works. Start claude in the repository. The status line shows the gate, /gate prints the active profile and tier, and npx context-gate health checks that gate.json is valid. A step-by-step course with many prompt examples is in docs/COURSE.md.
cd your-repo
npx context-gate init # writes .claude/gate.json (+ .gitignore lines)
claude # the mod loads; the status line shows the gate
In the session:
/gate status: profile, tier, how many skills / MCP tools / rules are on
/gate why the decision journal: who chose the profile and why
/gate rules every Cursor rule with its type, globs and whether it was delivered
/gate frontend fix a profile for this session; /gate +docs adds a group, /gate auto goes back
/gate apply leave shadow mode: the gate starts filtering
/gate health prompt health metrics (H0xx) with a "what to do" column
Day one is shadow mode: nothing is filtered, /gate why shows what the classifier would have chosen. After a week, npx context-gate report summarises the journal, and /gate apply turns the gate on.
| Layer | What it does | Where |
|---|---|---|
| 1. cursor-rules | .cursor/rules/*.mdc with Cursor semantics: Always rules go in after CLAUDE.md, Auto Attached rules arrive as context after the tool result of a matching Read/Edit/Write, Agent Requested rules become skills, Manual rules come in with @id or /rule <id>. Per-agent dedup, partial-read check, strictWrite. | hooks/layers/cursor-rules.ts, packages/core/src/mdc.ts |
| 2. skill-gate | Picks the skills, MCP tools and subagents for the task and the model: profiles built from groups, tiers by model, when signals (paths, branch, ticket type, expressions), a classifier once per task with hysteresis, budgets, escalation. Off items get a one-line description and { deny } with "enable with /gate +group". | hooks/layers/skill-gate.ts, packages/core/src/decide.ts |
| 3. prompt DSL | The system prompt as TSX (or Markdown with @ directives) compiled into a total AST, rendered on prompt.compose: conditions, loops, scripts (Run, Call), includes (inline/ref/lazy), tier variants. Static parts stay stable for the prompt cache. | packages/jsx, packages/core/src/render.ts, hooks/layers/dsl.ts |
A minimal prompt, .claude/prompt/main.prompt.tsx:
import { Prompt, Section, Each, Tier, Run, V } from '@context-gate/jsx'
export default (
<Prompt>
<Section id="identity" scope="static">You are a senior TypeScript engineer on this repository.</Section>
<Section id="rules" scope="profile" budget={4000}>
<Each of="cursor.always" as="r"><li><V expr="r.body" /></li></Each>
</Section>
<Section id="workflow" scope="profile">
<Tier is={['quick', 'standard']}>Plan 3–6 steps, show the plan, run the tests after every edit.</Tier>
</Section>
<Section id="repo-state" scope="volatile">
<Run lang="bash" cache="5m" as="log">git log --oneline -5</Run>
Recent commits: {'{{ log }}'}
</Section>
</Prompt>
)
npx context-gate build # → .claude/prompt/.compiled/main.json
npx context-gate run --trace --dry-scripts # what the model gets, with a trace table
.claude/gate.json referenceJSON Schema: schema/context-gate.schema.json. A complete example: examples/basic/.claude/gate.json; a monorepo with 12 rules, 20+ skills and 3 MCP servers: examples/reference/.
| Field | Meaning | |||||
|---|---|---|---|---|---|---|
groups | group → kind-prefixed globs: skill:react-*, tool:mcp__figma__*, agent:ui-reviewer, rule:api-* (legacy skillGroups/mcpGroups: context-gate migrate) | |||||
tiers | premium / standard / quick (any names): groups, preload (skill bodies inlined for weaker models), thresholds | |||||
models | model id glob → tier, or attributes { match, tier?, contextWindow, costPer1k } | |||||
profiles | name → groups plus when: paths, branch, ticketType, expr over providers | |||||
classify | `mode: shadow \ | auto, model, minConfidence, recheckOn, provider: builtin \ | jev \ | { kind: cli }` | ||
budgets, onExceed | softContextPct / hardContextPct per tier; actions section, notice, compact | |||||
escalation | order of tiers and after: { verifyFailed, stallTurns } → escalation-suggested in the journal | |||||
brief | a task brief written once per task by a strong model for weaker tiers | |||||
providers | named data sources for the DSL: cli (JSON stdout), file, mcp, module; schema, cache, onError, functions; cli: okExitCodes, parseOnError (eslint -f json exits 1) | |||||
executors | how Run/Call start a language (python3, node, bash, deno, …) | |||||
itemSources | item sources: cursor-mdc, markdown-dir, provider (field, as, template), prompt-dir (extra section dir, as: "section"), claude-skills, claude-tools | |||||
gates | deterministic checks: `on: write \ | commit \ | push \ | publish \ | turn \ | prompt, run or provider (or only a pass expression), pass expression (command for Bash gates, prompt for prompt gates; run of a prompt gate gets the prompt on stdin), drop (a failed prompt gate stops the prompt), message template, onlyNew + baseline, tiers; builtin read-before-write` |
cursorRules | enabled, nested (rules in sub-package .cursor/rules), maxCharsPerInjection, strictWrite | |||||
prompt | dir, runCacheDefault, `build: auto \ | never, commitCompiled, persist` | ||||
health | thresholds per code (H001: 12000, …) | |||||
debug, debugLog, assertFail | @debug evaluation and .claude/gate.debug.log (1 MB), a false @assert: skip or fail | |||||
env | env vars visible to the DSL as env.*, masked as *** in debug output | |||||
allowBinaries | narrows the user's binary whitelist (~/.claude/context-gate.json); never widens it | |||||
log | file: true also writes .claude/gate.log.jsonl (shared with the shiftwork runner) |
Provider and gate adapters for keylang, tsc and eslint: examples/providers/.
context-gate <command> [flags]; context-gate <command> --help for each one. Global flags: --root <dir>, --trust-repo, --no-user-skills (ignore ~/.claude/skills, also CONTEXT_GATE_NO_USER_SKILLS=1; bench does this by default). Exit codes: 0 ok, 1 failure, 2 bad arguments.
| Command | What it does | ||
|---|---|---|---|
| Prompts | |||
build | compile .claude/prompt/*.prompt.tsx into .compiled/*.json, prompt.lock.json and SKILL.md | ||
run | render the prompt, one section (--only) or a skill (run <skill> --args "…") with the same core as prompt.compose; --trace, --json, --dry-scripts, `--ctx-from session:latest\ | fixture.json, --diff, --watch, --debug` | |
render | render sections, or one section: render prompt://<id> | ||
health | prompt health metrics H0xx; --json for CI, --strict exits 1 over a threshold | ||
fmt | align @ directives in Markdown prompts | ||
expand | generate quick/standard variants of canonical sections into proposals/ | ||
explain <code> | explain a diagnostic code (G0xx…G5xx, H0xx, D0xx) | ||
index | write .claude/gate.index.json for the editor | ||
| Repository | |||
init | create .claude/gate.json from the repo structure (classify: shadow) and .gitignore lines | ||
migrate | convert legacy skillGroups/mcpGroups/ruleSources into groups/itemSources | ||
sync | the fallback without mods: .mdc → .claude/rules/cursor/ and skills, profile → skillOverrides, DSL → .claude/prompt.generated.md; --watch; --agents-md AGENTS.md,… writes only the sections without volatile ones between markers in files other agent CLIs read | ||
example skills | copy the example skill prompts into .claude/prompt/ | ||
trust | trust for the repository (Р2): processes, cli/module providers, @run/@call | ||
data | the script data store data.* | ||
schema infer <provider> | draft a provider JSON Schema from a real run | ||
tools | model tools: # gate-tool: headers of .claude/prompt/scripts and over exports of lib/*, module providers and use paths; --call <name> --input '{…}' runs one like the mod | ||
| Pipeline (JSONL) | |||
pipe "<stages>" | the whole pipeline in one line, the /gate grammar: `collect \ | decide --profile x \ | tokens` |
collect, normalize, signals, decide, budget, deliver --dry-run, observe | pipeline stages | ||
where, tokens, on, off, why, take, sort, preview | filters and views | ||
| Journal | |||
report | journal summary: when vs classifier vs manual, denies per tool with "add group X to profile Y" suggestions, rules never delivered, escalations, attempts and tokens per tier per task, runner vs mod by ticket, skill-prompt render cost | ||
bench | prompt and item tokens before/after the gate, unverified, over bench/repos.json |
What claude plugin validate --strict . reports for the mod (regenerate with scripts/validate-calls.sh --markdown; CI fails when a $ call outside scripts/expected-calls.txt appears).
| Hook | Purpose | |||
|---|---|---|---|---|
session.start | read gate.json, register /gate and /rule, build stale prompts, status line | |||
classic.SessionStart | watchPaths for .cursor/rules, gate.json, prompts; reset after /clear, recheck after compact | |||
session.end | state reset on /clear | |||
session.compact | instructions that keep the active profile and rules | |||
classic.FileChanged | rule cache drop, config reload, incremental prompt build | |||
command.run{command=gate}, command.run{command=rule}, command.run | /gate …, /rule <id>, skill args from /name args | |||
prompt.context | dedup reset; Always rules as instruction files after CLAUDE.md (or a cursorRules block) | |||
prompt.submit | @rule, @file → Auto Attached rules, [gate:x], signals, first-prompt classifier, brief, prompt gates | |||
prompt.attachment{type=skill_listing} | rewrite the skills listing for the gate | |||
| `tool.call{tool=Read\ | Edit\ | Write\ | NotebookEdit}` | glob rules after the result, strictWrite, write gates, read-before-write |
tool.call{tool=Bash} | commit, push and publish gates on git commit, git push and package publishes; failed test/lint runs count for escalation | |||
tool.call{tool=Skill} | skill args for skill.prompt; disabled skills | |||
tool.call{tool=/"^mcp__"/} | { deny } for MCP tools outside the profile; serves the plugin's own tools (lazy includes, script tools) | |||
tool.describe{tool=/"^mcp__"/} | one-line description and isDeferred for gated-off MCP tools | |||
agent.offer | hide subagents outside the profile | |||
skill.prompt | off text for a disabled skill; prompt-skill render with args | |||
turn.step | model and agent → tier recompute; prompt-cache usage | |||
turn.complete | turn gates, stall counter, budgets, escalation, journal flush | |||
session.measure | context percent, budgets, status line | |||
prompt.compose | render the DSL sections as context-gate:<id> session sections | |||
ui.render{component=AbovePrompt}, ui.render{component=Pane, requestId=?} | the band; the /gate why and health panes |
$ call | Why |
|---|---|
$.fs.read, $.fs.list, $.fs.exists, $.fs.stat | gate.json, .mdc, .compiled, skills, mtimes |
$.fs.write | .claude/gate.log.jsonl, gate.debug.log, baselines, .trace/last.json, gate.index.json |
$.session.root, .id, .model, .repo, .usage | root, journal key, tier, branch fallback, context percent |
$.session.append, $.session.compact | transcript notices; onExceed: compact |
$.state.get, $.state.set | session state atoms (context-gate.*) |
$.store.get, $.store.set, $.store.delete | per-repo trust, render cache, data.* |
$.tool.register, $.tool.list | lazy-include and script tools; MCP servers of the session |
$.command.register | /gate, /rule |
$.model.classify, $.model.complete | the classifier (with confidence) and the brief |
$.process.run, $.process.spawn | @run, cli providers, gates, the prompt build (trusted repos only); /gate edit |
$.mcp.call | @mcp and mcp providers (trusted repos only) |
$.settings.read, $.env.get | user binary whitelist and the env block; literal HOME/OS |
$.clock.after | defer $.session.compact past the running turn |
$.ui.* | ask (trust), toast, status, log, open/close panes, invalidate, resolve |
| Environment | What works | Replacement |
|---|---|---|
| CLI, Desktop Code tab | everything | — |
claude -p (shiftwork runner, CI agent) | hooks, @run, filtering; no /gate or panes | profile from userConfig or [gate:<profile>] in the prompt; --trust-repo; --append-system-prompt for preload |
| VS Code extension, cloud sessions | hooks without UI | as for -p |
Claude Code < 2.1.287, --bare, allowManagedModsOnly | the mod does not load | npx context-gate sync (native .claude/rules/cursor/, skills, skillOverrides, prompt.generated.md) or the settings-hooks adapter dist/hooks-adapter.js (docs/HOOKS-ADAPTER.md) |
| Cursor (same repo) | — | .cursor/rules stay the source; Cursor reads .claude/skills itself |
The shiftwork runner reads the same gate.json and the same journal (docs/SHIFTWORK.md).
The extension in editors/vscode needs nothing from npm: it ships the CLI (cli/dist/cli.js, run by VS Code's own Node runtime) with esbuild, the tsserver plugin and the gate.json schema.
npm run package:vscode # → editors/vscode/context-gate-vscode-0.1.0.vsix
code --install-extension editors/vscode/context-gate-vscode-0.1.0.vsix
.claude/prompt/**, imported files and .claude/gate.json; build diagnostics (G*) in Problems, status bar context-gate: ✓ built / ⚠ N (click: log); commands Build prompts, Build current file, Health, Preview section.init and build write .claude/prompt/tsconfig.json and .types/jsx/ (declarations of @context-gate/jsx), so components, props, arg.* and ctx resolve without the package; the tsserver plugin adds G* diagnostics, completion and hover inside expression strings (and live G160 for TSX level 2).@ and inside {{ }}, hover, outline, highlighting.gate.json validated against the schema; .mdc rules: G010–G015 and rule-type hover.Details, settings and limits: docs/EDITOR.md.
process.run and mcp.call that the repository's configuration starts (the prompt build, @run/@call, cli providers, command gates, @mcp). Until then only file reads and the plugin's own module providers run, and script sections render as unverified stubs. /gate trust revoke drops it; a gate.json with new commands asks again; claude -p and CI need --trust-repo.~/.claude/context-gate.json); a repository can narrow it (allowBinaries), never widen it.$.http is never called. Data reaches scripts through stdin as JSON..mdc and DSL files never become commands; provider results are data.env variables.deny is answered in tool.call, never in tool.check, so sec-default and managed PreToolUse hooks go first.npm ci
npm test # node:test unit tests (test/**/*.test.ts)
npx tsc -p tsconfig.json
npm run build # dist/cli.js (committed: the installed plugin runs it)
npm run build:hooks-adapter
npm run typecheck:mod && npm run test:mod # needs the claude CLI
npm run validate:mod # claude plugin validate --strict .
npm run build:jsx-types # dist/jsx-types (committed: copied into user repos as .claude/prompt/.types/jsx/)
npm run test:vscode # the extension in a real VS Code, isolated profile (docs/EDITOR.md)
scripts/validate-calls.sh # $ calls vs scripts/expected-calls.txt
scripts/e2e.sh # one claude -p turn on examples/reference with probe/context-gate-probe
dist/cli.js, dist/hooks-adapter.js and dist/jsx-types/ are committed; CI rebuilds them and fails on a diff. The live API probe for the open mods-API questions is probe/; the static results are in docs/PROBE.md. Bench: bench/.
MIT
hooks/register.ts 347 lines1// context-gate-probe: SPEC Р6 stage 0, docs/PROBE.md "Still LIVE". One observer per unverified mods API
2// point. Every hook passes through unchanged, never throws, appends one observation to an in-memory report
3// and rewrites `<session root>/.claude/<out>` (default probe.json; CONTEXT_GATE_PROBE_OUT overrides the
4// file name, as probe/run-print.sh does for the `-p` run) through $.fs.write.
5// Recorded: metadata and lengths only, except the skill_listing text (the parseSkillListing fixture).
6
7import { atom, read, update } from 'claude-code'
8import type { EngineInterface, Register } from 'claude-code'
9import type { ProbeMarker } from '../types'
10
11export type PointName =
12 | 'skill_listing'
13 | 'tool_describe_mcp'
14 | 'skill_prompt_bang'
15 | 'state_after_clear'
16 | 'filechanged_watchdir'
17 | 'prompt_context_subagent'
18 | 'model_complete_cost'
19 | 'prompt_compose_print'
20
21export const POINTS: readonly PointName[] = [
22 'skill_listing',
23 'tool_describe_mcp',
24 'skill_prompt_bang',
25 'state_after_clear',
26 'filechanged_watchdir',
27 'prompt_context_subagent',
28 'model_complete_cost',
29 'prompt_compose_print',
30]
31
32export type Verdict = 'works' | 'broken' | 'partial'
33export type Sample = Record<string, unknown> & { seq: number; at: string; kind: string }
34export interface Point { status: 'observed' | 'not-fired'; samples: Sample[]; verdict?: Verdict }
35export interface Report {
36 claudeCode: string | null
37 startedAt: string
38 out: string
39 points: Record<PointName, Point>
40}
41
42const MAX_SAMPLES = 60
43const LISTING_CHARS = 20_000
44const WATCH_DIR = '.cursor/rules'
45const WATCH_FILE = '.claude/probe-watch.txt'
46
47const markerAtom = atom({ plugin: 'context-gate-probe', key: 'marker' } as const, '')
48
49export function newReport(now: string): Report {
50 const points = {} as Record<PointName, Point>
51 for (const p of POINTS) points[p] = { status: 'not-fired', samples: [] }
52 return { claudeCode: null, startedAt: now, out: 'probe.json', points }
53}
54
55/** Automatic verdicts where a sample answers the question by itself; the rest stay for the person. */
56export function judge(r: Report): void {
57 const s = (p: PointName) => r.points[p].samples
58 const set = (p: PointName, v: Verdict | undefined) => {
59 if (v) r.points[p].verdict = v
60 }
61 set('tool_describe_mcp', s('tool_describe_mcp').some((x) => x.isDeferred === true) ? 'works' : undefined)
62 const clears = s('state_after_clear').filter((x) => x.kind === 'classic.SessionStart' && x.source === 'clear')
63 if (clears.length) set('state_after_clear', clears.some((x) => typeof x.markerBefore === 'string' && x.markerBefore !== '') ? 'works' : 'broken')
64 const fc = s('filechanged_watchdir').filter((x) => x.kind === 'classic.FileChanged')
65 if (fc.length) set('filechanged_watchdir', fc.some((x) => x.underWatchDir === true) ? 'works' : 'partial')
66 const mc = s('model_complete_cost').filter((x) => x.kind === 'model.complete')
67 if (mc.length) set('model_complete_cost', mc.some((x) => x.isAnswered === true) ? 'works' : 'broken')
68 if (s('prompt_compose_print').some((x) => Array.isArray(x.traits) && (x.traits as unknown[]).includes('print'))) set('prompt_compose_print', 'works')
69}
70
71const lengthOf = (v: unknown): number => (typeof v === 'string' ? v.length : 0)
72const agentKeys = (e: object): string[] => Object.keys(e).filter((k) => /agent/i.test(k))
73
74/** Repo-relative when inside `root`, else the basename: paths only, never content. */
75export function relPath(root: string, path: string): string {
76 if (root && path.startsWith(root.endsWith('/') ? root : root + '/')) return path.slice(root.replace(/\/+$/, '').length + 1)
77 return path.split(/[\\/]/).pop() ?? path
78}
79
80/** `.catch` for every hook: a failed observer degrades to the engine's own behaviour. */
81function pass<E, R>(_$: unknown, e: E, next: (e: E) => R): R {
82 return next(e)
83}
84
85/** The module's own runtime: the report and the write queue (reset on every load). */
86export interface Probe {
87 report: Report
88 seq: number
89 seen: Set<string>
90 writing: Promise<void> | null
91 dirty: boolean
92}
93
94export function newProbe(): Probe {
95 return { report: newReport(new Date().toISOString()), seq: 0, seen: new Set(), writing: null, dirty: false }
96}
97
98async function writeNow($: EngineInterface, rt: Probe): Promise<void> {
99 judge(rt.report)
100 const root = await $.session.root()
101 const name = (await $.env.get('CONTEXT_GATE_PROBE_OUT')) || 'probe.json'
102 rt.report.out = name
103 await $.fs.write(`${root}/.claude/${name}`, JSON.stringify(rt.report, null, 2) + '\n')
104}
105
106/** Overwrite the whole file; writes requested while one is in flight coalesce into one more. */
107async function flush($: EngineInterface, rt: Probe): Promise<void> {
108 rt.dirty = true
109 if (rt.writing) return rt.writing
110 let done: () => void = () => {}
111 rt.writing = new Promise<void>((resolve) => { done = resolve })
112 while (rt.dirty) {
113 rt.dirty = false
114 try {
115 await writeNow($, rt)
116 } catch (err) {
117 try { $.ui.log(`context-gate-probe: write failed: ${String(err)}`, { to: 'debug' }) } catch { /* ignore */ }
118 }
119 }
120 rt.writing = null
121 done()
122}
123
124/** Append one sample (deduplicated by `keyOf` when given) and rewrite probe.json. Never throws. */
125async function observe($: EngineInterface, rt: Probe, point: PointName, kind: string, data: () => Record<string, unknown>, keyOf?: () => string): Promise<void> {
126 try {
127 const key = keyOf?.()
128 if (key !== undefined) {
129 const k = `${point}|${kind}|${key}`
130 if (rt.seen.has(k)) return
131 rt.seen.add(k)
132 }
133 const p = rt.report.points[point]
134 p.status = 'observed'
135 const sample: Sample = { seq: ++rt.seq, at: new Date().toISOString(), kind, ...data() }
136 if (p.samples.length < MAX_SAMPLES) p.samples.push(sample)
137 const { text: _omit, ...logged } = sample
138 try { $.ui.log(`context-gate-probe: ${point} ${JSON.stringify(logged)}`, { to: 'debug' }) } catch { /* ignore */ }
139 await flush($, rt)
140 } catch {
141 /* the probe never breaks the session */
142 }
143}
144
145async function marker($: EngineInterface): Promise<ProbeMarker | null> {
146 try { return await read($, markerAtom) } catch { return null }
147}
148
149async function setMarker($: EngineInterface, value: ProbeMarker): Promise<void> {
150 try { await update($, markerAtom, () => value) } catch { /* ignore */ }
151}
152
153export const register: Register = (on) => {
154 const rt = newProbe()
155 const report = rt.report
156
157 // ── 4 (+ /probe registration): session lifecycle and $.state across /clear ──
158
159 on('session.start', async ($, e, next) => {
160 try {
161 if (report.claudeCode === null) {
162 try { report.claudeCode = (await $.session.version()).version } catch { /* not available */ }
163 }
164 const before = await marker($)
165 await observe($, rt, 'state_after_clear', 'session.start', () => ({ markerBefore: before, surface: e.surface, isInteractive: e.isInteractive }))
166 await setMarker($, `session.start@${new Date().toISOString()}`)
167 await $.command.register({ name: 'probe', description: 'context-gate-probe: classify (time $.model.complete) | dump', argumentHint: 'classify|dump' })
168 } catch { /* ignore */ }
169 return next(e)
170 }).catch(pass)
171
172 on('session.end', async ($, e, next) => {
173 const before = await marker($)
174 await observe($, rt, 'state_after_clear', 'session.end', () => ({ reason: e.reason, markerBefore: before }))
175 return next(e)
176 }).catch(pass)
177
178 // ── 4 + 5: classic SessionStart (source) and watchPaths with a directory entry ──
179
180 on('classic.SessionStart', async ($, e, next) => {
181 const r = await next(e)
182 try {
183 const before = await marker($)
184 await observe($, rt, 'state_after_clear', 'classic.SessionStart', () => ({ source: e.source, markerBefore: before }))
185 await setMarker($, `classic.SessionStart:${e.source}@${new Date().toISOString()}`)
186 const root = await $.session.root()
187 const watch = [`${root}/${WATCH_DIR}`, `${root}/${WATCH_FILE}`]
188 await observe($, rt, 'filechanged_watchdir', 'watchPaths', () => ({ source: e.source, added: watch, existing: r.watchPaths?.length ?? 0 }))
189 return { ...r, watchPaths: [...(r.watchPaths ?? []), ...watch] }
190 } catch {
191 return r
192 }
193 }).catch(pass)
194
195 on('classic.FileChanged', async ($, e, next) => {
196 try {
197 const root = await $.session.root()
198 const path = String(e.file_path)
199 await observe($, rt, 'filechanged_watchdir', 'classic.FileChanged', () => ({
200 file_path: path,
201 event: e.event,
202 underWatchDir: path.startsWith(`${root}/${WATCH_DIR}/`),
203 isWatchFile: path === `${root}/${WATCH_FILE}`,
204 }))
205 } catch { /* ignore */ }
206 return next(e)
207 }).catch(pass)
208
209 // ── 1: the skill listing attachment (passed through unchanged) ──
210
211 on('prompt.attachment', { type: 'skill_listing' }, async ($, e, next) => {
212 const r = await next(e)
213 await observe($, rt, 'skill_listing', 'prompt.attachment', () => ({
214 agentId: e.agentId ?? null,
215 origin: e.origin?.kind ?? null,
216 textLength: e.text.length,
217 resultChanged: r.text !== e.text,
218 hasDetail: e.detail !== undefined,
219 text: e.text.slice(0, LISTING_CHARS),
220 }), () => `${e.agentId ?? 'main'}|${e.text.length}`)
221 return r
222 }).catch(pass)
223
224 // ── 2: tool.describe for MCP tools (deferred?) ──
225
226 on('tool.describe', { tool: /^mcp__/ }, async ($, e, next) => {
227 const r = await next(e)
228 await observe($, rt, 'tool_describe_mcp', 'tool.describe', () => ({
229 tool: e.tool,
230 isDeferred: e.isDeferred === true,
231 descriptionLength: e.description.length,
232 resultIsDeferred: r.isDeferred ?? null,
233 resultDescriptionLength: r.description.length,
234 }), () => `${e.tool}|${e.isDeferred === true}|${e.description.length}`)
235 return r
236 }).catch(pass)
237
238 // ── 3: skill.prompt vs !`…`, Skill tool ordering (seq), command.run ──
239
240 on('skill.prompt', async ($, e, next) => {
241 await observe($, rt, 'skill_prompt_bang', 'skill.prompt', () => ({
242 skill: e.skill,
243 hasBangPattern: /!`/.test(e.text),
244 // the bundled probe-bang skill: its output present without its command means !`…` already ran
245 fixtureExpanded: e.text.includes('probe-bang-expanded') && !e.text.includes('echo probe-bang-expanded'),
246 textLength: e.text.length,
247 }))
248 return next(e)
249 }).catch(pass)
250
251 on('tool.call', { tool: 'Skill' }, async ($, e, next) => {
252 await observe($, rt, 'skill_prompt_bang', 'tool.call:Skill', () => ({ skill: e.skill, hasArgs: lengthOf(e.args) > 0, argsLength: lengthOf(e.args), agentId: e.agentId ?? null }))
253 return next(e)
254 }).catch(pass)
255
256 // ── 7: /probe classify times $.model.complete({ model: 'haiku' }); /probe (dump) summarises ──
257
258 on('command.run', { command: 'probe' }, async ($, e) => {
259 await observe($, rt, 'skill_prompt_bang', 'command.run', () => ({ command: e.command, hasArgs: e.args.trim().length > 0, origin: e.origin?.kind ?? null }))
260 if (e.args.trim() === 'classify') {
261 const t0 = Date.now()
262 try {
263 const res = await $.model.complete({
264 model: 'haiku',
265 system: 'Answer with exactly one label from: frontend, backend, docs.',
266 prompt: 'Task: fix the CSS of the login button.',
267 maxTokens: 16,
268 })
269 const ms = Date.now() - t0
270 await observe($, rt, 'model_complete_cost', 'model.complete', () => (res.isAnswered
271 ? { ms, isAnswered: true, answerLength: res.text.length, usage: res.usage }
272 : { ms, isAnswered: false, reason: res.reason }))
273 return { text: `probe classify: ${ms} ms, ${res.isAnswered ? `usage ${JSON.stringify(res.usage)}` : `not answered (${res.reason})`}` }
274 } catch (err) {
275 const ms = Date.now() - t0
276 await observe($, rt, 'model_complete_cost', 'model.complete', () => ({ ms, isAnswered: false, error: String(err) }))
277 return { text: `probe classify: failed: ${String(err)}` }
278 }
279 }
280 judge(report)
281 const lines = POINTS.map((p) => `${p}: ${report.points[p].status}${report.points[p].verdict ? ` (${report.points[p].verdict})` : ''}, ${report.points[p].samples.length} samples`)
282 return { text: ['context-gate-probe', ...lines, 'usage: /probe classify | dump'].join('\n') }
283 })
284
285 on('command.run', async ($, e, next) => {
286 await observe($, rt, 'skill_prompt_bang', 'command.run', () => ({ command: e.command, hasArgs: e.args.trim().length > 0, origin: e.origin?.kind ?? null }))
287 return next(e)
288 }).catch(pass)
289
290 // ── 6: prompt.context (agentId? instructionFiles?) and turn.step (subagents, cache reads) ──
291
292 on('prompt.context', async ($, e, next) => {
293 const r = await next(e)
294 let root = ''
295 try { root = await $.session.root() } catch { /* relPath falls back to basenames */ }
296 await observe($, rt, 'prompt_context_subagent', 'prompt.context', () => ({
297 blockNames: e.blocks.map((b) => b.name),
298 instructionFilesDefined: e.instructionFiles !== undefined,
299 instructionFilesCount: e.instructionFiles?.length ?? 0,
300 instructionFileKinds: [...new Set((e.instructionFiles ?? []).map((f) => f.kind))],
301 inputKeys: Object.keys(e),
302 agentLikeKeys: agentKeys(e),
303 // what came back from next(e): the plugins beneath (plugin order is unknown) plus the engine
304 resultBlockNames: r.blocks.map((b) => b.name),
305 resultInstructionFiles: (r.instructionFiles ?? []).map((f) => ({ path: relPath(root, f.path), kind: f.kind })),
306 hasCursorRulesBlock: r.blocks.some((b) => b.name === 'cursorRules'),
307 }), () => `${e.blocks.map((b) => b.name).join(',')}|${e.instructionFiles?.length ?? -1}|${r.blocks.map((b) => b.name).join(',')}|${r.instructionFiles?.length ?? -1}`)
308 return r
309 }).catch(pass)
310
311 on('turn.step', async function* ($, e, next) {
312 const r = yield* next(e)
313 await observe($, rt, 'prompt_context_subagent', 'turn.step', () => ({
314 agentId: e.agentId ?? null,
315 model: e.model,
316 index: e.index,
317 usageModel: r?.usage?.model ?? null,
318 cacheReadInputTokens: r?.usage?.cache_read_input_tokens ?? null,
319 stopReason: r?.stopReason ?? null,
320 }))
321 return r
322 })
323
324 // ── 8: prompt.compose traits ('print' under -p, 'sdk-preset') ──
325
326 on('prompt.compose', async ($, e, next) => {
327 const r = await next(e)
328 await observe($, rt, 'prompt_compose_print', 'prompt.compose', () => ({
329 traits: [...e.traits],
330 model: e.model,
331 promptModel: e.promptModel,
332 surfaces: [...e.surfaces],
333 toolsCount: e.tools.length,
334 sectionsCount: r.sections.length,
335 }), () => `${e.traits.join(',')}|${e.model}|${r.sections.length}`)
336 return r
337 }).catch(pass)
338
339 // ── 4 (continued): the marker on each prompt, to see the state right after /clear (no prompt text) ──
340
341 on('prompt.submit', async ($, e, next) => {
342 const before = await marker($)
343 await observe($, rt, 'state_after_clear', 'prompt.submit', () => ({ markerBefore: before, textLength: e.text.length }))
344 return next(e)
345 }).catch(pass)
346}
347types/index.d.ts 16 lines1// context-gate-probe's contract: the one session-state value the probe keeps in `$.state`.
2// Self-contained (no import, no reference), as the mods API requires of a plugin's `types` file.
3
4/** `<event>[:<source>]@<iso>`, or '' before the first write. */
5export type ProbeMarker = string
6
7declare module 'claude-code' {
8 interface PluginState {
9 'context-gate-probe': {
10 /** Written on session.start / classic.SessionStart (`<event>:<source>@<iso>`); read back after /clear. */
11 marker: ProbeMarker
12 }
13 }
14}
15
16