vigiles 35.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,199 @@
1
+ /**
2
+ * A tool stub that answers PER INVOCATION — the pure core.
3
+ *
4
+ * A stub shadows a binary on PATH (`gh`, `git`, `psql`) so a run that calls it
5
+ * gets a recorded answer instead of the real service. The earlier stub printed
6
+ * ONE answer for every argv. That is a test double answering by default, and a
7
+ * default answer is a fabricated observation: a stub set up to answer
8
+ * `gh issue create` with an issue URL also answered a script's
9
+ * `gh api repos/o/r/issues?…` with that URL, the script failed to parse it as
10
+ * JSON, and a model that read the stub's source stopped trusting it.
11
+ *
12
+ * So a stub is a non-empty list of RULES over the argv TOKEN ARRAY. Each rule has
13
+ * a positional pattern (an exact string or a RegExp per token, and an explicit
14
+ * trailing `rest` for "any further tokens"), an optional order-free `contains`
15
+ * for flags, and a reply. The first matching rule answers. An argv no rule
16
+ * matches is UNANSWERED: the stub exits non-zero, the call is logged, and the
17
+ * run fails — the same choice `script-overrun.ts` made for a scripted model that
18
+ * runs past its script.
19
+ *
20
+ * Argv is a list, not a line: joining it loses token boundaries (`--title "a b"`
21
+ * is one token), and a RegExp over the joined line matches across them.
22
+ *
23
+ * Everything here is pure and JSON-serialisable, because the same rules are
24
+ * read by the stub process (one per invocation), folded into the eval cache key
25
+ * and the lock, and printed in failure messages. No zod here on purpose: this
26
+ * module is on the stub process's path, which runs once per `gh` call.
27
+ */
28
+ /** "Any number of further tokens, including none." Only valid as the LAST token. */
29
+ export interface ArgvRest {
30
+ readonly kind: "rest";
31
+ }
32
+ /** The `rest` marker. One frozen value, so a pattern carries no free-form object. */
33
+ export declare const ARGV_REST: ArgvRest;
34
+ /** One positional token: exact string, or a RegExp tested against that one token. */
35
+ export type ArgvToken = string | Readonly<RegExp>;
36
+ /**
37
+ * A positional pattern: token i matches argv token i. Without a trailing
38
+ * {@link ARGV_REST} the argv must have EXACTLY this many tokens, so a read rule
39
+ * `["api", /^repos\//]` does not also answer `api repos/… -f title=x`.
40
+ */
41
+ export type ArgvPattern = readonly ArgvToken[] | readonly [...ArgvToken[], ArgvRest];
42
+ /** What the fake binary does when a rule answers. */
43
+ export interface StubAnswer {
44
+ /** Printed to stdout. Default "". */
45
+ readonly stdout?: string;
46
+ /** Printed to stderr. Default none. */
47
+ readonly stderr?: string;
48
+ /** Exit code, 0–255. Default 0. */
49
+ readonly exitCode?: number;
50
+ }
51
+ /**
52
+ * `always`: every matching call gets the answer. `inOrder`: the k-th matching
53
+ * call gets `answers[k-1]`, and a call past the end is UNANSWERED — repetition
54
+ * is something a rule says, never a default.
55
+ */
56
+ export type StubReply = ({
57
+ readonly kind: "always";
58
+ } & StubAnswer) | {
59
+ readonly kind: "inOrder";
60
+ readonly answers: readonly [StubAnswer, ...StubAnswer[]];
61
+ };
62
+ /** One rule: an argv pattern, optional order-free tokens, and a reply. */
63
+ export interface StubRule {
64
+ readonly argv: ArgvPattern;
65
+ /**
66
+ * Tokens that must each match some argv token AFTER the positional prefix, in
67
+ * any order — for flags a model writes in any order (`--repo o/r` or `-R o/r`).
68
+ * Needs a trailing `rest` in `argv` (without it nothing follows the prefix).
69
+ */
70
+ readonly contains?: readonly ArgvToken[];
71
+ readonly reply: StubReply;
72
+ }
73
+ /** A stub for one binary: its name on PATH and its rules, first match wins. */
74
+ export interface ToolStub {
75
+ readonly name: string;
76
+ readonly rules: readonly [StubRule, ...StubRule[]];
77
+ }
78
+ /**
79
+ * What happened to one invocation. Three cases, because the two ways a call goes
80
+ * unanswered need different fixes: no rule matched (add a rule), or a rule
81
+ * matched but its `inOrder` answers ran out (add an answer).
82
+ */
83
+ export type StubOutcome = {
84
+ readonly kind: "answered";
85
+ /** 0-based rule index. */
86
+ readonly rule: number;
87
+ /** 0-based answer index (always 0 for an `always` reply). */
88
+ readonly answer: number;
89
+ } | {
90
+ readonly kind: "no-rule";
91
+ } | {
92
+ readonly kind: "exhausted";
93
+ readonly rule: number;
94
+ }
95
+ /**
96
+ * The stub printed its neutral miss line but left no log line (its directory
97
+ * was not writable from the agent's shell). Never read from a log: the eval
98
+ * derives it from the agent's tool results.
99
+ */
100
+ | {
101
+ readonly kind: "unlogged";
102
+ };
103
+ /** One recorded invocation of a stub. */
104
+ export interface StubCall {
105
+ readonly tool: string;
106
+ readonly argv: readonly string[];
107
+ readonly outcome: StubOutcome;
108
+ }
109
+ /** Does `rule` match `argv`? Positional prefix, then `contains` over the tail. Pure, total. */
110
+ export declare function matchArgv(rule: Pick<StubRule, "argv" | "contains">, argv: readonly string[]): boolean;
111
+ /** How many times each rule of `tool` has already answered, from the log so far. */
112
+ export declare function priorAnswers(calls: readonly StubCall[], tool: string): ReadonlyMap<number, number>;
113
+ /** The decision for one invocation: (rules, answers so far, argv) → outcome. Pure, total. */
114
+ export declare function decideInvocation(stub: ToolStub, prior: ReadonlyMap<number, number>, argv: readonly string[]): StubOutcome;
115
+ /** Whether an outcome left the call without an answer. */
116
+ export declare function isUnanswered(o: StubOutcome): boolean;
117
+ /** The answer an `answered` outcome refers to; `undefined` for an unanswered one. */
118
+ export declare function answerFor(stub: ToolStub, o: StubOutcome): StubAnswer | undefined;
119
+ /**
120
+ * Serialise a stub to JSON. A RegExp becomes `{kind:"re",source,flags}` —
121
+ * `JSON.stringify(/x/)` is `{}`, which would make two different rules hash the
122
+ * same in the cache key and the lock.
123
+ */
124
+ export declare function encodeStub(stub: ToolStub): string;
125
+ /** A typed {@link ToolStub} from a value already known to have its shape. */
126
+ export declare function toToolStub(v: unknown): ToolStub;
127
+ /**
128
+ * Read back what {@link encodeStub} wrote. The input is vigiles' own output,
129
+ * written after the spec was parsed, so a bad shape is an internal error.
130
+ */
131
+ export declare function decodeStub(json: string): ToolStub;
132
+ /** One log line for one call (no trailing newline). */
133
+ export declare function stubLogLine(call: StubCall): string;
134
+ /** Read the JSONL log the stub processes appended. Blank lines are skipped. */
135
+ export declare function parseStubLog(jsonl: string): readonly StubCall[];
136
+ /** A pattern the way a rule is written: `["issue", "create", experimental_stub.rest]`. */
137
+ export declare function describePattern(p: readonly (ArgvToken | ArgvRest)[]): string;
138
+ /**
139
+ * The failure message for a run with unanswered calls, addressed to the AUTHOR
140
+ * (the model only ever sees a neutral one-liner). Each distinct argv once, with
141
+ * a count; the rules it was tried against; and a rule for that exact command to
142
+ * add. It never suggests a catch-all: answering everything is the defect this
143
+ * exists to stop. `undefined` when every call was answered.
144
+ */
145
+ export declare function unansweredMessage(calls: readonly StubCall[], stubs: readonly ToolStub[]): string | undefined;
146
+ /**
147
+ * The exit code of an unanswered call. Not 127: that tells the caller "not
148
+ * installed", and a model changes plans for the wrong reason.
149
+ */
150
+ export declare const UNANSWERED_EXIT_CODE = 97;
151
+ /**
152
+ * What an unanswered call prints to stderr — the only thing the MODEL sees. One
153
+ * fixed line: no argv, no rules, no "add a rule". Instructions here would tell
154
+ * the model it is in a test and what the author will do, and the rest of the
155
+ * trial would measure a reaction to that. The diagnostic goes to the author,
156
+ * from the log, through {@link unansweredMessage}.
157
+ */
158
+ export declare function unsupportedLine(name: string): string;
159
+ /**
160
+ * The misses a stub could not log, found from the agent's side: every
161
+ * occurrence of a stubbed tool's neutral miss line ({@link unsupportedLine}) in
162
+ * `texts` (the agent's tool results) beyond the unanswered calls the log already
163
+ * holds for that tool. The line is vigiles' own marker, not a guess about what
164
+ * the agent ran, so this is a count of a thing we printed. A miss the log has
165
+ * is counted once — by the log. Pure.
166
+ */
167
+ export declare function unloggedMisses(texts: readonly string[], logged: readonly StubCall[], names: readonly string[]): readonly StubCall[];
168
+ /** Where a stub root keeps its files (`name` picks the per-stub ones). Pure path arithmetic. */
169
+ export declare function stubPaths(root: string, name?: string): {
170
+ readonly binDir: string;
171
+ readonly rulesDir: string;
172
+ readonly rules: string;
173
+ readonly log: string;
174
+ readonly bin: string;
175
+ };
176
+ /** The two file operations one invocation needs — injected, so the decision stays testable. */
177
+ export interface StubIo {
178
+ /** File contents, or null when the file does not exist. */
179
+ readonly readFile: (path: string) => string | null;
180
+ /** Append text; one call per log line (`O_APPEND`, one write). */
181
+ readonly appendFile: (path: string, text: string) => void;
182
+ }
183
+ /** What the stub process prints and how it exits. */
184
+ export interface StubProcessResult {
185
+ readonly stdout: string;
186
+ readonly stderr: string;
187
+ readonly exitCode: number;
188
+ }
189
+ /**
190
+ * One invocation: read the rules and the log so far, decide, append one log
191
+ * line, return what to print. It never reads stdin: a caller that leaves the
192
+ * pipe open (Node's `execFile` does) would block until its own timeout.
193
+ *
194
+ * Concurrency, said plainly: two simultaneous calls of the same `inOrder` rule
195
+ * can both read "answered k times" and both get answer k. One line per write
196
+ * keeps the log itself intact.
197
+ */
198
+ export declare function runStub(root: string, name: string, argv: readonly string[], io: StubIo): StubProcessResult;
199
+ //# sourceMappingURL=stub-rules.d.ts.map
@@ -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