vigiles 15.0.3 → 15.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js CHANGED
@@ -59,6 +59,7 @@ const merge_conflict_js_1 = require("./core/merge-conflict.js");
59
59
  const hook_install_js_1 = require("./hook-install.js");
60
60
  const hook_providers_js_1 = require("./core/hook-providers.js");
61
61
  const toml_1 = require("@iarna/toml");
62
+ const hash_js_1 = require("./core/hash.js");
62
63
  const agent_runtime_js_1 = require("./adapters/claude-code/agent-runtime.js");
63
64
  const observe_js_1 = require("./observe.js");
64
65
  const effect_region_js_1 = require("./adapters/claude-code/effect-region.js");
@@ -5016,6 +5017,84 @@ async function compileProviders() {
5016
5017
  function hookStampPath(file) {
5017
5018
  return (0, node_path_1.resolve)(process.cwd(), ".vigiles/hooks", (0, node_path_1.basename)(file) + ".json");
5018
5019
  }
5020
+ /**
5021
+ * The directory a hook's recorded facts live in — the SCOPE of `state()`/`record()`.
5022
+ *
5023
+ * Derived from the hook's own location and never from anything the hook said, so
5024
+ * a key cannot address another owner's store: hooks shipped in the same directory
5025
+ * share their facts (the requirement — one hook records, another reads), a
5026
+ * vendored plugin's hooks get their own. The layout MIRRORS the hook's directory
5027
+ * rather than slugging it, which keeps it injective and lets a human debugging a
5028
+ * hook find the fact by walking the path they already know:
5029
+ *
5030
+ * .claude/hooks/calendar-sync-record.hook.ts
5031
+ * → .vigiles/state/.claude/hooks/calendar.synced.json
5032
+ *
5033
+ * A hook outside the project (an absolute path elsewhere) falls back to a hash of
5034
+ * its directory: still stable and still isolated, just not readable — which is the
5035
+ * right trade for a case that should not happen in a project's own harness.
5036
+ */
5037
+ function hookStateDir(file) {
5038
+ const dir = (0, node_path_1.dirname)((0, node_path_1.resolve)(process.cwd(), file));
5039
+ const rel = (0, node_path_1.relative)(process.cwd(), dir);
5040
+ const inside = rel !== "" && !rel.startsWith("..") && !(0, node_path_1.isAbsolute)(rel);
5041
+ return (0, node_path_1.resolve)(process.cwd(), ".vigiles/state", inside ? rel : `external-${(0, hash_js_1.sha256short)(dir)}`);
5042
+ }
5043
+ /** Read one recorded fact for a hook, or `null` if it was never recorded. */
5044
+ function readHookState(file, key) {
5045
+ try {
5046
+ const raw = (0, node_fs_1.readFileSync)((0, node_path_1.resolve)(hookStateDir(file), key + ".json"), "utf-8");
5047
+ const parsed = JSON.parse(raw);
5048
+ return typeof parsed.value === "string" && typeof parsed.at === "string"
5049
+ ? parsed
5050
+ : null;
5051
+ }
5052
+ catch {
5053
+ // Never recorded, unreadable, or corrupt — all "no fact", which `stateFact`
5054
+ // turns into an infinite age, so the reading hook SPEAKS. Failing toward
5055
+ // noise is the whole point; a store problem must never look like freshness.
5056
+ return null;
5057
+ }
5058
+ }
5059
+ /**
5060
+ * Record one fact. Atomic: written to a temp file in the same directory and
5061
+ * `rename()`d over, so a concurrent reader sees the whole old entry or the whole
5062
+ * new one — never one write's value with another's timestamp. Distinct keys are
5063
+ * distinct files and never interact at all.
5064
+ */
5065
+ function writeHookState(file, w) {
5066
+ const dir = hookStateDir(file);
5067
+ const target = (0, node_path_1.resolve)(dir, w.name + ".json");
5068
+ const entry = {
5069
+ value: w.value,
5070
+ at: new Date().toISOString(),
5071
+ by: (0, hook_install_js_1.normalizeHookRef)(file),
5072
+ };
5073
+ (0, node_fs_1.mkdirSync)(dir, { recursive: true });
5074
+ const tmp = `${target}.${String(process.pid)}.tmp`;
5075
+ (0, node_fs_1.writeFileSync)(tmp, JSON.stringify(entry, null, 2) + "\n");
5076
+ (0, node_fs_1.renameSync)(tmp, target);
5077
+ }
5078
+ /**
5079
+ * Perform the state writes a hook declared, after its output has been emitted.
5080
+ * A refused write (a hand-built record object with a key `record()` would have
5081
+ * thrown on) is announced — silence here would be a hook that believes it
5082
+ * remembered something.
5083
+ */
5084
+ function applyHookWrites(file, outcome) {
5085
+ const { ok, refused } = (0, hook_program_js_1.outcomeWrites)(outcome);
5086
+ for (const name of refused) {
5087
+ console.error(`vigiles: refused to record ${name} from ${file} — not a valid state key.`);
5088
+ }
5089
+ for (const w of ok) {
5090
+ try {
5091
+ writeHookState(file, w);
5092
+ }
5093
+ catch (e) {
5094
+ console.error(`vigiles: could not record ${w.name} from ${file}: ${String(e)}`);
5095
+ }
5096
+ }
5097
+ }
5019
5098
  /**
5020
5099
  * Compile ONE typed hook program (authored against `vigiles/hook`) and MERGE it
5021
5100
  * into the active harness's native config — the hook half of `vigiles compile`
@@ -5168,7 +5247,7 @@ async function installHooks(hookFiles, harnessFlag, configHarness) {
5168
5247
  * that can't resolve yields its default (never throws). The pure registry +
5169
5248
  * decision logic live in core/hook-providers.ts — this only injects the real IO.
5170
5249
  */
5171
- async function gatherHookContext(program) {
5250
+ async function gatherHookContext(program, file) {
5172
5251
  const needs = (0, hook_program_js_1.hookNeeds)(program);
5173
5252
  if (needs.length === 0)
5174
5253
  return {};
@@ -5185,6 +5264,10 @@ async function gatherHookContext(program) {
5185
5264
  cwd: process.cwd(),
5186
5265
  platform: process.platform,
5187
5266
  isCI,
5267
+ // The namespace is bound HERE, from the hook's own path — core never sees
5268
+ // it, so no key a hook can spell reaches another owner's store.
5269
+ readState: (key) => readHookState(file, key),
5270
+ now: Date.now(),
5188
5271
  }, registry);
5189
5272
  }
5190
5273
  /**
@@ -5498,13 +5581,30 @@ async function runHookProgramCommand(file) {
5498
5581
  verifyStampOrRefuse(file, event);
5499
5582
  switch ((0, hook_program_js_1.dispatchKind)(program)) {
5500
5583
  case "inject": {
5501
- const out = (0, hook_program_js_1.runInject)(program, { source: event.source });
5502
- process.stdout.write(JSON.stringify(out) + "\n");
5584
+ const ctx = await gatherHookContext(program, file);
5585
+ const injection = (0, hook_program_js_1.injectionOf)(program, event, ctx);
5586
+ process.stdout.write(JSON.stringify({
5587
+ hookSpecificOutput: {
5588
+ hookEventName: program.on,
5589
+ additionalContext: injection.context,
5590
+ },
5591
+ }) + "\n");
5592
+ // Writes land AFTER the output is emitted: a hook that recorded "I spoke"
5593
+ // must not have recorded it if emitting threw.
5594
+ applyHookWrites(file, {
5595
+ kind: "injection",
5596
+ context: injection.context,
5597
+ records: injection.records,
5598
+ });
5503
5599
  return;
5504
5600
  }
5505
5601
  case "react": {
5602
+ const ctx = await gatherHookContext(program, file);
5506
5603
  warnIfPathUndecidable(event, projectRoot);
5507
- const reaction = (0, hook_program_js_1.runReact)(program, event, projectRoot);
5604
+ const reaction = (0, hook_program_js_1.runReact)(program, event, ctx, projectRoot);
5605
+ if (reaction.kind === "notice")
5606
+ console.error(reaction.message);
5607
+ applyHookWrites(file, { kind: "reaction", reaction });
5508
5608
  if (reaction.kind === "run") {
5509
5609
  const { spawnSync } = require("node:child_process");
5510
5610
  const res = spawnSync(reaction.command, {
@@ -5513,18 +5613,16 @@ async function runHookProgramCommand(file) {
5513
5613
  });
5514
5614
  process.exit(res.status ?? 0);
5515
5615
  }
5516
- if (reaction.kind === "notice")
5517
- console.error(reaction.message);
5518
5616
  return;
5519
5617
  }
5520
5618
  case "file-gate": {
5521
- const ctx = await gatherHookContext(program);
5619
+ const ctx = await gatherHookContext(program, file);
5522
5620
  warnIfPathUndecidable(event, projectRoot);
5523
5621
  emitGate((0, hook_program_js_1.decideFileGate)(program, event, ctx, projectRoot), program.on, (0, hook_program_js_1.hookMode)(program), file);
5524
5622
  return;
5525
5623
  }
5526
5624
  case "bash-gate": {
5527
- const ctx = await gatherHookContext(program);
5625
+ const ctx = await gatherHookContext(program, file);
5528
5626
  // The same `projectRoot` the file gates get: without it every
5529
5627
  // repo-relative prefix in a DENYLIST matcher (`touches`/`writesTo`) is
5530
5628
  // matched by over-blocking alone, and with it an absolute token is placed
@@ -5534,12 +5632,12 @@ async function runHookProgramCommand(file) {
5534
5632
  return;
5535
5633
  }
5536
5634
  case "prompt-gate": {
5537
- const ctx = await gatherHookContext(program);
5635
+ const ctx = await gatherHookContext(program, file);
5538
5636
  emitGate((0, hook_program_js_1.decidePromptGate)(program, event, ctx), program.on, (0, hook_program_js_1.hookMode)(program), file);
5539
5637
  return;
5540
5638
  }
5541
5639
  case "stop-gate": {
5542
- const ctx = await gatherHookContext(program);
5640
+ const ctx = await gatherHookContext(program, file);
5543
5641
  emitGate((0, hook_program_js_1.decideStopGate)(program, event, ctx), program.on, (0, hook_program_js_1.hookMode)(program), file);
5544
5642
  return;
5545
5643
  }
@@ -112,6 +112,26 @@ export interface NormalizedLeaf {
112
112
  * {@link LeafRedirect}. Empty for a command with no redirection.
113
113
  */
114
114
  readonly redirects: readonly LeafRedirect[];
115
+ /**
116
+ * The directory a chdir WRAPPER moved this leaf into before exec'ing it —
117
+ * `env -C dir`, `env --chdir=dir`, `sudo -D dir` — or `null` when there was
118
+ * none. Nested wrappers accumulate (`sudo -D a env -C b cmd` → `a/b`).
119
+ *
120
+ * The parser already READ this token in order to skip past it, then threw the
121
+ * value away — so every relative operand of the wrapped command resolved
122
+ * against the wrong directory for every consumer. Same shape as the
123
+ * redirection targets the leaf used to drop: the parser knew, the leaf did
124
+ * not carry it. `git -C` is deliberately NOT here — `git` is not a wrapper
125
+ * (it does not exec the rest of its argv as a command).
126
+ *
127
+ * ⚠️ ONE LEAF'S OWN WRAPPER, NOT A CWD MODEL. A directory changed by a
128
+ * PRECEDING statement (`cd x && …`, `pushd`, a subshell) is not reported:
129
+ * connectors do not survive leaf extraction, and `cd x; cmd` writes into the
130
+ * OLD directory when the `cd` fails — that is a model with failure semantics,
131
+ * not a field. A dynamic value (`env -C "$DIR" …`) is not resolvable and the
132
+ * whole leaf is already unnormalizable in that case.
133
+ */
134
+ readonly chdir: string | null;
115
135
  }
116
136
  /**
117
137
  * Extract every simple command as a {@link NormalizedLeaf} — the operation-level
@@ -617,6 +617,38 @@ const WRAPPER_VALUE_OPTS = {
617
617
  ]),
618
618
  nohup: new Set(),
619
619
  };
620
+ /**
621
+ * Per-wrapper options whose value is a DIRECTORY the wrapper chdirs into before
622
+ * exec'ing the command. A subset of {@link WRAPPER_VALUE_OPTS}, and keyed by head
623
+ * for a reason: `-C` is `--chdir` for `env` but `--close-from` (a file
624
+ * descriptor) for `sudo`, whose chdir is `-D`. One shared set would read a
625
+ * number as a directory.
626
+ */
627
+ const WRAPPER_CHDIR_OPTS = {
628
+ env: new Set(["-C", "--chdir"]),
629
+ sudo: new Set(["-D", "--chdir"]),
630
+ };
631
+ /**
632
+ * The directory carried by an option WORD, when that word is one of this
633
+ * wrapper's chdir options — covering both spellings, since `--chdir=dir` is a
634
+ * single token that never reaches the separate-value table.
635
+ */
636
+ function chdirValue(word, next, chdirOpts) {
637
+ if (chdirOpts.has(word))
638
+ return next; // `-C dir`, `--chdir dir`
639
+ const eq = word.indexOf("=");
640
+ return eq > 0 && chdirOpts.has(word.slice(0, eq))
641
+ ? word.slice(eq + 1) // `--chdir=dir`
642
+ : undefined;
643
+ }
644
+ /** Layer a wrapper's chdir onto the one an outer wrapper already applied. */
645
+ function nestChdir(outer, inner) {
646
+ if (inner === undefined || inner === "")
647
+ return outer;
648
+ if (outer === null || inner.startsWith("/") || /^[A-Za-z]:\//.test(inner))
649
+ return inner;
650
+ return `${outer.replace(/\/+$/, "")}/${inner}`;
651
+ }
620
652
  /** Count of leading NON-option positionals a wrapper consumes before the command (timeout DURATION). */
621
653
  const WRAPPER_SKIP_POSITIONALS = {
622
654
  timeout: 1,
@@ -640,12 +672,14 @@ function splitAssignmentWord(word) {
640
672
  */
641
673
  function stripWrappers(argv) {
642
674
  const envAssigns = new Map();
675
+ let chdir = null;
643
676
  let cur = argv;
644
677
  for (let guard = 0; guard < 8; guard++) {
645
678
  const head = cur[0];
646
679
  if (head === undefined || !WRAPPER_HEADS.has(head))
647
680
  break;
648
681
  const valueOpts = WRAPPER_VALUE_OPTS[head] ?? new Set();
682
+ const chdirOpts = WRAPPER_CHDIR_OPTS[head] ?? new Set();
649
683
  let positionalsToSkip = WRAPPER_SKIP_POSITIONALS[head] ?? 0;
650
684
  let i = 1; // start after the wrapper head
651
685
  let ended = false;
@@ -659,6 +693,11 @@ function stripWrappers(argv) {
659
693
  break;
660
694
  }
661
695
  if (a.length > 1 && a.startsWith("-")) {
696
+ // The chdir value is CAPTURED before it is skipped. It was always read
697
+ // here — reading it is how the loop knows to skip past it — and then
698
+ // discarded, so every relative operand of the wrapped command resolved
699
+ // against the wrong directory downstream.
700
+ chdir = nestChdir(chdir, chdirValue(a, cur[i + 1], chdirOpts));
662
701
  if (valueOpts.has(a))
663
702
  i++; // skip this option's separate value too
664
703
  continue;
@@ -682,7 +721,7 @@ function stripWrappers(argv) {
682
721
  break; // no progress → stop
683
722
  cur = next;
684
723
  }
685
- return { argv: cur, envAssigns };
724
+ return { argv: cur, envAssigns, chdir };
686
725
  }
687
726
  /**
688
727
  * Extract every simple command as a {@link NormalizedLeaf} — the operation-level
@@ -1056,6 +1095,7 @@ function normalizeCallExpr(node, redirs) {
1056
1095
  assigns,
1057
1096
  hasAssign: (...names) => names.some((n) => assigns.has(n)),
1058
1097
  redirects: normalizeRedirects(redirs),
1098
+ chdir: stripped.chdir,
1059
1099
  };
1060
1100
  }
1061
1101
  //# sourceMappingURL=bash-effects.js.map
@@ -34,6 +34,47 @@ import { type SHA256Hash } from "./hash.js";
34
34
  import type { HarnessDialect } from "./dialect.js";
35
35
  import type { HookProtocol } from "./hook-protocol.js";
36
36
  import { type ProviderName, type NeedSpec, type HookCtx } from "./hook-providers.js";
37
+ import { type StateFact, type StateWrite } from "./hook-state.js";
38
+ /**
39
+ * The erased runtime shape of a gathered context. Author-facing types keep the
40
+ * precise `HookCtx<N>`; the decode functions re-narrow and cast.
41
+ */
42
+ type RawCtx = Record<string, string | boolean | StateFact>;
43
+ /**
44
+ * Does `name` match a hook's declared tool list, under the SAME semantics as the
45
+ * matcher the compiler emits for it?
46
+ *
47
+ * 🔴 IT DID NOT, AND THE DISAGREEMENT WAS SILENT. `hookRouting` joins a react's
48
+ * tools with `|` and emits that as the harness matcher, and a Claude Code matcher
49
+ * is a REGEX — `Edit|Write|MultiEdit` only works because it is one. The runtime
50
+ * meanwhile compared with `Array.includes`, i.e. exact string equality. So a hook
51
+ * declaring a tool FAMILY compiled fine, was wired up fine, was routed to by the
52
+ * harness fine, and was then dropped by vigiles' own filter without a word.
53
+ *
54
+ * MEASURED 2026-08-12 against the real runtime, before the fix:
55
+ *
56
+ * $ echo '{"tool_name":"mcp__4f54037d-0499__list_events",…}' \
57
+ * | vigiles hook-runtime run-program mcp-family.hook.mjs
58
+ * exit=0 # silence — react() never ran
59
+ * $ echo '{"tool_name":"mcp__.*",…}' | …
60
+ * FIRED on mcp__.* # fires only for a tool LITERALLY named "mcp__.*"
61
+ *
62
+ * That is the false-confidence class this whole subsystem exists to eliminate,
63
+ * living inside the subsystem. The live evidence that the harness really does
64
+ * route these: the knowledge base has shipped `"matcher": "mcp__.*"` in
65
+ * `.claude/settings.json` for months and its stamp file was last written the
66
+ * morning this was measured. The MCP server's id changes per session, so an exact
67
+ * list cannot be written down — a family matcher is the only correct spelling.
68
+ *
69
+ * Anchored `^(…)$` so a pattern cannot match a longer tool name by accident, and
70
+ * identical to `includes` for ordinary names, which contain no metacharacters.
71
+ * An unparseable pattern is rejected at COMPILE ({@link invalidToolPatterns}), so
72
+ * the fallback here is unreachable in a compiled hook and exists only so that a
73
+ * hand-constructed one degrades to exact matching rather than throwing mid-event.
74
+ */
75
+ export declare function matchesTool(tools: readonly string[], name: string): boolean;
76
+ /** Tool patterns that are not valid regexes — rejected at compile, see {@link matchesTool}. */
77
+ export declare function invalidToolPatterns(tools: readonly string[]): string[];
37
78
  export type Decision = {
38
79
  readonly kind: "allow";
39
80
  } | {
@@ -128,8 +169,33 @@ export interface CommandView {
128
169
  *
129
170
  * Deletion is a different question and is deliberately NOT reported here —
130
171
  * pair with `runs("rm")` if a gate cares about removal too.
172
+ *
173
+ * Exactly `writeTargets(prefixes).length > 0`, and implemented as that — reach
174
+ * for {@link writeTargets} when the gate needs to know WHICH file.
131
175
  */
132
176
  writesTo(prefixes: readonly string[]): boolean;
177
+ /**
178
+ * The write targets of this command that fall under one of the prefixes — the
179
+ * "WHICH file is written" counterpart to {@link writesTo}. Same two AST-backed
180
+ * sources (redirection targets + file-writing programs' argv positions), same
181
+ * denylist bias (an undecidable placement is INCLUDED).
182
+ *
183
+ * Spelling is as-written after normalization — quote-unwrapped,
184
+ * `$HOME`-canonicalized, and resolved against the leaf's own chdir wrapper
185
+ * (`env -C dir sed -i x` reports `dir/x`) — in order of appearance, exact
186
+ * duplicates collapsed. Filter by basename/suffix; never re-match the prefixes
187
+ * by hand, because hand-rolled prefix matching is the exact source of the
188
+ * trailing-slash, absolute-path and root-blindness defects this vocabulary
189
+ * exists to remove.
190
+ *
191
+ * An empty array ⇔ `writesTo(prefixes) === false`, so the natural
192
+ * `writeTargets(P).some(pred)` needs no emptiness check to behave correctly.
193
+ *
194
+ * `prefixes` is REQUIRED and there is no unfiltered overload: the raw list
195
+ * does not cross the API boundary, because a consumer holding it has to
196
+ * re-implement the matching that {@link prefixVerdict} exists to own.
197
+ */
198
+ writeTargets(prefixes: readonly string[]): readonly string[];
133
199
  /**
134
200
  * True iff the command pipes into a BARE shell interpreter (`curl … | sh`,
135
201
  * `… | bash -s`) — the remote-code-execution shape. High-signal: a shell leaf
@@ -214,7 +280,7 @@ export declare function decideProgram<N extends readonly NeedSpec[]>(program: Ho
214
280
  command?: unknown;
215
281
  };
216
282
  cwd?: unknown;
217
- }, ctx?: Record<string, string | boolean>, root?: string | undefined): Decision;
283
+ }, ctx?: RawCtx, root?: string | undefined): Decision;
218
284
  /** Map a Decision to the CC hook exit code — the protocol the author never writes. */
219
285
  export declare function decisionExitCode(d: Decision): number;
220
286
  /**
@@ -277,7 +343,7 @@ export interface CompileHookOptions {
277
343
  * (they never trust this type). Author-facing types keep the precise `N`.
278
344
  */
279
345
  type ErasedNeeds = readonly any[];
280
- export type AnyHook = HookProgram<ErasedNeeds> | FileGateHook<ErasedNeeds> | PromptGateHook<ErasedNeeds> | StopGateHook<ErasedNeeds> | InjectHook | ReactHook;
346
+ export type AnyHook = HookProgram<ErasedNeeds> | FileGateHook<ErasedNeeds> | PromptGateHook<ErasedNeeds> | StopGateHook<ErasedNeeds> | InjectHook<ErasedNeeds> | ReactHook<ErasedNeeds>;
281
347
  /**
282
348
  * The runtime dispatch shapes. A bare {@link HookProgram} (no `role`) is a Bash
283
349
  * command gate; the role-keyed hooks carry their own shape. The runtime uses this
@@ -456,7 +522,7 @@ export declare function decideFileGate<N extends readonly NeedSpec[]>(hook: File
456
522
  file_path?: unknown;
457
523
  };
458
524
  cwd?: unknown;
459
- }, ctx?: Record<string, string | boolean>, root?: string | undefined): Decision;
525
+ }, ctx?: RawCtx, root?: string | undefined): Decision;
460
526
  /** The event a UserPromptSubmit gate decides over — it sees the prompt TEXT. */
461
527
  export interface PromptEvent<N extends readonly NeedSpec[] = readonly ProviderName[]> {
462
528
  readonly event: string;
@@ -480,7 +546,7 @@ export declare function definePromptGate<const N extends readonly NeedSpec[] = r
480
546
  /** Run a prompt gate against a raw UserPromptSubmit event (reads `prompt`). */
481
547
  export declare function decidePromptGate<N extends readonly NeedSpec[]>(hook: PromptGateHook<N>, raw: {
482
548
  prompt?: unknown;
483
- }, ctx?: Record<string, string | boolean>): Decision;
549
+ }, ctx?: RawCtx): Decision;
484
550
  /**
485
551
  * The event a Stop gate decides over. `deny` BLOCKS the agent from ending its
486
552
  * turn (the reason is surfaced to the agent — e.g. "tests are red, keep going").
@@ -511,38 +577,65 @@ export declare function defineStopGate<const N extends readonly NeedSpec[] = rea
511
577
  /** Run a Stop gate against a raw Stop/SubagentStop event (reads `stop_hook_active`). */
512
578
  export declare function decideStopGate<N extends readonly NeedSpec[]>(hook: StopGateHook<N>, raw: {
513
579
  stop_hook_active?: unknown;
514
- }, ctx?: Record<string, string | boolean>): Decision;
580
+ }, ctx?: RawCtx): Decision;
515
581
  /** The output of an inject hook — text to add to context. NO allow/deny exists here. */
516
582
  export interface Injection {
517
583
  readonly kind: "inject";
518
584
  readonly context: string;
585
+ /** Facts to record — a DECLARATION; the trusted runtime performs the write. */
586
+ readonly records: readonly StateWrite[];
519
587
  }
520
- export declare const inject: (context: string) => Injection;
521
- /** The event an inject hook produces from (no tool SessionStart/UserPromptSubmit). */
522
- export interface SessionEvent {
588
+ /**
589
+ * Context to add, plus any facts that just became true:
590
+ * `inject(text, record("calendar.nagged"))`.
591
+ *
592
+ * The writes are trailing arguments on every output builder, so there is one rule
593
+ * to learn rather than a per-role spelling — and a hook that records nothing is
594
+ * written exactly as it was before.
595
+ */
596
+ export declare const inject: (context: string, ...records: readonly StateWrite[]) => Injection;
597
+ /**
598
+ * The event an inject hook produces from (no tool — SessionStart/UserPromptSubmit).
599
+ * Generic over its declared `needs`, exactly like the gate events.
600
+ */
601
+ export interface SessionEvent<N extends readonly NeedSpec[] = readonly ProviderName[]> {
523
602
  readonly event: string;
524
603
  readonly source: string;
604
+ /** Host-gathered, DECLARED facts — built-ins, inline providers, and `state()` reads. */
605
+ readonly ctx: HookCtx<N>;
525
606
  }
526
- export interface InjectHook {
607
+ export interface InjectHook<N extends readonly NeedSpec[] = readonly ProviderName[]> {
527
608
  readonly role: "inject";
528
609
  readonly on: string;
610
+ /**
611
+ * Declared context providers the trusted runtime gathers into `e.ctx`.
612
+ *
613
+ * Injects and reacts could not declare `needs` at all until state landed, for
614
+ * no reason anyone had recorded — and a fact a hook cannot read is not a
615
+ * feature. `needs` is now uniform across every role.
616
+ */
617
+ readonly needs?: N;
529
618
  /** Produces context to add. Its return type (Injection) has no `deny` — by design. */
530
- readonly produce: (e: SessionEvent) => Injection;
619
+ readonly produce: (e: SessionEvent<N>) => Injection;
531
620
  }
532
- export declare const defineInject: (p: Omit<InjectHook, "role">) => InjectHook;
621
+ export declare function defineInject<const N extends readonly NeedSpec[] = readonly []>(p: Omit<InjectHook<N>, "role">): InjectHook<N>;
533
622
  /**
534
623
  * Run an inject hook → the CC JSON the author never hand-writes. The compiler
535
624
  * targets `additionalContext` (the RIGHT field for this event), so the
536
625
  * wrong-JSON-field pain can't occur.
537
626
  */
538
- export declare function runInject(hook: InjectHook, raw: {
627
+ export declare function runInject<N extends readonly NeedSpec[]>(hook: InjectHook<N>, raw: {
539
628
  source?: string;
540
- }): {
629
+ }, ctx?: RawCtx): {
541
630
  hookSpecificOutput: {
542
631
  hookEventName: string;
543
632
  additionalContext: string;
544
633
  };
545
634
  };
635
+ /** The full {@link Injection} — the runtime needs its `records`, which the CC JSON drops. */
636
+ export declare function injectionOf<N extends readonly NeedSpec[]>(hook: InjectHook<N>, raw: {
637
+ source?: string;
638
+ }, ctx?: RawCtx): Injection;
546
639
  /**
547
640
  * A view of a tool's RESPONSE (PostToolUse) — the matching primitive a react hook
548
641
  * reasons over (e.g. capture/notify only when a command FAILED). The author never
@@ -561,54 +654,88 @@ export interface ResponseView {
561
654
  }
562
655
  export declare function responseView(raw: unknown): ResponseView;
563
656
  /** The event a react hook reacts over — the tool, the file path, AND its response. */
564
- export interface ReactEvent {
657
+ export interface ReactEvent<N extends readonly NeedSpec[] = readonly ProviderName[]> {
565
658
  readonly event: string;
566
659
  readonly tool: string;
567
660
  readonly path: PathView;
568
661
  /** The tool's response (PostToolUse) — react only on an error, capture output, … */
569
662
  readonly response: ResponseView;
663
+ /** Host-gathered, DECLARED facts — built-ins, inline providers, and `state()` reads. */
664
+ readonly ctx: HookCtx<N>;
570
665
  }
571
666
  export interface RunReaction {
572
667
  readonly kind: "run";
573
668
  readonly command: string;
574
669
  readonly effect: BashEffect;
670
+ readonly records: readonly StateWrite[];
575
671
  }
576
672
  export type Reaction = RunReaction | {
577
673
  readonly kind: "notice";
578
674
  readonly message: string;
675
+ readonly records: readonly StateWrite[];
579
676
  } | {
580
677
  readonly kind: "none";
678
+ readonly records: readonly StateWrite[];
581
679
  };
582
- /** Run a command in reaction — its effect is classified AT CONSTRUCTION (audit/diff-able). */
583
- export declare const run: (command: string) => RunReaction;
584
- /** Surface a non-blocking note (no execution). */
585
- export declare const notice: (message: string) => Reaction;
586
- /** Do nothing. */
587
- export declare const nothing: () => Reaction;
588
- export interface ReactHook {
680
+ /**
681
+ * Run a command in reaction — its effect is classified AT CONSTRUCTION
682
+ * (audit/diff-able). Trailing arguments record facts.
683
+ *
684
+ * ⚠️ `run()` is for invoking a real TOOL. Using it to write a stamp
685
+ * (`run("date +%s > .claude/.stamp")`) was the only way to remember anything
686
+ * before `record()` existed; it is now the wrong tool — it spends a subprocess
687
+ * and a shell on a variable assignment, and it classifies as side-effecting.
688
+ */
689
+ export declare const run: (command: string, ...records: readonly StateWrite[]) => RunReaction;
690
+ /** Surface a non-blocking note (no execution). Trailing arguments record facts. */
691
+ export declare const notice: (message: string, ...records: readonly StateWrite[]) => Reaction;
692
+ /**
693
+ * Take no action. Trailing arguments still record facts — `nothing(record("x"))`
694
+ * is the shape of a hook whose entire job is to WITNESS that something happened
695
+ * (an MCP call, a deploy) so a different hook can read it later.
696
+ */
697
+ export declare const nothing: (...records: readonly StateWrite[]) => Reaction;
698
+ export interface ReactHook<N extends readonly NeedSpec[] = readonly ProviderName[]> {
589
699
  readonly role: "react";
590
700
  readonly on: string;
591
- readonly match: {
701
+ /**
702
+ * Which tools to react to. OMIT IT for an event that carries no tool at all
703
+ * (`Stop`, `SubagentStop`, `SessionEnd`), exactly as inject and the prompt/stop
704
+ * gates already do — `hookRouting` then emits no matcher.
705
+ *
706
+ * 🔴 IT USED TO BE REQUIRED, WHICH MADE EVERY TOOL-LESS REACT DEAD. `runReact`
707
+ * gates on `tool_name`; a `Stop` event carries none, so the name defaulted to
708
+ * `""`, matched nothing, and the hook returned `nothing()` forever. MEASURED
709
+ * 2026-08-12 against the real runtime: a `Stop` react printed nothing and
710
+ * exited 0 — indistinguishable from a hook that decided to stay quiet, which
711
+ * is the whole reason advisory hooks die unnoticed. Three of the seven hooks
712
+ * this feature was built for are `Stop` nudges.
713
+ */
714
+ readonly match?: {
592
715
  readonly tools: readonly string[];
593
716
  };
717
+ /** Declared context providers the trusted runtime gathers into `e.ctx`. */
718
+ readonly needs?: N;
594
719
  /** Reacts to a tool that already ran. Returns a Reaction — NO `deny` exists here. */
595
- readonly react: (e: ReactEvent) => Reaction;
720
+ readonly react: (e: ReactEvent<N>) => Reaction;
596
721
  }
597
- export declare const defineReact: (p: Omit<ReactHook, "role">) => ReactHook;
722
+ export declare function defineReact<const N extends readonly NeedSpec[] = readonly []>(p: Omit<ReactHook<N>, "role">): ReactHook<N>;
598
723
  /**
599
724
  * Run a react hook against a raw PostToolUse event → the (classified) Reaction.
600
725
  *
601
726
  * `root` behaves exactly as in {@link decideFileGate}: the event's own `cwd` by
602
- * default, the CLI's {@link projectRootOf} when the runtime supplies one.
727
+ * default, the CLI's {@link projectRootOf} when the runtime supplies one. It
728
+ * trails `ctx` so the argument order matches {@link decideProgram} and
729
+ * {@link decideFileGate} — every decode function reads `(hook, raw, ctx, root)`.
603
730
  */
604
- export declare function runReact(hook: ReactHook, raw: {
731
+ export declare function runReact<N extends readonly NeedSpec[]>(hook: ReactHook<N>, raw: {
605
732
  tool_name?: string;
606
733
  tool_input?: {
607
734
  file_path?: unknown;
608
735
  };
609
736
  tool_response?: unknown;
610
737
  cwd?: unknown;
611
- }, root?: string | undefined): Reaction;
738
+ }, ctx?: RawCtx, root?: string | undefined): Reaction;
612
739
  /** The raw event fields the decode functions read (the union across roles). */
613
740
  export interface RawHookEvent {
614
741
  readonly tool_name?: string;
@@ -638,10 +765,22 @@ export type HookProgramOutcome = {
638
765
  } | {
639
766
  readonly kind: "injection";
640
767
  readonly context: string;
768
+ readonly records: readonly StateWrite[];
641
769
  } | {
642
770
  readonly kind: "reaction";
643
771
  readonly reaction: Reaction;
644
772
  };
773
+ /**
774
+ * The state writes an outcome declares, filtered to the ones the runtime may
775
+ * actually perform. A gate's `Decision` carries none — deliberately: a gate is
776
+ * the role that must be trustworthy and runs on every tool call, so it READS
777
+ * state (via `needs`) and never writes it. Adding a write there later is easy;
778
+ * removing one would not be.
779
+ */
780
+ export declare function outcomeWrites(outcome: HookProgramOutcome): {
781
+ readonly ok: readonly StateWrite[];
782
+ readonly refused: readonly string[];
783
+ };
645
784
  /** Record where a hook program was loaded from. Called by `loadHook` ONLY. */
646
785
  export declare function rememberHookSource(hook: AnyHook, file: string): void;
647
786
  /** The file a hook was loaded from, or `undefined` for one built in-process. */
@@ -652,7 +791,7 @@ export declare function hookSource(hook: AnyHook): string | undefined;
652
791
  * (effect-classified) `Reaction`. Pure — no subprocess, no model. The ergonomic
653
792
  * base for testing a compiled hook (see `assertHookDenies` / `assertHookAllows`).
654
793
  */
655
- export declare function runHookProgram(hook: AnyHook, event: RawHookEvent, ctx?: Record<string, string | boolean>, root?: string | undefined): HookProgramOutcome;
794
+ export declare function runHookProgram(hook: AnyHook, event: RawHookEvent, ctx?: RawCtx, root?: string | undefined): HookProgramOutcome;
656
795
  /**
657
796
  * The repository a wedged hook belongs to, as the runtime sees it. Supplied by
658
797
  * the caller because core takes no `node:path` and reads no disk — and because