@basein/runner 0.2.11 → 0.2.12
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.
- package/README.md +33 -14
- package/dist/bin/bir-scenario.d.ts +11 -0
- package/dist/bin/bir-scenario.js +132 -34
- package/dist/bin/bir.d.ts +2 -2
- package/dist/bin/bir.js +11 -8
- package/dist/bin/investigate.js +1 -1
- package/dist/bin/scenario-edit.d.ts +36 -9
- package/dist/bin/scenario-edit.js +240 -61
- package/dist/control/server.js +18 -5
- package/dist/record/housekeeping.d.ts +71 -0
- package/dist/record/housekeeping.js +417 -0
- package/dist/replay/controller.d.ts +3 -1
- package/dist/replay/controller.js +17 -12
- package/docs/calculatedReplay.md +89 -29
- package/docs/calculatedReplayGuide.md +294 -86
- package/docs/quickstart.md +42 -2
- package/package.json +1 -1
|
@@ -27,13 +27,430 @@
|
|
|
27
27
|
* possibly be the user's task. `Read`, `Bash` and `Grep` are NOT here: they do
|
|
28
28
|
* real work, they belong in a recording, and reaching for one mid-replay
|
|
29
29
|
* usually *is* the model doing the task another way.
|
|
30
|
+
*
|
|
31
|
+
* THE RUNNER'S OWN `bir` IS HOUSEKEEPING TOO (editSteps.md D13), and that is
|
|
32
|
+
* the one reason this file now looks at a call's input and not only its name.
|
|
33
|
+
* A session that fixes a plan — `bir investigate`, then `bir scenario edit`, or
|
|
34
|
+
* the same through the `bir` MCP server's `scenario_*` tools — is exactly the
|
|
35
|
+
* kind of session that gets recorded and calculated. Recorded, it becomes a
|
|
36
|
+
* scenario whose steps *edit plans*, and a steered or direct replay of it would
|
|
37
|
+
* then change a plan unattended, on a prompt nobody meant as "change the plan":
|
|
38
|
+
* the very thing D2 keeps the editing tools switched off in a fleet to prevent.
|
|
39
|
+
* And mid-replay, a model that stops to read `bir investigate` has not left the
|
|
40
|
+
* plan; it is reading about it.
|
|
41
|
+
*
|
|
42
|
+
* So two more kinds of call are housekeeping, and both are still narrow:
|
|
43
|
+
*
|
|
44
|
+
* - every tool of the `bir` MCP server **except `run_scenario`**, which is
|
|
45
|
+
* the direct plan's delivery vehicle and keeps its own handling (it is
|
|
46
|
+
* recorded, tagged `pinnedBy`, while its plan is live);
|
|
47
|
+
* - a `Bash` call whose command is **nothing but** `bir` invocations
|
|
48
|
+
* ({@link isBirOnlyCommand}), and the same for a `PowerShell` call
|
|
49
|
+
* ({@link isBirOnlyPowerShell}) — Claude Code on Windows reaches for its
|
|
50
|
+
* PowerShell tool as readily as for Bash, and a `bir scenario edit` run
|
|
51
|
+
* there is the same act. `npm test && bir investigate` is not: the
|
|
52
|
+
* `npm test` half is real work, and hiding it from the recording would be
|
|
53
|
+
* hiding the task. When in doubt the answer is "not housekeeping", because
|
|
54
|
+
* a `bir` call recorded by mistake costs a noisy step, while real work
|
|
55
|
+
* skipped by mistake is a plan with a hole in it.
|
|
30
56
|
*/
|
|
57
|
+
import { SCENARIO_SERVER_KEY } from "../config/generate.js";
|
|
31
58
|
/** Host tools that are never recorded as steps and never count as divergence. */
|
|
32
59
|
export const HOUSEKEEPING_TOOLS = new Set([
|
|
33
60
|
"ToolSearch",
|
|
34
61
|
"TodoWrite",
|
|
35
62
|
]);
|
|
63
|
+
/** By name alone — the host's own bookkeeping tools. See {@link isHousekeepingCall} for the full rule. */
|
|
36
64
|
export function isHousekeeping(toolName) {
|
|
37
65
|
return HOUSEKEEPING_TOOLS.has(toolName);
|
|
38
66
|
}
|
|
67
|
+
/** `mcp__bir__`: every tool the first-party `bir` MCP server offers is named under it. */
|
|
68
|
+
const SCENARIO_SERVER_PREFIX = `mcp__${SCENARIO_SERVER_KEY}__`;
|
|
69
|
+
/**
|
|
70
|
+
* The direct plan's delivery vehicle. Must equal `DIRECT_TOOL_NAME` in the
|
|
71
|
+
* replay controller (a test holds them together); spelled out here because
|
|
72
|
+
* the controller imports this file.
|
|
73
|
+
*/
|
|
74
|
+
export const RUN_SCENARIO_TOOL = `${SCENARIO_SERVER_PREFIX}run_scenario`;
|
|
75
|
+
/**
|
|
76
|
+
* Whether one call is host housekeeping: never recorded as a step, never a
|
|
77
|
+
* divergence from a plan. `toolInput` is what the hook saw (`tool_input`); only
|
|
78
|
+
* a `Bash` call's `command` is read from it.
|
|
79
|
+
*/
|
|
80
|
+
export function isHousekeepingCall(toolName, toolInput) {
|
|
81
|
+
if (HOUSEKEEPING_TOOLS.has(toolName))
|
|
82
|
+
return true;
|
|
83
|
+
if (toolName.startsWith(SCENARIO_SERVER_PREFIX))
|
|
84
|
+
return toolName !== RUN_SCENARIO_TOOL;
|
|
85
|
+
if (toolName === "Bash" || toolName === "PowerShell") {
|
|
86
|
+
const command = toolInput?.command;
|
|
87
|
+
if (typeof command !== "string")
|
|
88
|
+
return false;
|
|
89
|
+
return toolName === "Bash" ? isBirOnlyCommand(command) : isBirOnlyPowerShell(command);
|
|
90
|
+
}
|
|
91
|
+
return false;
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* {@link isBirOnlyCommand} for PowerShell, whose rules differ enough that the
|
|
95
|
+
* Bash reader would get it wrong: a backslash is a path separator, not an
|
|
96
|
+
* escape; the escape is the backtick; `$(…)` runs code even inside double
|
|
97
|
+
* quotes; `&` at the start of a part is the call operator. Deliberately
|
|
98
|
+
* narrower than the Bash reader: parts split on `;`, `&&`, `||` and newlines
|
|
99
|
+
* (outside quotes); each is `bir …` (as {@link isBirInvocation} reads it, also
|
|
100
|
+
* after a leading `&`) or a plain `cd`/`Set-Location`/`Push-Location <dir>`.
|
|
101
|
+
* A pipe, a backtick, `$(` or `@(` anywhere, a brace or parenthesis outside
|
|
102
|
+
* quotes, an `@` starting a word, or a quote left open makes the answer false — for the same
|
|
103
|
+
* reason as there: when unsure, record it.
|
|
104
|
+
*/
|
|
105
|
+
export function isBirOnlyPowerShell(command) {
|
|
106
|
+
if (command.includes("`") || command.includes("$(") || command.includes("@("))
|
|
107
|
+
return false;
|
|
108
|
+
const segments = [];
|
|
109
|
+
let words = [];
|
|
110
|
+
let word = "";
|
|
111
|
+
let inWord = false;
|
|
112
|
+
const endWord = () => {
|
|
113
|
+
if (inWord)
|
|
114
|
+
words.push(word);
|
|
115
|
+
word = "";
|
|
116
|
+
inWord = false;
|
|
117
|
+
};
|
|
118
|
+
const endSegment = () => {
|
|
119
|
+
endWord();
|
|
120
|
+
segments.push(words);
|
|
121
|
+
words = [];
|
|
122
|
+
};
|
|
123
|
+
for (let i = 0; i < command.length; i += 1) {
|
|
124
|
+
const c = command[i];
|
|
125
|
+
if (c === "'" || c === '"') {
|
|
126
|
+
const close = command.indexOf(c, i + 1);
|
|
127
|
+
if (close < 0)
|
|
128
|
+
return false;
|
|
129
|
+
word += command.slice(i + 1, close);
|
|
130
|
+
inWord = true;
|
|
131
|
+
i = close;
|
|
132
|
+
continue;
|
|
133
|
+
}
|
|
134
|
+
if (c === "\n" || c === "\r" || c === ";") {
|
|
135
|
+
endSegment();
|
|
136
|
+
continue;
|
|
137
|
+
}
|
|
138
|
+
if ((c === "&" || c === "|") && command[i + 1] === c) {
|
|
139
|
+
endSegment();
|
|
140
|
+
i += 1;
|
|
141
|
+
continue;
|
|
142
|
+
}
|
|
143
|
+
if (c === "|" || c === "{" || c === "}" || c === "(" || c === ")")
|
|
144
|
+
return false;
|
|
145
|
+
// `@` opens an array, a hash or a splat only at the start of a word; inside
|
|
146
|
+
// one (`…\node_modules\@basein\runner\…`) it is a plain character.
|
|
147
|
+
if (c === "@" && !inWord)
|
|
148
|
+
return false;
|
|
149
|
+
if (c === "&") {
|
|
150
|
+
// The call operator, as a word of its own at the start of a part; a
|
|
151
|
+
// redirection's `2>&1` keeps its `&` inside the word.
|
|
152
|
+
if (inWord && /\d?>$/.test(word)) {
|
|
153
|
+
word += c;
|
|
154
|
+
continue;
|
|
155
|
+
}
|
|
156
|
+
if (inWord || words.length > 0)
|
|
157
|
+
return false;
|
|
158
|
+
words.push("&");
|
|
159
|
+
continue;
|
|
160
|
+
}
|
|
161
|
+
if (c === " " || c === "\t") {
|
|
162
|
+
endWord();
|
|
163
|
+
continue;
|
|
164
|
+
}
|
|
165
|
+
word += c;
|
|
166
|
+
inWord = true;
|
|
167
|
+
}
|
|
168
|
+
endSegment();
|
|
169
|
+
let bir = false;
|
|
170
|
+
for (const raw of segments) {
|
|
171
|
+
const parts = raw[0] === "&" ? raw.slice(1) : raw;
|
|
172
|
+
if (parts.length === 0) {
|
|
173
|
+
if (raw.length > 0)
|
|
174
|
+
return false;
|
|
175
|
+
continue;
|
|
176
|
+
}
|
|
177
|
+
if (raw[0] !== "&" && /^(cd|chdir|sl|set-location|pushd|push-location)$/i.test(parts[0])) {
|
|
178
|
+
if (parts.length > 2 || parts.slice(1).some(isRedirect))
|
|
179
|
+
return false;
|
|
180
|
+
continue;
|
|
181
|
+
}
|
|
182
|
+
if (!isBirInvocation(parts.filter((w) => !isRedirect(w))))
|
|
183
|
+
return false;
|
|
184
|
+
bir = true;
|
|
185
|
+
}
|
|
186
|
+
return bir;
|
|
187
|
+
}
|
|
188
|
+
/**
|
|
189
|
+
* True when a shell command runs `bir` and nothing else: split on `&&`, `||`,
|
|
190
|
+
* `;`, `|`, `&` and newlines (outside quotes), every part is a `bir`
|
|
191
|
+
* invocation or a plain `cd <dir>`, and at least one is `bir`.
|
|
192
|
+
*
|
|
193
|
+
* A `bir` invocation is `bir` / `bir.cmd` / a path ending in `bir`, `bir.cmd`,
|
|
194
|
+
* `bir.js` or `bir.ps1`; `npx [-y] [-p @basein/runner…] [@basein/runner…] bir …`;
|
|
195
|
+
* or `node <path>/bir.js …` — each optionally after `NAME=value` assignments,
|
|
196
|
+
* which only set `bir`'s own environment. A heredoc is read as the data it is,
|
|
197
|
+
* because `bir scenario check … --input-logic - <<'EOF'` is the natural way to
|
|
198
|
+
* hand `bir` a logic body, and its lines are JavaScript, not commands.
|
|
199
|
+
*
|
|
200
|
+
* Anything that could run other code while looking like `bir` makes the answer
|
|
201
|
+
* false: a command substitution (`$(…)`, backticks) outside single quotes and
|
|
202
|
+
* quoted heredocs, and a subshell, group or process substitution (`(`, `{`).
|
|
203
|
+
* So does a quote or heredoc left open — a parse this side is not sure of is
|
|
204
|
+
* not one to hide a step on.
|
|
205
|
+
*/
|
|
206
|
+
export function isBirOnlyCommand(command) {
|
|
207
|
+
const segments = splitCommand(command);
|
|
208
|
+
if (!segments)
|
|
209
|
+
return false;
|
|
210
|
+
let bir = false;
|
|
211
|
+
for (const words of segments) {
|
|
212
|
+
if (words.length === 0)
|
|
213
|
+
continue;
|
|
214
|
+
if (isPlainCd(words))
|
|
215
|
+
continue;
|
|
216
|
+
if (!isBirInvocation(words))
|
|
217
|
+
return false;
|
|
218
|
+
bir = true;
|
|
219
|
+
}
|
|
220
|
+
return bir;
|
|
221
|
+
}
|
|
222
|
+
/**
|
|
223
|
+
* The command's parts, each as its words (quotes removed, as the shell would),
|
|
224
|
+
* or null when it cannot be read with confidence. Redirections stay words:
|
|
225
|
+
* `bir … < step.js` and `bir … > out.json 2>&1` are still only `bir`.
|
|
226
|
+
*/
|
|
227
|
+
function splitCommand(command) {
|
|
228
|
+
const n = command.length;
|
|
229
|
+
const segments = [];
|
|
230
|
+
const heredocs = [];
|
|
231
|
+
let words = [];
|
|
232
|
+
let word = "";
|
|
233
|
+
let inWord = false;
|
|
234
|
+
const endWord = () => {
|
|
235
|
+
if (inWord)
|
|
236
|
+
words.push(word);
|
|
237
|
+
word = "";
|
|
238
|
+
inWord = false;
|
|
239
|
+
};
|
|
240
|
+
const endSegment = () => {
|
|
241
|
+
endWord();
|
|
242
|
+
segments.push(words);
|
|
243
|
+
words = [];
|
|
244
|
+
};
|
|
245
|
+
for (let i = 0; i < n; i += 1) {
|
|
246
|
+
const c = command[i];
|
|
247
|
+
if (c === "'") {
|
|
248
|
+
const close = command.indexOf("'", i + 1);
|
|
249
|
+
if (close === -1)
|
|
250
|
+
return null;
|
|
251
|
+
word += command.slice(i + 1, close);
|
|
252
|
+
inWord = true;
|
|
253
|
+
i = close;
|
|
254
|
+
}
|
|
255
|
+
else if (c === '"') {
|
|
256
|
+
let j = i + 1;
|
|
257
|
+
for (; j < n && command[j] !== '"'; j += 1) {
|
|
258
|
+
const d = command[j];
|
|
259
|
+
if (d === "`" || (d === "$" && command[j + 1] === "("))
|
|
260
|
+
return null;
|
|
261
|
+
if (d === "\\" && j + 1 < n) {
|
|
262
|
+
j += 1;
|
|
263
|
+
// Inside double quotes a backslash escapes only these; before anything else it stays.
|
|
264
|
+
if (!'"\$`\n'.includes(command[j]))
|
|
265
|
+
word += "\\";
|
|
266
|
+
}
|
|
267
|
+
word += command[j];
|
|
268
|
+
}
|
|
269
|
+
if (j >= n)
|
|
270
|
+
return null;
|
|
271
|
+
inWord = true;
|
|
272
|
+
i = j;
|
|
273
|
+
}
|
|
274
|
+
else if (c === "`" || (c === "$" && command[i + 1] === "(")) {
|
|
275
|
+
return null;
|
|
276
|
+
}
|
|
277
|
+
else if (c === "$" && command[i + 1] === "{") {
|
|
278
|
+
// `${NAME}` only reads a variable; a substitution inside it was refused above.
|
|
279
|
+
const close = command.indexOf("}", i + 2);
|
|
280
|
+
if (close === -1)
|
|
281
|
+
return null;
|
|
282
|
+
const inner = command.slice(i + 2, close);
|
|
283
|
+
if (/`|\$\(/.test(inner))
|
|
284
|
+
return null;
|
|
285
|
+
word += command.slice(i, close + 1);
|
|
286
|
+
inWord = true;
|
|
287
|
+
i = close;
|
|
288
|
+
}
|
|
289
|
+
else if (c === "\\") {
|
|
290
|
+
// A backslash-newline joins lines; any other escaped character is itself.
|
|
291
|
+
if (command[i + 1] === "\n") {
|
|
292
|
+
i += 1;
|
|
293
|
+
}
|
|
294
|
+
else if (command[i + 1] === "\r" && command[i + 2] === "\n") {
|
|
295
|
+
i += 2;
|
|
296
|
+
}
|
|
297
|
+
else if (i + 1 < n) {
|
|
298
|
+
word += command[i + 1];
|
|
299
|
+
inWord = true;
|
|
300
|
+
i += 1;
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
else if (c === "<" && command[i + 1] === "<" && command[i + 2] === "<") {
|
|
304
|
+
// A here-string: one word of data, read as the words that follow.
|
|
305
|
+
word += "<<<";
|
|
306
|
+
inWord = true;
|
|
307
|
+
i += 2;
|
|
308
|
+
}
|
|
309
|
+
else if (c === "<" && command[i + 1] === "<") {
|
|
310
|
+
endWord();
|
|
311
|
+
let j = i + 2;
|
|
312
|
+
const stripTabs = command[j] === "-";
|
|
313
|
+
if (stripTabs)
|
|
314
|
+
j += 1;
|
|
315
|
+
while (command[j] === " " || command[j] === "\t")
|
|
316
|
+
j += 1;
|
|
317
|
+
let delimiter = "";
|
|
318
|
+
let literal = false;
|
|
319
|
+
for (; j < n; j += 1) {
|
|
320
|
+
const d = command[j];
|
|
321
|
+
if (d === "'" || d === '"') {
|
|
322
|
+
const close = command.indexOf(d, j + 1);
|
|
323
|
+
if (close === -1)
|
|
324
|
+
return null;
|
|
325
|
+
delimiter += command.slice(j + 1, close);
|
|
326
|
+
literal = true;
|
|
327
|
+
j = close;
|
|
328
|
+
}
|
|
329
|
+
else if (d === "\\" && j + 1 < n) {
|
|
330
|
+
delimiter += command[j + 1];
|
|
331
|
+
literal = true;
|
|
332
|
+
j += 1;
|
|
333
|
+
}
|
|
334
|
+
else if (/[\s;&|<>(){}]/.test(d)) {
|
|
335
|
+
break;
|
|
336
|
+
}
|
|
337
|
+
else {
|
|
338
|
+
delimiter += d;
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
if (!delimiter)
|
|
342
|
+
return null;
|
|
343
|
+
heredocs.push({ delimiter, literal, stripTabs });
|
|
344
|
+
words.push("<<", delimiter);
|
|
345
|
+
i = j - 1;
|
|
346
|
+
}
|
|
347
|
+
else if (c === "\n") {
|
|
348
|
+
endSegment();
|
|
349
|
+
// The bodies of the heredocs this line opened come next, in order.
|
|
350
|
+
while (heredocs.length > 0) {
|
|
351
|
+
const h = heredocs.shift();
|
|
352
|
+
let closed = false;
|
|
353
|
+
for (let pos = i + 1; pos <= n;) {
|
|
354
|
+
let eol = command.indexOf("\n", pos);
|
|
355
|
+
if (eol === -1)
|
|
356
|
+
eol = n;
|
|
357
|
+
let line = command.slice(pos, eol);
|
|
358
|
+
if (line.endsWith("\r"))
|
|
359
|
+
line = line.slice(0, -1);
|
|
360
|
+
if ((h.stripTabs ? line.replace(/^\t+/, "") : line) === h.delimiter) {
|
|
361
|
+
i = eol;
|
|
362
|
+
closed = true;
|
|
363
|
+
break;
|
|
364
|
+
}
|
|
365
|
+
if (!h.literal && /`|\$\(/.test(line))
|
|
366
|
+
return null;
|
|
367
|
+
pos = eol + 1;
|
|
368
|
+
}
|
|
369
|
+
if (!closed)
|
|
370
|
+
return null;
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
else if (c === ";" || c === "|") {
|
|
374
|
+
endSegment();
|
|
375
|
+
}
|
|
376
|
+
else if (c === "&") {
|
|
377
|
+
// `2>&1` and `&>file` redirect; every other `&` ends a command.
|
|
378
|
+
if (command[i - 1] === ">" || command[i - 1] === "<" || command[i + 1] === ">") {
|
|
379
|
+
word += c;
|
|
380
|
+
inWord = true;
|
|
381
|
+
}
|
|
382
|
+
else {
|
|
383
|
+
endSegment();
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
else if (c === "(" || c === ")" || c === "{" || c === "}") {
|
|
387
|
+
// A subshell, a group or a process substitution: not something this reads.
|
|
388
|
+
return null;
|
|
389
|
+
}
|
|
390
|
+
else if (c === " " || c === "\t" || c === "\r") {
|
|
391
|
+
endWord();
|
|
392
|
+
}
|
|
393
|
+
else {
|
|
394
|
+
word += c;
|
|
395
|
+
inWord = true;
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
// A heredoc whose body never came.
|
|
399
|
+
if (heredocs.length > 0)
|
|
400
|
+
return null;
|
|
401
|
+
endSegment();
|
|
402
|
+
return segments;
|
|
403
|
+
}
|
|
404
|
+
/** `cd` or `cd <dir>`, and nothing more. */
|
|
405
|
+
function isPlainCd(words) {
|
|
406
|
+
return words[0] === "cd" && words.length <= 2 && !words.slice(1).some(isRedirect);
|
|
407
|
+
}
|
|
408
|
+
const isRedirect = (w) => /^\d*[<>]|^&>/.test(w);
|
|
409
|
+
const ASSIGNMENT = /^[A-Za-z_][A-Za-z0-9_]*=/;
|
|
410
|
+
/** The last path component, on either separator. */
|
|
411
|
+
const basename = (w) => w.slice(Math.max(w.lastIndexOf("/"), w.lastIndexOf("\\")) + 1);
|
|
412
|
+
/** `bir`, `bir.cmd`, `bir.ps1`, or any path to one of those or to `bir.js`. */
|
|
413
|
+
function isBirProgram(w) {
|
|
414
|
+
const base = basename(w);
|
|
415
|
+
if (/^bir(\.cmd|\.ps1)?$/i.test(base))
|
|
416
|
+
return true;
|
|
417
|
+
return /^bir\.js$/i.test(base) && base !== w;
|
|
418
|
+
}
|
|
419
|
+
const isNode = (w) => /^node(\.exe)?$/i.test(basename(w));
|
|
420
|
+
/** `@basein/runner`, `@basein/runner@0.2.11`, `@basein/runner@latest`. */
|
|
421
|
+
const isRunnerPackage = (w) => /^@basein\/runner(@[\w.+-]+)?$/.test(w);
|
|
422
|
+
function isBirInvocation(input) {
|
|
423
|
+
let words = input;
|
|
424
|
+
while (words.length > 0 && ASSIGNMENT.test(words[0]))
|
|
425
|
+
words = words.slice(1);
|
|
426
|
+
const first = words[0];
|
|
427
|
+
if (first === undefined)
|
|
428
|
+
return false;
|
|
429
|
+
if (isBirProgram(first))
|
|
430
|
+
return true;
|
|
431
|
+
if (isNode(first)) {
|
|
432
|
+
// Exactly `node <path>/bir.js …`: a node flag could load other code first.
|
|
433
|
+
const script = words[1];
|
|
434
|
+
return script !== undefined && /^bir\.js$/i.test(basename(script));
|
|
435
|
+
}
|
|
436
|
+
if (first === "npx") {
|
|
437
|
+
let i = 1;
|
|
438
|
+
for (; i < words.length; i += 1) {
|
|
439
|
+
const w = words[i];
|
|
440
|
+
if (w === "-y" || w === "--yes")
|
|
441
|
+
continue;
|
|
442
|
+
if ((w === "-p" || w === "--package") && isRunnerPackage(words[i + 1] ?? "")) {
|
|
443
|
+
i += 1;
|
|
444
|
+
continue;
|
|
445
|
+
}
|
|
446
|
+
if (w.startsWith("--package=") && isRunnerPackage(w.slice("--package=".length)))
|
|
447
|
+
continue;
|
|
448
|
+
break;
|
|
449
|
+
}
|
|
450
|
+
if (isRunnerPackage(words[i] ?? ""))
|
|
451
|
+
i += 1;
|
|
452
|
+
return words[i] === "bir";
|
|
453
|
+
}
|
|
454
|
+
return false;
|
|
455
|
+
}
|
|
39
456
|
//# sourceMappingURL=housekeeping.js.map
|
|
@@ -299,7 +299,9 @@ export declare class ReplayController {
|
|
|
299
299
|
* and hand it back through the best channel available; if even that fails,
|
|
300
300
|
* abort to an ordinary turn.
|
|
301
301
|
*/
|
|
302
|
-
preTool(state: ReplayState, toolName: string, toolUseId: string
|
|
302
|
+
preTool(state: ReplayState, toolName: string, toolUseId: string,
|
|
303
|
+
/** The call's `tool_input`: whether a `Bash` call is only `bir` is read from it (D13). */
|
|
304
|
+
toolInput?: unknown): Promise<PreToolAction>;
|
|
303
305
|
/**
|
|
304
306
|
* `PostToolUse` for a call this plan pinned.
|
|
305
307
|
*
|
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
* ignore. Nothing throws at a hook.
|
|
13
13
|
*/
|
|
14
14
|
import { parseQualifiedName } from "../control/correlation.js";
|
|
15
|
-
import {
|
|
15
|
+
import { isHousekeepingCall } from "../record/housekeeping.js";
|
|
16
16
|
import { redact } from "../record/redact.js";
|
|
17
17
|
import { serializeCapped } from "../record/truncate.js";
|
|
18
18
|
import { logDetail, logLine, errText } from "../util/log.js";
|
|
@@ -392,7 +392,9 @@ export class ReplayController {
|
|
|
392
392
|
* and hand it back through the best channel available; if even that fails,
|
|
393
393
|
* abort to an ordinary turn.
|
|
394
394
|
*/
|
|
395
|
-
async preTool(state, toolName, toolUseId
|
|
395
|
+
async preTool(state, toolName, toolUseId,
|
|
396
|
+
/** The call's `tool_input`: whether a `Bash` call is only `bir` is read from it (D13). */
|
|
397
|
+
toolInput) {
|
|
396
398
|
const plan = state.plan;
|
|
397
399
|
if (!plan || state.retired)
|
|
398
400
|
return { kind: "passthrough" };
|
|
@@ -409,9 +411,21 @@ export class ReplayController {
|
|
|
409
411
|
error: errText(err),
|
|
410
412
|
});
|
|
411
413
|
}
|
|
414
|
+
// HOUSEKEEPING IS NOT DIVERGENCE. See `record/housekeeping.ts`. Asked
|
|
415
|
+
// before the pin, not after it: a `Bash` step is pinned by name, and a
|
|
416
|
+
// `bir scenario show` the model runs while a Bash step is expected must
|
|
417
|
+
// run as `bir`, never be swapped for the plan's command (D13).
|
|
418
|
+
const expected = plan.expectedTool();
|
|
419
|
+
if (isHousekeepingCall(toolName, toolInput)) {
|
|
420
|
+
logDetail("replay.housekeeping", {
|
|
421
|
+
tool: toolName,
|
|
422
|
+
step: `${plan.currentStepIndex}/${plan.stepCount}`,
|
|
423
|
+
why: "host bookkeeping, not task work — plan stays armed",
|
|
424
|
+
});
|
|
425
|
+
return { kind: "passthrough" };
|
|
426
|
+
}
|
|
412
427
|
// A direct plan does not steer individual calls: the model was asked for one
|
|
413
428
|
// tool call and made a different one. That is divergence.
|
|
414
|
-
const expected = plan.expectedTool();
|
|
415
429
|
if (state.mode === "steer" && toolName === expected && toolUseId) {
|
|
416
430
|
try {
|
|
417
431
|
const step = plan.currentStep();
|
|
@@ -460,15 +474,6 @@ export class ReplayController {
|
|
|
460
474
|
return { kind: "abort" };
|
|
461
475
|
}
|
|
462
476
|
}
|
|
463
|
-
// HOUSEKEEPING IS NOT DIVERGENCE. See `record/housekeeping.ts`.
|
|
464
|
-
if (isHousekeeping(toolName)) {
|
|
465
|
-
logDetail("replay.housekeeping", {
|
|
466
|
-
tool: toolName,
|
|
467
|
-
step: `${plan.currentStepIndex}/${plan.stepCount}`,
|
|
468
|
-
why: "host bookkeeping, not task work — plan stays armed",
|
|
469
|
-
});
|
|
470
|
-
return { kind: "passthrough" };
|
|
471
|
-
}
|
|
472
477
|
return await this.diverge(state, toolName, `expected ${expected ?? "no more tools"}, model called ${toolName}`);
|
|
473
478
|
}
|
|
474
479
|
/**
|