vigiles 36.0.0 → 37.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.
@@ -0,0 +1,397 @@
1
+ "use strict";
2
+ /**
3
+ * A tool stub that answers PER INVOCATION — the pure core.
4
+ *
5
+ * A stub shadows a binary on PATH (`gh`, `git`, `psql`) so a run that calls it
6
+ * gets a recorded answer instead of the real service. The earlier stub printed
7
+ * ONE answer for every argv. That is a test double answering by default, and a
8
+ * default answer is a fabricated observation: a stub set up to answer
9
+ * `gh issue create` with an issue URL also answered a script's
10
+ * `gh api repos/o/r/issues?…` with that URL, the script failed to parse it as
11
+ * JSON, and a model that read the stub's source stopped trusting it.
12
+ *
13
+ * So a stub is a non-empty list of RULES over the argv TOKEN ARRAY. Each rule has
14
+ * a positional pattern (an exact string or a RegExp per token, and an explicit
15
+ * trailing `rest` for "any further tokens"), an optional order-free `contains`
16
+ * for flags, and a reply. The first matching rule answers. An argv no rule
17
+ * matches is UNANSWERED: the stub exits non-zero, the call is logged, and the
18
+ * run fails — the same choice `script-overrun.ts` made for a scripted model that
19
+ * runs past its script.
20
+ *
21
+ * Argv is a list, not a line: joining it loses token boundaries (`--title "a b"`
22
+ * is one token), and a RegExp over the joined line matches across them.
23
+ *
24
+ * Everything here is pure and JSON-serialisable, because the same rules are
25
+ * read by the stub process (one per invocation), folded into the eval cache key
26
+ * and the lock, and printed in failure messages. No zod here on purpose: this
27
+ * module is on the stub process's path, which runs once per `gh` call.
28
+ */
29
+ Object.defineProperty(exports, "__esModule", { value: true });
30
+ exports.UNANSWERED_EXIT_CODE = exports.ARGV_REST = void 0;
31
+ exports.matchArgv = matchArgv;
32
+ exports.priorAnswers = priorAnswers;
33
+ exports.decideInvocation = decideInvocation;
34
+ exports.isUnanswered = isUnanswered;
35
+ exports.answerFor = answerFor;
36
+ exports.encodeStub = encodeStub;
37
+ exports.toToolStub = toToolStub;
38
+ exports.decodeStub = decodeStub;
39
+ exports.stubLogLine = stubLogLine;
40
+ exports.parseStubLog = parseStubLog;
41
+ exports.describePattern = describePattern;
42
+ exports.unansweredMessage = unansweredMessage;
43
+ exports.unsupportedLine = unsupportedLine;
44
+ exports.unloggedMisses = unloggedMisses;
45
+ exports.stubPaths = stubPaths;
46
+ exports.runStub = runStub;
47
+ const node_path_1 = require("node:path");
48
+ /** The `rest` marker. One frozen value, so a pattern carries no free-form object. */
49
+ exports.ARGV_REST = Object.freeze({ kind: "rest" });
50
+ const isRest = (t) => typeof t === "object" && !(t instanceof RegExp);
51
+ const isPlainToken = (t) => !isRest(t);
52
+ /** Does one pattern token match one argv token? A RegExp is stateless here: `g`/`y` are refused at parse. */
53
+ const tokenMatches = (pattern, token) => typeof pattern === "string" ? pattern === token : pattern.test(token);
54
+ /**
55
+ * The positional tokens of a pattern, and whether it ends in `rest`. `rest` can
56
+ * only be last (the type says so; the parse refuses it elsewhere).
57
+ */
58
+ function splitPattern(p) {
59
+ const tokens = p;
60
+ const fixed = tokens.filter(isPlainToken);
61
+ return { fixed, open: fixed.length !== tokens.length };
62
+ }
63
+ /** Does `rule` match `argv`? Positional prefix, then `contains` over the tail. Pure, total. */
64
+ function matchArgv(rule, argv) {
65
+ const { fixed, open } = splitPattern(rule.argv);
66
+ const lengthOk = open
67
+ ? argv.length >= fixed.length
68
+ : argv.length === fixed.length;
69
+ if (!lengthOk)
70
+ return false;
71
+ if (!fixed.every((t, i) => tokenMatches(t, argv[i] ?? "")))
72
+ return false;
73
+ const tail = argv.slice(fixed.length);
74
+ return (rule.contains ?? []).every((c) => tail.some((t) => tokenMatches(c, t)));
75
+ }
76
+ /** How many times each rule of `tool` has already answered, from the log so far. */
77
+ function priorAnswers(calls, tool) {
78
+ const rules = calls.flatMap((c) => c.tool === tool && c.outcome.kind === "answered" ? [c.outcome.rule] : []);
79
+ return rules.reduce((m, r) => new Map([...m, [r, (m.get(r) ?? 0) + 1]]), new Map());
80
+ }
81
+ /** The decision for one invocation: (rules, answers so far, argv) → outcome. Pure, total. */
82
+ function decideInvocation(stub, prior, argv) {
83
+ const rule = stub.rules.findIndex((r) => matchArgv(r, argv));
84
+ if (rule < 0)
85
+ return { kind: "no-rule" };
86
+ const reply = stub.rules[rule]?.reply;
87
+ if (reply === undefined || reply.kind === "always")
88
+ return { kind: "answered", rule, answer: 0 };
89
+ const k = prior.get(rule) ?? 0;
90
+ return k < reply.answers.length
91
+ ? { kind: "answered", rule, answer: k }
92
+ : { kind: "exhausted", rule };
93
+ }
94
+ /** Whether an outcome left the call without an answer. */
95
+ function isUnanswered(o) {
96
+ return o.kind !== "answered";
97
+ }
98
+ /** The answer an `answered` outcome refers to; `undefined` for an unanswered one. */
99
+ function answerFor(stub, o) {
100
+ if (o.kind !== "answered")
101
+ return undefined;
102
+ const reply = stub.rules[o.rule]?.reply;
103
+ if (reply === undefined)
104
+ return undefined;
105
+ return reply.kind === "always" ? reply : reply.answers[o.answer];
106
+ }
107
+ function encodeToken(t) {
108
+ if (typeof t === "string")
109
+ return t;
110
+ if (t instanceof RegExp)
111
+ return { kind: "re", source: t.source, flags: t.flags };
112
+ return { kind: "rest" };
113
+ }
114
+ /**
115
+ * Serialise a stub to JSON. A RegExp becomes `{kind:"re",source,flags}` —
116
+ * `JSON.stringify(/x/)` is `{}`, which would make two different rules hash the
117
+ * same in the cache key and the lock.
118
+ */
119
+ function encodeStub(stub) {
120
+ return JSON.stringify({
121
+ name: stub.name,
122
+ rules: stub.rules.map((r) => ({
123
+ argv: r.argv.map(encodeToken),
124
+ ...(r.contains === undefined
125
+ ? {}
126
+ : { contains: r.contains.map(encodeToken) }),
127
+ reply: r.reply,
128
+ })),
129
+ });
130
+ }
131
+ // --- reading data back: narrowing, never a cast ----------------------------------
132
+ //
133
+ // Two readers: the stub process (the rules file and the log — vigiles' own
134
+ // output, so a bad shape is an internal error) and the spec boundary (after its
135
+ // zod parse, to get the typed value without a cast). Neither pays for zod.
136
+ function internal(what) {
137
+ throw new Error(`vigiles stub: ${what} — an internal error, please report it.`);
138
+ }
139
+ const isObj = (v) => typeof v === "object" &&
140
+ v !== null &&
141
+ !Array.isArray(v) &&
142
+ !(v instanceof RegExp);
143
+ const listOf = (v, what) => Array.isArray(v) ? v : internal(`${what} is not a list`);
144
+ function nonEmpty(xs, what) {
145
+ const [first, ...more] = xs;
146
+ return first === undefined ? internal(`${what} is empty`) : [first, ...more];
147
+ }
148
+ function toToken(v) {
149
+ if (typeof v === "string" || v instanceof RegExp)
150
+ return v;
151
+ if (isObj(v) && v.kind === "rest")
152
+ return exports.ARGV_REST;
153
+ if (isObj(v) && typeof v.source === "string" && typeof v.flags === "string")
154
+ return new RegExp(v.source, v.flags);
155
+ return internal("a rule token is not a string, a RegExp or rest");
156
+ }
157
+ function toPattern(v) {
158
+ const tokens = listOf(v, "argv").map(toToken);
159
+ const fixed = tokens.filter(isPlainToken);
160
+ const last = tokens.at(-1);
161
+ const open = last !== undefined && isRest(last);
162
+ if (fixed.length !== tokens.length - (open ? 1 : 0))
163
+ return internal("rest is not the last token");
164
+ return open ? [...fixed, exports.ARGV_REST] : fixed;
165
+ }
166
+ function toAnswer(v) {
167
+ if (!isObj(v))
168
+ return internal("an answer is not an object");
169
+ const { stdout, stderr, exitCode } = v;
170
+ return {
171
+ ...(typeof stdout === "string" ? { stdout } : {}),
172
+ ...(typeof stderr === "string" ? { stderr } : {}),
173
+ ...(typeof exitCode === "number" ? { exitCode } : {}),
174
+ };
175
+ }
176
+ function toReply(v) {
177
+ if (!isObj(v))
178
+ return internal("a reply is not an object");
179
+ if (v.kind === "always")
180
+ return { kind: "always", ...toAnswer(v) };
181
+ if (v.kind === "inOrder")
182
+ return {
183
+ kind: "inOrder",
184
+ answers: nonEmpty(listOf(v.answers, "answers").map(toAnswer), "answers"),
185
+ };
186
+ return internal("a reply has an unknown kind");
187
+ }
188
+ function toRule(v) {
189
+ if (!isObj(v))
190
+ return internal("a rule is not an object");
191
+ return {
192
+ argv: toPattern(v.argv),
193
+ ...(v.contains === undefined
194
+ ? {}
195
+ : {
196
+ contains: listOf(v.contains, "contains")
197
+ .map(toToken)
198
+ .filter(isPlainToken),
199
+ }),
200
+ reply: toReply(v.reply),
201
+ };
202
+ }
203
+ /** A typed {@link ToolStub} from a value already known to have its shape. */
204
+ function toToolStub(v) {
205
+ if (!isObj(v) || typeof v.name !== "string")
206
+ return internal("a stub has no name");
207
+ return {
208
+ name: v.name,
209
+ rules: nonEmpty(listOf(v.rules, "rules").map(toRule), "rules"),
210
+ };
211
+ }
212
+ /**
213
+ * Read back what {@link encodeStub} wrote. The input is vigiles' own output,
214
+ * written after the spec was parsed, so a bad shape is an internal error.
215
+ */
216
+ function decodeStub(json) {
217
+ return toToolStub(JSON.parse(json));
218
+ }
219
+ // --- the invocation log --------------------------------------------------------
220
+ /** One log line for one call (no trailing newline). */
221
+ function stubLogLine(call) {
222
+ return JSON.stringify(call);
223
+ }
224
+ function toOutcome(v) {
225
+ if (!isObj(v))
226
+ return internal("a log outcome is not an object");
227
+ const { kind, rule, answer } = v;
228
+ if (kind === "no-rule")
229
+ return { kind };
230
+ if (kind === "exhausted" && typeof rule === "number")
231
+ return { kind, rule };
232
+ if (kind === "answered" &&
233
+ typeof rule === "number" &&
234
+ typeof answer === "number")
235
+ return { kind, rule, answer };
236
+ return internal("a log outcome has an unknown kind");
237
+ }
238
+ function toStubCall(v) {
239
+ if (!isObj(v) || typeof v.tool !== "string")
240
+ return internal("a log line has no tool");
241
+ return {
242
+ tool: v.tool,
243
+ argv: listOf(v.argv, "argv").map((t) => typeof t === "string" ? t : internal("an argv token is not a string")),
244
+ outcome: toOutcome(v.outcome),
245
+ };
246
+ }
247
+ /** Read the JSONL log the stub processes appended. Blank lines are skipped. */
248
+ function parseStubLog(jsonl) {
249
+ return jsonl.split("\n").flatMap((line, i) => {
250
+ if (line.trim() === "")
251
+ return [];
252
+ try {
253
+ return [toStubCall(JSON.parse(line))];
254
+ }
255
+ catch (e) {
256
+ const why = e instanceof Error ? e.message : String(e);
257
+ throw new Error(`vigiles: stub log line ${String(i + 1)} is unreadable (${why}) — an internal error, please report it.`, { cause: e });
258
+ }
259
+ });
260
+ }
261
+ // --- the author-facing diagnostic ---------------------------------------------
262
+ function showToken(t) {
263
+ if (typeof t === "string")
264
+ return JSON.stringify(t);
265
+ if (t instanceof RegExp)
266
+ return `/${t.source}/${t.flags}`;
267
+ return "experimental_stub.rest";
268
+ }
269
+ /** A pattern the way a rule is written: `["issue", "create", experimental_stub.rest]`. */
270
+ function describePattern(p) {
271
+ return `[${p.map(showToken).join(", ")}]`;
272
+ }
273
+ const whyUnanswered = (o) => o.kind === "exhausted"
274
+ ? `rule #${String(o.rule + 1)} has no answer left (its inOrder answers ran out)`
275
+ : "no rule matches";
276
+ /** The miss whose argv nobody recorded: what happened, and why it cannot name the command. */
277
+ function describeUnlogged(c, n) {
278
+ const times = n > 1 ? ` ×${String(n)}` : "";
279
+ return (` ${c.tool}${times}: the stub printed its unsupported-invocation line but could not log the call — ` +
280
+ `is the stub directory writable from the agent's shell? The argv is unknown, so no rule is suggested; ` +
281
+ `run once outside the confinement to see which command it was.`);
282
+ }
283
+ /** One unanswered argv, `n` times: why, the rules it was tried against, the rule to add. */
284
+ function describeMiss(c, n, stubs) {
285
+ if (c.outcome.kind === "unlogged")
286
+ return describeUnlogged(c, n);
287
+ const times = n > 1 ? ` ×${String(n)}` : "";
288
+ const rules = (stubs.find((s) => s.name === c.tool)?.rules ?? []).map((r, i) => `\n #${String(i + 1)} ${describePattern(r.argv)}`);
289
+ const tried = rules.length === 0 ? "" : `; the rules for ${c.tool} are:${rules.join("")}`;
290
+ return (` ${c.tool} ${JSON.stringify(c.argv)}${times} — ${whyUnanswered(c.outcome)}${tried}\n` +
291
+ ` add: { argv: ${describePattern(c.argv)}, reply: { kind: "always", stdout: "…" } }`);
292
+ }
293
+ /**
294
+ * The failure message for a run with unanswered calls, addressed to the AUTHOR
295
+ * (the model only ever sees a neutral one-liner). Each distinct argv once, with
296
+ * a count; the rules it was tried against; and a rule for that exact command to
297
+ * add. It never suggests a catch-all: answering everything is the defect this
298
+ * exists to stop. `undefined` when every call was answered.
299
+ */
300
+ function unansweredMessage(calls, stubs) {
301
+ const missed = calls.filter((c) => isUnanswered(c.outcome));
302
+ if (missed.length === 0)
303
+ return undefined;
304
+ const keyOf = (c) => `${c.tool}\u0000${JSON.stringify(c.argv)}\u0000${c.outcome.kind}`;
305
+ const distinct = [...new Map(missed.map((c) => [keyOf(c), c])).values()];
306
+ const lines = distinct.map((c) => describeMiss(c, missed.filter((m) => keyOf(m) === keyOf(c)).length, stubs));
307
+ return (`${String(missed.length)} stub call(s) went unanswered — the agent ran a command no rule scripts, ` +
308
+ `so the trial measured an answer nobody wrote:\n${lines.join("\n")}\n` +
309
+ ` Add a rule for each command the agent really runs, with the answer the real tool gives ` +
310
+ `(record it once; do not invent it). A rule that answers every argv would hide this again.`);
311
+ }
312
+ // --- one invocation, end to end (the stub process runs this) ------------------
313
+ /**
314
+ * The exit code of an unanswered call. Not 127: that tells the caller "not
315
+ * installed", and a model changes plans for the wrong reason.
316
+ */
317
+ exports.UNANSWERED_EXIT_CODE = 97;
318
+ /**
319
+ * What an unanswered call prints to stderr — the only thing the MODEL sees. One
320
+ * fixed line: no argv, no rules, no "add a rule". Instructions here would tell
321
+ * the model it is in a test and what the author will do, and the rest of the
322
+ * trial would measure a reaction to that. The diagnostic goes to the author,
323
+ * from the log, through {@link unansweredMessage}.
324
+ */
325
+ function unsupportedLine(name) {
326
+ return `${name}: unsupported invocation (vigiles stub)\n`;
327
+ }
328
+ /**
329
+ * The misses a stub could not log, found from the agent's side: every
330
+ * occurrence of a stubbed tool's neutral miss line ({@link unsupportedLine}) in
331
+ * `texts` (the agent's tool results) beyond the unanswered calls the log already
332
+ * holds for that tool. The line is vigiles' own marker, not a guess about what
333
+ * the agent ran, so this is a count of a thing we printed. A miss the log has
334
+ * is counted once — by the log. Pure.
335
+ */
336
+ function unloggedMisses(texts, logged, names) {
337
+ return names.flatMap((name) => {
338
+ const line = unsupportedLine(name).trimEnd();
339
+ const seen = texts.reduce((n, t) => n + (t.split(line).length - 1), 0);
340
+ const known = logged.filter((c) => c.tool === name && isUnanswered(c.outcome)).length;
341
+ const unlogged = {
342
+ tool: name,
343
+ argv: [],
344
+ outcome: { kind: "unlogged" },
345
+ };
346
+ return Array.from({ length: Math.max(0, seen - known) }, () => unlogged);
347
+ });
348
+ }
349
+ /** Where a stub root keeps its files (`name` picks the per-stub ones). Pure path arithmetic. */
350
+ function stubPaths(root, name = "") {
351
+ return {
352
+ binDir: (0, node_path_1.join)(root, "bin"),
353
+ rulesDir: (0, node_path_1.join)(root, "rules"),
354
+ rules: (0, node_path_1.join)(root, "rules", `${name}.json`),
355
+ log: (0, node_path_1.join)(root, "calls.jsonl"),
356
+ bin: (0, node_path_1.join)(root, "bin", name),
357
+ };
358
+ }
359
+ /**
360
+ * One invocation: read the rules and the log so far, decide, append one log
361
+ * line, return what to print. It never reads stdin: a caller that leaves the
362
+ * pipe open (Node's `execFile` does) would block until its own timeout.
363
+ *
364
+ * Concurrency, said plainly: two simultaneous calls of the same `inOrder` rule
365
+ * can both read "answered k times" and both get answer k. One line per write
366
+ * keeps the log itself intact.
367
+ */
368
+ function runStub(root, name, argv, io) {
369
+ const paths = stubPaths(root, name);
370
+ const json = io.readFile(paths.rules);
371
+ if (json === null)
372
+ return {
373
+ stdout: "",
374
+ stderr: `vigiles stub: no rules file for ${name} under ${root} — an internal error, please report it.\n`,
375
+ exitCode: exports.UNANSWERED_EXIT_CODE,
376
+ };
377
+ const stub = decodeStub(json);
378
+ const prior = priorAnswers(parseStubLog(io.readFile(paths.log) ?? ""), name);
379
+ const outcome = decideInvocation(stub, prior, argv);
380
+ io.appendFile(paths.log, stubLogLine({ tool: name, argv, outcome }) + "\n");
381
+ return printed(name, answerFor(stub, outcome));
382
+ }
383
+ /** What the process prints for an answer — or the neutral miss line for none. */
384
+ function printed(name, answer) {
385
+ if (answer === undefined)
386
+ return {
387
+ stdout: "",
388
+ stderr: unsupportedLine(name),
389
+ exitCode: exports.UNANSWERED_EXIT_CODE,
390
+ };
391
+ return {
392
+ stdout: answer.stdout ?? "",
393
+ stderr: answer.stderr ?? "",
394
+ exitCode: answer.exitCode ?? 0,
395
+ };
396
+ }
397
+ //# sourceMappingURL=stub-rules.js.map
@@ -1,5 +1,6 @@
1
1
  import { type SHA256Hash } from "./core/hash.js";
2
2
  import type { RunOut } from "./eval.js";
3
+ import type { StubCall } from "./core/stub-rules.js";
3
4
  /** Cache behaviour: never touch the cache / read-only / read-and-write. */
4
5
  export type CacheMode = "off" | "read" | "readwrite";
5
6
  /** Everything that determines a trial's model output (the cache key inputs). */
@@ -33,6 +34,17 @@ export interface CacheKeyInput {
33
34
  * arm, not a native install). See {@link hashDir}.
34
35
  */
35
36
  readonly pluginDirHash?: string;
37
+ /**
38
+ * The run's tool stubs, each ENCODED (`encodeStub` — a RegExp token as its
39
+ * source and flags, since `JSON.stringify(/x/)` is `{}`). A changed answer is
40
+ * a changed input. Omit when there are none.
41
+ */
42
+ readonly stubs?: readonly string[];
43
+ /**
44
+ * Where the run executes: the env kind and an ephemeral run's seed — never the
45
+ * per-trial HOME path. A seed file is part of what the agent sees.
46
+ */
47
+ readonly runEnv?: unknown;
36
48
  /**
37
49
  * The harness BINARY version (e.g. `claude --version`). The harness evolves
38
50
  * fast — a CLI upgrade changes the system prompt + tool definitions, which steer
@@ -49,6 +61,12 @@ export interface CacheRecord {
49
61
  readonly out: RunOut;
50
62
  /** Text files present in the cwd after the run (relative path → contents). */
51
63
  readonly files: Record<string, string>;
64
+ /**
65
+ * The run's stub calls. The stub log lives beside the work dir, not in it, so
66
+ * the cwd snapshot does not hold it; a replay must report the same calls —
67
+ * unanswered ones included. Absent when the run had no stubs.
68
+ */
69
+ readonly stubCalls?: readonly StubCall[];
52
70
  }
53
71
  /**
54
72
  * Canonicalize a value so the key is stable regardless of object key order —
@@ -64,7 +82,7 @@ export declare function canonical(value: unknown): unknown;
64
82
  * brittle read-time version gate needed). A major bump means orphaned files on
65
83
  * disk; reclaim them by deleting the cache dir.
66
84
  */
67
- export declare const CACHE_FORMAT_VERSION = 2;
85
+ export declare const CACHE_FORMAT_VERSION = 3;
68
86
  /** Deterministic content hash of the key inputs (order-independent). */
69
87
  export declare function cacheKey(input: CacheKeyInput): SHA256Hash;
70
88
  /**
@@ -56,7 +56,7 @@ function canonical(value) {
56
56
  * brittle read-time version gate needed). A major bump means orphaned files on
57
57
  * disk; reclaim them by deleting the cache dir.
58
58
  */
59
- exports.CACHE_FORMAT_VERSION = 2;
59
+ exports.CACHE_FORMAT_VERSION = 3;
60
60
  /**
61
61
  * Per-run env keys that are PURE NOISE for the cache key — a fresh random path
62
62
  * every run, never model-affecting. The opt-in ephemeral run env
@@ -19,6 +19,34 @@ export declare function trialsOverride(raw: string | undefined): number | undefi
19
19
  * hand-write its own `report.n === 0` check (and why several forgot to).
20
20
  */
21
21
  export declare function runsIn(report: AnyReport): number | undefined;
22
+ /**
23
+ * The exit code of an eval file whose report has an unanswered stub call. Its
24
+ * own code — not 2 (the CLI's usage error), 1 (a bad declaration, zero runs) or
25
+ * 77 (skip) — so the runner can say WHICH cause it is from a code only this path
26
+ * produces. `vigiles eval` itself exits {@link UNANSWERED_STUB_VERB_EXIT} for it.
27
+ */
28
+ export declare const UNANSWERED_STUB_EXIT_CODE = 78;
29
+ /**
30
+ * What `vigiles eval` exits with when a file exited {@link UNANSWERED_STUB_EXIT_CODE}:
31
+ * 2, the CLI's "error" — the run did not fail an assertion, it produced something
32
+ * that is not a measurement. Documented in docs/cli.md.
33
+ */
34
+ export declare const UNANSWERED_STUB_VERB_EXIT = 2;
35
+ /**
36
+ * The author-facing failure when a `runEval` report carries a stub call no rule
37
+ * answered, or undefined. Only `runEval` has `stubs`. Pure — the rule itself is
38
+ * `unansweredInReport`, which the lock's refusal reads too.
39
+ */
40
+ export declare function unansweredFailure(kind: EvalKind, report: AnyReport, spec: unknown): string | undefined;
41
+ /**
42
+ * Why a finished run fails before its `assert` runs — with the exit code — or
43
+ * undefined. Two reasons: it ran ZERO trials (exit 1; the check every file used
44
+ * to hand-write), or a stub call went unanswered (exit 2). Pure.
45
+ */
46
+ export declare function runFailure(file: string, kind: EvalKind, report: AnyReport, spec: unknown): {
47
+ readonly code: number;
48
+ readonly message: string;
49
+ } | undefined;
22
50
  /**
23
51
  * `evalDriver` is only wired on `measureTriggerRate` — the only measurement with
24
52
  * a public driver seam. Naming it beside any other measurement is a mistake the
@@ -1,7 +1,10 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.UNANSWERED_STUB_VERB_EXIT = exports.UNANSWERED_STUB_EXIT_CODE = void 0;
3
4
  exports.trialsOverride = trialsOverride;
4
5
  exports.runsIn = runsIn;
6
+ exports.unansweredFailure = unansweredFailure;
7
+ exports.runFailure = runFailure;
5
8
  exports.driverMisplaced = driverMisplaced;
6
9
  exports.notADescriptionMessage = notADescriptionMessage;
7
10
  exports.declarationProblem = declarationProblem;
@@ -27,7 +30,10 @@ exports.declarationProblem = declarationProblem;
27
30
  * 5. prints the report with the formatter that matches the measurement;
28
31
  * 6. fails on a run that executed ZERO trials — the check every file used to
29
32
  * hand-write as `if (report.n === 0) throw`;
30
- * 7. calls `assert(report)`.
33
+ * 7. fails — exit 2, before `assert` — on a `runEval` report with a stub call
34
+ * no rule answered: such a trial measured an answer nobody wrote, and the
35
+ * file's own `assert` may not look (most call only `compareArms`);
36
+ * 8. calls `assert(report)`.
31
37
  *
32
38
  * ## Not a CLI verb
33
39
  *
@@ -40,6 +46,7 @@ const node_path_1 = require("node:path");
40
46
  const eval_load_phase_js_1 = require("./core/eval-load-phase.js");
41
47
  const eval_define_js_1 = require("./eval-define.js");
42
48
  const eval_js_1 = require("./eval.js");
49
+ const eval_spec_parse_js_1 = require("./core/eval-spec-parse.js");
43
50
  const scan_behavioral_js_1 = require("./scan-behavioral.js");
44
51
  /**
45
52
  * How many trials this run should use, or `undefined` to leave the spec alone.
@@ -70,6 +77,55 @@ function runsIn(report) {
70
77
  }
71
78
  return "n" in report ? report.n : undefined;
72
79
  }
80
+ /**
81
+ * The exit code of an eval file whose report has an unanswered stub call. Its
82
+ * own code — not 2 (the CLI's usage error), 1 (a bad declaration, zero runs) or
83
+ * 77 (skip) — so the runner can say WHICH cause it is from a code only this path
84
+ * produces. `vigiles eval` itself exits {@link UNANSWERED_STUB_VERB_EXIT} for it.
85
+ */
86
+ exports.UNANSWERED_STUB_EXIT_CODE = 78;
87
+ /**
88
+ * What `vigiles eval` exits with when a file exited {@link UNANSWERED_STUB_EXIT_CODE}:
89
+ * 2, the CLI's "error" — the run did not fail an assertion, it produced something
90
+ * that is not a measurement. Documented in docs/cli.md.
91
+ */
92
+ exports.UNANSWERED_STUB_VERB_EXIT = 2;
93
+ /** A `runEval` report: every arm carries its unanswered stub calls. */
94
+ const isEvalReport = (r) => "arms" in r &&
95
+ Object.values(r.arms).every((a) => "unansweredStubCalls" in a);
96
+ /**
97
+ * The author-facing failure when a `runEval` report carries a stub call no rule
98
+ * answered, or undefined. Only `runEval` has `stubs`. Pure — the rule itself is
99
+ * `unansweredInReport`, which the lock's refusal reads too.
100
+ */
101
+ function unansweredFailure(kind, report, spec) {
102
+ if (kind !== "runEval" || !isEvalReport(report))
103
+ return undefined;
104
+ const raw = typeof spec === "object" && spec !== null && "stubs" in spec
105
+ ? spec.stubs
106
+ : undefined;
107
+ return (0, eval_js_1.unansweredInReport)(report, (0, eval_spec_parse_js_1.parseToolStubs)(raw, "runEval"));
108
+ }
109
+ /**
110
+ * Why a finished run fails before its `assert` runs — with the exit code — or
111
+ * undefined. Two reasons: it ran ZERO trials (exit 1; the check every file used
112
+ * to hand-write), or a stub call went unanswered (exit 2). Pure.
113
+ */
114
+ function runFailure(file, kind, report, spec) {
115
+ if (runsIn(report) === 0)
116
+ return {
117
+ code: 1,
118
+ message: `✗ ${file}: no runs executed (0 trials completed).`,
119
+ };
120
+ // `runEval` has already printed the calls in full (to stderr, before its
121
+ // report): this line is the file's verdict, not a second copy of them.
122
+ return unansweredFailure(kind, report, spec) === undefined
123
+ ? undefined
124
+ : {
125
+ code: exports.UNANSWERED_STUB_EXIT_CODE,
126
+ message: `✗ ${file}: stub call(s) went unanswered (listed above) — the report is not a measurement.`,
127
+ };
128
+ }
73
129
  /**
74
130
  * `evalDriver` is only wired on `measureTriggerRate` — the only measurement with
75
131
  * a public driver seam. Naming it beside any other measurement is a mistake the
@@ -111,10 +167,7 @@ function printReport(kind, report) {
111
167
  console.log((0, eval_js_1.formatCheckReport)(report));
112
168
  return;
113
169
  case "measureArms":
114
- for (const [name, arm] of Object.entries(report.arms)) {
115
- console.log(`\n[arm: ${name}]`);
116
- console.log((0, eval_js_1.formatCheckReport)(arm));
117
- }
170
+ console.log((0, eval_js_1.formatArmsCheckReport)(report));
118
171
  return;
119
172
  case "measureTriggerRate":
120
173
  console.log((0, eval_js_1.formatTriggerRateReport)(report));
@@ -156,11 +209,10 @@ async function main() {
156
209
  console.error("vigiles: eval-entry expects one eval file. Run `vigiles eval <file>`.");
157
210
  process.exit(2);
158
211
  }
159
- const url = (0, node_url_1.pathToFileURL)((0, node_path_1.resolve)(file)).href;
160
212
  let mod;
161
213
  (0, eval_load_phase_js_1.beginEvalLoad)();
162
214
  try {
163
- mod = await import(url);
215
+ mod = await import((0, node_url_1.pathToFileURL)((0, node_path_1.resolve)(file)).href);
164
216
  }
165
217
  finally {
166
218
  (0, eval_load_phase_js_1.endEvalLoad)();
@@ -187,9 +239,10 @@ async function main() {
187
239
  }
188
240
  const report = await runDeclared(declared.kind, declared.spec, trialsOverride(process.env["VIGILES_TRIALS"]), def.evalDriver);
189
241
  printReport(declared.kind, report);
190
- if (runsIn(report) === 0) {
191
- console.error(`✗ ${file}: no runs executed (0 trials completed).`);
192
- process.exit(1);
242
+ const failure = runFailure(file, declared.kind, report, declared.spec);
243
+ if (failure !== undefined) {
244
+ console.error(failure.message);
245
+ process.exit(failure.code);
193
246
  }
194
247
  await def.assert?.(report);
195
248
  }
@@ -123,5 +123,7 @@ export { judged as paid_judged } from "./check.js";
123
123
  export { claudeEvalDriver as paid_claudeEvalDriver } from "./eval.js";
124
124
  export type { Check, CheckResult, JudgeFn } from "./check.js";
125
125
  export type { Trace } from "./harness-test.js";
126
+ export type { HomeFiles, HomeSeed, RunEnv } from "./core/run-env.js";
127
+ export type { StubCall, StubOutcome, ToolStub } from "./core/stub-rules.js";
126
128
  export type { EvalArm, EvalDriver, EvalSpec, EvalReport, EvalUsage, MeasureSpec, ArmsMeasureSpec, ArmReport, ArmUsage, ArmsCheckReport, CheckRate, CheckReport, MetricStat, Metrics, ModelOutputParser, ParsedModelRun, PromptDiversityIssue, PromptTriggerStat, RunContext, RunOut, SelectionTrialResult, TriggerRateReport, TriggerRateSpec, AgentRunArgs, AgentRunner, } from "./eval.js";
127
129
  //# sourceMappingURL=eval-surface.d.ts.map