vigiles 32.1.0 → 33.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,3 +1,16 @@
1
+ /**
2
+ * vigiles — run harness-test / eval script files via the CLI.
3
+ *
4
+ * `vigiles test` and `vigiles eval` discover `*.harness.*` / `*.eval.*`
5
+ * scripts and run each as a child `node` process, so the two-tier
6
+ * harness-testing API (`src/harness-test.ts`, `src/eval.ts`) works as a CI
7
+ * command, not just `node x.mjs`. Scripts may be authored in **JavaScript**
8
+ * (`.mjs` / `.cjs` / `.js`) **or TypeScript** (`.ts` / `.mts` / `.cts`) — a TS
9
+ * script is run through `tsx` when installed, else Node's built-in type
10
+ * stripping (Node >= 22.6). The scripts import from the built `dist/`, so they
11
+ * also run standalone — the CLI just discovers, runs, and aggregates exit codes.
12
+ */
13
+ import { type TestTier } from "../../source-kinds.js";
1
14
  import { type SurfaceProbe } from "../../check-count.js";
2
15
  /**
3
16
  * The outcome of running one script.
@@ -102,10 +115,13 @@ export declare function statusFor(code: number, checks: number | undefined, load
102
115
  * retracting silently with no type error.
103
116
  */
104
117
  export declare function loadFailed(r: ScriptRunResult): boolean;
105
- /** Filename extensions accepted for harness/eval scripts (JS and TS). */
118
+ /**
119
+ * Filename extensions accepted for harness/eval scripts (JS and TS). The table
120
+ * lives in `source-kinds.ts`; this re-export keeps the runner's name for it.
121
+ */
106
122
  export declare const SCRIPT_EXTS: readonly ["mjs", "cjs", "js", "mts", "cts", "ts"];
107
- /** Glob suffix matching every accepted script extension, e.g. `harness`. */
108
- export declare function scriptGlob(kind: "harness" | "eval"): string;
123
+ /** The glob that discovers one tier's scripts, derived from the same table as everything else that names a test. */
124
+ export declare function scriptGlob(kind: TestTier): string;
109
125
  export { detectNodeCaps, canRunTypeScript, type NodeCaps, } from "../../ts-runner-caps.js";
110
126
  import type { NodeCaps } from "../../ts-runner-caps.js";
111
127
  import { type GlobIgnore } from "../../core/glob-ignore.js";
@@ -22,6 +22,7 @@ exports.formatScriptSummary = formatScriptSummary;
22
22
  * stripping (Node >= 22.6). The scripts import from the built `dist/`, so they
23
23
  * also run standalone — the CLI just discovers, runs, and aggregates exit codes.
24
24
  */
25
+ const source_kinds_js_1 = require("../../source-kinds.js");
25
26
  const node_child_process_1 = require("node:child_process");
26
27
  const node_os_1 = require("node:os");
27
28
  const node_path_1 = require("node:path");
@@ -103,11 +104,14 @@ function statusFor(code, checks, load) {
103
104
  function loadFailed(r) {
104
105
  return r.status === "skip" && r.code !== exports.SKIP_EXIT_CODE;
105
106
  }
106
- /** Filename extensions accepted for harness/eval scripts (JS and TS). */
107
- exports.SCRIPT_EXTS = ["mjs", "cjs", "js", "mts", "cts", "ts"];
108
- /** Glob suffix matching every accepted script extension, e.g. `harness`. */
107
+ /**
108
+ * Filename extensions accepted for harness/eval scripts (JS and TS). The table
109
+ * lives in `source-kinds.ts`; this re-export keeps the runner's name for it.
110
+ */
111
+ exports.SCRIPT_EXTS = source_kinds_js_1.RUNNABLE_EXTS;
112
+ /** The glob that discovers one tier's scripts, derived from the same table as everything else that names a test. */
109
113
  function scriptGlob(kind) {
110
- return `**/*.${kind}.{${exports.SCRIPT_EXTS.join(",")}}`;
114
+ return (0, source_kinds_js_1.testGlob)(kind);
111
115
  }
112
116
  const TS_EXT = /\.(?:m|c)?ts$/;
113
117
  // The capability probe lives in `src/ts-runner-caps.ts` so the SUGGESTER
@@ -194,6 +198,10 @@ function discoverScripts(patterns, defaultGlob, cwd, ignore) {
194
198
  ignore: (0, glob_ignore_js_1.withIgnored)([], ignore),
195
199
  dot: true,
196
200
  nodir: true,
201
+ // `glob` defaults this to TRUE on macOS and Windows, which made
202
+ // `a.hook.HARNESS.mjs` a test there and nothing on Linux. The kind of a
203
+ // name is decided case-sensitively (`source-kinds.ts`); the glob agrees.
204
+ nocase: false,
197
205
  })) {
198
206
  found.add(m);
199
207
  }
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
@@ -5980,7 +5981,16 @@ async function refsHookCommand() {
5980
5981
  */
5981
5982
  async function compileProviders() {
5982
5983
  const names = [];
5983
- for (const file of (0, hook_install_js_1.discoverProviderFiles)(process.cwd())) {
5984
+ const { claimed, unclaimed } = (0, hook_install_js_1.discoverProviderFiles)(process.cwd());
5985
+ // A provider that lost (or never had) its marker is not silently skipped: a
5986
+ // hook's `provider()` ref to it would then fail to resolve with a message
5987
+ // about the REF, far from the cause.
5988
+ if (unclaimed.length > 0) {
5989
+ // Every one gets the ✗ (the catch adds the first): the others used to read
5990
+ // as a continuation of the first message.
5991
+ throw new hook_program_js_1.HookCompileError(unclaimed.map((f) => (0, hook_install_js_1.unclaimedReport)(f, "provider")).join("\n✗ "));
5992
+ }
5993
+ for (const file of claimed) {
5984
5994
  const def = await (0, hook_runtime_js_1.loadProvider)(file);
5985
5995
  if ((0, hook_providers_js_1.unsafeProvider)(def)) {
5986
5996
  throw new hook_program_js_1.HookCompileError(`provider ${file} ("${def.run}") is not provably read-only — ` +
@@ -6017,12 +6027,11 @@ async function installHookFile(file, adapter, registeredProviders = []) {
6017
6027
  // cannot load must block — the repo seized, every command refused including the repair.
6018
6028
  //
6019
6029
  // 🔴 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.
6030
+ // cache, thirteen times, on every tool call. The `|| exit N` ending is what
6031
+ // the shell does when that file cannot start at all. `compileHookProgram`
6032
+ // adds it (see `hookRuntimeMissingExit`), so it is not written here.
6024
6033
  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))}`,
6034
+ (0, hook_install_js_1.hookGateRef)(ref, adapter.layout.projectRootTokens),
6026
6035
  dialect: adapter.dialect,
6027
6036
  hookProtocol: adapter.hookProtocol,
6028
6037
  settings: adapter.layout.settings,
@@ -6117,8 +6126,8 @@ async function installHookFile(file, adapter, registeredProviders = []) {
6117
6126
  * resolved from the `--harness=` flag, else `.vigilesrc.json#harnesses`, else auto-detect.
6118
6127
  * Returns false if any hook failed to compile for any harness.
6119
6128
  */
6120
- async function installHooks(hookFiles, harnessFlag, configHarness) {
6121
- if (hookFiles.length === 0)
6129
+ async function installHooks(hookFiles, harnessFlag, configHarness, validateProvidersAnyway = false) {
6130
+ if (hookFiles.length === 0 && !validateProvidersAnyway)
6122
6131
  return true;
6123
6132
  const adapters = (0, adapter_registry_js_1.resolveHarnessAdapters)({
6124
6133
  root: process.cwd(),
@@ -6138,6 +6147,8 @@ async function installHooks(hookFiles, harnessFlag, configHarness) {
6138
6147
  }
6139
6148
  throw e;
6140
6149
  }
6150
+ if (hookFiles.length === 0)
6151
+ return true; // providers validated; nothing to wire
6141
6152
  let ok = true;
6142
6153
  for (const file of hookFiles) {
6143
6154
  try {
@@ -6171,6 +6182,7 @@ async function installHooks(hookFiles, harnessFlag, configHarness) {
6171
6182
  }
6172
6183
  }
6173
6184
  }
6185
+ (0, hook_check_install_js_1.installHookCheck)(adapters);
6174
6186
  return ok;
6175
6187
  }
6176
6188
  /**
@@ -6644,15 +6656,40 @@ async function main() {
6644
6656
  .filter((f) => f.endsWith(".spec.ts"))
6645
6657
  .map((f) => noteExplicitOverride(excludes, f, "compiling"))
6646
6658
  : findSpecs(excludes);
6647
- const hooks = restArgs.length > 0
6648
- ? restArgs.filter((f) => !f.endsWith(".spec.ts"))
6649
- : (0, hook_install_js_1.discoverHookFiles)(process.cwd());
6650
- if (specs.length === 0 && hooks.length === 0) {
6659
+ // WHAT A HOOK IS is decided once, in `source-kinds.ts`: a bare `compile`
6660
+ // and `compile <file>` ask the same classifier, so they cannot disagree.
6661
+ // Only `.hook.` files are compiled; a runnable file that carries no marker
6662
+ // is refused below, out loud, with the command that fixes it.
6663
+ const found = restArgs.length > 0
6664
+ ? (0, hook_install_js_1.partitionHookArgs)(restArgs.filter((f) => !f.endsWith(".spec.ts")))
6665
+ : (0, hook_install_js_1.discoveredHookArgs)(process.cwd());
6666
+ const hooks = [...found.hooks];
6667
+ const unclaimed = found.unclaimed;
6668
+ if (specs.length === 0 &&
6669
+ hooks.length === 0 &&
6670
+ unclaimed.length === 0 &&
6671
+ found.skipped.length === 0 &&
6672
+ found.invalid.length === 0) {
6651
6673
  console.log("No .spec.ts or .vigiles/hooks/ hook files found.");
6652
6674
  console.log("Run `vigiles init` to create one.");
6653
6675
  process.exit(0);
6654
6676
  }
6655
6677
  let valid = true;
6678
+ // Refused files FIRST and on stderr: they are the news in this run, and a
6679
+ // line that scrolled past under forty `✓` lines is a silent skip with
6680
+ // extra steps. Compiling the rest still proceeds — one stray file must
6681
+ // not stop the hooks that are fine.
6682
+ for (const f of unclaimed) {
6683
+ console.error(`✗ ${(0, hook_install_js_1.unclaimedReport)(f, "hook")}`);
6684
+ valid = false;
6685
+ }
6686
+ for (const bad of found.invalid) {
6687
+ console.error(`✗ ${(0, hook_install_js_1.invalidArgMessage)(bad.path, bad.reason)}`);
6688
+ valid = false;
6689
+ }
6690
+ for (const s of found.skipped) {
6691
+ console.log(`- ${s.path} skipped: a ${s.kind}, not a hook.`);
6692
+ }
6656
6693
  if (specs.length > 0)
6657
6694
  valid =
6658
6695
  (await compile(specs, config, excludes, { harnessFlag })) && valid;
@@ -6669,13 +6706,21 @@ async function main() {
6669
6706
  console.log(`\n${weightLine}`);
6670
6707
  }
6671
6708
  valid =
6672
- (await installHooks(hooks, harnessFlag, (0, adapter_registry_js_1.declaredHarnessNames)(config.harnesses))) && valid;
6709
+ (await installHooks(hooks, harnessFlag, (0, adapter_registry_js_1.declaredHarnessNames)(config.harnesses),
6710
+ // A hook-side compile is under way even when every hook was refused:
6711
+ // validate the providers too, so ONE run shows every problem.
6712
+ unclaimed.length > 0)) && valid;
6673
6713
  // Keep an existing whole-harness registry in sync (cheap, opt-in) so the
6674
6714
  // user never hand-runs `generate-harness`. Skipped when no harness.gen.ts.
6675
6715
  if (specs.length > 0)
6676
6716
  valid = (await refreshHarnessGenIfPresent(harnessFlag)) && valid;
6677
6717
  console.log("");
6678
- if (valid) {
6718
+ if (valid && specs.length === 0 && hooks.length === 0) {
6719
+ // Skipped paths are not errors, but a run that compiled nothing must not
6720
+ // read like one that did.
6721
+ console.log("Nothing was compiled: every path given was skipped.");
6722
+ }
6723
+ else if (valid) {
6679
6724
  console.log("Compilation complete.");
6680
6725
  }
6681
6726
  else {
@@ -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;
@@ -513,7 +515,7 @@ function commandView(raw, root) {
513
515
  // uses, and for the same reason. This matcher read only `leaves` (raw), so
514
516
  // three ordinary rewrites walked past a guard that blocks the plain form.
515
517
  // MEASURED 2026-09-02 on the shipped dogfood artifact behind the public
516
- // 7/7 claim (`examples/harness/safe-bash-guard.mjs`):
518
+ // 7/7 claim (`examples/harness/safe-bash-guard.hook.mjs`):
517
519
  // git push --force origin main exit 2 blocked
518
520
  // git push "--force" origin main exit 0 ALLOWED
519
521
  // sudo git push --force origin main exit 0 ALLOWED
@@ -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
@@ -54,7 +54,7 @@ export declare const provide: <const Name extends string>(name: Name, run: strin
54
54
  export declare const dangerously: <const Name extends string>(name: Name, run: string) => InlineProvider<Name>;
55
55
  /**
56
56
  * A REGISTERED provider — a reusable, named fact authored once in
57
- * `.vigiles/providers/<name>.{mjs,ts}` (default-exported via {@link defineProvider})
57
+ * `.vigiles/providers/<name>.provider.{mjs,ts}` (default-exported via {@link defineProvider})
58
58
  * and referenced from many hooks by {@link provider} name. The reusable sibling of
59
59
  * the one-off inline {@link provide}; same read-only-by-default rule.
60
60
  */
@@ -64,9 +64,7 @@ export interface PreparedTest {
64
64
  export declare function isColocatedTest(surface: Pick<CoverableSurface, "name" | "path">, testPath: string): boolean;
65
65
  /** Wrap a discovered path. Kept as a function so both twins share one shape. */
66
66
  export declare function prepareTest(path: string): PreparedTest;
67
- /** @param filename a BASENAME — callers strip the directory with their own path
68
- * helper (`node:path` on disk, `posix-path` in the browser twin). */
69
- export declare function isEvalScript(filename: string): boolean;
67
+ export { isEvalScript } from "./source-kinds.js";
70
68
  /**
71
69
  * The root variables a hook command may be written against — the structural
72
70
  * slice of `PluginLayout` this needs, taken by SHAPE so the browser twin does
@@ -1,10 +1,9 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.SURFACE_TOKEN = void 0;
3
+ exports.SURFACE_TOKEN = exports.isEvalScript = void 0;
4
4
  exports.strongerEvidence = strongerEvidence;
5
5
  exports.isColocatedTest = isColocatedTest;
6
6
  exports.prepareTest = prepareTest;
7
- exports.isEvalScript = isEvalScript;
8
7
  exports.hookScriptRefs = hookScriptRefs;
9
8
  exports.evidenceFor = evidenceFor;
10
9
  exports.hasSurfaceToken = hasSurfaceToken;
@@ -185,39 +184,12 @@ function prepareTest(path) {
185
184
  * mode here is under-counting, not accusation.
186
185
  */
187
186
  const SCRIPT_RE = (0, source_refs_js_1.scriptRefPattern)();
188
- /**
189
- * Is this filename one the PAID real-model runner would actually run?
190
- *
191
- * The tier split decides which of two runners owns a test file, and it is the
192
- * only thing standing between a scheduled paid run and a per-push one. It has
193
- * now been wrong in both directions:
194
- *
195
- * - As the full suffix `.eval.mjs` it was a MONEY HAZARD — `foo.eval.ts` fell
196
- * into the free branch and would have spent real model calls on every push.
197
- * - As the bare INFIX `.eval.` it made a FALSE GRANT — `parser.eval.test.ts`,
198
- * an ordinary deterministic test discovered by an `include` of
199
- * `**\/*.test.ts`, was credited to the paid tier and dropped from the free
200
- * one. `vigiles eval` globs `**\/*.eval.{mjs,cjs,js,mts,cts,ts}`, so that name
201
- * is not discoverable by the eval runner at all: the surface was reported
202
- * covered by the tier that cannot run it, and uncovered by the tier that does.
203
- *
204
- * The rule that is wrong in neither direction is the one the runner itself uses:
205
- * `.eval.` followed by a runnable extension AT THE END of the name. Anything
206
- * else — including a name that merely contains `.eval.` — is deterministic, or
207
- * is not a script at all.
208
- *
209
- * ⚠️ The extension list is RE-DECLARED, not imported: the authority is
210
- * `SCRIPT_EXTS` in adapters/claude-code/run-scripts.ts, which pulls in
211
- * `node:child_process` and `glob` and so cannot be imported by this
212
- * browser-safe module. A test in test-coverage.test.ts asserts the two agree,
213
- * so an extension added to the runner cannot silently fail to be billed here.
214
- */
215
- const EVAL_SCRIPT_RE = /\.eval\.(?:mjs|cjs|js|mts|cts|ts)$/;
216
- /** @param filename a BASENAME — callers strip the directory with their own path
217
- * helper (`node:path` on disk, `posix-path` in the browser twin). */
218
- function isEvalScript(filename) {
219
- return EVAL_SCRIPT_RE.test(filename);
220
- }
187
+ // Which runner owns a test file — the paid real-model one or the free one — is
188
+ // answered by the shared source-kind table (`source-kinds.ts`), which this
189
+ // browser-safe module can import because it imports nothing. See `isEvalScript`
190
+ // there for the two ways this question has been answered wrongly before.
191
+ var source_kinds_js_1 = require("./source-kinds.js");
192
+ Object.defineProperty(exports, "isEvalScript", { enumerable: true, get: function () { return source_kinds_js_1.isEvalScript; } });
221
193
  /**
222
194
  * The hook-script paths a manifest's `hooks` block names, repo-relative.
223
195
  *
@@ -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`
@@ -60,6 +60,7 @@ exports.defineEval = defineEval;
60
60
  * never executes, so it is untouched.
61
61
  */
62
62
  const node_path_1 = require("node:path");
63
+ const source_kinds_js_1 = require("./source-kinds.js");
63
64
  /**
64
65
  * Brand marking a value as built by {@link defineEval}. A registered symbol, so
65
66
  * a descriptor still reads as one across two copies of the package on disk —
@@ -128,8 +129,6 @@ function moduleDefault(mod) {
128
129
  const inner = outer?.default;
129
130
  return isEvalDefinition(inner) ? inner : outer;
130
131
  }
131
- /** Filenames vigiles runs as evals — the runner's own glob, as a pattern. */
132
- const EVAL_FILE = /\.eval\.(?:m|c)?[jt]s$/;
133
132
  /**
134
133
  * Was node pointed STRAIGHT at an eval file? `argv1` is `process.argv[1]`: the
135
134
  * path node was started with, which no stray configuration can forge.
@@ -142,7 +141,8 @@ const EVAL_FILE = /\.eval\.(?:m|c)?[jt]s$/;
142
141
  * Pure: a fact in, a boolean out.
143
142
  */
144
143
  function ranAsEntry(argv1) {
145
- return argv1 !== undefined && EVAL_FILE.test(argv1.replaceAll("\\", "/"));
144
+ return (argv1 !== undefined &&
145
+ (0, source_kinds_js_1.isEvalScript)(argv1.replaceAll("\\", "/").split("/").pop() ?? ""));
146
146
  }
147
147
  /** The words shown when someone runs an eval file directly. Asserted by a test:
148
148
  * a refusal that stops a run without saying what to do instead is a ticket. */
package/dist/eval-lock.js CHANGED
@@ -58,6 +58,7 @@ const node_fs_1 = require("node:fs");
58
58
  const node_path_1 = require("node:path");
59
59
  const hash_js_1 = require("./core/hash.js");
60
60
  const eval_cache_js_1 = require("./eval-cache.js");
61
+ const source_kinds_js_1 = require("./source-kinds.js");
61
62
  /**
62
63
  * On-disk lock-format version, salted into nothing (the lock is keyed by name,
63
64
  * not by hash) but VALIDATED on read so an incompatible shape fails loud rather
@@ -162,7 +163,7 @@ function isEvalInputFile(path) {
162
163
  const p = path.replace(/\\/g, "/");
163
164
  if (/(^|\/)SKILL\.md$/.test(p))
164
165
  return true;
165
- return /\.eval\.(mjs|cjs|js|mts|cts|ts)$/.test(p);
166
+ return (0, source_kinds_js_1.isEvalScript)(p.split("/").pop() ?? "");
166
167
  }
167
168
  /**
168
169
  * The NON-BLOCKING nudge to emit after an eval-input edit when committed locks
@@ -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