vigiles 27.1.3 → 27.1.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli-main.js CHANGED
@@ -5694,7 +5694,13 @@ async function installHookFile(file, adapter, registeredProviders = []) {
5694
5694
  // appends a duplicate block instead of replacing the existing one.
5695
5695
  const ref = (0, hook_install_js_1.normalizeHookRef)(file);
5696
5696
  const compiled = (0, hook_program_js_1.compileHookProgram)(source, program, {
5697
- gateCommand: `npx vigiles hook-runtime run-program ${ref}`,
5697
+ // 🔴 ANCHORED AT THE PROJECT ROOT. A hook command does not run with a stable cwd —
5698
+ // this codebase says so twice (`bareToken`'s header, `PluginLayout.projectRootTokens`)
5699
+ // and `projectRootOf` relies on the anchored spelling "by construction", but the
5700
+ // emitter never produced it. Measured 2026-09-10 in a consumer repo: after a compile,
5701
+ // one `cd` into a subdirectory made a PreToolUse gate fail to load, and a gate that
5702
+ // cannot load must block — the repo seized, every command refused including the repair.
5703
+ gateCommand: `npx vigiles hook-runtime run-program ${(0, hook_install_js_1.hookGateRef)(ref, adapter.layout.projectRootTokens)}`,
5698
5704
  dialect: adapter.dialect,
5699
5705
  hookProtocol: adapter.hookProtocol,
5700
5706
  settingsFormat: adapter.layout.settingsFormat,
@@ -36,6 +36,26 @@ interface SettingsJson {
36
36
  * absolute — still stable, just not relative to anything).
37
37
  */
38
38
  export declare function normalizeHookRef(hookPath: string, cwd?: string): string;
39
+ /**
40
+ * The path token `compile` EMITS into the harness config — anchored at the project root
41
+ * when the harness declares such a variable.
42
+ *
43
+ * 🔴 IT LIVES BESIDE {@link bareToken} ON PURPOSE. That function STRIPS exactly this prefix
44
+ * and these quotes; this one ADDS them. They are one contract read from two ends, and while
45
+ * the ends sat apart only one got fixed: 2026-08-21 taught the reader to understand the
46
+ * anchored spelling, and the emitter went on writing the relative one for three more weeks.
47
+ *
48
+ * Why anchored at all, from the two measurements already in this file and in
49
+ * `PluginLayout.projectRootTokens`: a hook command does not run with a stable cwd, so a
50
+ * relative path "dies with exit 2 the moment the agent runs from a subdirectory". For a
51
+ * PreToolUse gate that is not a lost nudge — a gate that cannot load must block, so the
52
+ * repository seizes. Measured in a consumer repo 2026-09-10: recoverable by file writes
53
+ * only, because every command was refused, including the one that repairs it.
54
+ *
55
+ * `bareToken(hookGateRef(ref, tokens)) === ref` is what keeps a recompile idempotent, and
56
+ * it is asserted directly rather than left to inspection.
57
+ */
58
+ export declare function hookGateRef(ref: string, projectRootTokens: readonly string[] | undefined): string;
39
59
  /**
40
60
  * Idempotently merge a compiled hook's block into an existing `settings.json`
41
61
  * object. Entries managed by THIS hook file (the runtime command references
@@ -4,6 +4,7 @@ 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.hookGateRef = hookGateRef;
7
8
  exports.mergeHooksJson = mergeHooksJson;
8
9
  exports.mergeHooksToml = mergeHooksToml;
9
10
  exports.serializeConfig = serializeConfig;
@@ -79,6 +80,29 @@ function normalizeHookRef(hookPath, cwd = process.cwd()) {
79
80
  const chosen = rel === "" || rel.startsWith("..") ? abs : rel;
80
81
  return chosen.split(node_path_1.sep).join("/");
81
82
  }
83
+ /**
84
+ * The path token `compile` EMITS into the harness config — anchored at the project root
85
+ * when the harness declares such a variable.
86
+ *
87
+ * 🔴 IT LIVES BESIDE {@link bareToken} ON PURPOSE. That function STRIPS exactly this prefix
88
+ * and these quotes; this one ADDS them. They are one contract read from two ends, and while
89
+ * the ends sat apart only one got fixed: 2026-08-21 taught the reader to understand the
90
+ * anchored spelling, and the emitter went on writing the relative one for three more weeks.
91
+ *
92
+ * Why anchored at all, from the two measurements already in this file and in
93
+ * `PluginLayout.projectRootTokens`: a hook command does not run with a stable cwd, so a
94
+ * relative path "dies with exit 2 the moment the agent runs from a subdirectory". For a
95
+ * PreToolUse gate that is not a lost nudge — a gate that cannot load must block, so the
96
+ * repository seizes. Measured in a consumer repo 2026-09-10: recoverable by file writes
97
+ * only, because every command was refused, including the one that repairs it.
98
+ *
99
+ * `bareToken(hookGateRef(ref, tokens)) === ref` is what keeps a recompile idempotent, and
100
+ * it is asserted directly rather than left to inspection.
101
+ */
102
+ function hookGateRef(ref, projectRootTokens) {
103
+ const token = projectRootTokens?.[0];
104
+ return token === undefined ? ref : `"${token}/${ref}"`;
105
+ }
82
106
  /**
83
107
  * True when an entry's command routes through the runtime for `hookPath`.
84
108
  *
@@ -58,6 +58,13 @@ export declare function hookStampPath(file: string): string;
58
58
  * action itself ({@link isStampRepairEvent}), and — on a LOAD failure only — the
59
59
  * load-path repair WRITE ({@link isLoadPathRepairEvent}), or the repo wedges
60
60
  * with no way to fix whatever broke the load path.
61
+ *
62
+ * INJECT-HOOK-SPECIFIC: An inject hook that fails to load is a harness failure,
63
+ * not a decision failure. Unlike gates (which must be conservative and block on
64
+ * any error), an inject is pure context addition. It degrades gracefully: if it
65
+ * cannot load, the session continues without the injected context, and the error
66
+ * is logged for debugging. This prevents a single broken inject from wedging all
67
+ * sessions.
61
68
  */
62
69
  export declare function runHookProgramCommand(file: string | undefined): Promise<void>;
63
70
  //# sourceMappingURL=hook-runtime.d.ts.map
@@ -364,6 +364,13 @@ function warnIfPathUndecidable(event, root) {
364
364
  * action itself ({@link isStampRepairEvent}), and — on a LOAD failure only — the
365
365
  * load-path repair WRITE ({@link isLoadPathRepairEvent}), or the repo wedges
366
366
  * with no way to fix whatever broke the load path.
367
+ *
368
+ * INJECT-HOOK-SPECIFIC: An inject hook that fails to load is a harness failure,
369
+ * not a decision failure. Unlike gates (which must be conservative and block on
370
+ * any error), an inject is pure context addition. It degrades gracefully: if it
371
+ * cannot load, the session continues without the injected context, and the error
372
+ * is logged for debugging. This prevents a single broken inject from wedging all
373
+ * sessions.
367
374
  */
368
375
  async function runHookProgramCommand(file) {
369
376
  if (!file) {
@@ -394,13 +401,20 @@ async function runHookProgramCommand(file) {
394
401
  try {
395
402
  program = await (0, exports.loadHookProgram)(file);
396
403
  }
397
- catch {
404
+ catch (err) {
398
405
  // A LOAD failure is a fact about the harness, not a verdict about the
399
406
  // command that happened to arrive — so it must still fail CLOSED (a gate
400
407
  // that cannot run must not wave traffic through), but the two things it owes
401
408
  // the author are different from a `deny`'s: name the real cause, and leave a
402
409
  // way back.
403
410
  //
411
+ // EXCEPTION: inject hooks. An inject's purpose is to ADD context, not to
412
+ // ENFORCE a decision. If it fails to load, the session should degrade
413
+ // gracefully (no context injected) rather than wedging the entire harness.
414
+ // This is a harness failure, not a gating decision — so we handle it by
415
+ // logging the error and exiting 0. Gates (file, bash, prompt, stop) remain
416
+ // conservative and fail closed.
417
+ //
404
418
  // Escapes, both announced loudly on stderr:
405
419
  // - the stale-stamp one (an edit to the hook itself / `vigiles compile`),
406
420
  // for the hook broken mid-edit;
@@ -414,11 +428,18 @@ async function runHookProgramCommand(file) {
414
428
  // Everything else stays BLOCKED, and the escapes are whitelists of commands
415
429
  // that are WRITES — see `isLoadPathRepairEvent` for why no command is one.
416
430
  const conflicted = conflictedLoadPathFiles(file);
431
+ // 🔴 THE THROWN MESSAGE IS THE ONLY THING THAT NAMES THE REAL CAUSE when the
432
+ // merge-conflict heuristic above does not fire. Without it this said just
433
+ // "cannot be loaded" — a diagnosis that sends the reader looking in the wrong
434
+ // place, which is the defect this runtime has already shipped twice (the
435
+ // loader that advised `npm run build` when the answer was `npm install`).
436
+ // The comment above promises to name the cause; this is what keeps it.
437
+ const thrown = err instanceof Error ? err.message : String(err);
417
438
  const cause = conflicted.length > 0
418
439
  ? `cannot be loaded — ${conflicted.join(", ")} contains merge-conflict ` +
419
440
  `markers, so Node cannot resolve \`vigiles/hook\` from it (the hook itself ` +
420
441
  `may be fine)`
421
- : "cannot be loaded";
442
+ : `cannot be loaded — ${thrown}`;
422
443
  if ((0, hook_program_js_1.isLoadPathRepairEvent)(event, file, {
423
444
  // The root the REST of this runtime already uses: `hookStampPath` and
424
445
  // `verifyStampOrRefuse` read the hook and its sidecar via `process.cwd()`,
@@ -431,7 +452,8 @@ async function runHookProgramCommand(file) {
431
452
  announceRepairEscape(file, cause);
432
453
  return;
433
454
  }
434
- console.error(`vigiles: hook ${file} ${cause}.\n` +
455
+ // Log the error, but for inject hooks, degrade gracefully (exit 0).
456
+ const errorMsg = `vigiles: hook ${file} ${cause}.\n` +
435
457
  `vigiles: this is the state of the HARNESS, not a decision about your ` +
436
458
  `command — the gate never ran. Blocking anyway (a gate that cannot run ` +
437
459
  `must not pass traffic).\n` +
@@ -448,8 +470,22 @@ async function runHookProgramCommand(file) {
448
470
  `vigiles: no command is allowed, deliberately. \`git merge --abort\` and ` +
449
471
  `\`git checkout\` RUN \`.git/hooks/*\` (measured: reference-transaction, ` +
450
472
  `post-checkout), and \`vigiles compile\` loads the hook through the same ` +
451
- `resolver that just failed.`);
452
- process.exit(2);
473
+ `resolver that just failed.`;
474
+ console.error(errorMsg);
475
+ // Exit code depends on hook kind. This heuristic is based on the filename —
476
+ // a more robust approach would parse the stamp or metadata, but that requires
477
+ // the hook to load. Inject hooks typically have "inject" in the name; fall
478
+ // back to blocking (exit 2) for safety on gates.
479
+ const isLikelyInject = file.includes("inject");
480
+ if (isLikelyInject) {
481
+ // Inject hook: degrade gracefully. Log the error but don't wedge the session.
482
+ console.error(`vigiles: ${file} is an inject hook; degrading gracefully (no context injected).`);
483
+ process.exit(0);
484
+ }
485
+ else {
486
+ // Gate hook: fail closed.
487
+ process.exit(2);
488
+ }
453
489
  return;
454
490
  }
455
491
  verifyStampOrRefuse(file, event);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vigiles",
3
- "version": "27.1.3",
3
+ "version": "27.1.5",
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",
@@ -103,6 +103,7 @@
103
103
  "@types/markdown-it": "^14.1.2",
104
104
  "@types/minimatch": "^5.1.2",
105
105
  "@types/node": "^20.19.39",
106
+ "@types/picomatch": "^4.0.2",
106
107
  "@typescript-eslint/eslint-plugin": "^8.58.0",
107
108
  "@typescript-eslint/parser": "^8.58.0",
108
109
  "@vitest/coverage-v8": "^4.1.8",
@@ -113,6 +114,7 @@
113
114
  "eslint-plugin-sonarjs": "^4.0.2",
114
115
  "globals": "^17.4.0",
115
116
  "jest": "^30.4.2",
117
+ "picomatch": "^4.0.4",
116
118
  "prettier": "^3.8.1",
117
119
  "tsx": "^4.21.0",
118
120
  "typedoc": "^0.28.19",