vigiles 27.3.0 → 28.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.
@@ -14,7 +14,6 @@ const layout_js_1 = require("./layout.js");
14
14
  const runtime_js_1 = require("./runtime.js");
15
15
  const hook_protocol_js_1 = require("./hook-protocol.js");
16
16
  const model_mock_js_1 = require("./model-mock.js");
17
- const harness_test_js_1 = require("../../harness-test.js");
18
17
  exports.claudeCodeAdapter = {
19
18
  name: "claude-code",
20
19
  // The reference harness: every tier. Mockable transport (Anthropic SSE) and
@@ -30,7 +29,9 @@ exports.claudeCodeAdapter = {
30
29
  runtime: runtime_js_1.claudeCodeRuntime,
31
30
  hookProtocol: hook_protocol_js_1.claudeCodeHookProtocol,
32
31
  modelMock: model_mock_js_1.claudeCodeModelMock,
33
- harnessTestDriver: harness_test_js_1.claudeCodeDriver,
32
+ // Imported inside the thunk, not at the top: a top-level import runs at module
33
+ // init and would pull the whole test/compiler graph back in.
34
+ harnessTestDriver: async () => (await import("../../harness-test.js")).claudeCodeDriver,
34
35
  detect(root) {
35
36
  // Most specific signal wins: a plugin manifest (3) > repo settings (2) >
36
37
  // a bare CLAUDE.md (1, weak — many tools also read it / AGENTS.md).
@@ -20,7 +20,6 @@ const layout_js_1 = require("./layout.js");
20
20
  const runtime_js_1 = require("./runtime.js");
21
21
  const hook_protocol_js_1 = require("./hook-protocol.js");
22
22
  const model_mock_js_1 = require("./model-mock.js");
23
- const driver_js_1 = require("./driver.js");
24
23
  exports.codexAdapter = {
25
24
  name: "codex",
26
25
  // Full convergence with Claude Code: mockable (Responses SSE) + shell hooks
@@ -38,7 +37,7 @@ exports.codexAdapter = {
38
37
  runtime: runtime_js_1.codexRuntime,
39
38
  hookProtocol: hook_protocol_js_1.codexHookProtocol,
40
39
  modelMock: model_mock_js_1.codexModelMock,
41
- harnessTestDriver: driver_js_1.codexDriver,
40
+ harnessTestDriver: async () => (await import("./driver.js")).codexDriver,
42
41
  detect(root) {
43
42
  // A `.codex/config.toml` is a strong signal; a bare AGENTS.md is weak (many
44
43
  // harnesses read it). (Unused while unregistered — kept for symmetry.)
package/dist/cli-main.js CHANGED
@@ -5252,6 +5252,45 @@ function annotateLintForGitHub(report, flags) {
5252
5252
  ghAnnotate("warning", `${String(report.duplicatePairs)} near-duplicate rule pair(s) detected — consider merging`);
5253
5253
  }
5254
5254
  }
5255
+ /**
5256
+ * The project root a `hook-runtime` rail acts on — NEVER `process.cwd()` first.
5257
+ *
5258
+ * These rails are a SECOND execution path, wired by hand into a hooks config
5259
+ * (`npx vigiles hook-runtime <kind>`), and every one of them used to ask
5260
+ * `process.cwd()` where the project was. The hook process has no stable cwd: a
5261
+ * git worktree, or a session that has `cd`-ed into a subdirectory, stands
5262
+ * somewhere the project's files are not, and then the state store writes its
5263
+ * marker beside the wrong repo and `.vigiles/*.json` is simply not found. The
5264
+ * failure is SILENT in the permissive direction — no gates loaded reads exactly
5265
+ * like a project that declared none.
5266
+ *
5267
+ * Same order the compiled runtime uses (`projectRootOf`, core/hook-program.ts):
5268
+ * `$CLAUDE_PROJECT_DIR` first — the harness resolved the hook's own path against
5269
+ * it — then the event's own `cwd`, which Claude Code puts in every hook payload.
5270
+ * `process.cwd()` stays as the documented LAST resort, for the rails a human or
5271
+ * the model invokes as a plain command with no event and no env to go on.
5272
+ *
5273
+ * Pass the parsed event wherever stdin was already read; the handlers that take
5274
+ * only an argument pass nothing and get the env answer.
5275
+ */
5276
+ function runtimeRoot(event = {}) {
5277
+ return (0, hook_program_js_1.projectRootOf)(event, process.env) ?? process.cwd();
5278
+ }
5279
+ /**
5280
+ * The hook payload as a root SOURCE — `{}` when stdin was absent or malformed,
5281
+ * which {@link runtimeRoot} reads as "this event offers no root" and falls
5282
+ * through. Deliberately separate from each handler's own parse: a rail that
5283
+ * cannot understand its event still knows where the project is, and a rail that
5284
+ * only wants `tool_name` should not have to widen its own type to say so.
5285
+ */
5286
+ function eventRoot(raw) {
5287
+ try {
5288
+ return JSON.parse(raw);
5289
+ }
5290
+ catch {
5291
+ return {};
5292
+ }
5293
+ }
5255
5294
  /**
5256
5295
  * Run a compiled skill's deterministic gate ladder: execute each step gate in
5257
5296
  * order (short-circuiting on the first failure), then the result gate. This is
@@ -5264,7 +5303,8 @@ function runSkillCommand(target) {
5264
5303
  console.error("Usage: vigiles hook-runtime run-skill <SKILL.md>");
5265
5304
  process.exit(2);
5266
5305
  }
5267
- const path = (0, node_path_1.resolve)(process.cwd(), target);
5306
+ const root = runtimeRoot();
5307
+ const path = (0, node_path_1.resolve)(root, target);
5268
5308
  if (!(0, node_fs_1.existsSync)(path)) {
5269
5309
  console.error(`Not found: ${target}`);
5270
5310
  process.exit(2);
@@ -5275,7 +5315,7 @@ function runSkillCommand(target) {
5275
5315
  return;
5276
5316
  }
5277
5317
  console.log(`Running gate ladder for ${target}:\n`);
5278
- const report = (0, skill_runtime_js_1.runSkillGates)(gates, process.cwd());
5318
+ const report = (0, skill_runtime_js_1.runSkillGates)(gates, root);
5279
5319
  for (const r of report.results) {
5280
5320
  const label = r.at === "result" ? "result" : `step ${String(r.at)}`;
5281
5321
  console.log(` ${r.ok ? "✓" : "✗"} ${label} — ${(0, skill_runtime_js_1.gateLabel)(r.gate)}`);
@@ -5303,11 +5343,12 @@ function runSkillCommand(target) {
5303
5343
  * feeds the message back to the model; exit 0 allows it and clears the marker.
5304
5344
  */
5305
5345
  function skillHookCommand() {
5306
- const decision = (0, skill_runtime_js_1.evaluateStopHook)(process.cwd());
5346
+ const root = runtimeRoot();
5347
+ const decision = (0, skill_runtime_js_1.evaluateStopHook)(root);
5307
5348
  if (decision.allow) {
5308
5349
  if (decision.message)
5309
5350
  console.log(decision.message);
5310
- (0, skill_runtime_js_1.clearActiveSkill)(process.cwd());
5351
+ (0, skill_runtime_js_1.clearActiveSkill)(root);
5311
5352
  return;
5312
5353
  }
5313
5354
  console.error(decision.message);
@@ -5319,12 +5360,20 @@ function skillStartCommand(target) {
5319
5360
  console.error("Usage: vigiles hook-runtime skill-start <SKILL.md>");
5320
5361
  process.exit(2);
5321
5362
  }
5322
- (0, skill_runtime_js_1.setActiveSkill)(process.cwd(), target);
5363
+ const root = runtimeRoot();
5364
+ (0, skill_runtime_js_1.setActiveSkill)(root, target);
5323
5365
  // Record the fire in the flight recorder: the skill NAME is the parent dir of
5324
5366
  // its SKILL.md (skills/<name>/SKILL.md), falling back to the raw target.
5325
5367
  const parts = target.replace(/\\/g, "/").split("/").filter(Boolean);
5326
5368
  const name = parts.length >= 2 ? parts[parts.length - 2] : (parts[0] ?? target);
5327
- (0, observe_js_1.appendObservation)({ kind: "skill", name, fired: true });
5369
+ // The SAME root the decision above used. `appendObservation` defaults to
5370
+ // `process.cwd()`, which is correct as a library default and wrong here: a
5371
+ // hook does not run with a stable cwd, so the decision would land in the
5372
+ // project while its record landed beside whatever directory the process
5373
+ // happened to stand in. A ledger split across two directories is not untidy,
5374
+ // it is wrong in a way that reads as normal — the file in the project looks
5375
+ // complete, and nobody notices a flight recorder that is short.
5376
+ (0, observe_js_1.appendObservation)({ kind: "skill", name, fired: true }, root);
5328
5377
  console.log(`Active skill: ${target}`);
5329
5378
  }
5330
5379
  /**
@@ -5357,7 +5406,7 @@ function skillToolHookCommand() {
5357
5406
  }
5358
5407
  if (!tool)
5359
5408
  return;
5360
- const decision = (0, skill_runtime_js_1.evaluateSkillPreToolUse)(process.cwd(), tool, command);
5409
+ const decision = (0, skill_runtime_js_1.evaluateSkillPreToolUse)(runtimeRoot(eventRoot(raw)), tool, command);
5361
5410
  if (!decision.allow) {
5362
5411
  console.error(decision.message);
5363
5412
  process.exit(2);
@@ -5393,7 +5442,7 @@ function agentHookCommand() {
5393
5442
  catch {
5394
5443
  /* malformed input → no tool, allow */
5395
5444
  }
5396
- const cwd = process.cwd();
5445
+ const cwd = runtimeRoot(eventRoot(raw));
5397
5446
  // EXPERIMENTAL (parked P3 — do NOT auto-wire). The spawn/SubagentStop bracketing
5398
5447
  // is now nesting-safe: a depth-aware STACK (push on dispatch, POP on SubagentStop)
5399
5448
  // closes the contract-escape the flat single-slot model allowed under CC v2.1.172
@@ -5425,13 +5474,15 @@ function agentHookCommand() {
5425
5474
  return;
5426
5475
  const decision = (0, agent_runtime_js_1.evaluatePreToolUse)(cwd, tool, command);
5427
5476
  if (!decision.allow) {
5477
+ // Same root as the decision — see `skillStartCommand` for why the default
5478
+ // is wrong on a hook rail.
5428
5479
  (0, observe_js_1.appendObservation)({
5429
5480
  kind: "agent",
5430
5481
  name: (0, agent_runtime_js_1.readActiveAgent)(cwd) ?? "unknown",
5431
5482
  tool,
5432
5483
  allowed: false,
5433
5484
  reason: decision.message,
5434
- });
5485
+ }, cwd);
5435
5486
  console.error(decision.message);
5436
5487
  process.exit(2);
5437
5488
  }
@@ -5476,7 +5527,7 @@ function guardHookCommand() {
5476
5527
  catch {
5477
5528
  /* no stdin */
5478
5529
  }
5479
- const { decision } = (0, guards_js_1.runGuardHook)(process.cwd(), raw);
5530
+ const { decision } = (0, guards_js_1.runGuardHook)(runtimeRoot(eventRoot(raw)), raw);
5480
5531
  if (!decision.allow) {
5481
5532
  console.error(decision.reason ?? "Blocked by a vigiles guard.");
5482
5533
  process.exit(2);
@@ -5488,7 +5539,7 @@ function agentStartCommand(target) {
5488
5539
  console.error("Usage: vigiles hook-runtime agent-start <agents/<name>.md>");
5489
5540
  process.exit(2);
5490
5541
  }
5491
- (0, agent_runtime_js_1.pushActiveAgent)(process.cwd(), target);
5542
+ (0, agent_runtime_js_1.pushActiveAgent)(runtimeRoot(), target);
5492
5543
  console.log(`Active agent: ${target}`);
5493
5544
  }
5494
5545
  /** Dispatch the skill-runtime subcommands. Returns false if unrecognized. */
@@ -5512,7 +5563,7 @@ async function handleHookRuntime(kind, restArgs) {
5512
5563
  agentStartCommand(restArgs[0]);
5513
5564
  return;
5514
5565
  case "agent-done":
5515
- (0, agent_runtime_js_1.popActiveAgent)(process.cwd());
5566
+ (0, agent_runtime_js_1.popActiveAgent)(runtimeRoot());
5516
5567
  return;
5517
5568
  case "skill":
5518
5569
  skillHookCommand();
@@ -5524,7 +5575,7 @@ async function handleHookRuntime(kind, restArgs) {
5524
5575
  skillStartCommand(restArgs[0]);
5525
5576
  return;
5526
5577
  case "skill-done":
5527
- (0, skill_runtime_js_1.clearActiveSkill)(process.cwd());
5578
+ (0, skill_runtime_js_1.clearActiveSkill)(runtimeRoot());
5528
5579
  return;
5529
5580
  case "run-skill":
5530
5581
  runSkillCommand(restArgs[0]);
@@ -5545,11 +5596,11 @@ async function handleHookRuntime(kind, restArgs) {
5545
5596
  evalLockNudgeHookCommand();
5546
5597
  return;
5547
5598
  case "effect-enter":
5548
- (0, effect_region_js_1.setEffectActive)(process.cwd());
5599
+ (0, effect_region_js_1.setEffectActive)(runtimeRoot());
5549
5600
  console.log("Effect boundary entered.");
5550
5601
  return;
5551
5602
  case "effect-exit":
5552
- (0, effect_region_js_1.clearEffectActive)(process.cwd());
5603
+ (0, effect_region_js_1.clearEffectActive)(runtimeRoot());
5553
5604
  return;
5554
5605
  default:
5555
5606
  console.error(`vigiles hook-runtime: unknown runtime entrypoint "${kind ?? ""}". ` +
@@ -5580,7 +5631,8 @@ function actionHookCommand() {
5580
5631
  catch {
5581
5632
  /* malformed input → no event, allow */
5582
5633
  }
5583
- const decision = (0, action_gate_js_1.evaluateAction)(event, (0, action_gate_js_1.loadActionGates)(process.cwd()), process.cwd());
5634
+ const root = runtimeRoot(eventRoot(raw));
5635
+ const decision = (0, action_gate_js_1.evaluateAction)(event, (0, action_gate_js_1.loadActionGates)(root), root);
5584
5636
  if (!decision.allow) {
5585
5637
  console.error(decision.message);
5586
5638
  process.exit(2);
@@ -5624,7 +5676,7 @@ function evalLockNudgeHookCommand() {
5624
5676
  }
5625
5677
  if (!file)
5626
5678
  return;
5627
- const cwd = process.cwd();
5679
+ const cwd = runtimeRoot(eventRoot(raw));
5628
5680
  const target = (0, node_path_1.relative)(cwd, (0, node_path_1.resolve)(cwd, file)) || file;
5629
5681
  // 🔴 THE SAME CONFIG `vigiles lint` READS. This used to pass `basePath` alone,
5630
5682
  // so a repo that had switched `untested-skill` off, or pointed `include` at
@@ -5638,7 +5690,7 @@ function evalLockNudgeHookCommand() {
5638
5690
  // second gate saying the same thing is a branch no test can distinguish from
5639
5691
  // its absence (measured — the mutation passed), i.e. the dead-fragment class
5640
5692
  // this same change removed from the runner table.
5641
- const config = (0, validate_js_1.loadConfig)();
5693
+ const config = (0, validate_js_1.loadConfig)(cwd);
5642
5694
  const { options } = untestedRules(config);
5643
5695
  // 🔴 THE SAME LAYOUT `vigiles lint` RESOLVES, for the same reason as the config
5644
5696
  // above. This used to pass `basePath` alone, so the detector fell back to the
@@ -5701,10 +5753,12 @@ function refsHookCommand() {
5701
5753
  }
5702
5754
  if (!file || !isInstructionFile(file))
5703
5755
  return;
5704
- const severity = (0, types_js_1.ruleSeverity)((0, validate_js_1.loadConfig)().rules["unmarked-refs"]);
5756
+ // Root first: the config read below is anchored on it, and reading the config
5757
+ // from the process's directory is how a disabled rule comes back to life.
5758
+ const cwd = runtimeRoot(eventRoot(raw));
5759
+ const severity = (0, types_js_1.ruleSeverity)((0, validate_js_1.loadConfig)(cwd).rules["unmarked-refs"]);
5705
5760
  if (severity === false)
5706
5761
  return;
5707
- const cwd = process.cwd();
5708
5762
  const target = (0, node_path_1.relative)(cwd, (0, node_path_1.resolve)(cwd, file)) || file;
5709
5763
  let markdown;
5710
5764
  try {
@@ -5787,7 +5841,14 @@ async function installHookFile(file, adapter, registeredProviders = []) {
5787
5841
  // emitter never produced it. Measured 2026-09-10 in a consumer repo: after a compile,
5788
5842
  // one `cd` into a subdirectory made a PreToolUse gate fail to load, and a gate that
5789
5843
  // cannot load must block — the repo seized, every command refused including the repair.
5790
- gateCommand: `npx vigiles hook-runtime run-program ${(0, hook_install_js_1.hookGateRef)(ref, adapter.layout.projectRootTokens)}`,
5844
+ //
5845
+ // 🔴 AND LAUNCHED LOCALLY, NOT THROUGH `npx` — 193 ms against 2545 ms on a warm
5846
+ // cache, thirteen times, on every tool call. The trailing `|| exit N` is what the
5847
+ // shell does when that binary cannot start at all, and N is decided by the hook's
5848
+ // ROLE: see `hookRuntimeRef` and `hookRuntimeMissingExit` for both measurements
5849
+ // and for why a gate and a nudge must answer differently.
5850
+ gateCommand: `${(0, hook_install_js_1.hookRuntimeRef)(adapter.layout.projectRootTokens)} hook-runtime run-program ` +
5851
+ `${(0, hook_install_js_1.hookGateRef)(ref, adapter.layout.projectRootTokens)} || exit ${(0, hook_install_js_1.hookRuntimeMissingExit)((0, hook_program_js_1.dispatchKind)(program))}`,
5791
5852
  dialect: adapter.dialect,
5792
5853
  hookProtocol: adapter.hookProtocol,
5793
5854
  settingsFormat: adapter.layout.settingsFormat,
@@ -84,7 +84,28 @@ export interface HarnessAdapter {
84
84
  * one seam the runner dispatches through). Carried on the bundle so the runner
85
85
  * never imports a sibling adapter to find it.
86
86
  */
87
- readonly harnessTestDriver?: HarnessTestDriver;
87
+ /**
88
+ * 🔴 A THUNK, NOT THE DRIVER, and the indirection is the whole point. A driver
89
+ * lives in `harness-test.ts`, which imports the conformance suite, which
90
+ * imports the compiler, which imports the cross-language symbol index, which
91
+ * loads a NATIVE binary. Holding the driver eagerly meant every consumer of an
92
+ * adapter paid for all of it — including the hook runtime, which reads only
93
+ * `dialect` and `hookProtocol` and never runs a harness test at all.
94
+ *
95
+ * Measured 2026-09-19, `require("./adapter-registry.js")`:
96
+ *
97
+ * eager: 107 modules, 7 ast-grep, 1 native .node
98
+ *
99
+ * ...on a path whose actual work takes about a millisecond. Calling the thunk
100
+ * is what loads the driver, so the test tier pays and the runtime does not.
101
+ *
102
+ * ASYNC because a dynamic `import()` is the only form that defers in BOTH
103
+ * environments this code runs in: the CJS `dist/` build (where TypeScript
104
+ * lowers it to a deferred `require`) and vitest loading the TS sources
105
+ * directly, where a synchronous `require` of a sibling `.ts` does not resolve
106
+ * at all — measured, not assumed.
107
+ */
108
+ readonly harnessTestDriver?: () => Promise<HarnessTestDriver>;
88
109
  /**
89
110
  * How strongly a repo at `root` looks like it targets this harness — the CLI
90
111
  * uses it to auto-detect which adapter to use (the library selects by import).
@@ -895,6 +895,49 @@ export interface RawHookEvent {
895
895
  */
896
896
  readonly cwd?: string;
897
897
  }
898
+ /**
899
+ * A hook event whose project root has already been RESOLVED — the shape every
900
+ * consumer past the entry point takes.
901
+ *
902
+ * {@link RawHookEvent} is what arrives on stdin; this is what the runtime works
903
+ * with. The difference is one field, and that field is the whole point: with the
904
+ * root ON the event, an event and a root cannot be handed to different places
905
+ * and disagree. That divergence is not hypothetical. Measured 2026-09-19: the
906
+ * decision layer resolved against the payload while the tamper stamp resolved
907
+ * against `process.cwd()`, and under a git worktree the stamp check did not
908
+ * point at the wrong file — it returned silently and did not run at all.
909
+ *
910
+ * Threading the root as a second parameter beside the event fixes an instance
911
+ * and keeps the shape. A field removes the shape.
912
+ */
913
+ export interface HookEvent extends RawHookEvent {
914
+ /**
915
+ * The project root, always usable — so no consumer repeats a `?? cwd` fallback
916
+ * and none can forget it.
917
+ */
918
+ readonly root: string;
919
+ /**
920
+ * Whether {@link root} came from the payload (`$CLAUDE_PROJECT_DIR` or the
921
+ * event's own `cwd`) or is the fallback standing in for a payload that
922
+ * declared none.
923
+ *
924
+ * 🔴 THIS IS THE FIELD A POLICY DECISION READS. What to do with an undeclared
925
+ * root differs by ROLE, not by call site: a gate that cannot locate the
926
+ * project is a gate that cannot decide, and a gate that cannot decide must
927
+ * refuse; a nudge in the same position must stay quiet, because a reminder is
928
+ * never worth a wedged repository. Collapsing both into one behaviour inside
929
+ * the resolver would make that choice unexpressible.
930
+ */
931
+ readonly rootDeclared: boolean;
932
+ }
933
+ /**
934
+ * Resolve a raw payload's project root ONCE, at the entry point.
935
+ *
936
+ * `fallback` is injected rather than read here, because this module does no IO
937
+ * and holds no ambient state — the caller supplies `process.cwd()`. That is what
938
+ * keeps `process.cwd()` to a single occurrence in the whole hook runtime.
939
+ */
940
+ export declare function resolveHookEvent(raw: RawHookEvent, env: Readonly<Record<string, string | undefined>>, fallback: string): HookEvent;
898
941
  /** The normalized outcome of running a hook program — discriminated by role. */
899
942
  export type HookProgramOutcome = {
900
943
  readonly kind: "decision";
@@ -34,6 +34,7 @@ exports.injectionOf = injectionOf;
34
34
  exports.responseView = responseView;
35
35
  exports.experimental_defineReact = experimental_defineReact;
36
36
  exports.runReact = runReact;
37
+ exports.resolveHookEvent = resolveHookEvent;
37
38
  exports.outcomeWrites = outcomeWrites;
38
39
  exports.rememberHookSource = rememberHookSource;
39
40
  exports.hookSource = hookSource;
@@ -717,6 +718,22 @@ function hookRouting(hook) {
717
718
  // A react MAY also be tool-less (Stop/SessionEnd) — same shape, same reason.
718
719
  if (hook.match === undefined)
719
720
  return { on: hook.on };
721
+ // 🔴 SAY WHAT IS WRONG, IN THE AUTHOR'S VOCABULARY. From a typed `.ts` hook
722
+ // this is unreachable — tsc rejects a `match` without `tools`. From a `.mjs`
723
+ // hook, which is a supported authoring format, nothing checks it, and
724
+ // reading `.tools.join` off the wrong shape used to surface as
725
+ // `Cannot read properties of undefined (reading 'join')`: a message that
726
+ // names an internal property of an internal function and points nowhere
727
+ // near the author's file. This repo has already paid twice for a diagnosis
728
+ // that sends the reader to the wrong place (the loader that advised
729
+ // `npm run build` when the answer was `npm install`; the bare "cannot be
730
+ // loaded"). A `HookCompileError` is also what the installer catches to
731
+ // print the FILE alongside the reason — a TypeError falls past it.
732
+ if (!Array.isArray(hook.match.tools)) {
733
+ throw new HookCompileError(`a ${hook.role} hook's \`match\` must be \`{ tools: [...] }\` — got ` +
734
+ `${JSON.stringify(hook.match)}. Use \`tools("Edit", "Write")\` to build it; ` +
735
+ `a path condition belongs in the gate's own predicate, not in \`match\`.`);
736
+ }
720
737
  return { on: hook.on, matcher: hook.match.tools.join("|") };
721
738
  }
722
739
  // Bash by construction — see decideProgram; the author no longer declares it.
@@ -1331,6 +1348,21 @@ function runReact(hook, raw, ctx = {}, root = typeof raw.cwd === "string" ? raw.
1331
1348
  ctx: ctx,
1332
1349
  });
1333
1350
  }
1351
+ /**
1352
+ * Resolve a raw payload's project root ONCE, at the entry point.
1353
+ *
1354
+ * `fallback` is injected rather than read here, because this module does no IO
1355
+ * and holds no ambient state — the caller supplies `process.cwd()`. That is what
1356
+ * keeps `process.cwd()` to a single occurrence in the whole hook runtime.
1357
+ */
1358
+ function resolveHookEvent(raw, env, fallback) {
1359
+ const declared = projectRootOf(raw, env);
1360
+ return {
1361
+ ...raw,
1362
+ root: declared ?? fallback,
1363
+ rootDeclared: declared !== undefined,
1364
+ };
1365
+ }
1334
1366
  /**
1335
1367
  * The state writes an outcome declares, filtered to the ones the runtime may
1336
1368
  * actually perform. A gate's `Decision` carries none — deliberately: a gate is
@@ -19,7 +19,21 @@ export declare function normalizeSeverity(v: unknown): unknown;
19
19
  * A non-string/array value falls back with a warning (the `ruleMarkers` pattern).
20
20
  */
21
21
  export declare function asStringArray(v: unknown, fallback: readonly string[], key: string): readonly string[];
22
- export declare function loadConfig(): VigilesConfig;
22
+ /**
23
+ * Read `.vigilesrc.json`.
24
+ *
25
+ * 🔴 `searchFrom` IS NOT A CONVENIENCE. cosmiconfig defaults to the process's
26
+ * working directory and walks up — right for a CLI verb, where the user is
27
+ * standing in the project they mean, and wrong for a hook, whose process has no
28
+ * stable cwd. A hook rail that omits it reads a DIFFERENT project's config, or
29
+ * none, and the failure runs the wrong way: a missing file means defaults, so a
30
+ * rule the author switched OFF comes back on, silently, because the file saying
31
+ * "off" was never found. Nothing in the output distinguishes that from a project
32
+ * that never configured the rule.
33
+ *
34
+ * Every CLI verb still calls this with no argument and is unaffected.
35
+ */
36
+ export declare function loadConfig(searchFrom?: string): VigilesConfig;
23
37
  export declare function parseRules(content: string, { ruleMarkers }?: ParseOptions): ParsedRule[];
24
38
  export declare function validate(content: string, { ruleMarkers, rules: rulesConfig, filePath, dialect }?: ValidateOptions): ValidationResult;
25
39
  export declare function readInstructionFile(filePath: string, options?: ReadOptions): ReadResult;
@@ -172,13 +172,27 @@ function asStringArray(v, fallback, key) {
172
172
  console.warn(`Invalid ${key} in config: expected a string or string[], got ${JSON.stringify(v)}. Ignoring.`);
173
173
  return fallback;
174
174
  }
175
- function loadConfig() {
175
+ /**
176
+ * Read `.vigilesrc.json`.
177
+ *
178
+ * 🔴 `searchFrom` IS NOT A CONVENIENCE. cosmiconfig defaults to the process's
179
+ * working directory and walks up — right for a CLI verb, where the user is
180
+ * standing in the project they mean, and wrong for a hook, whose process has no
181
+ * stable cwd. A hook rail that omits it reads a DIFFERENT project's config, or
182
+ * none, and the failure runs the wrong way: a missing file means defaults, so a
183
+ * rule the author switched OFF comes back on, silently, because the file saying
184
+ * "off" was never found. Nothing in the output distinguishes that from a project
185
+ * that never configured the rule.
186
+ *
187
+ * Every CLI verb still calls this with no argument and is unaffected.
188
+ */
189
+ function loadConfig(searchFrom) {
176
190
  try {
177
191
  const explorer = (0, cosmiconfig_1.cosmiconfigSync)("vigiles", {
178
192
  searchPlaces: [".vigilesrc.json"],
179
193
  mergeSearchPlaces: false,
180
194
  });
181
- const result = explorer.search();
195
+ const result = explorer.search(searchFrom);
182
196
  if (!result?.config)
183
197
  return { ...DEFAULT_CONFIG };
184
198
  const userConfig = result.config;
@@ -430,7 +430,7 @@ async function runHarnessTest(spec, opts = {}) {
430
430
  // Default (no adapter): the unchanged Claude Code driver — keeps the
431
431
  // sandbox/confined path and behaviour byte-for-byte identical.
432
432
  const driver = adapter
433
- ? requireDriver(adapter)
433
+ ? await requireDriver(adapter)
434
434
  : exports.claudeCodeDriver;
435
435
  const isClaudeCode = driver.runtime.name === runtime_js_1.claudeCodeRuntime.name;
436
436
  const decision = (0, sandbox_js_1.decideSandbox)({
@@ -528,12 +528,12 @@ async function runHarness(spec, opts = {}) {
528
528
  return runHarnessTest(spec, opts);
529
529
  }
530
530
  /** Pull the pillar-2 driver off an adapter, asserting it supports testing. */
531
- function requireDriver(adapter) {
531
+ async function requireDriver(adapter) {
532
532
  (0, adapter_conformance_js_1.assertHarnessTestable)(adapter);
533
533
  if (!adapter.harnessTestDriver) {
534
534
  throw new Error(`Adapter "${adapter.name}" declares harnessTesting but carries no harnessTestDriver — it cannot drive runHarnessTest.`);
535
535
  }
536
- return adapter.harnessTestDriver;
536
+ return await adapter.harnessTestDriver();
537
537
  }
538
538
  /* v8 ignore stop */
539
539
  //# sourceMappingURL=harness-test.js.map
@@ -1,3 +1,4 @@
1
+ import type { DispatchKind } from "./core/hook-program.js";
1
2
  /** The agnostic, committed home for hook SOURCE — one dir, cross-adapter. */
2
3
  export declare const HOOKS_DIR = ".vigiles/hooks";
3
4
  /** The committed home for registered context-provider SOURCE (v2). */
@@ -55,6 +56,58 @@ export declare function normalizeHookRef(hookPath: string, cwd?: string): string
55
56
  * `bareToken(hookGateRef(ref, tokens)) === ref` is what keeps a recompile idempotent, and
56
57
  * it is asserted directly rather than left to inspection.
57
58
  */
59
+ /**
60
+ * Where the hook runtime lives, spelled so the shell can find it WITHOUT `npx`.
61
+ *
62
+ * 🔴 MEASURED 2026-09-19, warm cache, five runs each:
63
+ *
64
+ * node <local>/dist/cli.js hook-runtime run-program … 193 ms
65
+ * npx vigiles hook-runtime run-program … 2545 ms
66
+ *
67
+ * Thirteen times, on every tool call, because `npx` re-resolves the package on
68
+ * each invocation — local, then global, then the registry. That search is the
69
+ * single largest cost in a hook's life; everything the runtime does inside adds
70
+ * up to less than a fifth of it.
71
+ *
72
+ * A harness with no project-root token gets the relative spelling, which is all
73
+ * it can be given — see {@link hookGateRef} for the same fallback.
74
+ */
75
+ export declare function hookRuntimeRef(projectRootTokens: readonly string[] | undefined): string;
76
+ /**
77
+ * What the shell must do when the runtime above CANNOT START — a missing
78
+ * `node_modules/vigiles`, an unreadable file, an interpreter that dies before a
79
+ * single line of ours runs. No code of ours executes in that case, so the policy
80
+ * has to be expressed in the emitted command or not at all.
81
+ *
82
+ * 🔴 THIS IS NOT A NEW POLICY. `runHookProgramCommand`'s load-failure branch has
83
+ * decided it since 2026-08: *"an inject's purpose is to ADD context, not to
84
+ * ENFORCE a decision … Gates (file, bash, prompt, stop) remain conservative and
85
+ * fail closed."* That branch only reaches failures that happen AFTER the runtime
86
+ * starts. This carries the same rule one layer out, to the failures that happen
87
+ * before it.
88
+ *
89
+ * WHY THE SPLIT, RATHER THAN ONE ANSWER FOR EVERYTHING — the two failures are
90
+ * not comparable:
91
+ *
92
+ * A GATE THAT SILENTLY PASSES IS WORSE THAN NO GATE. Its whole value is the
93
+ * refusal, and a harness that reports protection it is not providing is the
94
+ * one state worse than admitting it has none. So a gate whose runtime is
95
+ * missing exits 2: loud, blocking, and the cause is on stderr.
96
+ *
97
+ * A NUDGE THAT BLOCKS COSTS THE WHOLE REPOSITORY. Measured here 2026-08-10:
98
+ * merge-conflict markers in `package.json` stopped every hook loading, the
99
+ * Bash gate then refused `git merge --abort` — the one command that undoes the
100
+ * cause — and the session could not be repaired from inside. A reminder is
101
+ * never worth that, so a nudge exits 0 and says nothing it cannot say.
102
+ *
103
+ * The role is not a flag someone can flip: `Reaction` has no `deny` and an
104
+ * inject returns context, so "nudge" is a fact about the TYPE the author chose.
105
+ *
106
+ * (Industry does not agree on one answer either — husky and lefthook skip,
107
+ * pre-commit fails. Which is itself the argument for deciding by role instead
108
+ * of picking one and imposing it on both.)
109
+ */
110
+ export declare function hookRuntimeMissingExit(kind: DispatchKind): 0 | 2;
58
111
  export declare function hookGateRef(ref: string, projectRootTokens: readonly string[] | undefined): string;
59
112
  /**
60
113
  * Idempotently merge a compiled hook's block into an existing `settings.json`
@@ -4,6 +4,8 @@ exports.PROVIDERS_DIR = exports.HOOKS_DIR = void 0;
4
4
  exports.discoverHookFiles = discoverHookFiles;
5
5
  exports.discoverProviderFiles = discoverProviderFiles;
6
6
  exports.normalizeHookRef = normalizeHookRef;
7
+ exports.hookRuntimeRef = hookRuntimeRef;
8
+ exports.hookRuntimeMissingExit = hookRuntimeMissingExit;
7
9
  exports.hookGateRef = hookGateRef;
8
10
  exports.mergeHooksJson = mergeHooksJson;
9
11
  exports.mergeHooksToml = mergeHooksToml;
@@ -99,6 +101,64 @@ function normalizeHookRef(hookPath, cwd = process.cwd()) {
99
101
  * `bareToken(hookGateRef(ref, tokens)) === ref` is what keeps a recompile idempotent, and
100
102
  * it is asserted directly rather than left to inspection.
101
103
  */
104
+ /**
105
+ * Where the hook runtime lives, spelled so the shell can find it WITHOUT `npx`.
106
+ *
107
+ * 🔴 MEASURED 2026-09-19, warm cache, five runs each:
108
+ *
109
+ * node <local>/dist/cli.js hook-runtime run-program … 193 ms
110
+ * npx vigiles hook-runtime run-program … 2545 ms
111
+ *
112
+ * Thirteen times, on every tool call, because `npx` re-resolves the package on
113
+ * each invocation — local, then global, then the registry. That search is the
114
+ * single largest cost in a hook's life; everything the runtime does inside adds
115
+ * up to less than a fifth of it.
116
+ *
117
+ * A harness with no project-root token gets the relative spelling, which is all
118
+ * it can be given — see {@link hookGateRef} for the same fallback.
119
+ */
120
+ function hookRuntimeRef(projectRootTokens) {
121
+ const rel = "node_modules/vigiles/dist/cli.js";
122
+ const token = projectRootTokens?.[0];
123
+ return token === undefined ? `node ${rel}` : `node "${token}/${rel}"`;
124
+ }
125
+ /**
126
+ * What the shell must do when the runtime above CANNOT START — a missing
127
+ * `node_modules/vigiles`, an unreadable file, an interpreter that dies before a
128
+ * single line of ours runs. No code of ours executes in that case, so the policy
129
+ * has to be expressed in the emitted command or not at all.
130
+ *
131
+ * 🔴 THIS IS NOT A NEW POLICY. `runHookProgramCommand`'s load-failure branch has
132
+ * decided it since 2026-08: *"an inject's purpose is to ADD context, not to
133
+ * ENFORCE a decision … Gates (file, bash, prompt, stop) remain conservative and
134
+ * fail closed."* That branch only reaches failures that happen AFTER the runtime
135
+ * starts. This carries the same rule one layer out, to the failures that happen
136
+ * before it.
137
+ *
138
+ * WHY THE SPLIT, RATHER THAN ONE ANSWER FOR EVERYTHING — the two failures are
139
+ * not comparable:
140
+ *
141
+ * A GATE THAT SILENTLY PASSES IS WORSE THAN NO GATE. Its whole value is the
142
+ * refusal, and a harness that reports protection it is not providing is the
143
+ * one state worse than admitting it has none. So a gate whose runtime is
144
+ * missing exits 2: loud, blocking, and the cause is on stderr.
145
+ *
146
+ * A NUDGE THAT BLOCKS COSTS THE WHOLE REPOSITORY. Measured here 2026-08-10:
147
+ * merge-conflict markers in `package.json` stopped every hook loading, the
148
+ * Bash gate then refused `git merge --abort` — the one command that undoes the
149
+ * cause — and the session could not be repaired from inside. A reminder is
150
+ * never worth that, so a nudge exits 0 and says nothing it cannot say.
151
+ *
152
+ * The role is not a flag someone can flip: `Reaction` has no `deny` and an
153
+ * inject returns context, so "nudge" is a fact about the TYPE the author chose.
154
+ *
155
+ * (Industry does not agree on one answer either — husky and lefthook skip,
156
+ * pre-commit fails. Which is itself the argument for deciding by role instead
157
+ * of picking one and imposing it on both.)
158
+ */
159
+ function hookRuntimeMissingExit(kind) {
160
+ return kind === "inject" || kind === "react" ? 0 : 2;
161
+ }
102
162
  function hookGateRef(ref, projectRootTokens) {
103
163
  const token = projectRootTokens?.[0];
104
164
  return token === undefined ? ref : `"${token}/${ref}"`;
@@ -45,9 +45,9 @@ import { loadHook } from "./load-hook.js";
45
45
  */
46
46
  export declare const loadHookProgram: typeof loadHook;
47
47
  /** Load a registered provider (`.vigiles/providers/<name>`) → its definition. */
48
- export declare function loadProvider(file: string): Promise<RegisteredProvider>;
48
+ export declare function loadProvider(file: string, root?: string): Promise<RegisteredProvider>;
49
49
  /** Path of the tamper-evident stamp sidecar for a hook file. */
50
- export declare function hookStampPath(file: string): string;
50
+ export declare function hookStampPath(file: string, root?: string): string;
51
51
  /**
52
52
  * `vigiles hook-runtime run-program <file>` — the runtime the compiled hooks block
53
53
  * points at. Reads the live event on stdin, loads the typed program, verifies
@@ -75,8 +75,8 @@ function injectableEventsFor(root) {
75
75
  */
76
76
  exports.loadHookProgram = load_hook_js_1.loadHook;
77
77
  /** Load a registered provider (`.vigiles/providers/<name>`) → its definition. */
78
- async function loadProvider(file) {
79
- const abs = (0, node_path_1.resolve)(process.cwd(), file);
78
+ async function loadProvider(file, root = process.cwd()) {
79
+ const abs = (0, node_path_1.resolve)(root, file);
80
80
  const { pathToFileURL } = require("node:url");
81
81
  let mod;
82
82
  try {
@@ -95,8 +95,8 @@ async function loadProvider(file) {
95
95
  return def;
96
96
  }
97
97
  /** Path of the tamper-evident stamp sidecar for a hook file. */
98
- function hookStampPath(file) {
99
- return (0, node_path_1.resolve)(process.cwd(), ".vigiles/hooks", (0, node_path_1.basename)(file) + ".json");
98
+ function hookStampPath(file, root = process.cwd()) {
99
+ return (0, node_path_1.resolve)(root, ".vigiles/hooks", (0, node_path_1.basename)(file) + ".json");
100
100
  }
101
101
  /**
102
102
  * Perform the state writes a hook declared, after its output has been emitted.
@@ -104,14 +104,14 @@ function hookStampPath(file) {
104
104
  * thrown on) is announced — silence here would be a hook that believes it
105
105
  * remembered something.
106
106
  */
107
- function applyHookWrites(file, outcome) {
107
+ function applyHookWrites(file, outcome, root) {
108
108
  const { ok, refused } = (0, hook_program_js_1.outcomeWrites)(outcome);
109
109
  for (const name of refused) {
110
110
  console.error(`vigiles: refused to record ${name} from ${file} — not a valid state key.`);
111
111
  }
112
112
  for (const w of ok) {
113
113
  try {
114
- (0, hook_state_store_js_1.writeHookState)(file, w);
114
+ (0, hook_state_store_js_1.writeHookState)(file, w, { cwd: root });
115
115
  }
116
116
  catch (e) {
117
117
  console.error(`vigiles: could not record ${w.name} from ${file}: ${String(e)}`);
@@ -124,13 +124,13 @@ function applyHookWrites(file, outcome) {
124
124
  * that can't resolve yields its default (never throws). The pure registry +
125
125
  * decision logic live in core/hook-providers.ts — this only injects the real IO.
126
126
  */
127
- async function gatherHookContext(program, file) {
127
+ async function gatherHookContext(program, file, root) {
128
128
  const needs = (0, hook_program_js_1.hookNeeds)(program);
129
129
  if (needs.length === 0)
130
130
  return {};
131
131
  // Only load the registered-provider registry if a provider() ref is declared.
132
132
  const hasRef = needs.some((n) => typeof n !== "string" && n.kind === "provider-ref");
133
- const registry = hasRef ? await loadProviderRegistry() : {};
133
+ const registry = hasRef ? await loadProviderRegistry(root) : {};
134
134
  const { execSync } = require("node:child_process");
135
135
  const { isCI } = require("ci-info");
136
136
  return (0, hook_providers_js_1.gatherContext)(needs, {
@@ -138,12 +138,12 @@ async function gatherHookContext(program, file) {
138
138
  encoding: "utf-8",
139
139
  stdio: ["ignore", "pipe", "ignore"],
140
140
  }),
141
- cwd: process.cwd(),
141
+ cwd: root,
142
142
  platform: process.platform,
143
143
  isCI,
144
144
  // The namespace is bound HERE, from the hook's own path — core never sees
145
145
  // it, so no key a hook can spell reaches another owner's store.
146
- readState: (key) => (0, hook_state_store_js_1.readHookState)(file, key),
146
+ readState: (key) => (0, hook_state_store_js_1.readHookState)(file, key, root),
147
147
  now: Date.now(),
148
148
  }, registry);
149
149
  }
@@ -152,11 +152,11 @@ async function gatherHookContext(program, file) {
152
152
  * for `provider()` ref resolution. A bad/unloadable provider file is skipped (the
153
153
  * ref then yields its default ""), never crashes a live session.
154
154
  */
155
- async function loadProviderRegistry() {
155
+ async function loadProviderRegistry(root) {
156
156
  const registry = {};
157
- for (const file of (0, hook_install_js_1.discoverProviderFiles)(process.cwd())) {
157
+ for (const file of (0, hook_install_js_1.discoverProviderFiles)(root)) {
158
158
  try {
159
- const def = await loadProvider(file);
159
+ const def = await loadProvider(file, root);
160
160
  registry[def.name] = def;
161
161
  }
162
162
  catch {
@@ -166,9 +166,9 @@ async function loadProviderRegistry() {
166
166
  return registry;
167
167
  }
168
168
  /** Append an observe-mode record to `.vigiles/hook-observations.jsonl` (best-effort). */
169
- function recordObservation(file, on, would, reason) {
169
+ function recordObservation(file, on, would, reason, root) {
170
170
  try {
171
- const dir = (0, node_path_1.resolve)(process.cwd(), ".vigiles");
171
+ const dir = (0, node_path_1.resolve)(root, ".vigiles");
172
172
  (0, node_fs_1.mkdirSync)(dir, { recursive: true });
173
173
  const line = JSON.stringify({
174
174
  ts: new Date().toISOString(),
@@ -189,7 +189,7 @@ function recordObservation(file, on, would, reason) {
189
189
  * shadow/rollout path. Harness-neutral — exit 2 / exit 0 are identical on Claude
190
190
  * Code and Codex; the record is vigiles-local.
191
191
  */
192
- function emitGate(decision, on, mode, file) {
192
+ function emitGate(decision, on, mode, file, root) {
193
193
  const action = (0, hook_program_js_1.gateAction)(decision, mode);
194
194
  switch (action.kind) {
195
195
  case "block":
@@ -200,7 +200,7 @@ function emitGate(decision, on, mode, file) {
200
200
  mode: "enforce",
201
201
  rule: file,
202
202
  reason: action.reason,
203
- });
203
+ }, root);
204
204
  console.error(action.reason);
205
205
  process.exit(2);
206
206
  return;
@@ -212,7 +212,7 @@ function emitGate(decision, on, mode, file) {
212
212
  mode: "enforce",
213
213
  rule: file,
214
214
  reason: action.reason,
215
- });
215
+ }, root);
216
216
  process.stdout.write(JSON.stringify({
217
217
  hookSpecificOutput: {
218
218
  hookEventName: on,
@@ -229,8 +229,8 @@ function emitGate(decision, on, mode, file) {
229
229
  mode: "observe",
230
230
  rule: file,
231
231
  reason: action.reason,
232
- });
233
- recordObservation(file, on, action.would, action.reason);
232
+ }, root);
233
+ recordObservation(file, on, action.would, action.reason, root);
234
234
  console.error(`⚠ [vigiles observe] ${on}: would ${action.would} — ${action.reason}`);
235
235
  return; // exit 0 — observe never blocks
236
236
  case "allow":
@@ -247,9 +247,9 @@ function emitGate(decision, on, mode, file) {
247
247
  * observed wedge came from a `package.json` the author was not thinking about at
248
248
  * the time — it had merge-conflict markers in it, nothing to do with hooks.
249
249
  */
250
- function hookLoadPathFiles(hookFile) {
250
+ function hookLoadPathFiles(hookFile, root) {
251
251
  const files = [];
252
- let dir = (0, node_path_1.dirname)((0, node_path_1.resolve)(process.cwd(), hookFile));
252
+ let dir = (0, node_path_1.dirname)((0, node_path_1.resolve)(root, hookFile));
253
253
  for (;;) {
254
254
  const pkg = (0, node_path_1.resolve)(dir, "package.json");
255
255
  files.push(pkg);
@@ -275,15 +275,15 @@ function hookLoadPathFiles(hookFile) {
275
275
  break;
276
276
  dir = up;
277
277
  }
278
- files.push((0, node_path_1.resolve)(process.cwd(), ".vigilesrc.json"));
278
+ files.push((0, node_path_1.resolve)(root, ".vigilesrc.json"));
279
279
  return files;
280
280
  }
281
281
  /**
282
282
  * The conflicted files on this hook's load path, if any — the difference between
283
283
  * "your hook is broken" and "your repo is mid-merge and the hook is collateral".
284
284
  */
285
- function conflictedLoadPathFiles(hookFile) {
286
- return hookLoadPathFiles(hookFile)
285
+ function conflictedLoadPathFiles(hookFile, root) {
286
+ return hookLoadPathFiles(hookFile, root)
287
287
  .filter((p) => {
288
288
  try {
289
289
  return ((0, node_fs_1.existsSync)(p) && (0, merge_conflict_js_1.hasMergeConflictMarkers)((0, node_fs_1.readFileSync)(p, "utf-8")));
@@ -292,7 +292,7 @@ function conflictedLoadPathFiles(hookFile) {
292
292
  return false; // unreadable is a different problem; don't guess about it
293
293
  }
294
294
  })
295
- .map((p) => (0, node_path_1.relative)(process.cwd(), p) || p);
295
+ .map((p) => (0, node_path_1.relative)(root, p) || p);
296
296
  }
297
297
  /**
298
298
  * Print the loud stderr banner that accompanies a REPAIR-only pass-through, and
@@ -321,14 +321,15 @@ function announceRepairEscape(file, why) {
321
321
  * `.claude/settings.json` to unwire the gate. Observed 2026-08-03.
322
322
  */
323
323
  function verifyStampOrRefuse(file, event) {
324
- const stampPath = hookStampPath(file);
324
+ const { root } = event;
325
+ const stampPath = hookStampPath(file, root);
325
326
  if (!(0, node_fs_1.existsSync)(stampPath))
326
327
  return;
327
328
  try {
328
329
  const { stamp } = JSON.parse((0, node_fs_1.readFileSync)(stampPath, "utf-8"));
329
- const source = (0, node_fs_1.readFileSync)((0, node_path_1.resolve)(process.cwd(), file), "utf-8");
330
+ const source = (0, node_fs_1.readFileSync)((0, node_path_1.resolve)(root, file), "utf-8");
330
331
  if (stamp && !(0, hook_program_js_1.verifyHookStamp)(source, stamp)) {
331
- if ((0, hook_program_js_1.isStampRepairEvent)(event, file, process.cwd())) {
332
+ if ((0, hook_program_js_1.isStampRepairEvent)(event, file, root)) {
332
333
  announceRepairEscape(file, "does not match its compiled stamp");
333
334
  return;
334
335
  }
@@ -388,21 +389,39 @@ async function runHookProgramCommand(file) {
388
389
  catch {
389
390
  /* no stdin */
390
391
  }
391
- let event = {};
392
+ let payload = {};
392
393
  try {
393
- event = JSON.parse(raw);
394
+ payload = JSON.parse(raw);
394
395
  }
395
396
  catch {
396
397
  /* malformed → empty event */
397
398
  }
398
- // The root repo-relative path prefixes resolve against. `$CLAUDE_PROJECT_DIR`
399
- // first (the same root the harness resolved THIS hook's own path against),
400
- // then the payload's `cwd`; never `process.cwd()`, which under a git worktree
401
- // can be a different checkout. See `projectRootOf`.
402
- const projectRoot = (0, hook_program_js_1.projectRootOf)(event, process.env);
399
+ // 🔴 THE ROOT IS RESOLVED ONCE, HERE, AND RIDES ON THE EVENT. Everything below
400
+ // reads `event.root`; nothing recomputes it and nothing is handed a root
401
+ // beside an event it might disagree with. That disagreement is the defect
402
+ // this shape exists to prevent — the stamp sidecar, the hook's own source,
403
+ // the state store, the ledger and the provider registry each used to resolve
404
+ // against `process.cwd()` while the decision layer resolved against the
405
+ // payload, so under a worktree the tamper check did not misfire, it did not
406
+ // run at all.
407
+ //
408
+ // This is the only `process.cwd()` on the runtime's own execution path, and
409
+ // it is the documented last resort for a payload that declares no root. The
410
+ // two others in this file are back-compat defaults on exported helpers
411
+ // (`loadProvider`, `hookStampPath`) for callers outside the runtime; the
412
+ // runtime itself always passes a root and never takes them.
413
+ const event = (0, hook_program_js_1.resolveHookEvent)(payload, process.env, process.cwd());
414
+ const root = event.root;
415
+ // ⚠️ THE DECISION LAYER MUST NOT SEE THE FALLBACK, and this is not a detail.
416
+ // `pathView` treats an undefined root as "I cannot place this path" and errs
417
+ // toward SILENCE. Handing it `process.cwd()` instead would turn that silence
418
+ // into confident decisions measured against a directory nobody declared —
419
+ // quietly widening what gates fire on. IO paths need a usable root; verdicts
420
+ // need an honest one, and they are not the same question.
421
+ const declaredRoot = event.rootDeclared ? event.root : undefined;
403
422
  let program;
404
423
  try {
405
- program = await (0, exports.loadHookProgram)(file);
424
+ program = await (0, exports.loadHookProgram)(file, root);
406
425
  }
407
426
  catch (err) {
408
427
  // A LOAD failure is a fact about the harness, not a verdict about the
@@ -430,7 +449,7 @@ async function runHookProgramCommand(file) {
430
449
  // not go through PreToolUse(Bash)).
431
450
  // Everything else stays BLOCKED, and the escapes are whitelists of commands
432
451
  // that are WRITES — see `isLoadPathRepairEvent` for why no command is one.
433
- const conflicted = conflictedLoadPathFiles(file);
452
+ const conflicted = conflictedLoadPathFiles(file, root);
434
453
  // 🔴 THE THROWN MESSAGE IS THE ONLY THING THAT NAMES THE REAL CAUSE when the
435
454
  // merge-conflict heuristic above does not fire. Without it this said just
436
455
  // "cannot be loaded" — a diagnosis that sends the reader looking in the wrong
@@ -444,13 +463,13 @@ async function runHookProgramCommand(file) {
444
463
  `may be fine)`
445
464
  : `cannot be loaded — ${thrown}`;
446
465
  if ((0, hook_program_js_1.isLoadPathRepairEvent)(event, file, {
447
- // The root the REST of this runtime already uses: `hookStampPath` and
448
- // `verifyStampOrRefuse` read the hook and its sidecar via `process.cwd()`,
449
- // so a repair accepted against any other root would name a file the
450
- // runtime never reads. The hook's own path cannot supply it (a hook sits
451
- // at any depth, and a `.git` probe would be a disk read core does not do).
452
- root: process.cwd(),
453
- loadPathFiles: hookLoadPathFiles(file),
466
+ // The root the REST of this runtime already uses — now the PROJECT's,
467
+ // not the process's. A repair accepted against any other root would name
468
+ // a file the runtime never reads. The hook's own path cannot supply it (a
469
+ // hook sits at any depth, and a `.git` probe would be a disk read core
470
+ // does not do), so it is passed in.
471
+ root,
472
+ loadPathFiles: hookLoadPathFiles(file, root),
454
473
  })) {
455
474
  announceRepairEscape(file, cause);
456
475
  return;
@@ -466,7 +485,7 @@ async function runHookProgramCommand(file) {
466
485
  `${file}, ${merge_conflict_js_1.HARNESS_CONFIG_FILES.join(", ")} is broken — those writes are ` +
467
486
  `allowed even while this refuses, and a Bash gate never gated file tools ` +
468
487
  `at all. The hook then loads and the gate decides normally again.\n` +
469
- `vigiles: those paths resolve under ${process.cwd()} — plus any ancestor ` +
488
+ `vigiles: those paths resolve under ${root} — plus any ancestor ` +
470
489
  `\`package.json\` Node actually reads, so whatever is named above as the ` +
471
490
  `cause is writable. A path in a DIFFERENT checkout is refused: it cannot ` +
472
491
  `repair this failure.\n` +
@@ -494,7 +513,7 @@ async function runHookProgramCommand(file) {
494
513
  verifyStampOrRefuse(file, event);
495
514
  switch ((0, hook_program_js_1.dispatchKind)(program)) {
496
515
  case "inject": {
497
- const ctx = await gatherHookContext(program, file);
516
+ const ctx = await gatherHookContext(program, file, root);
498
517
  const injection = (0, hook_program_js_1.injectionOf)(program, event, ctx);
499
518
  process.stdout.write(JSON.stringify({
500
519
  hookSpecificOutput: {
@@ -508,20 +527,20 @@ async function runHookProgramCommand(file) {
508
527
  kind: "injection",
509
528
  context: injection.context,
510
529
  records: injection.records,
511
- });
530
+ }, root);
512
531
  return;
513
532
  }
514
533
  case "react": {
515
- const ctx = await gatherHookContext(program, file);
516
- warnIfPathUndecidable(event, projectRoot);
517
- const reaction = (0, hook_program_js_1.runReact)(program, event, ctx, projectRoot);
534
+ const ctx = await gatherHookContext(program, file, root);
535
+ warnIfPathUndecidable(event, declaredRoot);
536
+ const reaction = (0, hook_program_js_1.runReact)(program, event, ctx, declaredRoot);
518
537
  // A notice has to REACH someone. stderr at exit 0 goes to the debug log
519
538
  // and nothing else (the host's docs are explicit: "Claude never sees it"),
520
539
  // and a react always exits 0 because its type has no `deny` — so stderr
521
540
  // alone delivered nowhere. Emit the same `additionalContext` shape the
522
541
  // shipped refs/eval-lock nudges use, gated on the ACTIVE adapter's
523
542
  // `injectableEvents` so this is per-harness fact, not a CC literal.
524
- const injectable = injectableEventsFor(projectRoot ?? process.cwd());
543
+ const injectable = injectableEventsFor(root);
525
544
  const delivery = (0, hook_program_js_1.noticeDelivery)(reaction, program.on, injectable);
526
545
  if (delivery.kind === "inject") {
527
546
  process.stdout.write(JSON.stringify({
@@ -537,7 +556,7 @@ async function runHookProgramCommand(file) {
537
556
  // Removing it would break existing consumers to gain nothing.
538
557
  if (reaction.kind === "notice")
539
558
  console.error(reaction.message);
540
- applyHookWrites(file, { kind: "reaction", reaction });
559
+ applyHookWrites(file, { kind: "reaction", reaction }, root);
541
560
  if (reaction.kind === "run") {
542
561
  const { spawnSync } = require("node:child_process");
543
562
  const res = spawnSync(reaction.command, {
@@ -549,29 +568,29 @@ async function runHookProgramCommand(file) {
549
568
  return;
550
569
  }
551
570
  case "file-gate": {
552
- const ctx = await gatherHookContext(program, file);
553
- warnIfPathUndecidable(event, projectRoot);
554
- emitGate((0, hook_program_js_1.decideFileGate)(program, event, ctx, projectRoot), program.on, (0, hook_program_js_1.hookMode)(program), file);
571
+ const ctx = await gatherHookContext(program, file, root);
572
+ warnIfPathUndecidable(event, declaredRoot);
573
+ emitGate((0, hook_program_js_1.decideFileGate)(program, event, ctx, declaredRoot), program.on, (0, hook_program_js_1.hookMode)(program), file, root);
555
574
  return;
556
575
  }
557
576
  case "bash-gate": {
558
- const ctx = await gatherHookContext(program, file);
559
- // The same `projectRoot` the file gates get: without it every
577
+ const ctx = await gatherHookContext(program, file, root);
578
+ // The same `declaredRoot` the file gates get: without it every
560
579
  // repo-relative prefix in a DENYLIST matcher (`touches`/`writesTo`) is
561
580
  // matched by over-blocking alone, and with it an absolute token is placed
562
581
  // exactly. Measured bypass this closes: `sed -i s/a/b/ <abs>/paper.tex`
563
582
  // exited 0 against a guard that blocked the relative spelling.
564
- emitGate((0, hook_program_js_1.decideProgram)(program, event, ctx, projectRoot), program.on, (0, hook_program_js_1.hookMode)(program), file);
583
+ emitGate((0, hook_program_js_1.decideProgram)(program, event, ctx, declaredRoot), program.on, (0, hook_program_js_1.hookMode)(program), file, root);
565
584
  return;
566
585
  }
567
586
  case "prompt-gate": {
568
- const ctx = await gatherHookContext(program, file);
569
- emitGate((0, hook_program_js_1.decidePromptGate)(program, event, ctx), program.on, (0, hook_program_js_1.hookMode)(program), file);
587
+ const ctx = await gatherHookContext(program, file, root);
588
+ emitGate((0, hook_program_js_1.decidePromptGate)(program, event, ctx), program.on, (0, hook_program_js_1.hookMode)(program), file, root);
570
589
  return;
571
590
  }
572
591
  case "stop-gate": {
573
- const ctx = await gatherHookContext(program, file);
574
- emitGate((0, hook_program_js_1.decideStopGate)(program, event, ctx), program.on, (0, hook_program_js_1.hookMode)(program), file);
592
+ const ctx = await gatherHookContext(program, file, root);
593
+ emitGate((0, hook_program_js_1.decideStopGate)(program, event, ctx), program.on, (0, hook_program_js_1.hookMode)(program), file, root);
575
594
  return;
576
595
  }
577
596
  }
@@ -20,5 +20,5 @@ import { type AnyHook } from "./core/hook-program.js";
20
20
  * @throws {HookCompileError} when the file can't be imported, or has no
21
21
  * default-exported hook program.
22
22
  */
23
- export declare function loadHook(file: string): Promise<AnyHook>;
23
+ export declare function loadHook(file: string, root?: string): Promise<AnyHook>;
24
24
  //# sourceMappingURL=load-hook.d.ts.map
package/dist/load-hook.js CHANGED
@@ -39,8 +39,8 @@ const hook_program_js_1 = require("./core/hook-program.js");
39
39
  * @throws {HookCompileError} when the file can't be imported, or has no
40
40
  * default-exported hook program.
41
41
  */
42
- async function loadHook(file) {
43
- const abs = (0, node_path_1.resolve)(process.cwd(), file);
42
+ async function loadHook(file, root = process.cwd()) {
43
+ const abs = (0, node_path_1.resolve)(root, file);
44
44
  let mod;
45
45
  try {
46
46
  mod = (await import((0, node_url_1.pathToFileURL)(abs).href));
package/dist/run-hook.js CHANGED
@@ -189,7 +189,23 @@ function runHookWith(command, input, opts, deps) {
189
189
  ran: false,
190
190
  conditionReason: verdict.why,
191
191
  };
192
- const res = (0, run_script_js_1.runScriptWith)(command, JSON.stringify(input), opts, deps);
192
+ // 🔴 A SPAWN DIRECTORY WITH NO DECLARED ROOT MEANS "THIS DIRECTORY IS THE
193
+ // PROJECT". Without this, a test that says `{ cwd: dir }` and nothing else
194
+ // inherits whatever `$CLAUDE_PROJECT_DIR` the AMBIENT shell exports, and since
195
+ // the runtime prefers the env over the payload (see `projectRootOf`), the hook
196
+ // resolves a root the test never named. Measured 2026-09-19: five rail suites
197
+ // pass with the variable unset — which is CI, and this container — and fail
198
+ // when it is set, which is any run inside a live Claude Code session. That is
199
+ // a one-sided failure, invisible exactly where it would be caught.
200
+ //
201
+ // An EXPLICIT value always wins, including the empty string: a test that pins
202
+ // `CLAUDE_PROJECT_DIR: ""` is saying "no env root, resolve from the payload",
203
+ // and that case has its own coverage.
204
+ const rooted = opts.cwd !== undefined &&
205
+ !Object.prototype.hasOwnProperty.call(opts.env ?? {}, "CLAUDE_PROJECT_DIR")
206
+ ? { ...opts, env: { ...opts.env, CLAUDE_PROJECT_DIR: opts.cwd } }
207
+ : opts;
208
+ const res = (0, run_script_js_1.runScriptWith)(command, JSON.stringify(input), rooted, deps);
193
209
  const json = parseHookOutput(res.stdout);
194
210
  const { blocked, decision, haltsTurn, blockedBy } = decideHook(res.exitCode, json, protocol);
195
211
  return {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vigiles",
3
- "version": "27.3.0",
3
+ "version": "28.0.0",
4
4
  "description": "Audit, test and measure the harness your AI agent runs on — grade your CLAUDE.md / AGENTS.md, skills, subagents and hooks, run them against a scripted model, and measure whether they actually fire.",
5
5
  "keywords": [
6
6
  "claude-code",