vigiles 27.2.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.
@@ -13,13 +13,13 @@ exports.assertAdapterLoadsHooks = assertAdapterLoadsHooks;
13
13
  * in their test suite. See `docs/authoring-an-adapter.md`.
14
14
  */
15
15
  const node_fs_1 = require("node:fs");
16
- const node_os_1 = require("node:os");
17
16
  const node_path_1 = require("node:path");
18
17
  const compile_js_1 = require("./core/compile.js");
19
18
  const spec_js_1 = require("./core/spec.js");
20
19
  const plugin_loader_js_1 = require("./plugin-loader.js");
21
20
  const vocabulary_consistency_js_1 = require("./core/vocabulary-consistency.js");
22
21
  const event_capability_js_1 = require("./core/event-capability.js");
22
+ const tmp_root_js_1 = require("./core/tmp-root.js");
23
23
  /** Check an adapter against the port contracts; returns the (possibly empty) failure list. */
24
24
  function checkAdapterConformance(adapter) {
25
25
  const failures = [];
@@ -166,7 +166,7 @@ function assertHarnessTestable(adapter) {
166
166
  * run with zero hooks. Does filesystem IO, so it's a separate opt-in assert.
167
167
  */
168
168
  function assertAdapterLoadsHooks(adapter) {
169
- const dir = (0, node_fs_1.mkdtempSync)((0, node_path_1.join)((0, node_os_1.tmpdir)(), "vigiles-conformance-"));
169
+ const dir = (0, tmp_root_js_1.makeTmpDir)("conformance");
170
170
  try {
171
171
  const settingsAbs = (0, node_path_1.join)(dir, adapter.layout.settingsPath);
172
172
  (0, node_fs_1.mkdirSync)((0, node_path_1.dirname)(settingsAbs), { recursive: true });
@@ -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).
@@ -26,7 +26,6 @@ const node_os_1 = require("node:os");
26
26
  const node_path_1 = require("node:path");
27
27
  const node_url_1 = require("node:url");
28
28
  const node_fs_1 = require("node:fs");
29
- const node_os_2 = require("node:os");
30
29
  const glob_1 = require("glob");
31
30
  const check_count_js_1 = require("../../check-count.js");
32
31
  /**
@@ -64,8 +63,21 @@ exports.SKIP_EXIT_CODE = 77;
64
63
  function statusFor(code, checks, output) {
65
64
  if (code === exports.SKIP_EXIT_CODE)
66
65
  return "skip";
66
+ // 🔴 `checks === undefined` GUARDS THE TEXT MATCH, and it is the load-bearing
67
+ // half. A script that REPORTED a count executed: the counter is written by an
68
+ // exit handler that exists only once the module was linked and run
69
+ // (`check-count.ts`), so the count is a STRUCTURAL fact about the child, while
70
+ // `didNotLoad` is a guess about its text.
71
+ //
72
+ // MEASURED: `statusFor(1, 3, "<hook stderr: Cannot find module …>\nAssertionError")`
73
+ // returned `"skip"` and the run exited 0 — a harness that ran, recorded three
74
+ // checks and FAILED an assertion, reported as skipped. That is not an exotic
75
+ // input: vigiles harnesses drive hooks and print their transcripts, so a
76
+ // loader phrase in the output is ordinary EVIDENCE about the thing under test,
77
+ // not a diagnosis of the harness. Watching the child from outside cannot tell
78
+ // those apart. The count can, and it was already in hand.
67
79
  if (code !== 0)
68
- return didNotLoad(output) ? "skip" : "fail";
80
+ return checks === undefined && didNotLoad(output) ? "skip" : "fail";
69
81
  return checks === 0 ? "vacuous" : "pass";
70
82
  }
71
83
  /**
@@ -83,10 +95,17 @@ function statusFor(code, checks, output) {
83
95
  * 'recordCheck' not found`, and the ledger dropped from 48 records to 34 and from
84
96
  * 47 to 33. Nothing about those surfaces had changed — the machine had.
85
97
  *
86
- * ⚠️ This does NOT make a broken environment quiet. A skip still prints `⊘
87
- * SKIPPED`, and `--no-skip` — which this repo's own CI passes — still fails the
88
- * run. What changes is only whether a machine problem is allowed to delete a
89
- * measurement taken on a machine that worked.
98
+ * ⚠️ This does NOT make a broken environment quiet: a script classified here
99
+ * fails the run by default (`cli-main.ts`, right after `anyFailed`), because a
100
+ * skip the AUTHOR never declared is not a skip. What this classification buys is
101
+ * only that a machine problem may not delete a measurement taken on a machine
102
+ * that worked.
103
+ *
104
+ * 🔴 That default is new, and the sentence it replaces was false. It read
105
+ * «`--no-skip` — which this repo's own CI passes — still fails the run».
106
+ * Measured: `--no-skip` appears ZERO times under `.github/`, `package.json`,
107
+ * `scripts/` and `.claude/`; CI passes `--min=14` and nothing else. The
108
+ * safety net the non-fatal classification leaned on was never strung.
90
109
  *
91
110
  * Deliberately literal, and only the loader's own vocabulary: these strings come
92
111
  * from Node's module resolution, not from user code. A test that legitimately
@@ -99,6 +118,13 @@ function didNotLoad(output) {
99
118
  output.includes("Cannot find package") ||
100
119
  output.includes("Cannot find module") ||
101
120
  /SyntaxError: Named export '[^']*' not found/.test(output) ||
121
+ // Same event, ESM spelling. Node phrases a missing named export one way for
122
+ // a CommonJS target and another for an ES module, and only the first was
123
+ // listed — so the 2026-08-20 class below still RETRACTED coverage whenever
124
+ // the dependency happened to be ESM. Measured on Node 22:
125
+ // CJS: SyntaxError: Named export 'recordCheck' not found. The requested module …
126
+ // ESM: SyntaxError: The requested module './x.mjs' does not provide an export named 'recordCheck'
127
+ /SyntaxError: The requested module '[^']*' does not provide an export named/.test(output) ||
102
128
  output.includes("ERR_UNSUPPORTED_DIR_IMPORT") ||
103
129
  output.includes("ERR_PACKAGE_PATH_NOT_EXPORTED"));
104
130
  }
@@ -118,6 +144,7 @@ var ts_runner_caps_js_1 = require("../../ts-runner-caps.js");
118
144
  Object.defineProperty(exports, "detectNodeCaps", { enumerable: true, get: function () { return ts_runner_caps_js_1.detectNodeCaps; } });
119
145
  Object.defineProperty(exports, "canRunTypeScript", { enumerable: true, get: function () { return ts_runner_caps_js_1.canRunTypeScript; } });
120
146
  const ts_runner_caps_js_2 = require("../../ts-runner-caps.js");
147
+ const tmp_root_js_1 = require("../../core/tmp-root.js");
121
148
  /**
122
149
  * The `node` argv (after the binary) to run a single script. Plain JS runs
123
150
  * directly; a TypeScript script picks `tsx` when available, else Node's native
@@ -159,7 +186,13 @@ function discoverScripts(patterns, defaultGlob, cwd, ignore) {
159
186
  const globs = patterns.length > 0 ? patterns : [defaultGlob];
160
187
  const found = new Set();
161
188
  for (const p of globs) {
162
- if ((0, node_fs_1.existsSync)((0, node_path_1.resolve)(cwd, p))) {
189
+ // 🔴 `isFile`, not `existsSync`: a DIRECTORY exists too. `vigiles test .`
190
+ // therefore passed `.` through as a script, `spawn("node", ["."])` died with
191
+ // Node's `ERR_UNSUPPORTED_DIR_IMPORT` stack, and the classifier downstream
192
+ // read that stack as "did not load" — a crash reported as a skip, exit 0.
193
+ // A directory now contributes no files, so the caller's own loud
194
+ // nothing-matched path owns the message (see `cli-main.ts`).
195
+ if ((0, node_fs_1.statSync)((0, node_path_1.resolve)(cwd, p), { throwIfNoEntry: false })?.isFile()) {
163
196
  found.add(p);
164
197
  continue;
165
198
  }
@@ -174,10 +207,16 @@ function discoverScripts(patterns, defaultGlob, cwd, ignore) {
174
207
  // `test-coverage.ts` and `cli.ts` both pass `dot: true` with comments saying why, and
175
208
  // `test-coverage.test.ts` records "glob without `dot:true` never found it and the surface
176
209
  // looked untested". Coverage learned it; the runner did not.
210
+ // 🔴 `nodir: true` for the same reason as the `isFile` guard above, and the
211
+ // guard alone was NOT enough — caught by its own test. A pattern that names
212
+ // an existing directory (`sub`, `.`) skips the fast path and then comes back
213
+ // out of the globber, because a directory matches a glob perfectly well.
214
+ // Asked of the globber rather than filtered afterwards: it already knows.
177
215
  for (const m of (0, glob_1.globSync)(p, {
178
216
  cwd,
179
217
  ignore: [...ignore],
180
218
  dot: true,
219
+ nodir: true,
181
220
  })) {
182
221
  found.add(m);
183
222
  }
@@ -219,7 +258,7 @@ function readCheckReport(path) {
219
258
  */
220
259
  async function runScripts(files, cwd, env = {}, opts = {}) {
221
260
  const caps = (0, ts_runner_caps_js_2.detectNodeCaps)(cwd);
222
- const countDir = (0, node_fs_1.mkdtempSync)((0, node_path_1.join)((0, node_os_2.tmpdir)(), "vigiles-checks-"));
261
+ const countDir = (0, tmp_root_js_1.makeTmpDir)("checks");
223
262
  // 🔴 THE DEFAULT IS DECIDED BY `entry`, NOT BY A FLAG, because the two commands
224
263
  // that share this runner have OPPOSITE right answers and the caller already
225
264
  // distinguishes them:
@@ -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
@@ -4936,10 +4936,19 @@ async function handleRunScripts(kind, args, restArgs, excludes) {
4936
4936
  // VALUE cannot tell them apart — only the flag's presence can. (Caught by a control:
4937
4937
  // the first version read `minRequired === 0` and made `--min=0` do nothing.)
4938
4938
  if (restArgs.length > 0 && minFlag === undefined) {
4939
- console.error(`✗ vigiles ${kind}: ${String(restArgs.length)} target(s) given and NOTHING matched — ` +
4940
- `${restArgs.join(", ")}\n` +
4941
- ` Nothing ran. A stale path, a wrong glob, or a moved file all look like this.\n` +
4942
- ` If an empty match is expected here, say so with --min=0.`);
4939
+ // A DIRECTORY is the one stale-looking target whose cause we actually know,
4940
+ // so say it instead of listing the three guesses. `vigiles test .` used to
4941
+ // reach `spawn("node", ["."])` and surface Node's module-resolution stack;
4942
+ // the generic message above would now be true but unhelpful.
4943
+ const dirs = restArgs.filter((a) => (0, node_fs_1.lstatSync)(a, { throwIfNoEntry: false })?.isDirectory() === true);
4944
+ console.error(dirs.length > 0
4945
+ ? `✗ vigiles ${kind}: ${dirs.join(", ")} ${dirs.length === 1 ? "is a directory" : "are directories"} — ` +
4946
+ `pass a file, or a glob like "${defaultGlob}".\n` +
4947
+ ` A directory is not a script; nothing ran.`
4948
+ : `✗ vigiles ${kind}: ${String(restArgs.length)} target(s) given and NOTHING matched — ` +
4949
+ `${restArgs.join(", ")}\n` +
4950
+ ` Nothing ran. A stale path, a wrong glob, or a moved file all look like this.\n` +
4951
+ ` If an empty match is expected here, say so with --min=0.`);
4943
4952
  process.exit(1);
4944
4953
  }
4945
4954
  console.log(`No ${defaultGlob} files found.`);
@@ -5002,6 +5011,35 @@ async function handleRunScripts(kind, args, restArgs, excludes) {
5002
5011
  console.log("\n" + (0, run_scripts_js_1.formatScriptSummary)(results));
5003
5012
  if ((0, run_scripts_js_1.anyFailed)(results))
5004
5013
  process.exit(1);
5014
+ // 🔴 A SKIP THE AUTHOR NEVER DECLARED IS NOT A SKIP — and the discriminator was
5015
+ // already sitting in the result. `skip()` exits 77 (`SKIP_EXIT_CODE`); a script
5016
+ // the runtime could not evaluate exits with whatever the loader gave it, 1 in
5017
+ // practice. Both are classified `"skip"` so that neither RETRACTS coverage —
5018
+ // which is right, a file that did not run proved nothing either way — but only
5019
+ // the declared one is a reason to stay green.
5020
+ //
5021
+ // Reported as #243: `vigiles test .` printed a resolver stack over a `⊘`, said
5022
+ // `0 passed, 1 skipped`, and exited 0. Downstream a consumer's README shipped
5023
+ // that exact command as its first setup step, so a new reader's suite silently
5024
+ // never ran. `--no-skip` would have caught it and is not the default; `--min=1`
5025
+ // does not, because it counts files MATCHED, not scripts executed.
5026
+ //
5027
+ // This is deliberately NOT a fifth `ScriptStatus`. Coverage retraction reads the
5028
+ // status as a bare STRING (`executedScripts`, `coverage-artifact.ts`, whose
5029
+ // parameter is typed `string`), so a new member would start retracting silently
5030
+ // with no type error — breaking the one property the classification exists to
5031
+ // protect.
5032
+ const notEvaluated = results.filter((r) => r.status === "skip" && r.code !== run_scripts_js_1.SKIP_EXIT_CODE);
5033
+ if (notEvaluated.length > 0) {
5034
+ console.error(`\n✗ vigiles ${kind}: ${String(notEvaluated.length)} script(s) never ran — the runtime could not load them:\n` +
5035
+ notEvaluated
5036
+ .map((r) => ` ${r.file} (exit ${String(r.code)})`)
5037
+ .join("\n") +
5038
+ `\n Their previous coverage is kept, because a script that did not run retracts nothing.\n` +
5039
+ ` If a missing dependency is expected here, import it dynamically and call skip() — ` +
5040
+ `a declared skip stays green.`);
5041
+ process.exit(1);
5042
+ }
5005
5043
  // `--no-skip`: in a context that ASSERTS the capability is present (a CI job),
5006
5044
  // a skipped tier is untested surface — fail loudly instead of passing green.
5007
5045
  if (args.includes("--no-skip") && results.some((r) => r.status === "skip")) {
@@ -5214,6 +5252,45 @@ function annotateLintForGitHub(report, flags) {
5214
5252
  ghAnnotate("warning", `${String(report.duplicatePairs)} near-duplicate rule pair(s) detected — consider merging`);
5215
5253
  }
5216
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
+ }
5217
5294
  /**
5218
5295
  * Run a compiled skill's deterministic gate ladder: execute each step gate in
5219
5296
  * order (short-circuiting on the first failure), then the result gate. This is
@@ -5226,7 +5303,8 @@ function runSkillCommand(target) {
5226
5303
  console.error("Usage: vigiles hook-runtime run-skill <SKILL.md>");
5227
5304
  process.exit(2);
5228
5305
  }
5229
- const path = (0, node_path_1.resolve)(process.cwd(), target);
5306
+ const root = runtimeRoot();
5307
+ const path = (0, node_path_1.resolve)(root, target);
5230
5308
  if (!(0, node_fs_1.existsSync)(path)) {
5231
5309
  console.error(`Not found: ${target}`);
5232
5310
  process.exit(2);
@@ -5237,7 +5315,7 @@ function runSkillCommand(target) {
5237
5315
  return;
5238
5316
  }
5239
5317
  console.log(`Running gate ladder for ${target}:\n`);
5240
- const report = (0, skill_runtime_js_1.runSkillGates)(gates, process.cwd());
5318
+ const report = (0, skill_runtime_js_1.runSkillGates)(gates, root);
5241
5319
  for (const r of report.results) {
5242
5320
  const label = r.at === "result" ? "result" : `step ${String(r.at)}`;
5243
5321
  console.log(` ${r.ok ? "✓" : "✗"} ${label} — ${(0, skill_runtime_js_1.gateLabel)(r.gate)}`);
@@ -5265,11 +5343,12 @@ function runSkillCommand(target) {
5265
5343
  * feeds the message back to the model; exit 0 allows it and clears the marker.
5266
5344
  */
5267
5345
  function skillHookCommand() {
5268
- const decision = (0, skill_runtime_js_1.evaluateStopHook)(process.cwd());
5346
+ const root = runtimeRoot();
5347
+ const decision = (0, skill_runtime_js_1.evaluateStopHook)(root);
5269
5348
  if (decision.allow) {
5270
5349
  if (decision.message)
5271
5350
  console.log(decision.message);
5272
- (0, skill_runtime_js_1.clearActiveSkill)(process.cwd());
5351
+ (0, skill_runtime_js_1.clearActiveSkill)(root);
5273
5352
  return;
5274
5353
  }
5275
5354
  console.error(decision.message);
@@ -5281,12 +5360,20 @@ function skillStartCommand(target) {
5281
5360
  console.error("Usage: vigiles hook-runtime skill-start <SKILL.md>");
5282
5361
  process.exit(2);
5283
5362
  }
5284
- (0, skill_runtime_js_1.setActiveSkill)(process.cwd(), target);
5363
+ const root = runtimeRoot();
5364
+ (0, skill_runtime_js_1.setActiveSkill)(root, target);
5285
5365
  // Record the fire in the flight recorder: the skill NAME is the parent dir of
5286
5366
  // its SKILL.md (skills/<name>/SKILL.md), falling back to the raw target.
5287
5367
  const parts = target.replace(/\\/g, "/").split("/").filter(Boolean);
5288
5368
  const name = parts.length >= 2 ? parts[parts.length - 2] : (parts[0] ?? target);
5289
- (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);
5290
5377
  console.log(`Active skill: ${target}`);
5291
5378
  }
5292
5379
  /**
@@ -5319,7 +5406,7 @@ function skillToolHookCommand() {
5319
5406
  }
5320
5407
  if (!tool)
5321
5408
  return;
5322
- 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);
5323
5410
  if (!decision.allow) {
5324
5411
  console.error(decision.message);
5325
5412
  process.exit(2);
@@ -5355,7 +5442,7 @@ function agentHookCommand() {
5355
5442
  catch {
5356
5443
  /* malformed input → no tool, allow */
5357
5444
  }
5358
- const cwd = process.cwd();
5445
+ const cwd = runtimeRoot(eventRoot(raw));
5359
5446
  // EXPERIMENTAL (parked P3 — do NOT auto-wire). The spawn/SubagentStop bracketing
5360
5447
  // is now nesting-safe: a depth-aware STACK (push on dispatch, POP on SubagentStop)
5361
5448
  // closes the contract-escape the flat single-slot model allowed under CC v2.1.172
@@ -5387,13 +5474,15 @@ function agentHookCommand() {
5387
5474
  return;
5388
5475
  const decision = (0, agent_runtime_js_1.evaluatePreToolUse)(cwd, tool, command);
5389
5476
  if (!decision.allow) {
5477
+ // Same root as the decision — see `skillStartCommand` for why the default
5478
+ // is wrong on a hook rail.
5390
5479
  (0, observe_js_1.appendObservation)({
5391
5480
  kind: "agent",
5392
5481
  name: (0, agent_runtime_js_1.readActiveAgent)(cwd) ?? "unknown",
5393
5482
  tool,
5394
5483
  allowed: false,
5395
5484
  reason: decision.message,
5396
- });
5485
+ }, cwd);
5397
5486
  console.error(decision.message);
5398
5487
  process.exit(2);
5399
5488
  }
@@ -5438,7 +5527,7 @@ function guardHookCommand() {
5438
5527
  catch {
5439
5528
  /* no stdin */
5440
5529
  }
5441
- const { decision } = (0, guards_js_1.runGuardHook)(process.cwd(), raw);
5530
+ const { decision } = (0, guards_js_1.runGuardHook)(runtimeRoot(eventRoot(raw)), raw);
5442
5531
  if (!decision.allow) {
5443
5532
  console.error(decision.reason ?? "Blocked by a vigiles guard.");
5444
5533
  process.exit(2);
@@ -5450,7 +5539,7 @@ function agentStartCommand(target) {
5450
5539
  console.error("Usage: vigiles hook-runtime agent-start <agents/<name>.md>");
5451
5540
  process.exit(2);
5452
5541
  }
5453
- (0, agent_runtime_js_1.pushActiveAgent)(process.cwd(), target);
5542
+ (0, agent_runtime_js_1.pushActiveAgent)(runtimeRoot(), target);
5454
5543
  console.log(`Active agent: ${target}`);
5455
5544
  }
5456
5545
  /** Dispatch the skill-runtime subcommands. Returns false if unrecognized. */
@@ -5474,7 +5563,7 @@ async function handleHookRuntime(kind, restArgs) {
5474
5563
  agentStartCommand(restArgs[0]);
5475
5564
  return;
5476
5565
  case "agent-done":
5477
- (0, agent_runtime_js_1.popActiveAgent)(process.cwd());
5566
+ (0, agent_runtime_js_1.popActiveAgent)(runtimeRoot());
5478
5567
  return;
5479
5568
  case "skill":
5480
5569
  skillHookCommand();
@@ -5486,7 +5575,7 @@ async function handleHookRuntime(kind, restArgs) {
5486
5575
  skillStartCommand(restArgs[0]);
5487
5576
  return;
5488
5577
  case "skill-done":
5489
- (0, skill_runtime_js_1.clearActiveSkill)(process.cwd());
5578
+ (0, skill_runtime_js_1.clearActiveSkill)(runtimeRoot());
5490
5579
  return;
5491
5580
  case "run-skill":
5492
5581
  runSkillCommand(restArgs[0]);
@@ -5507,11 +5596,11 @@ async function handleHookRuntime(kind, restArgs) {
5507
5596
  evalLockNudgeHookCommand();
5508
5597
  return;
5509
5598
  case "effect-enter":
5510
- (0, effect_region_js_1.setEffectActive)(process.cwd());
5599
+ (0, effect_region_js_1.setEffectActive)(runtimeRoot());
5511
5600
  console.log("Effect boundary entered.");
5512
5601
  return;
5513
5602
  case "effect-exit":
5514
- (0, effect_region_js_1.clearEffectActive)(process.cwd());
5603
+ (0, effect_region_js_1.clearEffectActive)(runtimeRoot());
5515
5604
  return;
5516
5605
  default:
5517
5606
  console.error(`vigiles hook-runtime: unknown runtime entrypoint "${kind ?? ""}". ` +
@@ -5542,7 +5631,8 @@ function actionHookCommand() {
5542
5631
  catch {
5543
5632
  /* malformed input → no event, allow */
5544
5633
  }
5545
- 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);
5546
5636
  if (!decision.allow) {
5547
5637
  console.error(decision.message);
5548
5638
  process.exit(2);
@@ -5586,7 +5676,7 @@ function evalLockNudgeHookCommand() {
5586
5676
  }
5587
5677
  if (!file)
5588
5678
  return;
5589
- const cwd = process.cwd();
5679
+ const cwd = runtimeRoot(eventRoot(raw));
5590
5680
  const target = (0, node_path_1.relative)(cwd, (0, node_path_1.resolve)(cwd, file)) || file;
5591
5681
  // 🔴 THE SAME CONFIG `vigiles lint` READS. This used to pass `basePath` alone,
5592
5682
  // so a repo that had switched `untested-skill` off, or pointed `include` at
@@ -5600,7 +5690,7 @@ function evalLockNudgeHookCommand() {
5600
5690
  // second gate saying the same thing is a branch no test can distinguish from
5601
5691
  // its absence (measured — the mutation passed), i.e. the dead-fragment class
5602
5692
  // this same change removed from the runner table.
5603
- const config = (0, validate_js_1.loadConfig)();
5693
+ const config = (0, validate_js_1.loadConfig)(cwd);
5604
5694
  const { options } = untestedRules(config);
5605
5695
  // 🔴 THE SAME LAYOUT `vigiles lint` RESOLVES, for the same reason as the config
5606
5696
  // above. This used to pass `basePath` alone, so the detector fell back to the
@@ -5663,10 +5753,12 @@ function refsHookCommand() {
5663
5753
  }
5664
5754
  if (!file || !isInstructionFile(file))
5665
5755
  return;
5666
- 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"]);
5667
5760
  if (severity === false)
5668
5761
  return;
5669
- const cwd = process.cwd();
5670
5762
  const target = (0, node_path_1.relative)(cwd, (0, node_path_1.resolve)(cwd, file)) || file;
5671
5763
  let markdown;
5672
5764
  try {
@@ -5749,7 +5841,14 @@ async function installHookFile(file, adapter, registeredProviders = []) {
5749
5841
  // emitter never produced it. Measured 2026-09-10 in a consumer repo: after a compile,
5750
5842
  // one `cd` into a subdirectory made a PreToolUse gate fail to load, and a gate that
5751
5843
  // cannot load must block — the repo seized, every command refused including the repair.
5752
- 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))}`,
5753
5852
  dialect: adapter.dialect,
5754
5853
  hookProtocol: adapter.hookProtocol,
5755
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
@@ -36,7 +36,6 @@ exports.parseGolangciEnabledLinters = parseGolangciEnabledLinters;
36
36
  exports.clearCedarCache = clearCedarCache;
37
37
  exports.checkLinterRule = checkLinterRule;
38
38
  const node_fs_1 = require("node:fs");
39
- const node_os_1 = require("node:os");
40
39
  const node_path_1 = require("node:path");
41
40
  const edit_distance_js_1 = require("./edit-distance.js");
42
41
  Object.defineProperty(exports, "editDistance", { enumerable: true, get: function () { return edit_distance_js_1.editDistance; } });
@@ -72,6 +71,7 @@ function augmentToolPath() {
72
71
  }
73
72
  }
74
73
  augmentToolPath();
74
+ const tmp_root_js_1 = require("./tmp-root.js");
75
75
  // ---------------------------------------------------------------------------
76
76
  // Parsing enforcement references
77
77
  // ---------------------------------------------------------------------------
@@ -267,7 +267,7 @@ function getDetektDefaultRules() {
267
267
  if (DETEKT_DEFAULT_RULE_CACHE)
268
268
  return DETEKT_DEFAULT_RULE_CACHE;
269
269
  let rules = new Set();
270
- const tmp = (0, node_fs_1.mkdtempSync)((0, node_path_1.join)((0, node_os_1.tmpdir)(), "vigiles-detekt-"));
270
+ const tmp = (0, tmp_root_js_1.makeTmpDir)("detekt");
271
271
  try {
272
272
  const target = (0, node_path_1.join)(tmp, "generated-default.yml");
273
273
  (0, node_child_process_1.execSync)(`detekt --generate-config --config ${target}`, {
@@ -481,7 +481,7 @@ function runCheckstyleProbe(configPath, probePath) {
481
481
  * either placement instantiates.
482
482
  */
483
483
  function checkstyleModuleInstantiates(ruleName) {
484
- const tmp = (0, node_fs_1.mkdtempSync)((0, node_path_1.join)((0, node_os_1.tmpdir)(), "vigiles-checkstyle-"));
484
+ const tmp = (0, tmp_root_js_1.makeTmpDir)("checkstyle");
485
485
  try {
486
486
  const probe = (0, node_path_1.join)(tmp, "Probe.java");
487
487
  (0, node_fs_1.writeFileSync)(probe, "class Probe {}\n");