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.
- package/dist/adapters/claude-code/adapter.js +3 -2
- package/dist/adapters/codex/adapter.js +1 -2
- package/dist/cli-main.js +82 -21
- package/dist/core/adapter.d.ts +22 -1
- package/dist/core/hook-program.d.ts +43 -0
- package/dist/core/hook-program.js +32 -0
- package/dist/core/validate.d.ts +15 -1
- package/dist/core/validate.js +16 -2
- package/dist/harness-test.js +3 -3
- package/dist/hook-install.d.ts +53 -0
- package/dist/hook-install.js +60 -0
- package/dist/hook-runtime.d.ts +2 -2
- package/dist/hook-runtime.js +82 -63
- package/dist/load-hook.d.ts +1 -1
- package/dist/load-hook.js +2 -2
- package/dist/run-hook.js +17 -1
- package/package.json +1 -1
|
@@ -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
|
-
|
|
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:
|
|
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
|
|
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,
|
|
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
|
|
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)(
|
|
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
|
-
|
|
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
|
-
|
|
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)(
|
|
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 =
|
|
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)(
|
|
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)(
|
|
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)(
|
|
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)(
|
|
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)(
|
|
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)(
|
|
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
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
package/dist/core/adapter.d.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
package/dist/core/validate.d.ts
CHANGED
|
@@ -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
|
-
|
|
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;
|
package/dist/core/validate.js
CHANGED
|
@@ -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
|
-
|
|
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;
|
package/dist/harness-test.js
CHANGED
|
@@ -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
|
package/dist/hook-install.d.ts
CHANGED
|
@@ -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`
|
package/dist/hook-install.js
CHANGED
|
@@ -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}"`;
|
package/dist/hook-runtime.d.ts
CHANGED
|
@@ -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
|
package/dist/hook-runtime.js
CHANGED
|
@@ -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)(
|
|
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)(
|
|
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:
|
|
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)(
|
|
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)(
|
|
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)(
|
|
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)(
|
|
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)(
|
|
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
|
|
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)(
|
|
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,
|
|
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
|
|
392
|
+
let payload = {};
|
|
392
393
|
try {
|
|
393
|
-
|
|
394
|
+
payload = JSON.parse(raw);
|
|
394
395
|
}
|
|
395
396
|
catch {
|
|
396
397
|
/* malformed → empty event */
|
|
397
398
|
}
|
|
398
|
-
//
|
|
399
|
-
//
|
|
400
|
-
//
|
|
401
|
-
//
|
|
402
|
-
|
|
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
|
|
448
|
-
//
|
|
449
|
-
//
|
|
450
|
-
//
|
|
451
|
-
//
|
|
452
|
-
root
|
|
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 ${
|
|
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,
|
|
517
|
-
const reaction = (0, hook_program_js_1.runReact)(program, event, ctx,
|
|
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(
|
|
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,
|
|
554
|
-
emitGate((0, hook_program_js_1.decideFileGate)(program, event, ctx,
|
|
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 `
|
|
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,
|
|
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
|
}
|
package/dist/load-hook.d.ts
CHANGED
|
@@ -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)(
|
|
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
|
-
|
|
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": "
|
|
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",
|