vigiles 27.2.0 → 28.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,5 @@
1
1
  import type { ClaudeSpec } from "./spec.js";
2
- export declare function makeTmpDir(suffix?: string): string;
3
- export declare function cleanupTmpDir(dir: string): void;
2
+ export { makeTmpDir, cleanupTmpDir } from "./tmp-root.js";
4
3
  export declare function makeSpec(overrides?: Partial<ClaudeSpec>): ClaudeSpec;
5
4
  declare function git(cwd: string, cmd: string): string;
6
5
  export declare function initGitRepo(dir: string): void;
@@ -1,20 +1,18 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.makeTmpDir = makeTmpDir;
4
- exports.cleanupTmpDir = cleanupTmpDir;
3
+ exports.cleanupTmpDir = exports.makeTmpDir = void 0;
5
4
  exports.makeSpec = makeSpec;
6
5
  exports.initGitRepo = initGitRepo;
7
6
  exports.git = git;
8
7
  const node_fs_1 = require("node:fs");
9
8
  const node_path_1 = require("node:path");
10
- const node_os_1 = require("node:os");
11
9
  const node_child_process_1 = require("node:child_process");
12
- function makeTmpDir(suffix = "test") {
13
- return (0, node_fs_1.mkdtempSync)((0, node_path_1.join)((0, node_os_1.tmpdir)(), `vigiles-${suffix}-`));
14
- }
15
- function cleanupTmpDir(dir) {
16
- (0, node_fs_1.rmSync)(dir, { recursive: true, force: true });
17
- }
10
+ // Re-exported, not redefined: the temp root lives in `tmp-root.ts` because the
11
+ // runtime modules that need one must not pull in `makeSpec`/`initGitRepo` and
12
+ // their dependencies. One definition, two doors.
13
+ var tmp_root_js_1 = require("./tmp-root.js");
14
+ Object.defineProperty(exports, "makeTmpDir", { enumerable: true, get: function () { return tmp_root_js_1.makeTmpDir; } });
15
+ Object.defineProperty(exports, "cleanupTmpDir", { enumerable: true, get: function () { return tmp_root_js_1.cleanupTmpDir; } });
18
16
  function makeSpec(overrides) {
19
17
  return {
20
18
  _specType: "claude",
@@ -0,0 +1,12 @@
1
+ /**
2
+ * Create a temporary directory and return the path with every symlink resolved.
3
+ *
4
+ * The `realpathSync` wrapper is the whole point: it must stay OUTSIDE
5
+ * `mkdtempSync`, because the directory has to exist before it can be resolved.
6
+ *
7
+ * @param suffix distinguishes roots in a listing; the name is `vigiles-<suffix>-*`
8
+ */
9
+ export declare function makeTmpDir(suffix?: string): string;
10
+ /** Remove a root made by {@link makeTmpDir}. Safe on a path that is already gone. */
11
+ export declare function cleanupTmpDir(dir: string): void;
12
+ //# sourceMappingURL=tmp-root.d.ts.map
@@ -0,0 +1,59 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.makeTmpDir = makeTmpDir;
4
+ exports.cleanupTmpDir = cleanupTmpDir;
5
+ /**
6
+ * The ONE way to make a temporary fixture root, with its symlinks resolved.
7
+ *
8
+ * ── THE TRAP THIS EXISTS TO ABOLISH (issue #241, measured) ──────────────────────
9
+ * On macOS `os.tmpdir()` returns a path under `/var/folders/…`, and `/var` is
10
+ * itself a symlink to `/private/var`. Node resolves a module's own URL to the
11
+ * REALPATH but leaves `process.argv[1]`, and any path a test composed itself,
12
+ * exactly as typed. A fixture built under `tmpdir()` therefore carries two
13
+ * spellings of one directory, and anything comparing them is red on macOS and
14
+ * green on Linux:
15
+ *
16
+ * const d = mkdtempSync(join(tmpdir(), "probe-"));
17
+ * // import.meta.url → "file:///private/var/folders/…/probe.mjs"
18
+ * // process.argv[1] → "/var/folders/…/probe.mjs"
19
+ *
20
+ * A consumer hit that three times in one suite: a resolver's return value against
21
+ * a composed expectation, an `isMain` control case, and a git fixture whose
22
+ * repository root git reported realpath'd while the relative path was computed
23
+ * against the other spelling (`zernie/research-paper-pipeline#9`).
24
+ *
25
+ * 🔴 WHY A SHIPPED HELPER AND NOT THREE FIXED CALL SITES. Those call sites were
26
+ * hand-rolled because the product shipped nothing to roll: `mkdtempSync(join(
27
+ * tmpdir(), …))` is the shape a harness author reaches for, and it is the shape
28
+ * that carries the trap. Fixing our own sites leaves every future author to
29
+ * rediscover it. So this is exported from the harness surface (`vigiles`), where
30
+ * `recordCheck` and `skip` already live.
31
+ *
32
+ * 🔴 WHY ITS OWN MODULE. `core/test-utils.ts` also carries `makeSpec` and
33
+ * `initGitRepo`, which pull in spec types and `execSync`; the runtime modules
34
+ * that need a temp root must not drag those in. `test-utils` re-exports these two
35
+ * so existing imports keep working, and there is still exactly one definition.
36
+ *
37
+ * ⚠️ On Linux `realpathSync` is the identity here, so no behavioural test on this
38
+ * platform can hold the fix in place. What holds it is the regression test beside
39
+ * this file, which builds its own symlink rather than relying on the platform's.
40
+ */
41
+ const node_fs_1 = require("node:fs");
42
+ const node_path_1 = require("node:path");
43
+ const node_os_1 = require("node:os");
44
+ /**
45
+ * Create a temporary directory and return the path with every symlink resolved.
46
+ *
47
+ * The `realpathSync` wrapper is the whole point: it must stay OUTSIDE
48
+ * `mkdtempSync`, because the directory has to exist before it can be resolved.
49
+ *
50
+ * @param suffix distinguishes roots in a listing; the name is `vigiles-<suffix>-*`
51
+ */
52
+ function makeTmpDir(suffix = "test") {
53
+ return (0, node_fs_1.realpathSync)((0, node_fs_1.mkdtempSync)((0, node_path_1.join)((0, node_os_1.tmpdir)(), `vigiles-${suffix}-`)));
54
+ }
55
+ /** Remove a root made by {@link makeTmpDir}. Safe on a path that is already gone. */
56
+ function cleanupTmpDir(dir) {
57
+ (0, node_fs_1.rmSync)(dir, { recursive: true, force: true });
58
+ }
59
+ //# sourceMappingURL=tmp-root.js.map
@@ -19,7 +19,21 @@ export declare function normalizeSeverity(v: unknown): unknown;
19
19
  * A non-string/array value falls back with a warning (the `ruleMarkers` pattern).
20
20
  */
21
21
  export declare function asStringArray(v: unknown, fallback: readonly string[], key: string): readonly string[];
22
- export declare function loadConfig(): VigilesConfig;
22
+ /**
23
+ * Read `.vigilesrc.json`.
24
+ *
25
+ * 🔴 `searchFrom` IS NOT A CONVENIENCE. cosmiconfig defaults to the process's
26
+ * working directory and walks up — right for a CLI verb, where the user is
27
+ * standing in the project they mean, and wrong for a hook, whose process has no
28
+ * stable cwd. A hook rail that omits it reads a DIFFERENT project's config, or
29
+ * none, and the failure runs the wrong way: a missing file means defaults, so a
30
+ * rule the author switched OFF comes back on, silently, because the file saying
31
+ * "off" was never found. Nothing in the output distinguishes that from a project
32
+ * that never configured the rule.
33
+ *
34
+ * Every CLI verb still calls this with no argument and is unaffected.
35
+ */
36
+ export declare function loadConfig(searchFrom?: string): VigilesConfig;
23
37
  export declare function parseRules(content: string, { ruleMarkers }?: ParseOptions): ParsedRule[];
24
38
  export declare function validate(content: string, { ruleMarkers, rules: rulesConfig, filePath, dialect }?: ValidateOptions): ValidationResult;
25
39
  export declare function readInstructionFile(filePath: string, options?: ReadOptions): ReadResult;
@@ -172,13 +172,27 @@ function asStringArray(v, fallback, key) {
172
172
  console.warn(`Invalid ${key} in config: expected a string or string[], got ${JSON.stringify(v)}. Ignoring.`);
173
173
  return fallback;
174
174
  }
175
- function loadConfig() {
175
+ /**
176
+ * Read `.vigilesrc.json`.
177
+ *
178
+ * 🔴 `searchFrom` IS NOT A CONVENIENCE. cosmiconfig defaults to the process's
179
+ * working directory and walks up — right for a CLI verb, where the user is
180
+ * standing in the project they mean, and wrong for a hook, whose process has no
181
+ * stable cwd. A hook rail that omits it reads a DIFFERENT project's config, or
182
+ * none, and the failure runs the wrong way: a missing file means defaults, so a
183
+ * rule the author switched OFF comes back on, silently, because the file saying
184
+ * "off" was never found. Nothing in the output distinguishes that from a project
185
+ * that never configured the rule.
186
+ *
187
+ * Every CLI verb still calls this with no argument and is unaffected.
188
+ */
189
+ function loadConfig(searchFrom) {
176
190
  try {
177
191
  const explorer = (0, cosmiconfig_1.cosmiconfigSync)("vigiles", {
178
192
  searchPlaces: [".vigilesrc.json"],
179
193
  mergeSearchPlaces: false,
180
194
  });
181
- const result = explorer.search();
195
+ const result = explorer.search(searchFrom);
182
196
  if (!result?.config)
183
197
  return { ...DEFAULT_CONFIG };
184
198
  const userConfig = result.config;
package/dist/eval.js CHANGED
@@ -84,6 +84,7 @@ const check_count_js_1 = require("./check-count.js");
84
84
  const coverage_probe_js_1 = require("./coverage-probe.js");
85
85
  const tool_intercept_js_1 = require("./tool-intercept.js");
86
86
  const tool_stub_js_1 = require("./tool-stub.js");
87
+ const tmp_root_js_1 = require("./core/tmp-root.js");
87
88
  function writeFiles(cwd, files) {
88
89
  for (const [p, content] of Object.entries(files)) {
89
90
  const full = (0, node_path_1.resolve)(cwd, p);
@@ -650,7 +651,7 @@ function whichSkillsFired(trace) {
650
651
  * falls out of one pass over the prompts (N× cheaper than re-running per pair).
651
652
  */
652
653
  async function runSkillSelectionTrial(args) {
653
- const cwd = (0, node_fs_1.mkdtempSync)((0, node_path_1.join)((0, node_os_1.tmpdir)(), "vigiles-selection-"));
654
+ const cwd = (0, tmp_root_js_1.makeTmpDir)("selection");
654
655
  try {
655
656
  if (args.fixture)
656
657
  writeFiles(cwd, args.fixture);
@@ -1008,7 +1009,7 @@ function seedEphemeralHome(throwawayHome, realHome, keep = exports.EPHEMERAL_HOM
1008
1009
  }
1009
1010
  /** Execute one trial in a fresh sandbox; returns its metric row + usage. */
1010
1011
  async function executeTrial(spec, arm, trialIndex, runner, cfg) {
1011
- const cwd = (0, node_fs_1.mkdtempSync)((0, node_path_1.join)((0, node_os_1.tmpdir)(), "vigiles-eval-"));
1012
+ const cwd = (0, tmp_root_js_1.makeTmpDir)("eval");
1012
1013
  try {
1013
1014
  const resolved = (0, plugin_loader_js_1.resolveHarness)({
1014
1015
  plugin: arm.plugin,
@@ -1456,7 +1457,7 @@ function packageSkillsDir(skillsDir, opts = {}) {
1456
1457
  const abs = (0, node_path_1.resolve)(skillsDir);
1457
1458
  if (!(0, node_fs_1.existsSync)(abs))
1458
1459
  throw new Error(`skillsDir not found: ${skillsDir} (resolved ${abs})`);
1459
- const root = (0, node_fs_1.mkdtempSync)((0, node_path_1.join)((0, node_os_1.tmpdir)(), "vigiles-skills-"));
1460
+ const root = (0, tmp_root_js_1.makeTmpDir)("skills");
1460
1461
  (0, node_fs_1.mkdirSync)((0, node_path_1.join)(root, ".claude-plugin"), { recursive: true });
1461
1462
  (0, node_fs_1.writeFileSync)((0, node_path_1.join)(root, ".claude-plugin", "plugin.json"), JSON.stringify({ name: opts.name ?? "vigiles-loose-skills", version: "0.0.0" }, null, 2));
1462
1463
  const skillsOut = (0, node_path_1.join)(root, "skills");
@@ -1649,7 +1650,7 @@ function copySkillsInto(src, skillsOut, stub, present) {
1649
1650
  * dir. Pure (filesystem only).
1650
1651
  */
1651
1652
  function packageInstallSet(opts) {
1652
- const root = (0, node_fs_1.mkdtempSync)((0, node_path_1.join)((0, node_os_1.tmpdir)(), "vigiles-harness-"));
1653
+ const root = (0, tmp_root_js_1.makeTmpDir)("harness");
1653
1654
  try {
1654
1655
  (0, node_fs_1.mkdirSync)((0, node_path_1.join)(root, ".claude-plugin"), { recursive: true });
1655
1656
  (0, node_fs_1.writeFileSync)((0, node_path_1.join)(root, ".claude-plugin", "plugin.json"), JSON.stringify({ name: opts.name, version: "0.0.0" }, null, 2));
@@ -1737,7 +1738,7 @@ function resolveTriggerPluginDir(spec) {
1737
1738
  /** Run one prompt set × trials through `runner`, aggregating fired counts. */
1738
1739
  /** Run one trigger trial in a throwaway cwd (fixture seeded) → fired 0/1. */
1739
1740
  async function runTriggerTrial(prompt, cfg, runner) {
1740
- const cwd = (0, node_fs_1.mkdtempSync)((0, node_path_1.join)((0, node_os_1.tmpdir)(), "vigiles-trigger-"));
1741
+ const cwd = (0, tmp_root_js_1.makeTmpDir)("trigger");
1741
1742
  try {
1742
1743
  if (cfg.fixture)
1743
1744
  writeFiles(cwd, cfg.fixture);
@@ -43,7 +43,6 @@ exports.runHarness = runHarness;
43
43
  */
44
44
  const node_child_process_1 = require("node:child_process");
45
45
  const node_fs_1 = require("node:fs");
46
- const node_os_1 = require("node:os");
47
46
  const node_path_1 = require("node:path");
48
47
  const adapter_conformance_js_1 = require("./adapter-conformance.js");
49
48
  const check_count_js_1 = require("./check-count.js");
@@ -61,6 +60,7 @@ var sandbox_js_2 = require("./sandbox.js");
61
60
  Object.defineProperty(exports, "decideSandbox", { enumerable: true, get: function () { return sandbox_js_2.decideSandbox; } });
62
61
  Object.defineProperty(exports, "specTrusted", { enumerable: true, get: function () { return sandbox_js_2.specTrusted; } });
63
62
  Object.defineProperty(exports, "sandboxAvailable", { enumerable: true, get: function () { return sandbox_js_2.sandboxAvailable; } });
63
+ const tmp_root_js_1 = require("./core/tmp-root.js");
64
64
  function contentText(content) {
65
65
  if (typeof content === "string")
66
66
  return content;
@@ -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)({
@@ -443,7 +443,7 @@ async function runHarnessTest(spec, opts = {}) {
443
443
  if (decision.action === "sandbox" && !isClaudeCode) {
444
444
  throw new Error(`sandbox not supported for ${driver.runtime.name}: confined execution is Claude Code only. Pass sandbox: false to run ${driver.runtime.name} unconfined (you audited the code, or trust the outer container).`);
445
445
  }
446
- const cwd = (0, node_fs_1.mkdtempSync)((0, node_path_1.join)((0, node_os_1.tmpdir)(), "vigiles-harness-"));
446
+ const cwd = (0, tmp_root_js_1.makeTmpDir)("harness");
447
447
  const { files, settings } = (0, plugin_loader_js_1.resolveHarness)({
448
448
  plugin: spec.plugin,
449
449
  settings: spec.settings,
@@ -528,12 +528,12 @@ async function runHarness(spec, opts = {}) {
528
528
  return runHarnessTest(spec, opts);
529
529
  }
530
530
  /** Pull the pillar-2 driver off an adapter, asserting it supports testing. */
531
- function requireDriver(adapter) {
531
+ async function requireDriver(adapter) {
532
532
  (0, adapter_conformance_js_1.assertHarnessTestable)(adapter);
533
533
  if (!adapter.harnessTestDriver) {
534
534
  throw new Error(`Adapter "${adapter.name}" declares harnessTesting but carries no harnessTestDriver — it cannot drive runHarnessTest.`);
535
535
  }
536
- return adapter.harnessTestDriver;
536
+ return await adapter.harnessTestDriver();
537
537
  }
538
538
  /* v8 ignore stop */
539
539
  //# sourceMappingURL=harness-test.js.map
@@ -1,3 +1,4 @@
1
+ import type { DispatchKind } from "./core/hook-program.js";
1
2
  /** The agnostic, committed home for hook SOURCE — one dir, cross-adapter. */
2
3
  export declare const HOOKS_DIR = ".vigiles/hooks";
3
4
  /** The committed home for registered context-provider SOURCE (v2). */
@@ -55,6 +56,58 @@ export declare function normalizeHookRef(hookPath: string, cwd?: string): string
55
56
  * `bareToken(hookGateRef(ref, tokens)) === ref` is what keeps a recompile idempotent, and
56
57
  * it is asserted directly rather than left to inspection.
57
58
  */
59
+ /**
60
+ * Where the hook runtime lives, spelled so the shell can find it WITHOUT `npx`.
61
+ *
62
+ * 🔴 MEASURED 2026-09-19, warm cache, five runs each:
63
+ *
64
+ * node <local>/dist/cli.js hook-runtime run-program … 193 ms
65
+ * npx vigiles hook-runtime run-program … 2545 ms
66
+ *
67
+ * Thirteen times, on every tool call, because `npx` re-resolves the package on
68
+ * each invocation — local, then global, then the registry. That search is the
69
+ * single largest cost in a hook's life; everything the runtime does inside adds
70
+ * up to less than a fifth of it.
71
+ *
72
+ * A harness with no project-root token gets the relative spelling, which is all
73
+ * it can be given — see {@link hookGateRef} for the same fallback.
74
+ */
75
+ export declare function hookRuntimeRef(projectRootTokens: readonly string[] | undefined): string;
76
+ /**
77
+ * What the shell must do when the runtime above CANNOT START — a missing
78
+ * `node_modules/vigiles`, an unreadable file, an interpreter that dies before a
79
+ * single line of ours runs. No code of ours executes in that case, so the policy
80
+ * has to be expressed in the emitted command or not at all.
81
+ *
82
+ * 🔴 THIS IS NOT A NEW POLICY. `runHookProgramCommand`'s load-failure branch has
83
+ * decided it since 2026-08: *"an inject's purpose is to ADD context, not to
84
+ * ENFORCE a decision … Gates (file, bash, prompt, stop) remain conservative and
85
+ * fail closed."* That branch only reaches failures that happen AFTER the runtime
86
+ * starts. This carries the same rule one layer out, to the failures that happen
87
+ * before it.
88
+ *
89
+ * WHY THE SPLIT, RATHER THAN ONE ANSWER FOR EVERYTHING — the two failures are
90
+ * not comparable:
91
+ *
92
+ * A GATE THAT SILENTLY PASSES IS WORSE THAN NO GATE. Its whole value is the
93
+ * refusal, and a harness that reports protection it is not providing is the
94
+ * one state worse than admitting it has none. So a gate whose runtime is
95
+ * missing exits 2: loud, blocking, and the cause is on stderr.
96
+ *
97
+ * A NUDGE THAT BLOCKS COSTS THE WHOLE REPOSITORY. Measured here 2026-08-10:
98
+ * merge-conflict markers in `package.json` stopped every hook loading, the
99
+ * Bash gate then refused `git merge --abort` — the one command that undoes the
100
+ * cause — and the session could not be repaired from inside. A reminder is
101
+ * never worth that, so a nudge exits 0 and says nothing it cannot say.
102
+ *
103
+ * The role is not a flag someone can flip: `Reaction` has no `deny` and an
104
+ * inject returns context, so "nudge" is a fact about the TYPE the author chose.
105
+ *
106
+ * (Industry does not agree on one answer either — husky and lefthook skip,
107
+ * pre-commit fails. Which is itself the argument for deciding by role instead
108
+ * of picking one and imposing it on both.)
109
+ */
110
+ export declare function hookRuntimeMissingExit(kind: DispatchKind): 0 | 2;
58
111
  export declare function hookGateRef(ref: string, projectRootTokens: readonly string[] | undefined): string;
59
112
  /**
60
113
  * Idempotently merge a compiled hook's block into an existing `settings.json`
@@ -4,6 +4,8 @@ exports.PROVIDERS_DIR = exports.HOOKS_DIR = void 0;
4
4
  exports.discoverHookFiles = discoverHookFiles;
5
5
  exports.discoverProviderFiles = discoverProviderFiles;
6
6
  exports.normalizeHookRef = normalizeHookRef;
7
+ exports.hookRuntimeRef = hookRuntimeRef;
8
+ exports.hookRuntimeMissingExit = hookRuntimeMissingExit;
7
9
  exports.hookGateRef = hookGateRef;
8
10
  exports.mergeHooksJson = mergeHooksJson;
9
11
  exports.mergeHooksToml = mergeHooksToml;
@@ -99,6 +101,64 @@ function normalizeHookRef(hookPath, cwd = process.cwd()) {
99
101
  * `bareToken(hookGateRef(ref, tokens)) === ref` is what keeps a recompile idempotent, and
100
102
  * it is asserted directly rather than left to inspection.
101
103
  */
104
+ /**
105
+ * Where the hook runtime lives, spelled so the shell can find it WITHOUT `npx`.
106
+ *
107
+ * 🔴 MEASURED 2026-09-19, warm cache, five runs each:
108
+ *
109
+ * node <local>/dist/cli.js hook-runtime run-program … 193 ms
110
+ * npx vigiles hook-runtime run-program … 2545 ms
111
+ *
112
+ * Thirteen times, on every tool call, because `npx` re-resolves the package on
113
+ * each invocation — local, then global, then the registry. That search is the
114
+ * single largest cost in a hook's life; everything the runtime does inside adds
115
+ * up to less than a fifth of it.
116
+ *
117
+ * A harness with no project-root token gets the relative spelling, which is all
118
+ * it can be given — see {@link hookGateRef} for the same fallback.
119
+ */
120
+ function hookRuntimeRef(projectRootTokens) {
121
+ const rel = "node_modules/vigiles/dist/cli.js";
122
+ const token = projectRootTokens?.[0];
123
+ return token === undefined ? `node ${rel}` : `node "${token}/${rel}"`;
124
+ }
125
+ /**
126
+ * What the shell must do when the runtime above CANNOT START — a missing
127
+ * `node_modules/vigiles`, an unreadable file, an interpreter that dies before a
128
+ * single line of ours runs. No code of ours executes in that case, so the policy
129
+ * has to be expressed in the emitted command or not at all.
130
+ *
131
+ * 🔴 THIS IS NOT A NEW POLICY. `runHookProgramCommand`'s load-failure branch has
132
+ * decided it since 2026-08: *"an inject's purpose is to ADD context, not to
133
+ * ENFORCE a decision … Gates (file, bash, prompt, stop) remain conservative and
134
+ * fail closed."* That branch only reaches failures that happen AFTER the runtime
135
+ * starts. This carries the same rule one layer out, to the failures that happen
136
+ * before it.
137
+ *
138
+ * WHY THE SPLIT, RATHER THAN ONE ANSWER FOR EVERYTHING — the two failures are
139
+ * not comparable:
140
+ *
141
+ * A GATE THAT SILENTLY PASSES IS WORSE THAN NO GATE. Its whole value is the
142
+ * refusal, and a harness that reports protection it is not providing is the
143
+ * one state worse than admitting it has none. So a gate whose runtime is
144
+ * missing exits 2: loud, blocking, and the cause is on stderr.
145
+ *
146
+ * A NUDGE THAT BLOCKS COSTS THE WHOLE REPOSITORY. Measured here 2026-08-10:
147
+ * merge-conflict markers in `package.json` stopped every hook loading, the
148
+ * Bash gate then refused `git merge --abort` — the one command that undoes the
149
+ * cause — and the session could not be repaired from inside. A reminder is
150
+ * never worth that, so a nudge exits 0 and says nothing it cannot say.
151
+ *
152
+ * The role is not a flag someone can flip: `Reaction` has no `deny` and an
153
+ * inject returns context, so "nudge" is a fact about the TYPE the author chose.
154
+ *
155
+ * (Industry does not agree on one answer either — husky and lefthook skip,
156
+ * pre-commit fails. Which is itself the argument for deciding by role instead
157
+ * of picking one and imposing it on both.)
158
+ */
159
+ function hookRuntimeMissingExit(kind) {
160
+ return kind === "inject" || kind === "react" ? 0 : 2;
161
+ }
102
162
  function hookGateRef(ref, projectRootTokens) {
103
163
  const token = projectRootTokens?.[0];
104
164
  return token === undefined ? ref : `"${token}/${ref}"`;
@@ -45,9 +45,9 @@ import { loadHook } from "./load-hook.js";
45
45
  */
46
46
  export declare const loadHookProgram: typeof loadHook;
47
47
  /** Load a registered provider (`.vigiles/providers/<name>`) → its definition. */
48
- export declare function loadProvider(file: string): Promise<RegisteredProvider>;
48
+ export declare function loadProvider(file: string, root?: string): Promise<RegisteredProvider>;
49
49
  /** Path of the tamper-evident stamp sidecar for a hook file. */
50
- export declare function hookStampPath(file: string): string;
50
+ export declare function hookStampPath(file: string, root?: string): string;
51
51
  /**
52
52
  * `vigiles hook-runtime run-program <file>` — the runtime the compiled hooks block
53
53
  * points at. Reads the live event on stdin, loads the typed program, verifies