vigiles 15.0.2 → 15.0.3

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.
Files changed (82) hide show
  1. package/dist/adapters/claude-code/layout.js +3 -0
  2. package/dist/adapters/claude-code/run-scripts.d.ts +21 -9
  3. package/dist/adapters/claude-code/run-scripts.js +33 -22
  4. package/dist/adapters/codex/driver.js +3 -1
  5. package/dist/adapters/codex/eval.js +5 -0
  6. package/dist/audit-score.js +32 -6
  7. package/dist/check-count.d.ts +62 -3
  8. package/dist/check-count.js +123 -5
  9. package/dist/check.d.ts +55 -0
  10. package/dist/check.js +91 -0
  11. package/dist/cli.js +456 -54
  12. package/dist/core/bash-effects.d.ts +41 -0
  13. package/dist/core/bash-effects.js +278 -0
  14. package/dist/core/foreign-runner.d.ts +185 -0
  15. package/dist/core/foreign-runner.js +228 -0
  16. package/dist/core/harness-driver.d.ts +18 -1
  17. package/dist/core/hook-program.d.ts +319 -20
  18. package/dist/core/hook-program.js +743 -49
  19. package/dist/core/layout.d.ts +13 -0
  20. package/dist/core/lethal-trifecta.d.ts +93 -0
  21. package/dist/core/lethal-trifecta.js +409 -65
  22. package/dist/core/markdown.d.ts +32 -0
  23. package/dist/core/markdown.js +36 -0
  24. package/dist/core/merge-conflict.d.ts +50 -0
  25. package/dist/core/merge-conflict.js +84 -0
  26. package/dist/core/test-file-ext.d.ts +80 -0
  27. package/dist/core/test-file-ext.js +90 -0
  28. package/dist/core/types.d.ts +11 -0
  29. package/dist/coverage-artifact.d.ts +383 -0
  30. package/dist/coverage-artifact.js +586 -0
  31. package/dist/coverage-evidence.d.ts +87 -79
  32. package/dist/coverage-evidence.js +212 -211
  33. package/dist/coverage-probe.d.ts +125 -0
  34. package/dist/coverage-probe.js +700 -0
  35. package/dist/doc-commands.d.ts +119 -0
  36. package/dist/doc-commands.js +158 -0
  37. package/dist/eval-lock.d.ts +9 -0
  38. package/dist/eval-lock.js +14 -2
  39. package/dist/eval.js +28 -0
  40. package/dist/fs-walk.d.ts +80 -0
  41. package/dist/fs-walk.js +148 -0
  42. package/dist/harness-assert.js +42 -3
  43. package/dist/harness-test.js +31 -2
  44. package/dist/judge.js +4 -0
  45. package/dist/leaderboard.d.ts +16 -1
  46. package/dist/leaderboard.js +19 -2
  47. package/dist/load-hook.js +6 -1
  48. package/dist/mock-model.d.ts +58 -4
  49. package/dist/mock-model.js +133 -5
  50. package/dist/observe.d.ts +1 -1
  51. package/dist/observe.js +44 -1
  52. package/dist/plugin-loader.js +52 -13
  53. package/dist/run-hook.d.ts +58 -0
  54. package/dist/run-hook.js +70 -0
  55. package/dist/run-script.js +75 -0
  56. package/dist/scaffold-test.d.ts +3 -0
  57. package/dist/scaffold-test.js +10 -5
  58. package/dist/scan-behavioral.js +4 -0
  59. package/dist/scan-core.d.ts +22 -0
  60. package/dist/scan-core.js +46 -14
  61. package/dist/scan-files.js +19 -2
  62. package/dist/scan.d.ts +22 -5
  63. package/dist/scan.js +67 -13
  64. package/dist/skill-contract.d.ts +46 -0
  65. package/dist/skill-contract.js +201 -0
  66. package/dist/skill-refs.d.ts +67 -0
  67. package/dist/skill-refs.js +117 -0
  68. package/dist/test-coverage-files.d.ts +7 -1
  69. package/dist/test-coverage-files.js +66 -55
  70. package/dist/test-coverage.d.ts +155 -19
  71. package/dist/test-coverage.js +430 -91
  72. package/dist/testing.d.ts +8 -2
  73. package/dist/testing.js +19 -1
  74. package/dist/trigger-containment.d.ts +85 -0
  75. package/dist/trigger-containment.js +125 -0
  76. package/dist/ts-runner-caps.d.ts +16 -0
  77. package/dist/ts-runner-caps.js +43 -0
  78. package/dist/unit.d.ts +2 -2
  79. package/dist/unit.js +2 -1
  80. package/hooks/eval-lock-nudge.sh +14 -6
  81. package/package.json +2 -2
  82. package/skills/test-harness/SKILL.md +68 -2
@@ -0,0 +1,228 @@
1
+ "use strict";
2
+ /**
3
+ * Refuse to spend model calls when somebody else's test runner owns this process.
4
+ *
5
+ * ## The failure this removes
6
+ *
7
+ * A vigiles eval spawns `claude` and spends real money. It is meant to run on a
8
+ * schedule, from `vigiles eval`. But an author who names one `foo.test.mjs` — a
9
+ * name vigiles used to CREDIT as coverage — hands it to every default `npx vitest`
10
+ * / `npx jest` run in the repository, because that name matches their default
11
+ * patterns exactly (read out of the installed packages):
12
+ *
13
+ * vitest ** /*.{test,spec}.?(c|m)[jt]s?(x)
14
+ * jest ** /?(*.)+(spec|test).?([mc])[jt]s?(x)
15
+ * ** /__tests__/** /*.?([mc])[jt]s?(x) ← everything in that directory
16
+ *
17
+ * Measured 2026-08-11: a bare project with `.claude/skills/foo/foo.test.mjs` and a
18
+ * plain `npx vitest run` — it executed. "It lives under a dot-directory, nothing
19
+ * else will find it" is false. The bill arrives silently, on every push, in CI.
20
+ *
21
+ * The naming half is fixed elsewhere (those globs no longer count). This is the
22
+ * part that does not depend on anybody obeying a convention: the money cannot be
23
+ * spent from inside a foreign runner, whatever the file is called.
24
+ *
25
+ * ## 🪦 THE STATIC HALF WAS DELETED 2026-08-12, AND THIS IS NOW THE WHOLE DEFENCE
26
+ *
27
+ * There used to be a companion, `core/foreign-runner-tests.ts`: a lexical gate
28
+ * that read every file under a harness surface dir, decided FROM THE TEXT whether
29
+ * it drives an agent (`runEval(`, `runHarnessTest(`, …), and warned in the audit
30
+ * report when a default vitest/jest glob would collect it. It is gone, with its
31
+ * test file and the disk walker in `scan.ts` that fed it.
32
+ *
33
+ * It was deleted under a rule that file pre-registered, not on a whim:
34
+ *
35
+ * > A seventh false positive is not another bug to fix — it is the measurement
36
+ * > saying the lexical rule cannot be made tight enough, and the answer then is
37
+ * > to DELETE the gate and let the money guard be the whole defence.
38
+ *
39
+ * THE SEVENTH ARRIVED: `function testDriver(runEval) { runEval(fake); }`. The
40
+ * binding check looked at declarations and import aliases but never at PARAMETER
41
+ * lists, so an ordinary offline unit test injecting a fake was reported as
42
+ * spawning a real agent. Run, not argued — the gate over one file of that shape
43
+ * returned one finding, whose warning read *"It calls `runEval`, which spawns an
44
+ * agent"* and told the author to rename the file. That sentence was false about
45
+ * that file.
46
+ *
47
+ * Six shapes had already been narrowed away one at a time, and this one had been
48
+ * SELF-DISCLOSED in the file as a known residual ("costs one warning and needs a
49
+ * scope analysis this module cannot have"). Pre-disclosure is not a defence: a
50
+ * pre-registered stopping rule exists precisely to stop "we know, it is
51
+ * acceptable" from running forever, so the count was honoured rather than
52
+ * reasoned around.
53
+ *
54
+ * The measurement agreed independently. Across three corpora (this repo, a
55
+ * 43-harness consumer repo, five vendored third-party plugin repos — 3 613 js/ts
56
+ * files), and re-measured at deletion time over this repo's 941 files:
57
+ *
58
+ * true positives, ever 0
59
+ * distinct false-positive shapes reported 7
60
+ *
61
+ * Its only demonstrated effect on a real repository was an effect of NOT firing.
62
+ * Every error it made was the expensive kind: the remedy it printed is "rename
63
+ * this file", so a false warning costs someone a working test, while a missed
64
+ * warning costs nothing — the refusal below already fires at all four spawn doors
65
+ * (`judge.ts`, `eval.ts`, `scan-behavioral.ts`, `adapters/codex/eval.ts`), so a
66
+ * miss is at most a less friendly notice, never a bill.
67
+ *
68
+ * ⚠️ WHAT IS GENUINELY LOST: the early, report-time heads-up. An author who names
69
+ * a harness test `foo.test.mjs` now learns it at the refusal below rather than in
70
+ * the audit. That is the trade, made knowingly — an advisory that has never once
71
+ * been right is not worth a warning that has been wrong seven times.
72
+ *
73
+ * The removal is PINNED, not merely done: `scan-vendor.test.ts` still asserts that
74
+ * no `COLLECTS AND EXECUTES` warning appears on the vendored corpus, so bringing
75
+ * the gate back fails a test instead of passing silently.
76
+ *
77
+ * ## Why not an environment variable
78
+ *
79
+ * The obvious version is `process.env.VITEST`. It was proposed and rejected: that
80
+ * variable can sit in a `.env` or in a CI environment for unrelated reasons, and
81
+ * then a legitimate scheduled eval refuses to run — a guard that fires on correct
82
+ * input is a guard people delete.
83
+ *
84
+ * `process.argv[1]` is the path node was actually started with, which no stray
85
+ * configuration can forge. Measured, same spike:
86
+ *
87
+ * under `npx vitest run` → …/node_modules/vitest/dist/workers/forks.js
88
+ * under `node foo.eval.mjs`→ …/foo.eval.mjs
89
+ * under `vigiles eval` → the script vigiles spawned
90
+ *
91
+ * So the check is a POSITIVE identification of a known runner, not "am I the entry
92
+ * point". Positive is the conservative direction here: an unrecognised wrapper
93
+ * runs (and may cost money) rather than a legitimate run being blocked by a
94
+ * pattern nobody predicted.
95
+ *
96
+ * ## 🔴 `node --test` HAS NO argv SIGNAL AT ALL, and the line that claimed one
97
+ * was dead from the day it was written
98
+ *
99
+ * `["node --test", ["node_modules/.bin/node--test"]]` used to sit in the table
100
+ * below. It was added by analogy with the npx-installed runners and never
101
+ * measured. There is no such binary: Node's test runner is a FLAG on node
102
+ * itself, so nothing under `node_modules/.bin/` can appear in `argv[1]`, and the
103
+ * entry could not fire under any invocation. That is worse than its absence — it
104
+ * READ as coverage while the paid tier stayed reachable from a legacy harness
105
+ * named `*.test.mjs`, a name Node's own default patterns collect.
106
+ *
107
+ * Measured 2026-08-12 (Node 22.22, a fixture printing its own process facts):
108
+ *
109
+ * node --test foo.test.mjs
110
+ * argv[1] = /abs/foo.test.mjs ← the TEST FILE, no runner in sight
111
+ * execArgv = [] ← the flag is not here either
112
+ * NODE_TEST_CONTEXT = child-v8 ← the only signal
113
+ *
114
+ * node --test --experimental-test-isolation=none foo.test.mjs
115
+ * argv[1] = foo.test.mjs
116
+ * execArgv = ["--test", "--experimental-test-isolation=none"]
117
+ * NODE_TEST_CONTEXT = undefined ← in-process: no child, no var
118
+ *
119
+ * The two modes therefore need two different facts, and both are read here.
120
+ *
121
+ * ⚠️ THIS IS NOT THE `process.env.VITEST` IDIOM REJECTED ABOVE, and the
122
+ * difference is the reason it is allowed. `VITEST` is a USER-VISIBLE CONVENTION:
123
+ * a person can put it in a `.env` or a CI job for unrelated reasons, so a guard
124
+ * reading it fires on correct input. `NODE_TEST_CONTEXT` is set by NODE ITSELF in
125
+ * the child it spawns — the internal protocol between runner and test process,
126
+ * which nobody writes into their own environment. Same for `execArgv`: the flag
127
+ * list node was launched with (`--test` is explicitly refused inside
128
+ * `NODE_OPTIONS` — verified — so it cannot arrive from a stray env either). Do
129
+ * not "fix" this back into an argv fragment: for `node --test` an argv signal
130
+ * does not exist, so the choice is these facts or no detection at all.
131
+ *
132
+ * Pure: facts in, a name or null out. No process access, no filesystem.
133
+ */
134
+ Object.defineProperty(exports, "__esModule", { value: true });
135
+ exports.foreignRunner = foreignRunner;
136
+ exports.foreignRunnerRefusal = foreignRunnerRefusal;
137
+ exports.refuseUnderForeignRunner = refuseUnderForeignRunner;
138
+ /** Path fragments that identify a runner owning the process. POSIX-normalised.
139
+ * Every fragment was MEASURED from a real run of that runner — see the tests. */
140
+ const RUNNERS = [
141
+ ["vitest", ["node_modules/vitest/", "node_modules/.bin/vitest"]],
142
+ [
143
+ "jest",
144
+ [
145
+ "node_modules/jest/",
146
+ "node_modules/jest-cli/",
147
+ "node_modules/jest-worker/",
148
+ "node_modules/.bin/jest",
149
+ ],
150
+ ],
151
+ ["mocha", ["node_modules/mocha/", "node_modules/.bin/mocha"]],
152
+ ["ava", ["node_modules/ava/", "node_modules/.bin/ava"]],
153
+ ];
154
+ /** The name reported for Node's built-in runner — also the command to blame. */
155
+ const NODE_TEST = "node --test";
156
+ /**
157
+ * The foreign test runner that started this process, or `null`.
158
+ *
159
+ * `argv1` is `process.argv[1]`; `node` carries the two facts that identify Node's
160
+ * built-in runner (see the header). Both are passed in rather than read, so the
161
+ * decision is a pure function the tests drive with values measured from real runs.
162
+ */
163
+ function foreignRunner(argv1, node = {}) {
164
+ // `--test` EXACTLY: `--test-only` / `--test-name-pattern` are ordinary flags a
165
+ // plain `node foo.eval.mjs` may carry, and a prefix match would refuse it.
166
+ if ((node.nodeTestContext ?? "") !== "" ||
167
+ (node.execArgv ?? []).includes("--test"))
168
+ return NODE_TEST;
169
+ if (!argv1)
170
+ return null;
171
+ const p = argv1.replaceAll("\\", "/");
172
+ for (const [name, fragments] of RUNNERS)
173
+ if (fragments.some((f) => p.includes(f)))
174
+ return name;
175
+ return null;
176
+ }
177
+ /**
178
+ * The refusal message. Separate from the check so a test can assert the WORDS —
179
+ * a guard that stops a run without saying what to do instead is a support ticket.
180
+ */
181
+ function foreignRunnerRefusal(runner, what) {
182
+ return (`vigiles refused to spawn a model: this process is running under ${runner}.\n` +
183
+ ` ${what} spends real model calls, and a general test runner picking it up ` +
184
+ `would spend them on every push.\n` +
185
+ ` If this file is a vigiles eval, run it with \`vigiles eval\` (or \`node <file>\`) ` +
186
+ `and name it \`<surface>.eval.mjs\` — that name is outside ${runner}'s default patterns.\n` +
187
+ ` If ${runner} is meant to run it, it must not call the real-model tier.`);
188
+ }
189
+ /**
190
+ * Refuse to spend model budget when a foreign test runner started this process.
191
+ *
192
+ * 🔴 CALL THIS AT EVERY REAL-MODEL SPAWN. It used to live privately in `eval.ts`
193
+ * and be called from exactly one place — `spawnAgent`, the real `claude` runner —
194
+ * on the reasoning that the composition root funnels every paid path. Measured
195
+ * 2026-08-12: it funnels ONE of four.
196
+ *
197
+ * src/eval.ts spawn(claudeCodeRuntime.agentBinary) guarded
198
+ * src/adapters/codex/eval.ts spawnSync("codex", …) UNGUARDED
199
+ * src/judge.ts spawnSync("claude", …) UNGUARDED
200
+ * src/scan-behavioral.ts spawnSync("claude", …) UNGUARDED
201
+ *
202
+ * `measureTriggerRate(spec, { evalDriver })` calls the injected driver's runner
203
+ * DIRECTLY, so a Codex eval collected by a stray `npx vitest run` spent real
204
+ * money on every push while the guard looked complete. The irony is worth
205
+ * recording: the untested-skill nudge now tells Codex users to pass
206
+ * `{ evalDriver }` — the product recommends the shape that bypassed the guard.
207
+ *
208
+ * Lives here, in a module that imports NOTHING, so any adapter can call it
209
+ * without a cycle. `foreignRunner` stays pure; this is the one impure wrapper,
210
+ * and it is impure precisely so callers cannot forget to pass the facts.
211
+ *
212
+ * ⚠️ CALL IT OUTSIDE ANY `try` THAT SWALLOWS. `judge` and `deriveAttackReal` both
213
+ * wrap their spawn in `try { … } catch { return fallback }`, so a refusal thrown
214
+ * inside would be caught and downgraded to a score of 0 / a canned string — a
215
+ * silent wrong answer instead of a stop. Both call it before the `try`.
216
+ */
217
+ function refuseUnderForeignRunner(what) {
218
+ // `node --test` is INVISIBLE in argv (it is a flag on node, not an installed
219
+ // binary), so the two facts that do identify it are read here — the one impure
220
+ // place — and handed to the pure decision above.
221
+ const runner = foreignRunner(process.argv[1], {
222
+ execArgv: process.execArgv,
223
+ nodeTestContext: process.env["NODE_TEST_CONTEXT"],
224
+ });
225
+ if (runner !== null)
226
+ throw new Error(foreignRunnerRefusal(runner, what));
227
+ }
228
+ //# sourceMappingURL=foreign-runner.js.map
@@ -44,6 +44,16 @@ export interface ModelRequest {
44
44
  readonly role: string;
45
45
  readonly text: string;
46
46
  }[];
47
+ /**
48
+ * `true` when the agent CLI made this call for its OWN bookkeeping rather than
49
+ * to advance the agent loop — a SIDE-CHANNEL call.
50
+ *
51
+ * MEASURED on Claude Code 2.1.228: a `post_turn_summary` classifier (it decides
52
+ * whether to push the user a phone notification) runs on every turn, on the
53
+ * user's own credential, and it declares NO tools. The mock does not let such a
54
+ * request consume a script entry — see `isMainLoopRequest` in `mock-model.ts`.
55
+ */
56
+ readonly sideChannel?: boolean;
47
57
  }
48
58
  /** A tool the agent invoked, paired with its result (transcript mode only). */
49
59
  export interface ToolCall {
@@ -77,8 +87,15 @@ export interface HarnessMockHandle {
77
87
  readonly url: string;
78
88
  /** Every request the mock received, flattened for assertions, in order. */
79
89
  readonly requests: readonly ModelRequest[];
80
- /** Number of model turns served so far. */
90
+ /**
91
+ * Number of SCRIPT turns served so far — i.e. main-loop requests. A
92
+ * side-channel request ({@link ModelRequest.sideChannel}) is answered without
93
+ * touching the script and is NOT counted here, so this stays the agent's turn
94
+ * count rather than the CLI's HTTP-call count.
95
+ */
81
96
  readonly count: number;
97
+ /** Side-channel requests answered so far (never script-consuming). */
98
+ readonly sideChannelCount?: number;
82
99
  /** Stop the mock server. */
83
100
  close(): void | Promise<void>;
84
101
  }