SLOPSHOPPER

context-gate-probe

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

newguardcommandpromptmodel
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · context-gate-probe
› fix the failing auth test and add an audit log call ● context-gate-probe: context-gate-probe: state_after_clear {"seq":1,"at":"2026-10-07T15:23:19.452Z","kind":"session.start","markerBefore":"","surface":"terminal","isInteractive":true} ● context-gate-probe: context-gate-probe: state_after_clear {"seq":2,"at":"2026-10-07T15:23:19.453Z","kind":"classic.SessionStart","source":"startup","markerBefore":"session.start@2026-10-07T15:23:19.453Z"} ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /probe ⎿ context-gate-probe: context-gate-probe ⎿ context-gate-probe: skill_listing: not-fired, 0 samples ⎿ context-gate-probe: tool_describe_mcp: not-fired, 0 samples ⎿ context-gate-probe: skill_prompt_bang: observed, 1 samples ⎿ context-gate-probe: state_after_clear: observed, 3 samples ⎿ context-gate-probe: filechanged_watchdir: observed, 1 samples ● context-gate-probe: context-gate-probe: filechanged_watchdir {"seq":3,"at":"2026-10-07T15:23:19.453Z","kind":"watchPaths","source":"startup","added":["/work/app/.cursor/rules","/work/app/.claude/probe-watch.txt"],"existing":0} ● context-gate-probe: context-gate-probe: state_after_clear {"seq":4,"at":"2026-10-07T15:23:19.458Z","kind":"prompt.submit","markerBefore":"classic.SessionStart:startup@2026-10-07T15:23:19.453Z","textLength":51} ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
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 2 files
hooks/register.ts 347 lines
1// 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}
347
types/index.d.ts 16 lines
1// 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