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 +108 -10
- package/dist/core/hook-program.d.ts +142 -28
- package/dist/core/hook-program.js +161 -31
- package/dist/core/hook-providers.d.ts +22 -5
- package/dist/core/hook-providers.js +13 -1
- package/dist/core/hook-state.d.ts +307 -0
- package/dist/core/hook-state.js +349 -0
- package/dist/hook.d.ts +3 -1
- package/dist/hook.js +18 -1
- package/package.json +1 -1
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
|
|
5502
|
-
|
|
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?:
|
|
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?:
|
|
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?:
|
|
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?:
|
|
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
|
-
|
|
521
|
-
|
|
522
|
-
|
|
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
|
|
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
|
|
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
|
-
/**
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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?:
|
|
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
|