vigiles 32.1.0 → 32.1.1

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
@@ -69,6 +69,7 @@ const action_gate_js_1 = require("./action-gate.js");
69
69
  const guards_js_1 = require("./core/guards.js");
70
70
  const hook_program_js_1 = require("./core/hook-program.js");
71
71
  const hook_install_js_1 = require("./hook-install.js");
72
+ const hook_check_install_js_1 = require("./hook-check-install.js");
72
73
  const hook_providers_js_1 = require("./core/hook-providers.js");
73
74
  const toml_1 = require("@iarna/toml");
74
75
  // The named-state STORE. Lifted out of this file so a TEST can seed a fact
@@ -6017,12 +6018,11 @@ async function installHookFile(file, adapter, registeredProviders = []) {
6017
6018
  // cannot load must block — the repo seized, every command refused including the repair.
6018
6019
  //
6019
6020
  // 🔴 AND LAUNCHED LOCALLY, NOT THROUGH `npx` — 193 ms against 2545 ms on a warm
6020
- // cache, thirteen times, on every tool call. The trailing `|| exit N` is what the
6021
- // shell does when that binary cannot start at all, and N is decided by the hook's
6022
- // ROLE: see `hookRuntimeRef` and `hookRuntimeMissingExit` for both measurements
6023
- // and for why a gate and a nudge must answer differently.
6021
+ // cache, thirteen times, on every tool call. The `|| exit N` ending is what
6022
+ // the shell does when that file cannot start at all. `compileHookProgram`
6023
+ // adds it (see `hookRuntimeMissingExit`), so it is not written here.
6024
6024
  gateCommand: `${(0, hook_install_js_1.hookRuntimeRef)(adapter.layout.projectRootTokens)} hook-runtime run-program ` +
6025
- `${(0, hook_install_js_1.hookGateRef)(ref, adapter.layout.projectRootTokens)} || exit ${(0, hook_install_js_1.hookRuntimeMissingExit)((0, hook_program_js_1.dispatchKind)(program))}`,
6025
+ (0, hook_install_js_1.hookGateRef)(ref, adapter.layout.projectRootTokens),
6026
6026
  dialect: adapter.dialect,
6027
6027
  hookProtocol: adapter.hookProtocol,
6028
6028
  settings: adapter.layout.settings,
@@ -6171,6 +6171,7 @@ async function installHooks(hookFiles, harnessFlag, configHarness) {
6171
6171
  }
6172
6172
  }
6173
6173
  }
6174
+ (0, hook_check_install_js_1.installHookCheck)(adapters);
6174
6175
  return ok;
6175
6176
  }
6176
6177
  /**
@@ -408,7 +408,11 @@ export interface CompiledHookProgram {
408
408
  * core depends only on these interfaces, never an adapter (core ⊄ adapter).
409
409
  */
410
410
  export interface CompileHookOptions {
411
- /** The command the emitted block routes the event to. */
411
+ /**
412
+ * The command the emitted block runs. The compiler adds `|| exit 2` (a hook
413
+ * that can block) or `|| exit 0` (a reminder-only hook) at the end; one that is
414
+ * already there is replaced.
415
+ */
412
416
  readonly gateCommand?: string;
413
417
  /** Validate `hook.on` against this harness's hook-event catalog (a typo won't compile). */
414
418
  readonly dialect?: HarnessDialect;
@@ -451,6 +455,45 @@ export type AnyHook = HookProgram<ErasedNeeds> | FileGateHook<ErasedNeeds> | Pro
451
455
  */
452
456
  export type DispatchKind = "bash-gate" | "file-gate" | "prompt-gate" | "stop-gate" | "inject" | "react";
453
457
  export declare function dispatchKind(hook: AnyHook): DispatchKind;
458
+ /**
459
+ * What the shell should do when a hook's command fails before the hook gives an
460
+ * answer: the runtime is not installed, or it starts but cannot load the hook.
461
+ * No hook code ran, so the hook cannot be asked. The answer has to be written
462
+ * into the command itself, and this function is the one place that decides it.
463
+ *
464
+ * There are two kinds of hook and they need different answers:
465
+ *
466
+ * - A gate (it can block a tool call) must exit 2. A gate that cannot run must
467
+ * not let calls through: a protection that silently stops working is worse
468
+ * than one that is known to be missing.
469
+ * - A nudge (it only adds a reminder: an inject or a react) must exit 0. If it
470
+ * blocked, one missing file could stop the whole session. This happened on
471
+ * 2026-08-10: merge-conflict markers in `package.json` stopped every hook from
472
+ * loading, the Bash gate then refused `git merge --abort` (the command that
473
+ * undoes the damage), and the session could not be repaired from inside.
474
+ *
475
+ * The kind is fixed by the type the author picked: a react hook has no `deny`,
476
+ * and an inject hook only returns text.
477
+ *
478
+ * Only this function decides. The runtime used to guess a second time, from the
479
+ * hook's file name ("inject" in the name meant nudge), and the two guesses
480
+ * disagreed (#312). Now the runtime always exits 2 when a hook cannot load, and
481
+ * the `|| exit N` ending (see {@link withRoleExit}) is the only thing that turns
482
+ * that 2 into a 0. It is shell, so a different runtime version cannot change it.
483
+ *
484
+ * This does not rescue a gate that cannot load: it still refuses every command,
485
+ * including the install that would fix it. It only stops a nudge doing the same.
486
+ *
487
+ * (Other tools disagree on this too: husky and lefthook skip, pre-commit fails.)
488
+ */
489
+ export declare function hookRuntimeMissingExit(kind: DispatchKind): 0 | 2;
490
+ /**
491
+ * `command` with `|| exit 0` or `|| exit 2` (chosen by the kind of hook) as its
492
+ * last words. A `|| exit N` already there is replaced, not added to: the shell
493
+ * stops at the first `exit` it reaches, so a nudge wired `|| exit 2 || exit 0`
494
+ * would still block. Applying it twice gives the same result as applying it once.
495
+ */
496
+ export declare function withRoleExit(command: string, kind: DispatchKind): string;
454
497
  /** A gate's {@link HookMode} (`enforce` default); non-gate roles report `enforce` too. */
455
498
  export declare function hookMode(hook: AnyHook): HookMode;
456
499
  /** The context providers a hook declared via `needs` ([] if none / not a gate). */
@@ -1109,6 +1152,29 @@ export declare function isStampRepairEvent(event: RawHookEvent, hookFile: string
1109
1152
  * loads, the gate decides normally and `git merge --abort` is an ordinary allowed
1110
1153
  * command — through the gate, not around it.
1111
1154
  *
1155
+ * ## Why running an install is not allowed either (#312, considered and rejected)
1156
+ *
1157
+ * The usual reason a hook cannot load is that nothing is installed yet (a fresh
1158
+ * container, or a merge that brought the wiring but not the package). The cure
1159
+ * is `npm ci`, which is exactly what the gate refuses. Letting a lockfile
1160
+ * install through was proposed and rejected, for four reasons:
1161
+ *
1162
+ * - Like `git merge --abort` and `vigiles compile` above, it would be allowed for
1163
+ * what it is meant to do, not for what it can do. An install runs the
1164
+ * lifecycle scripts of every package in the lockfile, which is more than the
1165
+ * `.git/hooks/*` scripts that got the git commands removed.
1166
+ * - "The gate is not running anyway" would equally justify allowing `ls`. It
1167
+ * does not single out `npm ci`.
1168
+ * - On a fresh container the runtime itself is missing, so none of this code
1169
+ * runs; the command's own `|| exit 2` decides.
1170
+ * - `npm ci` fails when the lockfile is behind `package.json`. Fixing that needs
1171
+ * plain `npm install`, which fetches versions the repo has not pinned.
1172
+ *
1173
+ * So the way out stays a file write. What is left over is described in
1174
+ * `docs/compiled-hooks.md` ("When a hook cannot load"). Removing the cause would
1175
+ * need hook files that live in the same git commit as their wiring; that is a
1176
+ * separate decision (#312).
1177
+ *
1112
1178
  * ## What is accepted
1113
1179
  *
1114
1180
  * A write to the hook's own source, its stamp sidecar, or one of
@@ -12,6 +12,8 @@ exports.decideProgram = decideProgram;
12
12
  exports.decisionExitCode = decisionExitCode;
13
13
  exports.checkHookImports = checkHookImports;
14
14
  exports.dispatchKind = dispatchKind;
15
+ exports.hookRuntimeMissingExit = hookRuntimeMissingExit;
16
+ exports.withRoleExit = withRoleExit;
15
17
  exports.hookMode = hookMode;
16
18
  exports.hookNeeds = hookNeeds;
17
19
  exports.hookRouting = hookRouting;
@@ -686,6 +688,51 @@ function dispatchKind(hook) {
686
688
  return hook.role === "gate" ? "file-gate" : hook.role;
687
689
  return "bash-gate";
688
690
  }
691
+ /**
692
+ * What the shell should do when a hook's command fails before the hook gives an
693
+ * answer: the runtime is not installed, or it starts but cannot load the hook.
694
+ * No hook code ran, so the hook cannot be asked. The answer has to be written
695
+ * into the command itself, and this function is the one place that decides it.
696
+ *
697
+ * There are two kinds of hook and they need different answers:
698
+ *
699
+ * - A gate (it can block a tool call) must exit 2. A gate that cannot run must
700
+ * not let calls through: a protection that silently stops working is worse
701
+ * than one that is known to be missing.
702
+ * - A nudge (it only adds a reminder: an inject or a react) must exit 0. If it
703
+ * blocked, one missing file could stop the whole session. This happened on
704
+ * 2026-08-10: merge-conflict markers in `package.json` stopped every hook from
705
+ * loading, the Bash gate then refused `git merge --abort` (the command that
706
+ * undoes the damage), and the session could not be repaired from inside.
707
+ *
708
+ * The kind is fixed by the type the author picked: a react hook has no `deny`,
709
+ * and an inject hook only returns text.
710
+ *
711
+ * Only this function decides. The runtime used to guess a second time, from the
712
+ * hook's file name ("inject" in the name meant nudge), and the two guesses
713
+ * disagreed (#312). Now the runtime always exits 2 when a hook cannot load, and
714
+ * the `|| exit N` ending (see {@link withRoleExit}) is the only thing that turns
715
+ * that 2 into a 0. It is shell, so a different runtime version cannot change it.
716
+ *
717
+ * This does not rescue a gate that cannot load: it still refuses every command,
718
+ * including the install that would fix it. It only stops a nudge doing the same.
719
+ *
720
+ * (Other tools disagree on this too: husky and lefthook skip, pre-commit fails.)
721
+ */
722
+ function hookRuntimeMissingExit(kind) {
723
+ return kind === "inject" || kind === "react" ? 0 : 2;
724
+ }
725
+ /** A trailing `|| exit <number>`, the only ending this module writes. */
726
+ const ROLE_EXIT_SUFFIX = /\s*\|\|\s*exit\s+\d+\s*$/;
727
+ /**
728
+ * `command` with `|| exit 0` or `|| exit 2` (chosen by the kind of hook) as its
729
+ * last words. A `|| exit N` already there is replaced, not added to: the shell
730
+ * stops at the first `exit` it reaches, so a nudge wired `|| exit 2 || exit 0`
731
+ * would still block. Applying it twice gives the same result as applying it once.
732
+ */
733
+ function withRoleExit(command, kind) {
734
+ return `${command.replace(ROLE_EXIT_SUFFIX, "")} || exit ${hookRuntimeMissingExit(kind)}`;
735
+ }
689
736
  /** A gate's {@link HookMode} (`enforce` default); non-gate roles report `enforce` too. */
690
737
  function hookMode(hook) {
691
738
  return "mode" in hook && hook.mode ? hook.mode : "enforce";
@@ -942,7 +989,9 @@ function compileHookProgram(source, hook, opts = {}) {
942
989
  .map((u) => `${u.name} ("${u.run}")`)
943
990
  .join(", ")} — use dangerously(name, cmd) to acknowledge a side-effecting/undecidable command, or keep provide() only for a read-only one.`);
944
991
  }
945
- const gateCommand = opts.gateCommand ?? "npx vigiles hook-runtime run-program";
992
+ // The `|| exit N` ending is added here, not by the caller, so every caller gets
993
+ // the right one and none can forget it (#312).
994
+ const gateCommand = withRoleExit(opts.gateCommand ?? "npx vigiles hook-runtime run-program", dispatchKind(hook));
946
995
  const matcher = styleMatcher(rawMatcher, opts.hookProtocol);
947
996
  const entry = matcher === undefined
948
997
  ? { hooks: [{ type: "command", command: gateCommand }] }
@@ -1759,6 +1808,29 @@ function isStampRepairEvent(event, hookFile, repoRoot) {
1759
1808
  * loads, the gate decides normally and `git merge --abort` is an ordinary allowed
1760
1809
  * command — through the gate, not around it.
1761
1810
  *
1811
+ * ## Why running an install is not allowed either (#312, considered and rejected)
1812
+ *
1813
+ * The usual reason a hook cannot load is that nothing is installed yet (a fresh
1814
+ * container, or a merge that brought the wiring but not the package). The cure
1815
+ * is `npm ci`, which is exactly what the gate refuses. Letting a lockfile
1816
+ * install through was proposed and rejected, for four reasons:
1817
+ *
1818
+ * - Like `git merge --abort` and `vigiles compile` above, it would be allowed for
1819
+ * what it is meant to do, not for what it can do. An install runs the
1820
+ * lifecycle scripts of every package in the lockfile, which is more than the
1821
+ * `.git/hooks/*` scripts that got the git commands removed.
1822
+ * - "The gate is not running anyway" would equally justify allowing `ls`. It
1823
+ * does not single out `npm ci`.
1824
+ * - On a fresh container the runtime itself is missing, so none of this code
1825
+ * runs; the command's own `|| exit 2` decides.
1826
+ * - `npm ci` fails when the lockfile is behind `package.json`. Fixing that needs
1827
+ * plain `npm install`, which fetches versions the repo has not pinned.
1828
+ *
1829
+ * So the way out stays a file write. What is left over is described in
1830
+ * `docs/compiled-hooks.md` ("When a hook cannot load"). Removing the cause would
1831
+ * need hook files that live in the same git commit as their wiring; that is a
1832
+ * separate decision (#312).
1833
+ *
1762
1834
  * ## What is accepted
1763
1835
  *
1764
1836
  * A write to the hook's own source, its stamp sidecar, or one of
@@ -106,6 +106,21 @@ import { type SurfaceProbe } from "./check-count.js";
106
106
  * runner resolves refs against real discovered surfaces or drops them.
107
107
  */
108
108
  export declare function commandRefs(command: string, env?: Record<string, string>): string[];
109
+ /**
110
+ * The files that a `hook-runtime run-program` command needs to exist: the hook
111
+ * file it names, and the runtime script when the command starts it by path
112
+ * (`node "$DIR/node_modules/vigiles/dist/cli.js" hook-runtime run-program …`).
113
+ *
114
+ * Narrower than {@link commandRefs}, which lists every program a command runs.
115
+ * This lists only what a compiled hook depends on, because the start-of-session
116
+ * check (`hook-check.ts`) reports these files as missing. `bash scripts/build.sh`
117
+ * is not one of them; reporting it missing in a fresh clone would be a false
118
+ * alarm that `npm install` does not fix.
119
+ *
120
+ * Like `commandRefs`, it only counts a command that really starts vigiles, so
121
+ * `echo vigiles hook-runtime run-program x.mjs` names nothing.
122
+ */
123
+ export declare function runProgramFiles(command: string): string[];
109
124
  /** The transcript shape this module reads — structural, so it imports no tier. */
110
125
  export interface ProbeableTrace {
111
126
  readonly toolCalls: readonly {
@@ -1,6 +1,7 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.commandRefs = commandRefs;
4
+ exports.runProgramFiles = runProgramFiles;
4
5
  exports.traceRefs = traceRefs;
5
6
  exports.probeCommand = probeCommand;
6
7
  exports.probeTrace = probeTrace;
@@ -586,6 +587,34 @@ function commandRefs(command, env) {
586
587
  }
587
588
  return [...out];
588
589
  }
590
+ /**
591
+ * The files that a `hook-runtime run-program` command needs to exist: the hook
592
+ * file it names, and the runtime script when the command starts it by path
593
+ * (`node "$DIR/node_modules/vigiles/dist/cli.js" hook-runtime run-program …`).
594
+ *
595
+ * Narrower than {@link commandRefs}, which lists every program a command runs.
596
+ * This lists only what a compiled hook depends on, because the start-of-session
597
+ * check (`hook-check.ts`) reports these files as missing. `bash scripts/build.sh`
598
+ * is not one of them; reporting it missing in a fresh clone would be a false
599
+ * alarm that `npm install` does not fix.
600
+ *
601
+ * Like `commandRefs`, it only counts a command that really starts vigiles, so
602
+ * `echo vigiles hook-runtime run-program x.mjs` names nothing.
603
+ */
604
+ function runProgramFiles(command) {
605
+ const out = new Set();
606
+ for (const argv of (0, bash_effects_js_1.leafArgvSource)(command)) {
607
+ const head = argv[0] ?? "";
608
+ const entry = SCRIPT_RE.test(head) ? head : interpretedScript(argv);
609
+ const hook = runProgramRef(argv, entry);
610
+ if (hook === undefined)
611
+ continue;
612
+ if (entry !== undefined)
613
+ out.add(entry);
614
+ out.add(hook);
615
+ }
616
+ return [...out];
617
+ }
589
618
  /**
590
619
  * The tools that DISPATCH a subagent. Both spellings, because the live CLI names
591
620
  * it `Agent` and older docs name it `Task` — the same pair `parseSubagents`
@@ -0,0 +1,3 @@
1
+ import type { HarnessAdapter } from "./core/adapter.js";
2
+ export declare function installHookCheck(adapters: readonly HarnessAdapter[], cwd?: string): void;
3
+ //# sourceMappingURL=hook-check-install.d.ts.map
@@ -0,0 +1,57 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.installHookCheck = installHookCheck;
4
+ /**
5
+ * Writes the start-of-session check described in `src/hook-check.ts` and adds its
6
+ * entry to each harness's settings. `vigiles compile` calls this once, after it
7
+ * has merged the hooks.
8
+ *
9
+ * The list of files comes from the settings as they are now, not from the hooks
10
+ * this run compiled. So compiling one hook does not make the check forget the
11
+ * others, and compiling twice changes nothing. The entry is found again by the
12
+ * script's path, so a recompile replaces it in place and leaves any
13
+ * `SessionStart` hook the user wrote alone.
14
+ */
15
+ const node_fs_1 = require("node:fs");
16
+ const node_path_1 = require("node:path");
17
+ const hook_check_js_1 = require("./hook-check.js");
18
+ const hook_install_js_1 = require("./hook-install.js");
19
+ function isShellAdapter(adapter) {
20
+ return adapter.shellHooks;
21
+ }
22
+ /** One harness's settings as a plain object, or `undefined` if the file does not exist yet. */
23
+ function readSettings(adapter, cwd) {
24
+ const file = (0, node_path_1.resolve)(cwd, adapter.layout.settingsPath);
25
+ return (0, node_fs_1.existsSync)(file)
26
+ ? adapter.layout.settings.parse((0, node_fs_1.readFileSync)(file, "utf-8"))
27
+ : undefined;
28
+ }
29
+ /** Add the check's `SessionStart` entry to one harness's settings, or replace it where it already is. */
30
+ function registerCheck(adapter, existing, cwd) {
31
+ const ref = (0, hook_install_js_1.hookGateRef)(hook_check_js_1.HOOK_CHECK_REF, adapter.layout.projectRootTokens);
32
+ const merged = adapter.hookProtocol.mergeRegistrations(existing, {
33
+ SessionStart: [
34
+ { hooks: [{ type: "command", command: `sh ${ref} || exit 0` }] },
35
+ ],
36
+ }, hook_check_js_1.HOOK_CHECK_REF);
37
+ (0, node_fs_1.writeFileSync)((0, node_path_1.resolve)(cwd, adapter.layout.settingsPath), adapter.layout.settings.render(merged));
38
+ }
39
+ function installHookCheck(adapters, cwd = process.cwd()) {
40
+ const found = adapters.filter(isShellAdapter).flatMap((adapter) => {
41
+ const settings = readSettings(adapter, cwd);
42
+ return settings === undefined ? [] : [{ adapter, settings }];
43
+ });
44
+ const { paths, unsafe } = (0, hook_check_js_1.checkPaths)(found.flatMap((f) => (0, hook_check_js_1.commandsIn)(f.settings.hooks)));
45
+ for (const path of unsafe) {
46
+ console.warn(`⚠ hook-check: ${path} has characters the check cannot quote safely, so it is NOT covered.`);
47
+ }
48
+ if (paths.length === 0)
49
+ return;
50
+ const script = (0, node_path_1.resolve)(cwd, hook_check_js_1.HOOK_CHECK_REF);
51
+ (0, node_fs_1.mkdirSync)((0, node_path_1.dirname)(script), { recursive: true });
52
+ (0, node_fs_1.writeFileSync)(script, (0, hook_check_js_1.renderHookCheck)(paths));
53
+ for (const { adapter, settings } of found)
54
+ registerCheck(adapter, settings, cwd);
55
+ console.log(`✓ ${hook_check_js_1.HOOK_CHECK_REF} → SessionStart notice for ${paths.length} wired hook file(s)`);
56
+ }
57
+ //# sourceMappingURL=hook-check-install.js.map
@@ -0,0 +1,36 @@
1
+ /** Where the generated script is written. It is committed, next to the hook sources. */
2
+ export declare const HOOK_CHECK_REF = ".vigiles/hook-check.sh";
3
+ /**
4
+ * Every `command` string in a parsed hooks block. It looks for the key `command`
5
+ * instead of following one layout, because the layout depends on the harness
6
+ * (Claude Code nests commands under a matcher block, Codex's TOML is flat).
7
+ */
8
+ export declare function commandsIn(node: unknown): string[];
9
+ export interface CheckPaths {
10
+ /** Paths relative to the project, sorted, no duplicates: what the script tests. */
11
+ readonly paths: readonly string[];
12
+ /**
13
+ * Paths the check found but cannot safely write into the script. `compile`
14
+ * warns about each one, because a path left out is a hook the check does not
15
+ * cover.
16
+ */
17
+ readonly unsafe: readonly string[];
18
+ }
19
+ /**
20
+ * The files that the compiled-hook commands in `commands` need, as paths relative
21
+ * to the project. Two kinds of path are left out on purpose: absolute paths
22
+ * (another machine's checkout will not have them) and paths that use any variable
23
+ * except the project root, such as `$HOME` or a plugin root (we cannot resolve
24
+ * them here).
25
+ */
26
+ export declare function checkPaths(commands: readonly string[]): CheckPaths;
27
+ /**
28
+ * The text of the script. The same paths always give the same text, so a
29
+ * recompile that finds the same hooks writes the same bytes.
30
+ *
31
+ * The script prints JSON with an `additionalContext` field instead of plain text.
32
+ * That form is confirmed for the start of a session on both Claude Code and
33
+ * Codex; plain text is only confirmed on Claude Code.
34
+ */
35
+ export declare function renderHookCheck(paths: readonly string[]): string;
36
+ //# sourceMappingURL=hook-check.d.ts.map
@@ -0,0 +1,125 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.HOOK_CHECK_REF = void 0;
4
+ exports.commandsIn = commandsIn;
5
+ exports.checkPaths = checkPaths;
6
+ exports.renderHookCheck = renderHookCheck;
7
+ /**
8
+ * A check that runs when a session starts and says "run npm install" if files
9
+ * that the repo's hooks need are missing.
10
+ *
11
+ * Why it exists (#312). A compiled hook lives in two places: the wiring in
12
+ * `.claude/settings.json` (stored in git) and the files that wiring runs (in
13
+ * `node_modules`, stored by npm). A fresh container, a merge that brings new
14
+ * wiring, or a dependency update can change one without the other. Today the
15
+ * first sign is a hook refusing the first command. This check notices one step
16
+ * earlier and names the fix.
17
+ *
18
+ * What it does not do: fix anything. If a hook that can block commands has
19
+ * missing files, it still refuses every shell command, including the install.
20
+ * The check only says so before the first command is tried, so a person can run
21
+ * the install from a terminal outside the session. Removing the cause would need
22
+ * hook files stored in git next to their wiring; that is a separate decision
23
+ * (#312).
24
+ *
25
+ * Three rules shape the code:
26
+ *
27
+ * - It must work when nothing is installed. So the script is plain POSIX `sh`
28
+ * using only built-in commands (no node, no vigiles, no grep), and it is
29
+ * committed, so it arrives together with the wiring.
30
+ * - It only prints and always exits 0. We have not verified what exit code 2
31
+ * does at session start, and a check that could fail would be one more way to
32
+ * break the session it is meant to help. It is wired `|| exit 0`, like any
33
+ * reminder.
34
+ * - It lists only what a compiled hook needs: the hook files named by
35
+ * `hook-runtime run-program` commands in the settings, plus the local runtime
36
+ * when it is started by path. Reporting any other script (say
37
+ * `scripts/build.sh`) as missing in a fresh clone would be a false alarm,
38
+ * because `npm install` does not create it.
39
+ *
40
+ * The list is read from the settings after `compile` has merged into them. So it
41
+ * also covers hooks that another tool wrote there, and it does not shrink when
42
+ * `compile` is run on just one hook. A hook added after the last `compile` is
43
+ * not covered until the next one.
44
+ *
45
+ * Limits: it checks that a file exists, not that it loads. A package that is
46
+ * present but no longer exports what a hook imports passes this check and still
47
+ * breaks. A path is recognised by the same pattern the coverage code uses, which
48
+ * has no room for spaces or quotes, so a hook under such a path is not covered,
49
+ * and the check cannot say so.
50
+ */
51
+ const coverage_probe_js_1 = require("./coverage-probe.js");
52
+ /** Where the generated script is written. It is committed, next to the hook sources. */
53
+ exports.HOOK_CHECK_REF = ".vigiles/hook-check.sh";
54
+ /**
55
+ * Every `command` string in a parsed hooks block. It looks for the key `command`
56
+ * instead of following one layout, because the layout depends on the harness
57
+ * (Claude Code nests commands under a matcher block, Codex's TOML is flat).
58
+ */
59
+ function commandsIn(node) {
60
+ if (Array.isArray(node))
61
+ return node.flatMap(commandsIn);
62
+ if (typeof node !== "object" || node === null)
63
+ return [];
64
+ return Object.entries(node).flatMap(([key, value]) => key === "command" && typeof value === "string"
65
+ ? [value]
66
+ : commandsIn(value));
67
+ }
68
+ /** How a command may spell the project root. It is removed to leave a relative path. */
69
+ const PROJECT_ROOT_PREFIX = /^\$(?:\{CLAUDE_PROJECT_DIR\}|CLAUDE_PROJECT_DIR)\//;
70
+ /** The characters a path may contain. It is written into a shell string and a JSON string. */
71
+ const SAFE_PATH = /^[A-Za-z0-9._@+/-]+$/;
72
+ /**
73
+ * The files that the compiled-hook commands in `commands` need, as paths relative
74
+ * to the project. Two kinds of path are left out on purpose: absolute paths
75
+ * (another machine's checkout will not have them) and paths that use any variable
76
+ * except the project root, such as `$HOME` or a plugin root (we cannot resolve
77
+ * them here).
78
+ */
79
+ function checkPaths(commands) {
80
+ const relative = commands
81
+ .flatMap(coverage_probe_js_1.runProgramFiles)
82
+ .map((file) => file.replace(PROJECT_ROOT_PREFIX, ""))
83
+ .filter((file) => !file.startsWith("/") && !file.includes("$"));
84
+ const unique = [...new Set(relative)].sort();
85
+ return {
86
+ paths: unique.filter((p) => SAFE_PATH.test(p)),
87
+ unsafe: unique.filter((p) => !SAFE_PATH.test(p)),
88
+ };
89
+ }
90
+ /**
91
+ * The text of the script. The same paths always give the same text, so a
92
+ * recompile that finds the same hooks writes the same bytes.
93
+ *
94
+ * The script prints JSON with an `additionalContext` field instead of plain text.
95
+ * That form is confirmed for the start of a session on both Claude Code and
96
+ * Codex; plain text is only confirmed on Claude Code.
97
+ */
98
+ function renderHookCheck(paths) {
99
+ const list = paths.map((p) => ` '${p}'`).join(" \\\n");
100
+ return `#!/bin/sh
101
+ # Generated by \`vigiles compile\`. Do not edit; the next compile rewrites it.
102
+ #
103
+ # Start-of-session notice (vigiles #312). If a file that a compiled hook runs is
104
+ # not installed, it says so in the first turn. It uses only plain sh and shell
105
+ # built-ins (no node, no node_modules, no vigiles), because "not installed" is
106
+ # exactly the situation it reports. It only prints and always exits 0.
107
+ #
108
+ # It does not fix anything. A hook that can block commands, with its files
109
+ # missing, refuses every shell command including the install, so the install has
110
+ # to be run from a terminal outside the session.
111
+ root="\${CLAUDE_PROJECT_DIR:-.}"
112
+ missing=""
113
+ for p in \\
114
+ ${list}
115
+ do
116
+ [ -e "$root/$p" ] || missing="$missing $p"
117
+ done
118
+ if [ -n "$missing" ]; then
119
+ printf '{"hookSpecificOutput":{"hookEventName":"SessionStart","additionalContext":"%s"}}\\n' \\
120
+ "vigiles: hook files that this repo runs are not installed:$missing. Run npm ci (or npm install) from a terminal outside this session before anything else. A blocking hook whose files are missing refuses every shell command in a session, the install included; a reminder-only hook that cannot load is skipped. See docs/compiled-hooks.md, When a hook cannot load."
121
+ fi
122
+ exit 0
123
+ `;
124
+ }
125
+ //# sourceMappingURL=hook-check.js.map
@@ -1,4 +1,3 @@
1
- import type { DispatchKind } from "./core/hook-program.js";
2
1
  /** The agnostic, committed home for hook SOURCE — one dir, cross-adapter. */
3
2
  export declare const HOOKS_DIR = ".vigiles/hooks";
4
3
  /** The committed home for registered context-provider SOURCE (v2). */
@@ -73,41 +72,6 @@ export declare function normalizeHookRef(hookPath: string, cwd?: string): string
73
72
  * it can be given — see {@link hookGateRef} for the same fallback.
74
73
  */
75
74
  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;
111
75
  export declare function hookGateRef(ref: string, projectRootTokens: readonly string[] | undefined): string;
112
76
  /**
113
77
  * Idempotently merge a compiled hook's block into an existing `settings.json`
@@ -5,7 +5,6 @@ exports.discoverHookFiles = discoverHookFiles;
5
5
  exports.discoverProviderFiles = discoverProviderFiles;
6
6
  exports.normalizeHookRef = normalizeHookRef;
7
7
  exports.hookRuntimeRef = hookRuntimeRef;
8
- exports.hookRuntimeMissingExit = hookRuntimeMissingExit;
9
8
  exports.hookGateRef = hookGateRef;
10
9
  exports.mergeHooksJson = mergeHooksJson;
11
10
  exports.mergeHooksToml = mergeHooksToml;
@@ -122,43 +121,6 @@ function hookRuntimeRef(projectRootTokens) {
122
121
  const token = projectRootTokens?.[0];
123
122
  return token === undefined ? `node ${rel}` : `node "${token}/${rel}"`;
124
123
  }
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
- }
162
124
  function hookGateRef(ref, projectRootTokens) {
163
125
  const token = projectRootTokens?.[0];
164
126
  return token === undefined ? ref : `"${token}/${ref}"`;
@@ -432,12 +432,16 @@ async function runHookProgramCommand(file) {
432
432
  // the author are different from a `deny`'s: name the real cause, and leave a
433
433
  // way back.
434
434
  //
435
- // EXCEPTION: inject hooks. An inject's purpose is to ADD context, not to
436
- // ENFORCE a decision. If it fails to load, the session should degrade
437
- // gracefully (no context injected) rather than wedging the entire harness.
438
- // This is a harness failure, not a gating decision — so we handle it by
439
- // logging the error and exiting 0. Gates (file, bash, prompt, stop) remain
440
- // conservative and fail closed.
435
+ // A hook that cannot be loaded always exits 2 here, whatever kind of hook it
436
+ // is. We cannot tell: the hook did not load, so there is nothing to ask
437
+ // whether it is a gate (can block) or a nudge (reminder only). This code used
438
+ // to guess from the file name (`file.includes("inject")`), and the guess
439
+ // disagreed with the `|| exit N` ending that `vigiles compile` writes on the
440
+ // command: a nudge without "inject" in its name blocked the session, and a
441
+ // gate with it let every command through (#312).
442
+ // Now only that ending decides. A nudge is wired `|| exit 0`, so the shell
443
+ // turns our 2 into a 0 and it degrades; a gate is wired `|| exit 2` and
444
+ // blocks. A command with no such ending is treated as a gate, the safe default.
441
445
  //
442
446
  // Escapes, both announced loudly on stderr:
443
447
  // - the stale-stamp one (an edit to the hook itself / `vigiles compile`),
@@ -476,11 +480,13 @@ async function runHookProgramCommand(file) {
476
480
  announceRepairEscape(file, cause);
477
481
  return;
478
482
  }
479
- // Log the error, but for inject hooks, degrade gracefully (exit 0).
480
483
  const errorMsg = `vigiles: hook ${file} ${cause}.\n` +
481
484
  `vigiles: this is the state of the HARNESS, not a decision about your ` +
482
485
  `command — the gate never ran. Blocking anyway (a gate that cannot run ` +
483
486
  `must not pass traffic).\n` +
487
+ `vigiles: if this hook is a NUDGE (an inject or a react), it must not ` +
488
+ `block: its wiring should end in \`|| exit 0\`, which \`vigiles compile\` ` +
489
+ `writes. A hook wired by hand with no such ending is treated as a gate.\n` +
484
490
  `vigiles: the way out is a FILE WRITE, not a command — under a tool that ` +
485
491
  `WRITES (Write/Edit/MultiEdit); a Read of the same path repairs nothing ` +
486
492
  `and is refused. Fix whichever of ` +
@@ -493,24 +499,11 @@ async function runHookProgramCommand(file) {
493
499
  `repair this failure.\n` +
494
500
  `vigiles: no command is allowed, deliberately. \`git merge --abort\` and ` +
495
501
  `\`git checkout\` RUN \`.git/hooks/*\` (measured: reference-transaction, ` +
496
- `post-checkout), and \`vigiles compile\` loads the hook through the same ` +
497
- `resolver that just failed.`;
502
+ `post-checkout), \`vigiles compile\` loads the hook through the same ` +
503
+ `resolver that just failed, and an install runs the lifecycle scripts of ` +
504
+ `every package in the lockfile.`;
498
505
  console.error(errorMsg);
499
- // Exit code depends on hook kind. This heuristic is based on the filename —
500
- // a more robust approach would parse the stamp or metadata, but that requires
501
- // the hook to load. Inject hooks typically have "inject" in the name; fall
502
- // back to blocking (exit 2) for safety on gates.
503
- const isLikelyInject = file.includes("inject");
504
- if (isLikelyInject) {
505
- // Inject hook: degrade gracefully. Log the error but don't wedge the session.
506
- console.error(`vigiles: ${file} is an inject hook; degrading gracefully (no context injected).`);
507
- process.exit(0);
508
- }
509
- else {
510
- // Gate hook: fail closed.
511
- process.exit(2);
512
- }
513
- return;
506
+ process.exit(2);
514
507
  }
515
508
  verifyStampOrRefuse(file, event);
516
509
  switch ((0, hook_program_js_1.dispatchKind)(program)) {
@@ -100,6 +100,7 @@ exports.LOCAL_FILES = [
100
100
  */
101
101
  exports.COMMITTED_PATHS = [
102
102
  "hooks", // hook sources + their stamps (`hook-install.ts`)
103
+ "hook-check.sh", // the start-of-session check, generated by compile (`hook-check.ts`)
103
104
  "providers", // registered hook-context providers
104
105
  "eval-locks", // committed eval staleness stamps (`eval-lock.ts`)
105
106
  "eval-baseline.json", // the committed regression baseline (`eval-baseline.ts`)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vigiles",
3
- "version": "32.1.0",
3
+ "version": "32.1.1",
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",