vigiles 15.0.3 → 15.1.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
  }
@@ -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
  } | {
@@ -214,7 +255,7 @@ export declare function decideProgram<N extends readonly NeedSpec[]>(program: Ho
214
255
  command?: unknown;
215
256
  };
216
257
  cwd?: unknown;
217
- }, ctx?: Record<string, string | boolean>, root?: string | undefined): Decision;
258
+ }, ctx?: RawCtx, root?: string | undefined): Decision;
218
259
  /** Map a Decision to the CC hook exit code — the protocol the author never writes. */
219
260
  export declare function decisionExitCode(d: Decision): number;
220
261
  /**
@@ -277,7 +318,7 @@ export interface CompileHookOptions {
277
318
  * (they never trust this type). Author-facing types keep the precise `N`.
278
319
  */
279
320
  type ErasedNeeds = readonly any[];
280
- export type AnyHook = HookProgram<ErasedNeeds> | FileGateHook<ErasedNeeds> | PromptGateHook<ErasedNeeds> | StopGateHook<ErasedNeeds> | InjectHook | ReactHook;
321
+ export type AnyHook = HookProgram<ErasedNeeds> | FileGateHook<ErasedNeeds> | PromptGateHook<ErasedNeeds> | StopGateHook<ErasedNeeds> | InjectHook<ErasedNeeds> | ReactHook<ErasedNeeds>;
281
322
  /**
282
323
  * The runtime dispatch shapes. A bare {@link HookProgram} (no `role`) is a Bash
283
324
  * command gate; the role-keyed hooks carry their own shape. The runtime uses this
@@ -456,7 +497,7 @@ export declare function decideFileGate<N extends readonly NeedSpec[]>(hook: File
456
497
  file_path?: unknown;
457
498
  };
458
499
  cwd?: unknown;
459
- }, ctx?: Record<string, string | boolean>, root?: string | undefined): Decision;
500
+ }, ctx?: RawCtx, root?: string | undefined): Decision;
460
501
  /** The event a UserPromptSubmit gate decides over — it sees the prompt TEXT. */
461
502
  export interface PromptEvent<N extends readonly NeedSpec[] = readonly ProviderName[]> {
462
503
  readonly event: string;
@@ -480,7 +521,7 @@ export declare function definePromptGate<const N extends readonly NeedSpec[] = r
480
521
  /** Run a prompt gate against a raw UserPromptSubmit event (reads `prompt`). */
481
522
  export declare function decidePromptGate<N extends readonly NeedSpec[]>(hook: PromptGateHook<N>, raw: {
482
523
  prompt?: unknown;
483
- }, ctx?: Record<string, string | boolean>): Decision;
524
+ }, ctx?: RawCtx): Decision;
484
525
  /**
485
526
  * The event a Stop gate decides over. `deny` BLOCKS the agent from ending its
486
527
  * turn (the reason is surfaced to the agent — e.g. "tests are red, keep going").
@@ -511,38 +552,65 @@ export declare function defineStopGate<const N extends readonly NeedSpec[] = rea
511
552
  /** Run a Stop gate against a raw Stop/SubagentStop event (reads `stop_hook_active`). */
512
553
  export declare function decideStopGate<N extends readonly NeedSpec[]>(hook: StopGateHook<N>, raw: {
513
554
  stop_hook_active?: unknown;
514
- }, ctx?: Record<string, string | boolean>): Decision;
555
+ }, ctx?: RawCtx): Decision;
515
556
  /** The output of an inject hook — text to add to context. NO allow/deny exists here. */
516
557
  export interface Injection {
517
558
  readonly kind: "inject";
518
559
  readonly context: string;
560
+ /** Facts to record — a DECLARATION; the trusted runtime performs the write. */
561
+ readonly records: readonly StateWrite[];
519
562
  }
520
- export declare const inject: (context: string) => Injection;
521
- /** The event an inject hook produces from (no tool SessionStart/UserPromptSubmit). */
522
- export interface SessionEvent {
563
+ /**
564
+ * Context to add, plus any facts that just became true:
565
+ * `inject(text, record("calendar.nagged"))`.
566
+ *
567
+ * The writes are trailing arguments on every output builder, so there is one rule
568
+ * to learn rather than a per-role spelling — and a hook that records nothing is
569
+ * written exactly as it was before.
570
+ */
571
+ export declare const inject: (context: string, ...records: readonly StateWrite[]) => Injection;
572
+ /**
573
+ * The event an inject hook produces from (no tool — SessionStart/UserPromptSubmit).
574
+ * Generic over its declared `needs`, exactly like the gate events.
575
+ */
576
+ export interface SessionEvent<N extends readonly NeedSpec[] = readonly ProviderName[]> {
523
577
  readonly event: string;
524
578
  readonly source: string;
579
+ /** Host-gathered, DECLARED facts — built-ins, inline providers, and `state()` reads. */
580
+ readonly ctx: HookCtx<N>;
525
581
  }
526
- export interface InjectHook {
582
+ export interface InjectHook<N extends readonly NeedSpec[] = readonly ProviderName[]> {
527
583
  readonly role: "inject";
528
584
  readonly on: string;
585
+ /**
586
+ * Declared context providers the trusted runtime gathers into `e.ctx`.
587
+ *
588
+ * Injects and reacts could not declare `needs` at all until state landed, for
589
+ * no reason anyone had recorded — and a fact a hook cannot read is not a
590
+ * feature. `needs` is now uniform across every role.
591
+ */
592
+ readonly needs?: N;
529
593
  /** Produces context to add. Its return type (Injection) has no `deny` — by design. */
530
- readonly produce: (e: SessionEvent) => Injection;
594
+ readonly produce: (e: SessionEvent<N>) => Injection;
531
595
  }
532
- export declare const defineInject: (p: Omit<InjectHook, "role">) => InjectHook;
596
+ export declare function defineInject<const N extends readonly NeedSpec[] = readonly []>(p: Omit<InjectHook<N>, "role">): InjectHook<N>;
533
597
  /**
534
598
  * Run an inject hook → the CC JSON the author never hand-writes. The compiler
535
599
  * targets `additionalContext` (the RIGHT field for this event), so the
536
600
  * wrong-JSON-field pain can't occur.
537
601
  */
538
- export declare function runInject(hook: InjectHook, raw: {
602
+ export declare function runInject<N extends readonly NeedSpec[]>(hook: InjectHook<N>, raw: {
539
603
  source?: string;
540
- }): {
604
+ }, ctx?: RawCtx): {
541
605
  hookSpecificOutput: {
542
606
  hookEventName: string;
543
607
  additionalContext: string;
544
608
  };
545
609
  };
610
+ /** The full {@link Injection} — the runtime needs its `records`, which the CC JSON drops. */
611
+ export declare function injectionOf<N extends readonly NeedSpec[]>(hook: InjectHook<N>, raw: {
612
+ source?: string;
613
+ }, ctx?: RawCtx): Injection;
546
614
  /**
547
615
  * A view of a tool's RESPONSE (PostToolUse) — the matching primitive a react hook
548
616
  * reasons over (e.g. capture/notify only when a command FAILED). The author never
@@ -561,54 +629,88 @@ export interface ResponseView {
561
629
  }
562
630
  export declare function responseView(raw: unknown): ResponseView;
563
631
  /** The event a react hook reacts over — the tool, the file path, AND its response. */
564
- export interface ReactEvent {
632
+ export interface ReactEvent<N extends readonly NeedSpec[] = readonly ProviderName[]> {
565
633
  readonly event: string;
566
634
  readonly tool: string;
567
635
  readonly path: PathView;
568
636
  /** The tool's response (PostToolUse) — react only on an error, capture output, … */
569
637
  readonly response: ResponseView;
638
+ /** Host-gathered, DECLARED facts — built-ins, inline providers, and `state()` reads. */
639
+ readonly ctx: HookCtx<N>;
570
640
  }
571
641
  export interface RunReaction {
572
642
  readonly kind: "run";
573
643
  readonly command: string;
574
644
  readonly effect: BashEffect;
645
+ readonly records: readonly StateWrite[];
575
646
  }
576
647
  export type Reaction = RunReaction | {
577
648
  readonly kind: "notice";
578
649
  readonly message: string;
650
+ readonly records: readonly StateWrite[];
579
651
  } | {
580
652
  readonly kind: "none";
653
+ readonly records: readonly StateWrite[];
581
654
  };
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 {
655
+ /**
656
+ * Run a command in reaction — its effect is classified AT CONSTRUCTION
657
+ * (audit/diff-able). Trailing arguments record facts.
658
+ *
659
+ * ⚠️ `run()` is for invoking a real TOOL. Using it to write a stamp
660
+ * (`run("date +%s > .claude/.stamp")`) was the only way to remember anything
661
+ * before `record()` existed; it is now the wrong tool — it spends a subprocess
662
+ * and a shell on a variable assignment, and it classifies as side-effecting.
663
+ */
664
+ export declare const run: (command: string, ...records: readonly StateWrite[]) => RunReaction;
665
+ /** Surface a non-blocking note (no execution). Trailing arguments record facts. */
666
+ export declare const notice: (message: string, ...records: readonly StateWrite[]) => Reaction;
667
+ /**
668
+ * Take no action. Trailing arguments still record facts — `nothing(record("x"))`
669
+ * is the shape of a hook whose entire job is to WITNESS that something happened
670
+ * (an MCP call, a deploy) so a different hook can read it later.
671
+ */
672
+ export declare const nothing: (...records: readonly StateWrite[]) => Reaction;
673
+ export interface ReactHook<N extends readonly NeedSpec[] = readonly ProviderName[]> {
589
674
  readonly role: "react";
590
675
  readonly on: string;
591
- readonly match: {
676
+ /**
677
+ * Which tools to react to. OMIT IT for an event that carries no tool at all
678
+ * (`Stop`, `SubagentStop`, `SessionEnd`), exactly as inject and the prompt/stop
679
+ * gates already do — `hookRouting` then emits no matcher.
680
+ *
681
+ * 🔴 IT USED TO BE REQUIRED, WHICH MADE EVERY TOOL-LESS REACT DEAD. `runReact`
682
+ * gates on `tool_name`; a `Stop` event carries none, so the name defaulted to
683
+ * `""`, matched nothing, and the hook returned `nothing()` forever. MEASURED
684
+ * 2026-08-12 against the real runtime: a `Stop` react printed nothing and
685
+ * exited 0 — indistinguishable from a hook that decided to stay quiet, which
686
+ * is the whole reason advisory hooks die unnoticed. Three of the seven hooks
687
+ * this feature was built for are `Stop` nudges.
688
+ */
689
+ readonly match?: {
592
690
  readonly tools: readonly string[];
593
691
  };
692
+ /** Declared context providers the trusted runtime gathers into `e.ctx`. */
693
+ readonly needs?: N;
594
694
  /** Reacts to a tool that already ran. Returns a Reaction — NO `deny` exists here. */
595
- readonly react: (e: ReactEvent) => Reaction;
695
+ readonly react: (e: ReactEvent<N>) => Reaction;
596
696
  }
597
- export declare const defineReact: (p: Omit<ReactHook, "role">) => ReactHook;
697
+ export declare function defineReact<const N extends readonly NeedSpec[] = readonly []>(p: Omit<ReactHook<N>, "role">): ReactHook<N>;
598
698
  /**
599
699
  * Run a react hook against a raw PostToolUse event → the (classified) Reaction.
600
700
  *
601
701
  * `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.
702
+ * default, the CLI's {@link projectRootOf} when the runtime supplies one. It
703
+ * trails `ctx` so the argument order matches {@link decideProgram} and
704
+ * {@link decideFileGate} — every decode function reads `(hook, raw, ctx, root)`.
603
705
  */
604
- export declare function runReact(hook: ReactHook, raw: {
706
+ export declare function runReact<N extends readonly NeedSpec[]>(hook: ReactHook<N>, raw: {
605
707
  tool_name?: string;
606
708
  tool_input?: {
607
709
  file_path?: unknown;
608
710
  };
609
711
  tool_response?: unknown;
610
712
  cwd?: unknown;
611
- }, root?: string | undefined): Reaction;
713
+ }, ctx?: RawCtx, root?: string | undefined): Reaction;
612
714
  /** The raw event fields the decode functions read (the union across roles). */
613
715
  export interface RawHookEvent {
614
716
  readonly tool_name?: string;
@@ -638,10 +740,22 @@ export type HookProgramOutcome = {
638
740
  } | {
639
741
  readonly kind: "injection";
640
742
  readonly context: string;
743
+ readonly records: readonly StateWrite[];
641
744
  } | {
642
745
  readonly kind: "reaction";
643
746
  readonly reaction: Reaction;
644
747
  };
748
+ /**
749
+ * The state writes an outcome declares, filtered to the ones the runtime may
750
+ * actually perform. A gate's `Decision` carries none — deliberately: a gate is
751
+ * the role that must be trustworthy and runs on every tool call, so it READS
752
+ * state (via `needs`) and never writes it. Adding a write there later is easy;
753
+ * removing one would not be.
754
+ */
755
+ export declare function outcomeWrites(outcome: HookProgramOutcome): {
756
+ readonly ok: readonly StateWrite[];
757
+ readonly refused: readonly string[];
758
+ };
645
759
  /** Record where a hook program was loaded from. Called by `loadHook` ONLY. */
646
760
  export declare function rememberHookSource(hook: AnyHook, file: string): void;
647
761
  /** The file a hook was loaded from, or `undefined` for one built in-process. */
@@ -652,7 +766,7 @@ export declare function hookSource(hook: AnyHook): string | undefined;
652
766
  * (effect-classified) `Reaction`. Pure — no subprocess, no model. The ergonomic
653
767
  * base for testing a compiled hook (see `assertHookDenies` / `assertHookAllows`).
654
768
  */
655
- export declare function runHookProgram(hook: AnyHook, event: RawHookEvent, ctx?: Record<string, string | boolean>, root?: string | undefined): HookProgramOutcome;
769
+ export declare function runHookProgram(hook: AnyHook, event: RawHookEvent, ctx?: RawCtx, root?: string | undefined): HookProgramOutcome;
656
770
  /**
657
771
  * The repository a wedged hook belongs to, as the runtime sees it. Supplied by
658
772
  * the caller because core takes no `node:path` and reads no disk — and because