vigiles 26.2.0 → 27.0.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.
@@ -0,0 +1,63 @@
1
+ /**
2
+ * The compiled-hook RUNTIME — `vigiles hook-runtime run-program <file>`, the
3
+ * process the harness spawns on every matching tool call.
4
+ *
5
+ * WHY IT IS ITS OWN MODULE, and why that is the whole point (#216): this code
6
+ * used to live in `src/cli.ts` beside `compile` / `lint` / `audit` / `eval`, and
7
+ * CommonJS resolves a module's top-level imports before a single argument is
8
+ * parsed — so deciding `allow` on `ls -la` loaded the report template loader, the
9
+ * linter catalogs, the adapter registry and ~30 more barrels. MEASURED
10
+ * 2026-09-08 (Node 22.22.2, `tools/measure-hook-startup.mjs`, median of 20
11
+ * spawns): `require("dist/cli.js")` cost 623 ms against a 42 ms bare Node start,
12
+ * and a real `run-program` spawn cost 610-661 ms. A shell hook doing the same job
13
+ * costs ~12 ms.
14
+ *
15
+ * So `src/cli.ts` is now a dispatcher shim that `require`s THIS file for
16
+ * `hook-runtime run-program` and the verb barrel for everything else. The
17
+ * emitted command is unchanged and MUST stay unchanged — it is baked into every
18
+ * already-emitted settings block and into the SHA stamp beside each hook.
19
+ *
20
+ * KEEP THIS MODULE'S TOP-LEVEL IMPORTS MINIMAL. Everything imported here is paid
21
+ * on every gated tool call. Two deliberately-lazy edges, each with the measurement
22
+ * at its call site: `@iarna/toml` (compile-time only, in `core/hook-program.ts`
23
+ * and `hook-install.ts`) and `mvdan-sh` (bash predicates only, in
24
+ * `core/bash-effects.ts`). `src/hook-runtime-graph.test.ts` asserts the graph
25
+ * deterministically, because a timing threshold on a shared CI runner would be
26
+ * flaky and would be the first thing quarantined.
27
+ *
28
+ * Harness-neutral: the gate protocol (deny → exit 2) is byte-identical on Claude
29
+ * Code and Codex, and the ONE harness-specific fact the runtime needs — which
30
+ * events accept injected context — is read from the resolved adapter's
31
+ * `HookProtocol.injectableEvents`, never a literal. That resolution is itself a
32
+ * lazy `require` because only the react role needs it (the adapter registry
33
+ * measured 279 ms on its own).
34
+ *
35
+ * This is COMPOSITION-ROOT code (`src/` root), not `src/core/`: it does real I/O
36
+ * (stdin, the stamp sidecar, state writes, `spawnSync`) and reaches the adapter
37
+ * registry, so `core ⊄ adapter` keeps it out of the domain layer.
38
+ */
39
+ import { type RegisteredProvider } from "./core/hook-providers.js";
40
+ import { loadHook } from "./load-hook.js";
41
+ /**
42
+ * Load a compiled-hook program's default export — the SHARED loader, also the
43
+ * public `vigiles` `loadHook` a `.harness.mjs` test uses, so a hook that
44
+ * loads in a test loads identically here (one loader, no drift).
45
+ */
46
+ export declare const loadHookProgram: typeof loadHook;
47
+ /** Load a registered provider (`.vigiles/providers/<name>`) → its definition. */
48
+ export declare function loadProvider(file: string): Promise<RegisteredProvider>;
49
+ /** Path of the tamper-evident stamp sidecar for a hook file. */
50
+ export declare function hookStampPath(file: string): string;
51
+ /**
52
+ * `vigiles hook-runtime run-program <file>` — the runtime the compiled hooks block
53
+ * points at. Reads the live event on stdin, loads the typed program, verifies
54
+ * its stamp, and dispatches by role: a gate exits 2 + reason on `deny`; an
55
+ * inject prints `additionalContext`; a react runs its effect-classified
56
+ * command. A hook that won't load — or whose stamp is stale — fails CLOSED
57
+ * (exit 2), never silent-allow, with two loudly-announced exceptions: the repair
58
+ * action itself ({@link isStampRepairEvent}), and — on a LOAD failure only — the
59
+ * load-path repair WRITE ({@link isLoadPathRepairEvent}), or the repo wedges
60
+ * with no way to fix whatever broke the load path.
61
+ */
62
+ export declare function runHookProgramCommand(file: string | undefined): Promise<void>;
63
+ //# sourceMappingURL=hook-runtime.d.ts.map
@@ -0,0 +1,540 @@
1
+ "use strict";
2
+ /**
3
+ * The compiled-hook RUNTIME — `vigiles hook-runtime run-program <file>`, the
4
+ * process the harness spawns on every matching tool call.
5
+ *
6
+ * WHY IT IS ITS OWN MODULE, and why that is the whole point (#216): this code
7
+ * used to live in `src/cli.ts` beside `compile` / `lint` / `audit` / `eval`, and
8
+ * CommonJS resolves a module's top-level imports before a single argument is
9
+ * parsed — so deciding `allow` on `ls -la` loaded the report template loader, the
10
+ * linter catalogs, the adapter registry and ~30 more barrels. MEASURED
11
+ * 2026-09-08 (Node 22.22.2, `tools/measure-hook-startup.mjs`, median of 20
12
+ * spawns): `require("dist/cli.js")` cost 623 ms against a 42 ms bare Node start,
13
+ * and a real `run-program` spawn cost 610-661 ms. A shell hook doing the same job
14
+ * costs ~12 ms.
15
+ *
16
+ * So `src/cli.ts` is now a dispatcher shim that `require`s THIS file for
17
+ * `hook-runtime run-program` and the verb barrel for everything else. The
18
+ * emitted command is unchanged and MUST stay unchanged — it is baked into every
19
+ * already-emitted settings block and into the SHA stamp beside each hook.
20
+ *
21
+ * KEEP THIS MODULE'S TOP-LEVEL IMPORTS MINIMAL. Everything imported here is paid
22
+ * on every gated tool call. Two deliberately-lazy edges, each with the measurement
23
+ * at its call site: `@iarna/toml` (compile-time only, in `core/hook-program.ts`
24
+ * and `hook-install.ts`) and `mvdan-sh` (bash predicates only, in
25
+ * `core/bash-effects.ts`). `src/hook-runtime-graph.test.ts` asserts the graph
26
+ * deterministically, because a timing threshold on a shared CI runner would be
27
+ * flaky and would be the first thing quarantined.
28
+ *
29
+ * Harness-neutral: the gate protocol (deny → exit 2) is byte-identical on Claude
30
+ * Code and Codex, and the ONE harness-specific fact the runtime needs — which
31
+ * events accept injected context — is read from the resolved adapter's
32
+ * `HookProtocol.injectableEvents`, never a literal. That resolution is itself a
33
+ * lazy `require` because only the react role needs it (the adapter registry
34
+ * measured 279 ms on its own).
35
+ *
36
+ * This is COMPOSITION-ROOT code (`src/` root), not `src/core/`: it does real I/O
37
+ * (stdin, the stamp sidecar, state writes, `spawnSync`) and reaches the adapter
38
+ * registry, so `core ⊄ adapter` keeps it out of the domain layer.
39
+ */
40
+ Object.defineProperty(exports, "__esModule", { value: true });
41
+ exports.loadHookProgram = void 0;
42
+ exports.loadProvider = loadProvider;
43
+ exports.hookStampPath = hookStampPath;
44
+ exports.runHookProgramCommand = runHookProgramCommand;
45
+ const node_fs_1 = require("node:fs");
46
+ const node_path_1 = require("node:path");
47
+ const hook_program_js_1 = require("./core/hook-program.js");
48
+ const merge_conflict_js_1 = require("./core/merge-conflict.js");
49
+ const hook_install_js_1 = require("./hook-install.js");
50
+ const hook_providers_js_1 = require("./core/hook-providers.js");
51
+ const hook_state_store_js_1 = require("./hook-state-store.js");
52
+ const observe_js_1 = require("./observe.js");
53
+ const load_hook_js_1 = require("./load-hook.js");
54
+ /**
55
+ * Which events accept injected context, from the ACTIVE adapter — the one
56
+ * harness-specific fact a react needs, read through the `HookProtocol` port so
57
+ * this stays harness-neutral (never a Claude Code literal).
58
+ *
59
+ * LAZY on purpose: `./adapter-registry.js` pulls every registered adapter and
60
+ * its ports, and measured 279 ms to require on its own (2026-09-08, Node
61
+ * 22.22.2) — more than the whole rest of this module's graph. Only the REACT
62
+ * role needs it; a gate or an inject must not pay for it.
63
+ */
64
+ function injectableEventsFor(root) {
65
+ const { resolveAdapter } = require("./adapter-registry.js");
66
+ return resolveAdapter(root).hookProtocol?.injectableEvents ?? [];
67
+ }
68
+ /**
69
+ * Load a compiled-hook program's default export — the SHARED loader, also the
70
+ * public `vigiles` `loadHook` a `.harness.mjs` test uses, so a hook that
71
+ * loads in a test loads identically here (one loader, no drift).
72
+ */
73
+ exports.loadHookProgram = load_hook_js_1.loadHook;
74
+ /** Load a registered provider (`.vigiles/providers/<name>`) → its definition. */
75
+ async function loadProvider(file) {
76
+ const abs = (0, node_path_1.resolve)(process.cwd(), file);
77
+ const { pathToFileURL } = require("node:url");
78
+ let mod;
79
+ try {
80
+ mod = (await import(pathToFileURL(abs).href));
81
+ }
82
+ catch (e) {
83
+ throw new hook_program_js_1.HookCompileError(`Cannot load provider "${file}": ${e.message}`);
84
+ }
85
+ const def = mod.default?.default ?? mod.default;
86
+ if (!def ||
87
+ typeof def !== "object" ||
88
+ def.kind !== "provider-def") {
89
+ throw new hook_program_js_1.HookCompileError(`${file} has no default-exported provider ` +
90
+ `(use \`export default defineProvider({…})\`).`);
91
+ }
92
+ return def;
93
+ }
94
+ /** Path of the tamper-evident stamp sidecar for a hook file. */
95
+ function hookStampPath(file) {
96
+ return (0, node_path_1.resolve)(process.cwd(), ".vigiles/hooks", (0, node_path_1.basename)(file) + ".json");
97
+ }
98
+ /**
99
+ * Perform the state writes a hook declared, after its output has been emitted.
100
+ * A refused write (a hand-built record object with a key `record()` would have
101
+ * thrown on) is announced — silence here would be a hook that believes it
102
+ * remembered something.
103
+ */
104
+ function applyHookWrites(file, outcome) {
105
+ const { ok, refused } = (0, hook_program_js_1.outcomeWrites)(outcome);
106
+ for (const name of refused) {
107
+ console.error(`vigiles: refused to record ${name} from ${file} — not a valid state key.`);
108
+ }
109
+ for (const w of ok) {
110
+ try {
111
+ (0, hook_state_store_js_1.writeHookState)(file, w);
112
+ }
113
+ catch (e) {
114
+ console.error(`vigiles: could not record ${w.name} from ${file}: ${String(e)}`);
115
+ }
116
+ }
117
+ }
118
+ /**
119
+ * Gather a gate's DECLARED context providers (the trusted-host I/O step). Runs
120
+ * each declared read-only command via execSync in the hook's cwd; a provider
121
+ * that can't resolve yields its default (never throws). The pure registry +
122
+ * decision logic live in core/hook-providers.ts — this only injects the real IO.
123
+ */
124
+ async function gatherHookContext(program, file) {
125
+ const needs = (0, hook_program_js_1.hookNeeds)(program);
126
+ if (needs.length === 0)
127
+ return {};
128
+ // Only load the registered-provider registry if a provider() ref is declared.
129
+ const hasRef = needs.some((n) => typeof n !== "string" && n.kind === "provider-ref");
130
+ const registry = hasRef ? await loadProviderRegistry() : {};
131
+ const { execSync } = require("node:child_process");
132
+ const { isCI } = require("ci-info");
133
+ return (0, hook_providers_js_1.gatherContext)(needs, {
134
+ exec: (command) => execSync(command, {
135
+ encoding: "utf-8",
136
+ stdio: ["ignore", "pipe", "ignore"],
137
+ }),
138
+ cwd: process.cwd(),
139
+ platform: process.platform,
140
+ isCI,
141
+ // The namespace is bound HERE, from the hook's own path — core never sees
142
+ // it, so no key a hook can spell reaches another owner's store.
143
+ readState: (key) => (0, hook_state_store_js_1.readHookState)(file, key),
144
+ now: Date.now(),
145
+ }, registry);
146
+ }
147
+ /**
148
+ * Load the registered providers (`.vigiles/providers/`) into a name→def registry
149
+ * for `provider()` ref resolution. A bad/unloadable provider file is skipped (the
150
+ * ref then yields its default ""), never crashes a live session.
151
+ */
152
+ async function loadProviderRegistry() {
153
+ const registry = {};
154
+ for (const file of (0, hook_install_js_1.discoverProviderFiles)(process.cwd())) {
155
+ try {
156
+ const def = await loadProvider(file);
157
+ registry[def.name] = def;
158
+ }
159
+ catch {
160
+ /* skip an unloadable provider file */
161
+ }
162
+ }
163
+ return registry;
164
+ }
165
+ /** Append an observe-mode record to `.vigiles/hook-observations.jsonl` (best-effort). */
166
+ function recordObservation(file, on, would, reason) {
167
+ try {
168
+ const dir = (0, node_path_1.resolve)(process.cwd(), ".vigiles");
169
+ (0, node_fs_1.mkdirSync)(dir, { recursive: true });
170
+ const line = JSON.stringify({
171
+ ts: new Date().toISOString(),
172
+ hook: file,
173
+ event: on,
174
+ would,
175
+ reason,
176
+ }) + "\n";
177
+ (0, node_fs_1.appendFileSync)((0, node_path_1.resolve)(dir, "hook-observations.jsonl"), line);
178
+ }
179
+ catch {
180
+ /* recording is best-effort — never let it break a live session */
181
+ }
182
+ }
183
+ /**
184
+ * Emit a gate Decision in the harness protocol — the author never writes it.
185
+ * `observe` mode turns a would-be block/ask into a recorded no-op (exit 0): the
186
+ * shadow/rollout path. Harness-neutral — exit 2 / exit 0 are identical on Claude
187
+ * Code and Codex; the record is vigiles-local.
188
+ */
189
+ function emitGate(decision, on, mode, file) {
190
+ const action = (0, hook_program_js_1.gateAction)(decision, mode);
191
+ switch (action.kind) {
192
+ case "block":
193
+ (0, observe_js_1.appendObservation)({
194
+ kind: "hook",
195
+ event: on,
196
+ decision: "deny",
197
+ mode: "enforce",
198
+ rule: file,
199
+ reason: action.reason,
200
+ });
201
+ console.error(action.reason);
202
+ process.exit(2);
203
+ return;
204
+ case "ask":
205
+ (0, observe_js_1.appendObservation)({
206
+ kind: "hook",
207
+ event: on,
208
+ decision: "ask",
209
+ mode: "enforce",
210
+ rule: file,
211
+ reason: action.reason,
212
+ });
213
+ process.stdout.write(JSON.stringify({
214
+ hookSpecificOutput: {
215
+ hookEventName: on,
216
+ permissionDecision: "ask",
217
+ permissionDecisionReason: action.reason,
218
+ },
219
+ }) + "\n");
220
+ return;
221
+ case "observe":
222
+ (0, observe_js_1.appendObservation)({
223
+ kind: "hook",
224
+ event: on,
225
+ decision: action.would,
226
+ mode: "observe",
227
+ rule: file,
228
+ reason: action.reason,
229
+ });
230
+ recordObservation(file, on, action.would, action.reason);
231
+ console.error(`⚠ [vigiles observe] ${on}: would ${action.would} — ${action.reason}`);
232
+ return; // exit 0 — observe never blocks
233
+ case "allow":
234
+ return; // emit nothing, exit 0
235
+ }
236
+ }
237
+ /**
238
+ * Every `package.json` between the hook file and the filesystem root, plus the
239
+ * project's `.vigilesrc.json` — the files Node and the runtime must PARSE for a
240
+ * compiled hook to load at all. Repo-relative-ish paths, for a message.
241
+ *
242
+ * Walking UP is not decoration: `vigiles/hook` is a bare specifier, so Node reads
243
+ * the nearest `package.json` (and every one above it) while resolving it. The
244
+ * observed wedge came from a `package.json` the author was not thinking about at
245
+ * the time — it had merge-conflict markers in it, nothing to do with hooks.
246
+ */
247
+ function hookLoadPathFiles(hookFile) {
248
+ const files = [];
249
+ let dir = (0, node_path_1.dirname)((0, node_path_1.resolve)(process.cwd(), hookFile));
250
+ for (;;) {
251
+ const pkg = (0, node_path_1.resolve)(dir, "package.json");
252
+ files.push(pkg);
253
+ // 🔴 THE WALK STOPS AT THE FIRST ONE THAT EXISTS, and this list is now also
254
+ // the set of writes the repair door accepts, so its length is a blast
255
+ // radius. Unbounded, it reached `/home/package.json` and `/package.json` —
256
+ // files Node never opens once a nearer one is found. MEASURED against this
257
+ // runtime, hook at `gp/p/repo/.claude/hooks/`, conflict markers planted at
258
+ // one ancestor:
259
+ //
260
+ // repo pkg PRESENT , parent conflicted → loads fine
261
+ // repo pkg absent , parent conflicted → WEDGES (cause: ../package.json)
262
+ // repo pkg absent , parent absent, gp broken → WEDGES (cause: ../../package.json)
263
+ // repo pkg PRESENT , parent absent, gp broken → loads fine
264
+ // repo pkg absent , parent HEALTHY, gp broken→ loads fine
265
+ //
266
+ // A missing one is still pushed before the check: `package.json` may be the
267
+ // file the author has to CREATE, and it is the commonest repair of all.
268
+ if ((0, node_fs_1.existsSync)(pkg))
269
+ break;
270
+ const up = (0, node_path_1.dirname)(dir);
271
+ if (up === dir)
272
+ break;
273
+ dir = up;
274
+ }
275
+ files.push((0, node_path_1.resolve)(process.cwd(), ".vigilesrc.json"));
276
+ return files;
277
+ }
278
+ /**
279
+ * The conflicted files on this hook's load path, if any — the difference between
280
+ * "your hook is broken" and "your repo is mid-merge and the hook is collateral".
281
+ */
282
+ function conflictedLoadPathFiles(hookFile) {
283
+ return hookLoadPathFiles(hookFile)
284
+ .filter((p) => {
285
+ try {
286
+ return ((0, node_fs_1.existsSync)(p) && (0, merge_conflict_js_1.hasMergeConflictMarkers)((0, node_fs_1.readFileSync)(p, "utf-8")));
287
+ }
288
+ catch {
289
+ return false; // unreadable is a different problem; don't guess about it
290
+ }
291
+ })
292
+ .map((p) => (0, node_path_1.relative)(process.cwd(), p) || p);
293
+ }
294
+ /**
295
+ * Print the loud stderr banner that accompanies a REPAIR-only pass-through, and
296
+ * return true — the caller allows exactly this one tool call. Shared by the two
297
+ * refusal paths (stale stamp, unloadable program) so the wording can't drift.
298
+ */
299
+ function announceRepairEscape(file, why) {
300
+ console.error(`vigiles: hook ${file} ${why}.\n` +
301
+ `vigiles: ALLOWING this one call because it is a repair or recovery action ` +
302
+ `(a write to ${file}, to ${hookStampPath(file)}, or to one of ` +
303
+ `${merge_conflict_js_1.HARNESS_CONFIG_FILES.join(", ")}) ` +
304
+ `— without this the gate blocks the only actions that can fix it.\n` +
305
+ `vigiles: every OTHER tool call stays BLOCKED until the hook loads again.`);
306
+ return true;
307
+ }
308
+ /**
309
+ * Fail closed if a stamp sidecar exists and the on-disk source no longer
310
+ * matches it — a hand-edit that smuggles in a capability breaks the stamp.
311
+ * No sidecar → run uncompiled (e.g. a test fixture or a not-yet-compiled hook).
312
+ *
313
+ * ONE exception, and it is loud: the author's own REPAIR action
314
+ * ({@link isStampRepairEvent}) is let through, or a repo WEDGES. A stale stamp on
315
+ * a PreToolUse Bash gate blocks every Bash command — including `vigiles compile`,
316
+ * the only command that regenerates the stamp — so a normal edit-compile cycle
317
+ * could paint you into a corner whose only escape was hand-editing
318
+ * `.claude/settings.json` to unwire the gate. Observed 2026-08-03.
319
+ */
320
+ function verifyStampOrRefuse(file, event) {
321
+ const stampPath = hookStampPath(file);
322
+ if (!(0, node_fs_1.existsSync)(stampPath))
323
+ return;
324
+ try {
325
+ const { stamp } = JSON.parse((0, node_fs_1.readFileSync)(stampPath, "utf-8"));
326
+ const source = (0, node_fs_1.readFileSync)((0, node_path_1.resolve)(process.cwd(), file), "utf-8");
327
+ if (stamp && !(0, hook_program_js_1.verifyHookStamp)(source, stamp)) {
328
+ if ((0, hook_program_js_1.isStampRepairEvent)(event, file, process.cwd())) {
329
+ announceRepairEscape(file, "does not match its compiled stamp");
330
+ return;
331
+ }
332
+ console.error(`vigiles: hook ${file} does not match its compiled stamp (tampered).\n` +
333
+ `vigiles: if YOU edited it, the way out is a FILE WRITE, not a command — ` +
334
+ `this refusal blocks the recompile too. Either edit ${file} back to what ` +
335
+ `was compiled, or clear its stamp by writing \`{}\` into ` +
336
+ `${stampPath}. The hook then runs UNSTAMPED but still ENFORCES, so ` +
337
+ `\`vigiles compile ${file}\` goes through the normal gate.`);
338
+ process.exit(2);
339
+ }
340
+ }
341
+ catch {
342
+ /* unreadable sidecar → don't block a live session on it */
343
+ }
344
+ }
345
+ /**
346
+ * Say so, ON STDERR, when this event's path cannot be matched against a
347
+ * repo-relative prefix — an absolute `file_path` and no project root anywhere.
348
+ * The failure it announces is otherwise invisible: the hook runs, exits 0, and
349
+ * decides on nothing. Deliberately silent for a relative `file_path` (decidable
350
+ * without a root) so it cannot be mistaken for a react hook's `notice`.
351
+ */
352
+ function warnIfPathUndecidable(event, root) {
353
+ const warning = (0, hook_program_js_1.undecidablePathWarning)(event.tool_input?.file_path, root);
354
+ if (warning !== undefined)
355
+ console.error(warning);
356
+ }
357
+ /**
358
+ * `vigiles hook-runtime run-program <file>` — the runtime the compiled hooks block
359
+ * points at. Reads the live event on stdin, loads the typed program, verifies
360
+ * its stamp, and dispatches by role: a gate exits 2 + reason on `deny`; an
361
+ * inject prints `additionalContext`; a react runs its effect-classified
362
+ * command. A hook that won't load — or whose stamp is stale — fails CLOSED
363
+ * (exit 2), never silent-allow, with two loudly-announced exceptions: the repair
364
+ * action itself ({@link isStampRepairEvent}), and — on a LOAD failure only — the
365
+ * load-path repair WRITE ({@link isLoadPathRepairEvent}), or the repo wedges
366
+ * with no way to fix whatever broke the load path.
367
+ */
368
+ async function runHookProgramCommand(file) {
369
+ if (!file) {
370
+ console.error("Usage: vigiles hook-runtime run-program <hook-file>");
371
+ process.exit(2);
372
+ return;
373
+ }
374
+ let raw = "";
375
+ try {
376
+ raw = (0, node_fs_1.readFileSync)(0, "utf-8");
377
+ }
378
+ catch {
379
+ /* no stdin */
380
+ }
381
+ let event = {};
382
+ try {
383
+ event = JSON.parse(raw);
384
+ }
385
+ catch {
386
+ /* malformed → empty event */
387
+ }
388
+ // The root repo-relative path prefixes resolve against. `$CLAUDE_PROJECT_DIR`
389
+ // first (the same root the harness resolved THIS hook's own path against),
390
+ // then the payload's `cwd`; never `process.cwd()`, which under a git worktree
391
+ // can be a different checkout. See `projectRootOf`.
392
+ const projectRoot = (0, hook_program_js_1.projectRootOf)(event, process.env);
393
+ let program;
394
+ try {
395
+ program = await (0, exports.loadHookProgram)(file);
396
+ }
397
+ catch {
398
+ // A LOAD failure is a fact about the harness, not a verdict about the
399
+ // command that happened to arrive — so it must still fail CLOSED (a gate
400
+ // that cannot run must not wave traffic through), but the two things it owes
401
+ // the author are different from a `deny`'s: name the real cause, and leave a
402
+ // way back.
403
+ //
404
+ // Escapes, both announced loudly on stderr:
405
+ // - the stale-stamp one (an edit to the hook itself / `vigiles compile`),
406
+ // for the hook broken mid-edit;
407
+ // - the RECOVERY set, for the case where the hook is fine and something
408
+ // else on its load path is not (observed 2026-08-10: `package.json` left
409
+ // holding merge-conflict markers → Node can't resolve `vigiles/hook` →
410
+ // no compiled hook loads → the Bash gate refuses `git merge --abort`,
411
+ // the one command that undoes the cause. Irreversible from inside the
412
+ // session; it was fixed by hand-editing the JSON, because file tools do
413
+ // not go through PreToolUse(Bash)).
414
+ // Everything else stays BLOCKED, and the escapes are whitelists of commands
415
+ // that are WRITES — see `isLoadPathRepairEvent` for why no command is one.
416
+ const conflicted = conflictedLoadPathFiles(file);
417
+ const cause = conflicted.length > 0
418
+ ? `cannot be loaded — ${conflicted.join(", ")} contains merge-conflict ` +
419
+ `markers, so Node cannot resolve \`vigiles/hook\` from it (the hook itself ` +
420
+ `may be fine)`
421
+ : "cannot be loaded";
422
+ if ((0, hook_program_js_1.isLoadPathRepairEvent)(event, file, {
423
+ // The root the REST of this runtime already uses: `hookStampPath` and
424
+ // `verifyStampOrRefuse` read the hook and its sidecar via `process.cwd()`,
425
+ // so a repair accepted against any other root would name a file the
426
+ // runtime never reads. The hook's own path cannot supply it (a hook sits
427
+ // at any depth, and a `.git` probe would be a disk read core does not do).
428
+ root: process.cwd(),
429
+ loadPathFiles: hookLoadPathFiles(file),
430
+ })) {
431
+ announceRepairEscape(file, cause);
432
+ return;
433
+ }
434
+ console.error(`vigiles: hook ${file} ${cause}.\n` +
435
+ `vigiles: this is the state of the HARNESS, not a decision about your ` +
436
+ `command — the gate never ran. Blocking anyway (a gate that cannot run ` +
437
+ `must not pass traffic).\n` +
438
+ `vigiles: the way out is a FILE WRITE, not a command — under a tool that ` +
439
+ `WRITES (Write/Edit/MultiEdit); a Read of the same path repairs nothing ` +
440
+ `and is refused. Fix whichever of ` +
441
+ `${file}, ${merge_conflict_js_1.HARNESS_CONFIG_FILES.join(", ")} is broken — those writes are ` +
442
+ `allowed even while this refuses, and a Bash gate never gated file tools ` +
443
+ `at all. The hook then loads and the gate decides normally again.\n` +
444
+ `vigiles: those paths resolve under ${process.cwd()} — plus any ancestor ` +
445
+ `\`package.json\` Node actually reads, so whatever is named above as the ` +
446
+ `cause is writable. A path in a DIFFERENT checkout is refused: it cannot ` +
447
+ `repair this failure.\n` +
448
+ `vigiles: no command is allowed, deliberately. \`git merge --abort\` and ` +
449
+ `\`git checkout\` RUN \`.git/hooks/*\` (measured: reference-transaction, ` +
450
+ `post-checkout), and \`vigiles compile\` loads the hook through the same ` +
451
+ `resolver that just failed.`);
452
+ process.exit(2);
453
+ return;
454
+ }
455
+ verifyStampOrRefuse(file, event);
456
+ switch ((0, hook_program_js_1.dispatchKind)(program)) {
457
+ case "inject": {
458
+ const ctx = await gatherHookContext(program, file);
459
+ const injection = (0, hook_program_js_1.injectionOf)(program, event, ctx);
460
+ process.stdout.write(JSON.stringify({
461
+ hookSpecificOutput: {
462
+ hookEventName: program.on,
463
+ additionalContext: injection.context,
464
+ },
465
+ }) + "\n");
466
+ // Writes land AFTER the output is emitted: a hook that recorded "I spoke"
467
+ // must not have recorded it if emitting threw.
468
+ applyHookWrites(file, {
469
+ kind: "injection",
470
+ context: injection.context,
471
+ records: injection.records,
472
+ });
473
+ return;
474
+ }
475
+ case "react": {
476
+ const ctx = await gatherHookContext(program, file);
477
+ warnIfPathUndecidable(event, projectRoot);
478
+ const reaction = (0, hook_program_js_1.runReact)(program, event, ctx, projectRoot);
479
+ // A notice has to REACH someone. stderr at exit 0 goes to the debug log
480
+ // and nothing else (the host's docs are explicit: "Claude never sees it"),
481
+ // and a react always exits 0 because its type has no `deny` — so stderr
482
+ // alone delivered nowhere. Emit the same `additionalContext` shape the
483
+ // shipped refs/eval-lock nudges use, gated on the ACTIVE adapter's
484
+ // `injectableEvents` so this is per-harness fact, not a CC literal.
485
+ const injectable = injectableEventsFor(projectRoot ?? process.cwd());
486
+ const delivery = (0, hook_program_js_1.noticeDelivery)(reaction, program.on, injectable);
487
+ if (delivery.kind === "inject") {
488
+ process.stdout.write(JSON.stringify({
489
+ hookSpecificOutput: {
490
+ hookEventName: program.on,
491
+ additionalContext: delivery.context,
492
+ },
493
+ }) + "\n");
494
+ }
495
+ // The stderr copy STAYS, deliberately. It is what the debug log and every
496
+ // `runHook`-based probe already read, it costs nothing, and on an event
497
+ // this harness does not inject it is the only trace that exists at all.
498
+ // Removing it would break existing consumers to gain nothing.
499
+ if (reaction.kind === "notice")
500
+ console.error(reaction.message);
501
+ applyHookWrites(file, { kind: "reaction", reaction });
502
+ if (reaction.kind === "run") {
503
+ const { spawnSync } = require("node:child_process");
504
+ const res = spawnSync(reaction.command, {
505
+ shell: true,
506
+ stdio: "inherit",
507
+ });
508
+ process.exit(res.status ?? 0);
509
+ }
510
+ return;
511
+ }
512
+ case "file-gate": {
513
+ const ctx = await gatherHookContext(program, file);
514
+ warnIfPathUndecidable(event, projectRoot);
515
+ emitGate((0, hook_program_js_1.decideFileGate)(program, event, ctx, projectRoot), program.on, (0, hook_program_js_1.hookMode)(program), file);
516
+ return;
517
+ }
518
+ case "bash-gate": {
519
+ const ctx = await gatherHookContext(program, file);
520
+ // The same `projectRoot` the file gates get: without it every
521
+ // repo-relative prefix in a DENYLIST matcher (`touches`/`writesTo`) is
522
+ // matched by over-blocking alone, and with it an absolute token is placed
523
+ // exactly. Measured bypass this closes: `sed -i s/a/b/ <abs>/paper.tex`
524
+ // exited 0 against a guard that blocked the relative spelling.
525
+ emitGate((0, hook_program_js_1.decideProgram)(program, event, ctx, projectRoot), program.on, (0, hook_program_js_1.hookMode)(program), file);
526
+ return;
527
+ }
528
+ case "prompt-gate": {
529
+ const ctx = await gatherHookContext(program, file);
530
+ emitGate((0, hook_program_js_1.decidePromptGate)(program, event, ctx), program.on, (0, hook_program_js_1.hookMode)(program), file);
531
+ return;
532
+ }
533
+ case "stop-gate": {
534
+ const ctx = await gatherHookContext(program, file);
535
+ emitGate((0, hook_program_js_1.decideStopGate)(program, event, ctx), program.on, (0, hook_program_js_1.hookMode)(program), file);
536
+ return;
537
+ }
538
+ }
539
+ }
540
+ //# sourceMappingURL=hook-runtime.js.map
package/dist/hook.d.ts CHANGED
@@ -49,7 +49,7 @@
49
49
  * entirely (#45427 / #32376 — a Bash heredoc instead of `Write`), so a gate is a
50
50
  * strong default and is NEVER an unbypassable wall. See `docs/compiled-hooks.md`.
51
51
  */
52
- export { experimental_defineHook, experimental_defineFileGate, experimental_definePromptGate, experimental_defineStopGate, tool, tools, allow, deny, ask, commandView, pathView, gateAction, hookMode, experimental_defineInject, inject, experimental_defineReact, run, notice, nothing, responseView, decideProgram, decideFileGate, decidePromptGate, decideStopGate, runInject, runReact, runHookProgram, decisionExitCode, dispatchKind, hookRouting, hookNeeds, injectionOf, outcomeWrites, matchesTool, invalidToolPatterns, compileHookProgram, checkHookImports, stampHook, verifyHookStamp, HookCompileError, } from "./core/hook-program.js";
52
+ export { experimental_defineHook, experimental_defineFileGate, experimental_definePromptGate, experimental_defineStopGate, tools, allow, deny, ask, commandView, pathView, gateAction, hookMode, experimental_defineInject, inject, experimental_defineReact, run, notice, nothing, responseView, decideProgram, decideFileGate, decidePromptGate, decideStopGate, runInject, runReact, runHookProgram, decisionExitCode, dispatchKind, hookRouting, hookNeeds, injectionOf, outcomeWrites, matchesTool, invalidToolPatterns, compileHookProgram, checkHookImports, stampHook, verifyHookStamp, HookCompileError, } from "./core/hook-program.js";
53
53
  export type { Decision, HookMode, GateAction, CommandView, PathView, ResponseView, BashToolEvent, FileToolEvent, PromptEvent, StopEvent, ReactEvent, SessionEvent, HookProgram, FileGateHook, PromptGateHook, StopGateHook, InjectHook, ReactHook, AnyHook, DispatchKind, Injection, Reaction, RunReaction, CompiledHookProgram, CompileHookOptions, RawHookEvent, HookProgramOutcome, } from "./core/hook-program.js";
54
54
  export { provide, dangerously, defineProvider, provider, } from "./core/hook-providers.js";
55
55
  export { state, record, stateFact, isValidStateKey, isStateNeed, isStateWrite, admissibleWrites, durationSeconds, HookStateError, } from "./core/hook-state.js";
package/dist/hook.js CHANGED
@@ -1,7 +1,7 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.isStateWrite = exports.isStateNeed = exports.isValidStateKey = exports.stateFact = exports.record = exports.state = exports.provider = exports.defineProvider = exports.dangerously = exports.provide = exports.HookCompileError = exports.verifyHookStamp = exports.stampHook = exports.checkHookImports = exports.compileHookProgram = exports.invalidToolPatterns = exports.matchesTool = exports.outcomeWrites = exports.injectionOf = exports.hookNeeds = exports.hookRouting = exports.dispatchKind = exports.decisionExitCode = exports.runHookProgram = exports.runReact = exports.runInject = exports.decideStopGate = exports.decidePromptGate = exports.decideFileGate = exports.decideProgram = exports.responseView = exports.nothing = exports.notice = exports.run = exports.experimental_defineReact = exports.inject = exports.experimental_defineInject = exports.hookMode = exports.gateAction = exports.pathView = exports.commandView = exports.ask = exports.deny = exports.allow = exports.tools = exports.tool = exports.experimental_defineStopGate = exports.experimental_definePromptGate = exports.experimental_defineFileGate = exports.experimental_defineHook = void 0;
4
- exports.leafCommandsNormalized = exports.HookStateError = exports.durationSeconds = exports.admissibleWrites = void 0;
3
+ exports.admissibleWrites = exports.isStateWrite = exports.isStateNeed = exports.isValidStateKey = exports.stateFact = exports.record = exports.state = exports.provider = exports.defineProvider = exports.dangerously = exports.provide = exports.HookCompileError = exports.verifyHookStamp = exports.stampHook = exports.checkHookImports = exports.compileHookProgram = exports.invalidToolPatterns = exports.matchesTool = exports.outcomeWrites = exports.injectionOf = exports.hookNeeds = exports.hookRouting = exports.dispatchKind = exports.decisionExitCode = exports.runHookProgram = exports.runReact = exports.runInject = exports.decideStopGate = exports.decidePromptGate = exports.decideFileGate = exports.decideProgram = exports.responseView = exports.nothing = exports.notice = exports.run = exports.experimental_defineReact = exports.inject = exports.experimental_defineInject = exports.hookMode = exports.gateAction = exports.pathView = exports.commandView = exports.ask = exports.deny = exports.allow = exports.tools = exports.experimental_defineStopGate = exports.experimental_definePromptGate = exports.experimental_defineFileGate = exports.experimental_defineHook = void 0;
4
+ exports.leafCommandsNormalized = exports.HookStateError = exports.durationSeconds = void 0;
5
5
  /**
6
6
  * `vigiles/hook` — the **closed vocabulary** for authoring a compiled hook.
7
7
  *
@@ -78,7 +78,6 @@ Object.defineProperty(exports, "experimental_defineHook", { enumerable: true, ge
78
78
  Object.defineProperty(exports, "experimental_defineFileGate", { enumerable: true, get: function () { return hook_program_js_1.experimental_defineFileGate; } });
79
79
  Object.defineProperty(exports, "experimental_definePromptGate", { enumerable: true, get: function () { return hook_program_js_1.experimental_definePromptGate; } });
80
80
  Object.defineProperty(exports, "experimental_defineStopGate", { enumerable: true, get: function () { return hook_program_js_1.experimental_defineStopGate; } });
81
- Object.defineProperty(exports, "tool", { enumerable: true, get: function () { return hook_program_js_1.tool; } });
82
81
  Object.defineProperty(exports, "tools", { enumerable: true, get: function () { return hook_program_js_1.tools; } });
83
82
  Object.defineProperty(exports, "allow", { enumerable: true, get: function () { return hook_program_js_1.allow; } });
84
83
  Object.defineProperty(exports, "deny", { enumerable: true, get: function () { return hook_program_js_1.deny; } });
@@ -173,7 +173,7 @@ export interface UntestedReport {
173
173
  */
174
174
  readonly staleRuns?: readonly StaleRun[];
175
175
  /**
176
- * DETERMINISTIC coverage only — `*.harness.*`, plus any custom `testGlobs`.
176
+ * DETERMINISTIC coverage only — `*.harness.*`, plus any custom `include`.
177
177
  * Free, millisecond, every-push. Answers "does this gate still catch what it
178
178
  * claims?" (`*.test.*` has NOT counted since 15.x — see DEFAULT_TEST_GLOBS.)
179
179
  */
@@ -194,7 +194,7 @@ export interface TestCoverageOptions {
194
194
  /** Scan hook scripts referenced from plugin.json / settings.json. Default true. */
195
195
  readonly hooks?: boolean;
196
196
  /** Globs of test files that count as coverage. */
197
- readonly testGlobs?: readonly string[];
197
+ readonly include?: readonly string[];
198
198
  /**
199
199
  * Which extension a GENERATED test gets. Detection (a tsconfig.json, a
200
200
  * typescript dependency) decides by default; this field exists only to
@@ -206,7 +206,7 @@ export interface TestCoverageOptions {
206
206
  * (or the `-subagent` / `-hook` twin — the three share their options). The
207
207
  * previous wording here said only "from `.vigilesrc.json`", which read as a
208
208
  * promise the CLI did not keep: `TestCoverageConfig` had no such key and
209
- * `checkUntestedSurfaces` forwarded only `testGlobs`/`exclude`, so a configured
209
+ * `checkUntestedSurfaces` forwarded only `include`/`exclude`, so a configured
210
210
  * `mjs` was silently ignored on any TypeScript-shaped repo.
211
211
  */
212
212
  readonly testExtension?: string;