@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.
@@ -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): Promise<PreToolAction>;
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 { isHousekeeping } from "../record/housekeeping.js";
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
  /**