vigiles 34.0.0 → 35.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.
@@ -75,7 +75,8 @@ async function startCodexMockHandle(script) {
75
75
  }));
76
76
  },
77
77
  get count() {
78
- return mock.requests.length;
78
+ // Turns SERVED: a request past the end of the script gets a 400.
79
+ return Math.min(mock.requests.length, script.length);
79
80
  },
80
81
  close: () => mock.close(),
81
82
  };
@@ -42,8 +42,9 @@ export declare function parseResponsesRequest(body: string): {
42
42
  };
43
43
  /**
44
44
  * Start the scripted Codex Responses mock on a free port. Each
45
- * `POST /v1/responses` consumes the next scripted turn (the last turn repeats if
46
- * codex asks for more); HEAD and any other path get `{}`. Resolves to a handle
45
+ * `POST /v1/responses` consumes the next scripted turn; one past the end of the
46
+ * script is a 400, never a copy of the last turn (#340); HEAD and any other path
47
+ * get `{}`. Resolves to a handle
47
48
  * with the base `url`, the `port`, the recorded `requests`, and `close()`.
48
49
  */
49
50
  export declare function startCodexMock(script: readonly CodexTurn[], opts?: {
@@ -22,6 +22,7 @@ exports.startCodexMock = startCodexMock;
22
22
  * `src/adapters/codex/runtime.ts` (`codexMockArgs`/`codexMockEnv`).
23
23
  */
24
24
  const node_http_1 = __importDefault(require("node:http"));
25
+ const script_overrun_js_1 = require("../../core/script-overrun.js");
25
26
  const MSG_ID = "msg_mock_1";
26
27
  const RESP_ID = "resp_mock_1";
27
28
  /**
@@ -183,8 +184,9 @@ function parseResponsesRequest(body) {
183
184
  }
184
185
  /**
185
186
  * Start the scripted Codex Responses mock on a free port. Each
186
- * `POST /v1/responses` consumes the next scripted turn (the last turn repeats if
187
- * codex asks for more); HEAD and any other path get `{}`. Resolves to a handle
187
+ * `POST /v1/responses` consumes the next scripted turn; one past the end of the
188
+ * script is a 400, never a copy of the last turn (#340); HEAD and any other path
189
+ * get `{}`. Resolves to a handle
188
190
  * with the base `url`, the `port`, the recorded `requests`, and `close()`.
189
191
  */
190
192
  function startCodexMock(script, opts = {}) {
@@ -204,7 +206,17 @@ function startCodexMock(script, opts = {}) {
204
206
  const parsed = parseResponsesRequest(body);
205
207
  requests.push(parsed);
206
208
  opts.onRequest?.(parsed);
207
- const turn = script[Math.min(i, script.length - 1)] ?? { text: "" };
209
+ const turn = script[i];
210
+ if (turn === undefined) {
211
+ const message = (0, script_overrun_js_1.scriptOverrunMessage)({
212
+ scripted: script.length,
213
+ request: script.length + 1,
214
+ lastMessage: parsed.prompt,
215
+ });
216
+ res.writeHead(400, { "content-type": "application/json" });
217
+ res.end(JSON.stringify({ error: { type: "invalid_request_error", message } }));
218
+ return;
219
+ }
208
220
  i++;
209
221
  const model = parsed.model || "gpt-5-codex";
210
222
  res.writeHead(200, {
package/dist/cli-main.js CHANGED
@@ -2671,17 +2671,21 @@ function vigilesWorkflow(plan, harnesses, hasPackageJson) {
2671
2671
  eval-check:
2672
2672
  # Eval staleness gate — real-model evals run LOCALLY on your subscription
2673
2673
  # (\`npx vigiles eval --update\`, which commits a lock); this job VERIFIES those
2674
- # committed results against the current inputs with NO model call. It stays a
2675
- # green no-op until you commit your first lock. See docs/harness-testing.md.
2674
+ # committed results against the current inputs with NO model call.
2675
+ # \`--min=0\` is deliberate: a repo with no eval file yet matches nothing, and
2676
+ # \`vigiles eval --check\` fails on an empty match by default (a gate that
2677
+ # verified nothing must not read as a pass). With \`--min=0\` this job is green
2678
+ # until the first \`*.eval.*\` file exists; from then on it is red until that
2679
+ # eval's lock is committed. Raise the floor (\`--min=N\`) once you want
2680
+ # a vanished eval file to fail it. See docs/harness-testing.md.
2676
2681
  runs-on: ubuntu-latest
2677
2682
  steps:
2678
2683
  - uses: actions/checkout@v4
2679
2684
  - uses: actions/setup-node@v4
2680
2685
  with:
2681
2686
  node-version: "20"
2682
- - uses: zernie/vigiles@v1
2683
- with:
2684
- command: eval-check
2687
+ - run: npm install
2688
+ - run: npx vigiles eval --check --min=0
2685
2689
  `
2686
2690
  : "";
2687
2691
  // No selectable jobs (e.g. `--no-lint --no-test`, or `--test` on a repo with no
@@ -5195,10 +5199,11 @@ async function promptYesNo(question) {
5195
5199
  * Resolve the eval LOCK env from the `eval` flags. `--update` records each named
5196
5200
  * eval's report to a committed `.vigiles/eval-locks/<name>.lock.json` (run locally
5197
5201
  * on your subscription); `--check` (CI) verifies the committed result against the
5198
- * current inputs WITHOUT a model call. `--check` is a green NO-OP until the first
5199
- * lock is committed (smooth adoption). Returns the env to thread, or `"skip"` to
5200
- * exit green now. `--check`+`--update` together is a usage error (exit 2). The
5201
- * behavior epoch comes from `.vigilesrc.json` `eval.apiVersion` (committed).
5202
+ * current inputs WITHOUT a model call. `--check` with no lock committed at all is
5203
+ * a FAILURE, not a green no-op ({@link refuseEvalCheckWithoutLocks}, #197): a gate
5204
+ * that has verified nothing must not read as one that passed.
5205
+ * `--check`+`--update` together is a usage error (exit 2). The behavior epoch
5206
+ * comes from `.vigilesrc.json` `eval.apiVersion` (committed).
5202
5207
  */
5203
5208
  function resolveEvalLockEnv(args) {
5204
5209
  const wantCheck = args.includes("--check");
@@ -5207,13 +5212,6 @@ function resolveEvalLockEnv(args) {
5207
5212
  console.error("vigiles eval: --check and --update are mutually exclusive (one verifies, one records).");
5208
5213
  process.exit(2);
5209
5214
  }
5210
- if (wantCheck &&
5211
- !(0, eval_lock_js_1.anyLocksCommitted)((0, node_path_1.resolve)(process.cwd(), eval_lock_js_1.DEFAULT_LOCK_DIR))) {
5212
- console.log("ℹ vigiles eval --check: no committed eval locks found — nothing to verify.\n" +
5213
- " Run `vigiles eval --update` locally (on your subscription) and commit the\n" +
5214
- " lock to enable the CI staleness gate.");
5215
- return "skip";
5216
- }
5217
5215
  const env = {};
5218
5216
  if (wantCheck)
5219
5217
  env.VIGILES_EVAL_LOCK = "check";
@@ -5226,6 +5224,22 @@ function resolveEvalLockEnv(args) {
5226
5224
  }
5227
5225
  return env;
5228
5226
  }
5227
+ /**
5228
+ * `eval --check` over eval files when no lock is committed anywhere: every one of
5229
+ * them has no recorded result to compare the current inputs against, so the gate
5230
+ * would verify nothing and pass. It used to return green BEFORE discovery, which
5231
+ * also bypassed the empty-match failure. Once any lock exists, a file without one
5232
+ * already fails as "lock missing" in the eval itself. Exits 1; returns otherwise.
5233
+ */
5234
+ function refuseEvalCheckWithoutLocks(args, evalCount) {
5235
+ if (!args.includes("--check"))
5236
+ return;
5237
+ if ((0, eval_lock_js_1.anyLocksCommitted)((0, node_path_1.resolve)(process.cwd(), eval_lock_js_1.DEFAULT_LOCK_DIR)))
5238
+ return;
5239
+ console.error(`✗ vigiles eval --check: ${String(evalCount)} eval file(s) have no recorded result — no eval lock is committed, so there is nothing to verify the current inputs against.\n` +
5240
+ " Run `vigiles eval --update` locally (on your subscription) and commit the lock(s).");
5241
+ process.exit(1);
5242
+ }
5229
5243
  /**
5230
5244
  * Turn a completed run into `.vigiles/coverage.json` — the composition root for
5231
5245
  * the execution tier of coverage (`coverage-artifact.ts`).
@@ -5400,6 +5414,31 @@ function gitHead(cwd) {
5400
5414
  return "";
5401
5415
  }
5402
5416
  }
5417
+ /**
5418
+ * Why a `vigiles test` / `vigiles eval` run matched no file — the message names
5419
+ * what was looked for, so a stale path, a wrong glob and an empty default
5420
+ * discovery each say so. A DIRECTORY is the one stale-looking target whose cause
5421
+ * we actually know, so it is said instead of the three guesses: `vigiles test .`
5422
+ * used to reach `spawn("node", ["."])` and surface Node's module-resolution stack.
5423
+ */
5424
+ function emptyMatchMessage(kind, targets, defaultGlob) {
5425
+ const dirs = targets.filter((a) => (0, node_fs_1.lstatSync)(a, { throwIfNoEntry: false })?.isDirectory() === true);
5426
+ if (dirs.length > 0) {
5427
+ return (`✗ vigiles ${kind}: ${dirs.join(", ")} ${dirs.length === 1 ? "is a directory" : "are directories"} — ` +
5428
+ `pass a file, or a glob like "${defaultGlob}".\n` +
5429
+ ` A directory is not a script; nothing ran.`);
5430
+ }
5431
+ const expected = ` If an empty match is expected here, say so with --min=0.`;
5432
+ if (targets.length === 0) {
5433
+ return (`✗ vigiles ${kind}: NOTHING matched the default glob ${defaultGlob}\n` +
5434
+ ` Nothing ran. An empty run is not a pass: a renamed or moved file looks like this.\n` +
5435
+ expected);
5436
+ }
5437
+ return (`✗ vigiles ${kind}: ${String(targets.length)} target(s) given and NOTHING matched — ` +
5438
+ `${targets.join(", ")}\n` +
5439
+ ` Nothing ran. A stale path, a wrong glob, or a moved file all look like this.\n` +
5440
+ expected);
5441
+ }
5403
5442
  async function handleRunScripts(kind, args, restArgs, excludes) {
5404
5443
  const cwd = process.cwd();
5405
5444
  // The ignore file keeps NEW copies of `.vigiles/` local files out of git, but
@@ -5420,14 +5459,10 @@ async function handleRunScripts(kind, args, restArgs, excludes) {
5420
5459
  // Harness/eval scripts may be authored in JS or TS (see run-scripts.ts).
5421
5460
  const defaultGlob = (0, run_scripts_js_1.scriptGlob)(kind === "test" ? "harness" : "eval");
5422
5461
  // The eval LOCK flags (`--check`/`--update`) are resolved BEFORE file discovery
5423
- // so mutual-exclusion + the cold-start no-op are honored regardless of file
5424
- // count. Returns the env to thread to scripts, or `"skip"` to exit green now.
5462
+ // so mutual-exclusion (exit 2) is honored regardless of file count.
5425
5463
  let lockEnv = {};
5426
5464
  if (kind === "eval") {
5427
- const r = resolveEvalLockEnv(args);
5428
- if (r === "skip")
5429
- return;
5430
- lockEnv = r;
5465
+ lockEnv = resolveEvalLockEnv(args);
5431
5466
  }
5432
5467
  // A script under an excluded path is not DISCOVERED (a vendored corpus's own
5433
5468
  // harness must not run as ours), but a script you NAME still runs (#192).
@@ -5449,44 +5484,33 @@ async function handleRunScripts(kind, args, restArgs, excludes) {
5449
5484
  process.exit(1);
5450
5485
  }
5451
5486
  if (files.length === 0) {
5452
- // 🔴 ASKING FOR SOMETHING AND GETTING NOTHING IS A FAILURE; FINDING NOTHING IS NOT.
5453
- // The two cases were collapsed into one silent exit 0, and the collapse cost a real
5454
- // repository three days of green CI verifying zero files: a named step ran
5487
+ // 🔴 A RUN THAT MATCHES NO FILE IS A FAILURE, NAMED OR NOT.
5488
+ // An empty match used to be one silent exit 0, and it cost a real repository three
5489
+ // days of green CI verifying zero files: a named step ran
5455
5490
  // `vigiles test .claude/pipeline/skills.harness.mjs` after that file had been split
5456
5491
  // into one-per-skill, printed "No **/*.harness.* files found" and passed, right next
5457
- // to a step that was red for the same root cause.
5458
- //
5459
- // They are different states. A POSITIONAL argument is a claim that something is there —
5460
- // when nothing matches it, the path is stale, the glob is wrong, or the run never
5461
- // reached its target, and every one of those is a defect. Bare discovery finding
5462
- // nothing is just an empty repository, which is a legitimate place to stand and must
5463
- // stay quiet.
5492
+ // to a step that was red for the same root cause. The first repair failed only the
5493
+ // NAMED case and kept bare discovery quiet ("an empty repository is a legitimate
5494
+ // place to stand") — and bare `vigiles test` is the line `init` writes into CI, so
5495
+ // the same green no-op was one rename away from the default setup (#197).
5464
5496
  //
5465
- // This is the default the field settled on: Jest and Vitest FAIL on no tests found and
5466
- // make you opt in with `--passWithNoTests`; pytest exits 5. `--min=0` remains the
5467
- // explicit opt-out here, so no new flag is introduced by this change.
5468
- // `minFlag`, not `minRequired`: 0 is both the DEFAULT and the explicit opt-out, so the
5469
- // VALUE cannot tell them apart — only the flag's presence can. (Caught by a control:
5470
- // the first version read `minRequired === 0` and made `--min=0` do nothing.)
5471
- if (restArgs.length > 0 && minFlag === undefined) {
5472
- // A DIRECTORY is the one stale-looking target whose cause we actually know,
5473
- // so say it instead of listing the three guesses. `vigiles test .` used to
5474
- // reach `spawn("node", ["."])` and surface Node's module-resolution stack;
5475
- // the generic message above would now be true but unhelpful.
5476
- const dirs = restArgs.filter((a) => (0, node_fs_1.lstatSync)(a, { throwIfNoEntry: false })?.isDirectory() === true);
5477
- console.error(dirs.length > 0
5478
- ? `✗ vigiles ${kind}: ${dirs.join(", ")} ${dirs.length === 1 ? "is a directory" : "are directories"} — ` +
5479
- `pass a file, or a glob like "${defaultGlob}".\n` +
5480
- ` A directory is not a script; nothing ran.`
5481
- : `✗ vigiles ${kind}: ${String(restArgs.length)} target(s) given and NOTHING matched — ` +
5482
- `${restArgs.join(", ")}\n` +
5483
- ` Nothing ran. A stale path, a wrong glob, or a moved file all look like this.\n` +
5484
- ` If an empty match is expected here, say so with --min=0.`);
5497
+ // This is the default the field settled on: Jest and Vitest FAIL on no tests found
5498
+ // and make you opt in with `--passWithNoTests`; pytest exits 5. Here there is no
5499
+ // such flag (a person who wants an empty run does not call the command), and
5500
+ // `--min=0` — the existing floor, spelled as zero — is the only way to say an empty
5501
+ // match is expected. `minFlag`, not `minRequired`: 0 is both the DEFAULT and that
5502
+ // explicit opt-out, so the VALUE cannot tell them apart — only the flag's presence
5503
+ // can. (Caught by a control: the first version read `minRequired === 0` and made
5504
+ // `--min=0` do nothing.)
5505
+ if (minFlag === undefined) {
5506
+ console.error(emptyMatchMessage(kind, restArgs, defaultGlob));
5485
5507
  process.exit(1);
5486
5508
  }
5487
5509
  console.log(`No ${defaultGlob} files found.`);
5488
5510
  return;
5489
5511
  }
5512
+ if (kind === "eval")
5513
+ refuseEvalCheckWithoutLocks(args, files.length);
5490
5514
  // Consent gate for a bare `vigiles eval`: it runs the REAL model on your
5491
5515
  // subscription, and a no-target run discovered the whole tree — so never fan out
5492
5516
  // over an unbounded glob without explicit intent. Mirrors audit's read-vs-run
@@ -107,8 +107,11 @@ export interface HarnessDriverContext {
107
107
  readonly cwd: string;
108
108
  /** Whether a settings file was written (CC: pass `--settings`). */
109
109
  readonly hasSettings: boolean;
110
- /** Tools the agent may use. */
111
- readonly tools: readonly string[];
110
+ /**
111
+ * The ONLY tools the agent has, when the spec lists them; undefined = no
112
+ * restriction (every tool is offered, the default four are pre-approved).
113
+ */
114
+ readonly tools?: readonly string[];
112
115
  /** Capture the full event transcript instead of just the final result. */
113
116
  readonly transcript: boolean;
114
117
  /** Path to a plugin dir to install natively, if any. */
@@ -0,0 +1,32 @@
1
+ /**
2
+ * Running past the end of a scripted model is an error.
3
+ *
4
+ * A harness test scripts the turns the agent should take, then asserts on what
5
+ * happened. When the agent asks for more turns than the script has, the mock
6
+ * used to answer with a copy of the LAST turn: a looping agent (a Stop hook
7
+ * that keeps blocking, a retry, a reaction to a tool result) then ran on turns
8
+ * nobody scripted, a final `{ tool }` re-ran its side effect, and the test went
9
+ * green over a run it never described (#340). Pure and shared by both mocks
10
+ * (Anthropic Messages, OpenAI Responses) and the runner that fails the test.
11
+ */
12
+ import type { ModelRequest } from "./harness-driver.js";
13
+ /** The first request the script could not answer. */
14
+ export interface ScriptOverrun {
15
+ /** How many turns the script had. */
16
+ readonly scripted: number;
17
+ /** 1-based number of the agent request that had no turn left. */
18
+ readonly request: number;
19
+ /** The last message that request carried (what the agent was reacting to). */
20
+ readonly lastMessage: string;
21
+ }
22
+ /** Why a test failed because the agent outran its script. */
23
+ export declare function scriptOverrunMessage(o: ScriptOverrun): string;
24
+ /**
25
+ * The first agent request past the end of a script of `scripted` turns, read
26
+ * off the recorded requests (side-channel calls never consume a turn, so they
27
+ * do not count). Undefined when the agent stayed inside the script.
28
+ */
29
+ export declare function findScriptOverrun(scripted: number, requests: readonly ModelRequest[]): ScriptOverrun | undefined;
30
+ /** {@link findScriptOverrun} and {@link scriptOverrunMessage} in one: the message, or undefined. */
31
+ export declare function overrunMessageFor(scripted: number, requests: readonly ModelRequest[]): string | undefined;
32
+ //# sourceMappingURL=script-overrun.d.ts.map
@@ -0,0 +1,42 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.scriptOverrunMessage = scriptOverrunMessage;
4
+ exports.findScriptOverrun = findScriptOverrun;
5
+ exports.overrunMessageFor = overrunMessageFor;
6
+ const MAX_QUOTED = 160;
7
+ const quote = (text) => {
8
+ const flat = text.replace(/\s+/g, " ").trim();
9
+ return flat.length > MAX_QUOTED ? `${flat.slice(0, MAX_QUOTED)}...` : flat;
10
+ };
11
+ /** Why a test failed because the agent outran its script. */
12
+ function scriptOverrunMessage(o) {
13
+ const last = o.lastMessage.trim() === ""
14
+ ? ""
15
+ : ` The request carried this last message: "${quote(o.lastMessage)}".`;
16
+ return (`the agent asked for more model turns than the script has: ${String(o.scripted)} turn(s) scripted, ` +
17
+ `and agent request #${String(o.request)} had none left.${last} ` +
18
+ "The mock does not repeat the last turn, because a looping agent would then pass on turns nobody scripted. " +
19
+ "Script every turn the agent should take.");
20
+ }
21
+ /**
22
+ * The first agent request past the end of a script of `scripted` turns, read
23
+ * off the recorded requests (side-channel calls never consume a turn, so they
24
+ * do not count). Undefined when the agent stayed inside the script.
25
+ */
26
+ function findScriptOverrun(scripted, requests) {
27
+ const agent = requests.filter((r) => r.sideChannel !== true);
28
+ const first = agent[scripted];
29
+ if (first === undefined)
30
+ return undefined;
31
+ return {
32
+ scripted,
33
+ request: scripted + 1,
34
+ lastMessage: first.messages.at(-1)?.text ?? "",
35
+ };
36
+ }
37
+ /** {@link findScriptOverrun} and {@link scriptOverrunMessage} in one: the message, or undefined. */
38
+ function overrunMessageFor(scripted, requests) {
39
+ const overrun = findScriptOverrun(scripted, requests);
40
+ return overrun === undefined ? undefined : scriptOverrunMessage(overrun);
41
+ }
42
+ //# sourceMappingURL=script-overrun.js.map
@@ -20,7 +20,7 @@
20
20
  * anywhere in the prose counts, because a name like `test:harness` cannot
21
21
  * collide with an English word the way `test` and `audit` do. The only thing the
22
22
  * lookarounds buy is that a LONGER script name is never read as a shorter one
23
- * (`test:cli-e2e` does not document `test:e2e`).
23
+ * (`test:unit-extra` does not document `test:unit`).
24
24
  *
25
25
  * Scope is the `test:` PREFIX, deliberately. Bare `npm test` is the vitest
26
26
  * suite, named in the map as prose; every other script (`build`, `lint`,
@@ -21,7 +21,7 @@
21
21
  * anywhere in the prose counts, because a name like `test:harness` cannot
22
22
  * collide with an English word the way `test` and `audit` do. The only thing the
23
23
  * lookarounds buy is that a LONGER script name is never read as a shorter one
24
- * (`test:cli-e2e` does not document `test:e2e`).
24
+ * (`test:unit-extra` does not document `test:unit`).
25
25
  *
26
26
  * Scope is the `test:` PREFIX, deliberately. Bare `npm test` is the vitest
27
27
  * suite, named in the map as prose; every other script (`build`, `lint`,
@@ -116,12 +116,11 @@ export declare function lockPath(dir: string, name: string): string;
116
116
  */
117
117
  export declare function readLock(dir: string, name: string): EvalLock | null;
118
118
  /**
119
- * Whether ANY lock has been committed under `dir`. The CI staleness gate
120
- * (`eval --check`) uses this to stay a NO-OP until the feature is in use: a repo
121
- * that has never run `eval --update` has no locks, so there is nothing to verify
122
- * and CI passes green. Once the first lock is committed, every named eval is held
123
- * to having a fresh one (a new unlocked eval then reads as stale). The graduated,
124
- * opt-in-by-committing behavior that keeps a fresh `init` from going red.
119
+ * Whether ANY lock has been committed under `dir`. `eval --check` fails when none
120
+ * is (#197): with no lock there is no recorded result to verify the current inputs
121
+ * against, so a pass would mean nothing was checked. Once the first lock is
122
+ * committed, every named eval is held to having a fresh one (a new unlocked eval
123
+ * then reads as stale). The edit-time nudge also stays silent until then.
125
124
  */
126
125
  export declare function anyLocksCommitted(dir: string): boolean;
127
126
  /**
package/dist/eval-lock.js CHANGED
@@ -130,12 +130,11 @@ function readLock(dir, name) {
130
130
  return obj;
131
131
  }
132
132
  /**
133
- * Whether ANY lock has been committed under `dir`. The CI staleness gate
134
- * (`eval --check`) uses this to stay a NO-OP until the feature is in use: a repo
135
- * that has never run `eval --update` has no locks, so there is nothing to verify
136
- * and CI passes green. Once the first lock is committed, every named eval is held
137
- * to having a fresh one (a new unlocked eval then reads as stale). The graduated,
138
- * opt-in-by-committing behavior that keeps a fresh `init` from going red.
133
+ * Whether ANY lock has been committed under `dir`. `eval --check` fails when none
134
+ * is (#197): with no lock there is no recorded result to verify the current inputs
135
+ * against, so a pass would mean nothing was checked. Once the first lock is
136
+ * committed, every named eval is held to having a fresh one (a new unlocked eval
137
+ * then reads as stale). The edit-time nudge also stays silent until then.
139
138
  */
140
139
  function anyLocksCommitted(dir) {
141
140
  return countLocks(dir) > 0;
@@ -52,7 +52,18 @@ export interface HarnessTestSpec {
52
52
  readonly model: readonly ModelTurn[];
53
53
  /** The user prompt. Default: "go". */
54
54
  readonly prompt?: string;
55
- /** Tools the agent may use. Default: Read Edit Write Bash. */
55
+ /**
56
+ * The ONLY tools the agent has, when you list them: a tool left out is not
57
+ * offered (`claude --tools`, Claude Code 2.0.31+), `[]` is refused, and a
58
+ * scripted call to a tool the list leaves out throws before the run starts.
59
+ * Omitted, nothing is withheld: every tool is offered and Read, Edit, Write and
60
+ * Bash are pre-approved. Each listed tool is also pre-approved
61
+ * (`--allowedTools`), so it does not stop on a permission prompt; a permission
62
+ * rule such as `Bash(git *)` keeps its specifier for the approval and is
63
+ * offered as plain `Bash`. An MCP tool is not a built-in: left out, it is still
64
+ * offered but not approved, so a call to it is refused for permission rather
65
+ * than as "No such tool".
66
+ */
56
67
  readonly allowedTools?: readonly string[];
57
68
  /**
58
69
  * Capture the full event transcript (`--output-format stream-json`) into
@@ -119,6 +130,27 @@ export declare function parseResultEvent(stdout: string): Record<string, unknown
119
130
  /** The agent's final answer text from a transcript / result object, or "". */
120
131
  export declare function parseOutput(stdout: string): string;
121
132
  export declare function parseHooks(stdout: string): HookFire[];
133
+ /**
134
+ * The names `--tools` should offer for an `allowedTools` list. `--allowedTools`
135
+ * takes permission rules (`Bash(git *)`); `--tools` takes tool NAMES, and a rule
136
+ * with a specifier there makes the CLI offer no tools at all. So the specifier is
137
+ * dropped (`Bash(git *)` → `Bash`) and repeats collapse. Pure.
138
+ */
139
+ export declare function toolAvailabilityList(allowed: readonly string[]): readonly string[];
140
+ /**
141
+ * A scripted call to a tool an EXPLICIT `allowedTools` does not offer. The CLI
142
+ * answers it with "No such tool available" and the run goes on, so a test that
143
+ * scripts the call and then asserts on something else passes over a step that
144
+ * never happened. Returns the message to throw, or undefined. MCP tools are not
145
+ * built-ins, so `--tools` does not withhold them and they are skipped. Pure.
146
+ */
147
+ export declare function unofferedScriptedTool(model: readonly ModelTurn[], allowed: readonly string[] | undefined): string | undefined;
148
+ /**
149
+ * A run on a `claude` that predates `--tools` exits at argument parsing, and
150
+ * commander's "unknown option" says nothing about WHY vigiles passed it. Returns
151
+ * the message to throw, or undefined when the run did not die that way. Pure.
152
+ */
153
+ export declare function unsupportedToolsFlag(stderr: string): string | undefined;
122
154
  /**
123
155
  * The `claude` CLI argv for a harness run (shared by the direct and sandboxed
124
156
  * paths). `ANTHROPIC_BASE_URL` is set by the caller's environment / wrapper, not
@@ -170,6 +202,8 @@ export interface RunHarnessTestOptions {
170
202
  */
171
203
  readonly adapter?: HarnessAdapter;
172
204
  }
205
+ /** Say so on stderr when side-channel calls arrived and no script turn was served. */
206
+ export declare function warnUnconsumed(count: number, sideChannelCount: number): void;
173
207
  /**
174
208
  * Run the real agent CLI against a scripted mock model, with the given fixture
175
209
  * and settings (hooks). Deterministic — same script, same result. Adapter-driven
@@ -6,10 +6,14 @@ exports.parseSubagents = parseSubagents;
6
6
  exports.parseResultEvent = parseResultEvent;
7
7
  exports.parseOutput = parseOutput;
8
8
  exports.parseHooks = parseHooks;
9
+ exports.toolAvailabilityList = toolAvailabilityList;
10
+ exports.unofferedScriptedTool = unofferedScriptedTool;
11
+ exports.unsupportedToolsFlag = unsupportedToolsFlag;
9
12
  exports.buildClaudeArgs = buildClaudeArgs;
10
13
  exports.parseClaudeRun = parseClaudeRun;
11
14
  exports.harnessSpawnEnv = harnessSpawnEnv;
12
15
  exports.claudeAvailable = claudeAvailable;
16
+ exports.warnUnconsumed = warnUnconsumed;
13
17
  exports.runHarnessTest = runHarnessTest;
14
18
  exports.runHarnessTestIn = runHarnessTestIn;
15
19
  exports.runHarness = runHarness;
@@ -49,6 +53,7 @@ const node_fs_1 = require("node:fs");
49
53
  const node_path_1 = require("node:path");
50
54
  const adapter_conformance_js_1 = require("./adapter-conformance.js");
51
55
  const check_count_js_1 = require("./check-count.js");
56
+ const script_overrun_js_1 = require("./core/script-overrun.js");
52
57
  const coverage_probe_js_1 = require("./coverage-probe.js");
53
58
  const runtime_js_1 = require("./adapters/claude-code/runtime.js");
54
59
  const mock_model_js_1 = require("./mock-model.js");
@@ -274,6 +279,51 @@ function parseHooks(stdout) {
274
279
  }
275
280
  return hooks;
276
281
  }
282
+ /**
283
+ * The first `claude` that has `--tools` (print mode). Found by diffing the
284
+ * published `@anthropic-ai/claude-code` tarballs: 2.0.30 has no such option,
285
+ * 2.0.31 does (the changelog does not list it; its 2.1.0 entry only extends the
286
+ * flag to interactive mode).
287
+ */
288
+ const TOOLS_FLAG_MIN_CLAUDE = "2.0.31";
289
+ /**
290
+ * The names `--tools` should offer for an `allowedTools` list. `--allowedTools`
291
+ * takes permission rules (`Bash(git *)`); `--tools` takes tool NAMES, and a rule
292
+ * with a specifier there makes the CLI offer no tools at all. So the specifier is
293
+ * dropped (`Bash(git *)` → `Bash`) and repeats collapse. Pure.
294
+ */
295
+ function toolAvailabilityList(allowed) {
296
+ const names = allowed.map((rule) => rule.replace(/\(.*$/, "").trim());
297
+ return [...new Set(names.filter((n) => n !== ""))];
298
+ }
299
+ /**
300
+ * A scripted call to a tool an EXPLICIT `allowedTools` does not offer. The CLI
301
+ * answers it with "No such tool available" and the run goes on, so a test that
302
+ * scripts the call and then asserts on something else passes over a step that
303
+ * never happened. Returns the message to throw, or undefined. MCP tools are not
304
+ * built-ins, so `--tools` does not withhold them and they are skipped. Pure.
305
+ */
306
+ function unofferedScriptedTool(model, allowed) {
307
+ if (allowed === undefined)
308
+ return undefined;
309
+ const offered = toolAvailabilityList(allowed);
310
+ const at = model.findIndex((t) => t.tool !== undefined &&
311
+ !t.tool.startsWith("mcp__") &&
312
+ !offered.includes(t.tool));
313
+ return at < 0
314
+ ? undefined
315
+ : `scripted call to "${model[at]?.tool ?? ""}" on turn ${String(at + 1)}, but this run offers only: ${offered.join(", ")} — add it to allowedTools`;
316
+ }
317
+ /**
318
+ * A run on a `claude` that predates `--tools` exits at argument parsing, and
319
+ * commander's "unknown option" says nothing about WHY vigiles passed it. Returns
320
+ * the message to throw, or undefined when the run did not die that way. Pure.
321
+ */
322
+ function unsupportedToolsFlag(stderr) {
323
+ return /unknown option '--tools'/.test(stderr)
324
+ ? `this \`claude\` does not know \`--tools\`, which runHarnessTest uses to withhold every tool not in \`allowedTools\` (#252). Update Claude Code to ${TOOLS_FLAG_MIN_CLAUDE} or newer. Running without it would hand the agent every tool while the test reads as if it were fenced.`
325
+ : undefined;
326
+ }
277
327
  /**
278
328
  * The `claude` CLI argv for a harness run (shared by the direct and sandboxed
279
329
  * paths). `ANTHROPIC_BASE_URL` is set by the caller's environment / wrapper, not
@@ -293,8 +343,17 @@ function buildClaudeArgs(spec, hasSettings) {
293
343
  ? ["--plugin-dir", (0, node_path_1.resolve)(spec.pluginDir)]
294
344
  : []),
295
345
  ...(hasSettings ? ["--settings", "settings.json"] : []),
296
- // A permission allowlist: an empty one approves nothing, so the flag is
297
- // left out (a bare `--allowedTools` exits before any model turn).
346
+ // An EXPLICIT list restricts: `--tools` is the AVAILABILITY list, a tool left
347
+ // out is not offered at all. `--allowedTools` only PRE-APPROVES (it never
348
+ // restricts), so on its own it left every unnamed tool runnable (#252). An
349
+ // empty list is `--tools ""` = no tools. With no list, nothing is withheld:
350
+ // the default four are pre-approved and every other tool stays available.
351
+ ...(spec.allowedTools === undefined
352
+ ? []
353
+ : ["--tools", toolAvailabilityList(spec.allowedTools).join(",")]),
354
+ // Pre-approval for the offered tools, so none stops on a permission prompt
355
+ // (headless). An empty one approves nothing, so the flag is left out (a bare
356
+ // `--allowedTools` exits before any model turn).
298
357
  ...(tools.length === 0 ? [] : ["--allowedTools", ...tools]),
299
358
  ];
300
359
  }
@@ -415,6 +474,26 @@ exports.claudeCodeDriver = {
415
474
  parseRun: parseClaudeRun,
416
475
  available: claudeAvailable,
417
476
  };
477
+ /** Say so on stderr when side-channel calls arrived and no script turn was served. */
478
+ function warnUnconsumed(count, sideChannelCount) {
479
+ const unconsumed = (0, mock_model_js_1.scriptUnconsumedWarning)(count, sideChannelCount);
480
+ if (unconsumed !== undefined)
481
+ console.error(unconsumed);
482
+ }
483
+ /**
484
+ * A run that proves nothing about the script fails the test instead of coming
485
+ * back as a result: the `claude` on PATH predates `--tools` (#252), or the agent
486
+ * asked for a model turn the script did not have and the mock answered with an
487
+ * error rather than an invented turn (#340). Removes the run's directory, since
488
+ * no result is handed back to clean it up.
489
+ */
490
+ function assertRunSound(cwd, stderr, scripted, modelRequests) {
491
+ const problem = unsupportedToolsFlag(stderr) ?? (0, script_overrun_js_1.overrunMessageFor)(scripted, modelRequests);
492
+ if (problem === undefined)
493
+ return;
494
+ (0, node_fs_1.rmSync)(cwd, { recursive: true, force: true });
495
+ throw new Error(problem);
496
+ }
418
497
  /* v8 ignore start -- spawns the real agent CLI + filesystem; exercised by the
419
498
  claude-backed + gated codex suites, excluded from the deterministic coverage
420
499
  gate (the parse helpers above carry the testable logic). */
@@ -457,8 +536,22 @@ function makeResult(cwd, out, parsed, turns, modelRequests) {
457
536
  * only — requesting confinement for another harness throws.
458
537
  */
459
538
  async function runHarnessTest(spec, opts = {}) {
539
+ refuseUnrunnableSpec(spec);
460
540
  return runHarnessTestIn(spec, opts, "inherit");
461
541
  }
542
+ /**
543
+ * The scripted model tells an agent turn from the CLI's own bookkeeping calls by
544
+ * the tools the request declares (`isMainLoopRequest`). An agent with none is
545
+ * never served a script turn, and the run would decide on nothing.
546
+ */
547
+ function refuseUnrunnableSpec(spec) {
548
+ if (spec.allowedTools?.length === 0) {
549
+ throw new Error("allowedTools: [] leaves the agent with no tools, and the scripted model only serves a turn to a request that declares tools — no script turn would be consumed. Name the tools the agent has.");
550
+ }
551
+ const unoffered = unofferedScriptedTool(spec.model, spec.allowedTools);
552
+ if (unoffered !== undefined)
553
+ throw new Error(unoffered);
554
+ }
462
555
  /**
463
556
  * {@link runHarnessTest} with the run's HOME chosen by the caller — see
464
557
  * {@link HarnessRunHome}. Not on a public entry point: the one caller that needs
@@ -491,13 +584,12 @@ async function runHarnessTestIn(spec, opts, home) {
491
584
  const { files, settings, check } = fixtureFor(spec, opts.adapter);
492
585
  const cwd = (0, tmp_root_js_1.makeTmpDir)("harness");
493
586
  writeFixture(cwd, files, settings);
494
- const tools = spec.allowedTools ?? ["Read", "Edit", "Write", "Bash"];
495
587
  const timeoutMs = spec.timeoutMs ?? 60000;
496
588
  const buildArgs = (mockArgs) => driver.buildArgs({
497
589
  prompt: spec.prompt ?? "go",
498
590
  cwd,
499
591
  hasSettings: settings !== undefined,
500
- tools,
592
+ tools: spec.allowedTools,
501
593
  transcript: spec.transcript ?? false,
502
594
  pluginDir: spec.pluginDir,
503
595
  mockArgs,
@@ -519,9 +611,8 @@ async function runHarnessTestIn(spec, opts, home) {
519
611
  // the direct one stays right. Same recovery feeds the unconsumed-script
520
612
  // warning, which the sandbox path could not emit at all before.
521
613
  const { count, sideChannelCount } = (0, mock_model_js_1.splitRequestCounts)(out.requests);
522
- const unconsumed = (0, mock_model_js_1.scriptUnconsumedWarning)(count, sideChannelCount);
523
- if (unconsumed !== undefined)
524
- console.error(unconsumed);
614
+ warnUnconsumed(count, sideChannelCount);
615
+ assertRunSound(cwd, "", spec.model.length, out.requests);
525
616
  return check(makeResult(cwd, out, parseClaudeRun(out.stdout), count, out.requests));
526
617
  }
527
618
  // Direct path: mock runs in this process; the agent reaches it over localhost.
@@ -539,9 +630,8 @@ async function runHarnessTestIn(spec, opts, home) {
539
630
  // looks empty. `scriptUnconsumedWarning` names the one shape that produces
540
631
  // it (every request arriving without tool declarations, so nothing looked
541
632
  // like an agent turn) instead of leaving it to be rediscovered.
542
- const unconsumed = (0, mock_model_js_1.scriptUnconsumedWarning)(mock.count, mock.sideChannelCount ?? 0);
543
- if (unconsumed !== undefined)
544
- console.error(unconsumed);
633
+ warnUnconsumed(mock.count, mock.sideChannelCount ?? 0);
634
+ assertRunSound(cwd, out.stderr, spec.model.length, mock.requests);
545
635
  return check(makeResult(cwd, out, driver.parseRun(out.stdout), mock.count, [
546
636
  ...mock.requests,
547
637
  ]));
@@ -78,7 +78,10 @@ export declare function extractRequest(body: {
78
78
  }): ModelRequest;
79
79
  /**
80
80
  * Start the scripted mock on a free port. Each MAIN-LOOP `/v1/messages` POST
81
- * consumes the next turn (the last turn repeats if the client asks for more);
81
+ * consumes the next turn; one past the end of the script is answered with a
82
+ * non-retryable API error, never a copy of the last turn (#340 — a looping
83
+ * agent would pass on turns nobody scripted), and `runHarnessTest` then fails
84
+ * the test from the recorded requests ({@link findScriptOverrun});
82
85
  * a SIDE-CHANNEL POST — the CLI's own bookkeeping calls, see
83
86
  * {@link isMainLoopRequest} — is answered from outside the script and consumes
84
87
  * nothing. Resolves to a handle with the base `url` and a `close()`.
@@ -28,6 +28,7 @@ exports.startMock = startMock;
28
28
  * and echoing the requested model.
29
29
  */
30
30
  const node_http_1 = __importDefault(require("node:http"));
31
+ const script_overrun_js_1 = require("./core/script-overrun.js");
31
32
  /** Build a scripted model from an ordered list of turns. */
32
33
  function scriptModel(turns) {
33
34
  return [...turns];
@@ -388,9 +389,41 @@ function extractRequest(body) {
388
389
  : [];
389
390
  return { system: flattenContent(body.system), messages };
390
391
  }
392
+ /**
393
+ * The 400 body for an agent request past the end of the script: an API error,
394
+ * because the CLI retries 429/5xx and an invented turn would let the agent act on
395
+ * a step nobody scripted (#340). Pure.
396
+ */
397
+ function pastScriptBody(scripted, request) {
398
+ const message = (0, script_overrun_js_1.scriptOverrunMessage)({
399
+ scripted,
400
+ request: scripted + 1,
401
+ lastMessage: request.messages.at(-1)?.text ?? "",
402
+ });
403
+ return JSON.stringify({
404
+ type: "error",
405
+ error: { type: "invalid_request_error", message },
406
+ });
407
+ }
408
+ /** Does a message's content carry a tool result (the agent reacting to a tool)? */
409
+ function endsWithToolResult(content) {
410
+ return JSON.stringify(content ?? "").includes('"tool_result"');
411
+ }
412
+ /** The JSON body of a request, `{}` for the HEAD / health checks that have none. */
413
+ function parseReqBody(body) {
414
+ try {
415
+ return JSON.parse(body);
416
+ }
417
+ catch {
418
+ return {};
419
+ }
420
+ }
391
421
  /**
392
422
  * Start the scripted mock on a free port. Each MAIN-LOOP `/v1/messages` POST
393
- * consumes the next turn (the last turn repeats if the client asks for more);
423
+ * consumes the next turn; one past the end of the script is answered with a
424
+ * non-retryable API error, never a copy of the last turn (#340 — a looping
425
+ * agent would pass on turns nobody scripted), and `runHarnessTest` then fails
426
+ * the test from the recorded requests ({@link findScriptOverrun});
394
427
  * a SIDE-CHANNEL POST — the CLI's own bookkeeping calls, see
395
428
  * {@link isMainLoopRequest} — is answered from outside the script and consumes
396
429
  * nothing. Resolves to a handle with the base `url` and a `close()`.
@@ -406,13 +439,7 @@ function startMock(script, opts = {}) {
406
439
  const url = req.url ?? "";
407
440
  const isCount = url.includes("count_tokens");
408
441
  const isMessages = url.includes("/v1/messages") && !isCount;
409
- let reqBody = {};
410
- try {
411
- reqBody = JSON.parse(body);
412
- }
413
- catch {
414
- /* HEAD / health checks have no JSON body */
415
- }
442
+ const reqBody = parseReqBody(body);
416
443
  if (req.method === "HEAD" || (!isMessages && !isCount)) {
417
444
  res.writeHead(200, { "content-type": "application/json" });
418
445
  res.end("{}");
@@ -445,13 +472,17 @@ function startMock(script, opts = {}) {
445
472
  jsonTurn(res, reply, model);
446
473
  return;
447
474
  }
448
- const last = JSON.stringify(reqBody.messages?.at(-1)?.content ?? "");
475
+ const turn = script[i];
476
+ if (turn === undefined) {
477
+ res.writeHead(400, { "content-type": "application/json" });
478
+ res.end(pastScriptBody(script.length, request));
479
+ return;
480
+ }
449
481
  opts.onTurn?.({
450
482
  n: i,
451
483
  stream: reqBody.stream === true,
452
- hasToolResult: last.includes('"tool_result"'),
484
+ hasToolResult: endsWithToolResult(reqBody.messages?.at(-1)?.content),
453
485
  });
454
- const turn = script[Math.min(i, script.length - 1)] ?? { text: "" };
455
486
  i++;
456
487
  if (reqBody.stream === true)
457
488
  streamTurn(res, turn, model);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vigiles",
3
- "version": "34.0.0",
3
+ "version": "35.0.0",
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",
@@ -83,7 +83,6 @@
83
83
  "test:unit": "npm run build && vitest run --project unit",
84
84
  "test:integration": "npm run build && vitest run --project integration",
85
85
  "test:e2e": "npm run build && vitest run --project e2e",
86
- "test:cli-e2e": "bash test/e2e/run.sh",
87
86
  "test:harness": "npm run build && node dist/cli.js test",
88
87
  "test:eval": "npm run build && node dist/cli.js eval --all",
89
88
  "test:vitest": "npm run build && vitest run --project runners",