@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.
@@ -7,8 +7,8 @@
7
7
  * bir scenario edit <runId|scnId> --step <n> [--input-logic <file|->] [--output-logic <file|->]
8
8
  * [--freeze | --unfreeze] [--note "why"] [--force --note "why"] [--revision <n>]
9
9
  * bir scenario edits <runId|scnId>
10
- * bir scenario undo <runId|scnId> [--edit <sedit_id>]
11
- * bir scenario calc <runId> [--force [--discard-edits]]
10
+ * bir scenario undo <runId|scnId> [--edit <sedit_id> | --step <n>] [--force --note "why"]
11
+ * bir scenario calc <runId|scnId> [--force [--discard-edits]]
12
12
  * bir scenario editing on|off|status
13
13
  *
14
14
  * WHY THIS IS A CLIENT AND NOTHING MORE. Whether a change may be saved is
@@ -51,8 +51,8 @@ const USAGE = {
51
51
  edit: "usage: bir scenario edit <runId|scnId> --step <n> [--input-logic <file|->] [--output-logic <file|->]\n" +
52
52
  ' [--freeze | --unfreeze] [--note "why"] [--force --note "why"] [--revision <n>] [--json]',
53
53
  edits: "usage: bir scenario edits <runId|scnId> [--json]",
54
- undo: "usage: bir scenario undo <runId|scnId> [--edit <sedit_id>] [--json]",
55
- calc: "usage: bir scenario calc <runId> [--force [--discard-edits]]",
54
+ undo: 'usage: bir scenario undo <runId|scnId> [--edit <sedit_id> | --step <n>] [--force --note "why"] [--json]',
55
+ calc: "usage: bir scenario calc <runId|scnId> [--force [--discard-edits]]",
56
56
  editing: "usage: bir scenario editing on|off|status",
57
57
  };
58
58
  export async function scenarioEditCommand(args, deps) {
@@ -79,7 +79,11 @@ export async function scenarioEditCommand(args, deps) {
79
79
  /**
80
80
  * The raw JSON, as it always was — scripts and the guide read it. A `scn_` id
81
81
  * goes to `GET /scenarios/:id`, which also serves sub-task scenarios (they have
82
- * no run of their own); `--step` narrows the print to that one step object.
82
+ * no run of their own); `--step` narrows the print to that one step object,
83
+ * with the scenario's id and `chainRevision` put in front of it. The revision
84
+ * is a scenario's, not a step's, and it is what `edit --revision` (and the MCP
85
+ * `scenario_edit`'s `revision`) takes: a step printed without it sent the model
86
+ * that had just read the step off to guess one (C20).
83
87
  */
84
88
  async function show(args, deps) {
85
89
  const { out } = deps;
@@ -101,11 +105,10 @@ async function show(args, deps) {
101
105
  out(JSON.stringify(reply.body, null, 2));
102
106
  return 0;
103
107
  }
104
- const scenario = reply.body?.scenario;
105
- if (!scenario) {
106
- out(`${target} has no calculated scenario yet — \`bir scenario calc ${target}\` first.`);
107
- return 1;
108
- }
108
+ const served = reply.body;
109
+ const scenario = served?.scenario;
110
+ if (!scenario)
111
+ return noWholeRunScenario(target, served?.segments, args, deps);
109
112
  if (!Array.isArray(scenario.steps)) {
110
113
  out(`${scenario.id ?? target} is ${scenario.state ?? "not ready"}, so it has no steps to show yet.`);
111
114
  return 1;
@@ -116,7 +119,7 @@ async function show(args, deps) {
116
119
  out(`${scenario.id ?? target} has no step ${args.step}${indexes.length ? ` (its steps are ${Math.min(...indexes)}–${Math.max(...indexes)})` : " (it has no steps)"}.`);
117
120
  return 1;
118
121
  }
119
- out(JSON.stringify(step, null, 2));
122
+ out(JSON.stringify({ scenarioId: scenario.id ?? null, chainRevision: scenario.chainRevision ?? null, ...step }, null, 2));
120
123
  return 0;
121
124
  }
122
125
  /**
@@ -182,18 +185,27 @@ async function checkOrEdit(args, deps, save) {
182
185
  const report = reply.body?.report;
183
186
  if (args.json) {
184
187
  out(JSON.stringify(reply.body, null, 2));
185
- return report?.ok ? 0 : 1;
188
+ return report?.ok || report?.noChange ? 0 : 1;
186
189
  }
187
190
  if (!report) {
188
191
  out("The service answered the check without a report.");
189
192
  return 1;
190
193
  }
191
194
  renderReport(out, report);
195
+ // Before `ok`: a change that is already the step's would pass the check and
196
+ // then be answered `nothing_to_change` by `edit` — so `check` says so
197
+ // itself, instead of printing an `edit` command that cannot save (C15).
198
+ if (report.noChange) {
199
+ out("Nothing would change: the step already has this code and mark.");
200
+ return 0;
201
+ }
192
202
  if (report.ok) {
193
203
  out(`Nothing saved. To save: ${editCommand(target, args)}`);
194
204
  return 0;
195
205
  }
196
- refusal(out, report.problems ?? [], "Nothing saved, and `bir scenario edit` would refuse it", ' Fix the code and check again. If the recording itself was wrong, edit can save it on purpose: add --force --note "why".');
206
+ refusal(out, report, "Nothing saved, and `bir scenario edit` would refuse it", report.budgetExceeded
207
+ ? ' Make the code cheaper to run and check again. If the recording itself was wrong, edit can save it on purpose: add --force --note "why".'
208
+ : ' Fix the code and check again. If the recording itself was wrong, edit can save it on purpose: add --force --note "why".');
197
209
  return 1;
198
210
  }
199
211
  if (args.freeze)
@@ -217,9 +229,11 @@ async function checkOrEdit(args, deps, save) {
217
229
  return refused ? 1 : 0;
218
230
  }
219
231
  if (answer?.report)
220
- renderReport(out, answer.report);
221
- if (refused) {
222
- refusal(out, answer?.report?.problems ?? [], "Not saved", ' Fix the code and try again, or save it on purpose: add --force --note "why".');
232
+ renderReport(out, answer.report, { brief: true });
233
+ if (refused && answer?.report) {
234
+ refusal(out, answer.report, "Not saved", answer.report.budgetExceeded
235
+ ? ' Make the code cheaper to run and try again, or save it on purpose: add --force --note "why".'
236
+ : ' Fix the code and try again, or save it on purpose: add --force --note "why".');
223
237
  return 1;
224
238
  }
225
239
  const edit = answer?.edit;
@@ -283,12 +297,28 @@ const label = (name, text) => ` ${name.padEnd(9)}${text}`;
283
297
  * The report in the lines editSteps.md "What a person sees" shows:
284
298
  * `input`, `output` (when the output logic changed), `later`, `answer` (when
285
299
  * the final answer read anything before) and `mark`.
300
+ *
301
+ * `check` prints every line: it is where a person explores a change, and "step
302
+ * 5 still reproduces" is an answer there. `edit` and `undo` are acts, and print
303
+ * `brief`ly — what changed and what went wrong: `input` always, `output` when
304
+ * it changed, `later` only for a step that broke, `answer` only when it lost its
305
+ * values, `mark` only when it moves. The report is the same one either way (D11);
306
+ * only the lines that say "nothing to see" are left out.
307
+ *
308
+ * An `undo` does not judge the step itself — its code and mark go back exactly
309
+ * as they were (editSteps.md, "Undo") — so its report's input status is
310
+ * `not_checked` by design, and printing the usual reason for that ("the
311
+ * recorded call is plain text") would be false. It says what happened instead.
286
312
  */
287
- export function renderReport(out, r) {
313
+ export function renderReport(out, r, opts = {}) {
314
+ const brief = opts.brief === true;
288
315
  out(`Step ${r.stepIndex} (${r.toolName ?? "?"}) — checked against ${r.sourceRunId}`);
289
316
  const i = r.input ?? { changed: false, before: "not_checked", after: "not_checked" };
290
317
  const unchanged = i.changed ? "" : " (input logic unchanged)";
291
- if (i.after === "differs") {
318
+ if (opts.undo) {
319
+ out(label("input", i.changed ? "back as it was before that edit" : "unchanged"));
320
+ }
321
+ else if (i.after === "differs") {
292
322
  const d = i.firstDifference ?? firstDifference(i.computed, i.recorded);
293
323
  out(label("input", `differs from the recorded call${d ? ` at character ${d.at}` : ""}${unchanged}`));
294
324
  if (d) {
@@ -314,8 +344,18 @@ export function renderReport(out, r) {
314
344
  }
315
345
  const later = r.later ?? [];
316
346
  const regressed = later.filter((l) => l.regressed);
317
- if (later.length === 0) {
318
- out(label("later", "no later steps"));
347
+ if (brief && regressed.length === 0) {
348
+ // Nothing broke: an act has nothing to say about later steps.
349
+ }
350
+ else if (later.length === 0) {
351
+ // The service judges later steps only when what they read can change: an
352
+ // input-only change (or a mark) leaves them alone, and marked or
353
+ // unverifiable later steps are never judged (editSteps.md rule 6).
354
+ out(label("later", r.output?.changed
355
+ ? "none to check"
356
+ : r.input?.changed
357
+ ? "not affected: only the input logic changed"
358
+ : "not affected: the code did not change"));
319
359
  }
320
360
  else if (regressed.length === 0) {
321
361
  const still = later.filter((l) => l.after === "reproduces").map((l) => l.stepIndex);
@@ -330,18 +370,21 @@ export function renderReport(out, r) {
330
370
  out(label("later", `step ${l.stepIndex} (${l.toolName ?? "?"}) reproduced before; now it ${laterPhrase(l.after)}`));
331
371
  }
332
372
  }
333
- if (r.answer?.informativeBefore) {
373
+ if (r.answer?.informativeBefore && !(brief && r.answer.informativeAfter)) {
334
374
  out(label("answer", r.answer.informativeAfter
335
375
  ? "still gets its values"
336
376
  : "no longer gets its values: the final answer's logic stops finding what it reads"));
337
377
  }
338
378
  if (r.mark?.before && r.mark.after) {
339
379
  const { before, after } = r.mark;
340
- out(label("mark", before.marked === after.marked && before.why === after.why
341
- ? before.marked
342
- ? `${markWord(before)} → stays marked`
343
- : "runs by itself"
344
- : `${markWord(before)} → ${markWord(after)}`));
380
+ const moves = before.marked !== after.marked || before.why !== after.why;
381
+ if (moves || !brief) {
382
+ out(label("mark", moves
383
+ ? `${markWord(before)} → ${markWord(after)}`
384
+ : before.marked
385
+ ? `${markWord(before)} → stays marked`
386
+ : "runs by itself"));
387
+ }
345
388
  }
346
389
  }
347
390
  function inputPhrase(status, verifiable) {
@@ -413,8 +456,27 @@ function sentence(problem) {
413
456
  const lowered = /^[A-Z][a-z]/.test(text) ? text[0].toLowerCase() + text.slice(1) : text;
414
457
  return /[.!?]$/.test(lowered) ? lowered : `${lowered}.`;
415
458
  }
416
- function refusal(out, problems, opening, hint) {
417
- const list = problems.length ? problems : ["the check did not pass"];
459
+ /**
460
+ * The verdict under a refused report. When the one thing wrong is that the
461
+ * changed input logic does not compute the recorded call, the `input` line
462
+ * above has already said where it parts, and the verdict says it in the words
463
+ * editSteps.md shows: "the change does not reproduce the recording". Anything
464
+ * else — a copy, a later step, the answer, the mark, *too costly to check* —
465
+ * is the service's own sentence, one per problem.
466
+ */
467
+ function refusal(out, report, opening, hint) {
468
+ const problems = report.problems ?? [];
469
+ const inputOnly = problems.length === 1 &&
470
+ report.input?.changed === true &&
471
+ (report.input.after === "differs" || report.input.after === "throws") &&
472
+ !report.budgetExceeded &&
473
+ !(report.later ?? []).some((l) => l.regressed) &&
474
+ !(report.answer?.informativeBefore && !report.answer.informativeAfter);
475
+ const list = inputOnly
476
+ ? ["the change does not reproduce the recording"]
477
+ : problems.length
478
+ ? problems
479
+ : ["the check did not pass"];
418
480
  if (list.length === 1) {
419
481
  out(`${opening}: ${sentence(list[0])}`);
420
482
  }
@@ -489,17 +551,28 @@ function editSummary(e) {
489
551
  return parts.length ? parts.join("; ") : "no change to the code or the mark";
490
552
  }
491
553
  /**
492
- * Undo puts one step back as it was before its newest edit (D9). Without
493
- * `--edit`, that is the newest entry nothing has put back and no
494
- * recalculation replaced — which is by construction the newest of its step.
495
- * An undo is itself such an entry, so a second `undo` redoes.
554
+ * What `undo` without `--edit` takes back (D9): the newest **edit** — never an
555
+ * undo entry — that nothing has put back and no recalculation replaced; of
556
+ * step `step` when it is given.
557
+ *
558
+ * Undo entries are skipped on purpose. They used to be picked like any other,
559
+ * so a second `undo` redid the first one, and a step edited twice toggled
560
+ * between its two edits for ever and never got back to the calculation's code
561
+ * (C12). Now a second `undo` goes one further back; a redo is asked for by
562
+ * name, `--edit <the undo's id>`, which the undo prints.
563
+ *
564
+ * Whether the step is still exactly as that edit left it is the service's
565
+ * question, not this list's: it answers `not_latest_edit` when it is not.
496
566
  */
497
- export function newestUndoable(edits) {
567
+ export function newestUndoable(edits, step) {
498
568
  // Newest first, as the service lists them; sorted again so a reordering
499
569
  // there cannot make this undo the wrong one.
500
570
  return [...edits]
501
571
  .sort((x, y) => (x.createdAt < y.createdAt ? 1 : x.createdAt > y.createdAt ? -1 : 0))
502
- .find((e) => !e.revertedAt && !e.replacedAt);
572
+ .find((e) => e.kind !== "revert" &&
573
+ !e.revertedAt &&
574
+ !e.replacedAt &&
575
+ (step === undefined || e.stepIndex === step));
503
576
  }
504
577
  async function undo(args, deps) {
505
578
  const { out } = deps;
@@ -509,6 +582,15 @@ async function undo(args, deps) {
509
582
  if (args.edit !== undefined && !/^sedit_[\w-]+$/.test(args.edit)) {
510
583
  return usageError(deps, "undo", `--edit takes a sedit_… id, as \`bir scenario edits\` prints it; got ${args.edit}.`);
511
584
  }
585
+ if (args.edit !== undefined && args.step !== undefined) {
586
+ return usageError(deps, "undo", "--edit names one entry and --step picks one; give one of them.");
587
+ }
588
+ const note = args.note?.trim() ? args.note : undefined;
589
+ if (args.force && !note) {
590
+ // As for `edit`: an undo the check refused is taken back only on purpose,
591
+ // and the note is the only record of why.
592
+ return usageError(deps, "undo", '--force needs --note "why" (note_required): an undo made on purpose says why, and the history keeps it.');
593
+ }
512
594
  const resolved = await resolveTarget(target, args, deps);
513
595
  if ("code" in resolved)
514
596
  return resolved.code;
@@ -517,34 +599,61 @@ async function undo(args, deps) {
517
599
  const listed = await fetchEdits(resolved.id, deps);
518
600
  if ("reply" in listed)
519
601
  return explain(listed.reply, args, deps, { verb: "Could not read the history", target });
520
- const pick = newestUndoable(listed.edits);
602
+ const pick = newestUndoable(listed.edits, args.step);
521
603
  if (!pick) {
522
- return localFailure(args, deps, "nothing_to_undo", `Nothing to undo: ${target} has no hand edit that can be undone.`);
604
+ return localFailure(args, deps, "nothing_to_undo", args.step === undefined
605
+ ? `Nothing to undo: ${target} has no hand edit that can be undone.`
606
+ : `Nothing to undo: step ${args.step} of ${target} has no hand edit that can be undone.`);
523
607
  }
524
608
  editId = pick.id;
525
609
  }
526
610
  const body = {};
527
- if (args.note?.trim())
528
- body.note = args.note;
611
+ if (note)
612
+ body.note = note;
613
+ if (args.force)
614
+ body.force = true;
529
615
  if (args.revision !== undefined)
530
616
  body.expectedRevision = args.revision;
531
617
  const reply = await deps.service("POST", `/scenarios/${seg(resolved.id)}/edits/${seg(editId)}/revert`, body);
532
- if ("error" in reply || reply.status !== 200) {
618
+ const answer = ("error" in reply ? undefined : reply.body);
619
+ const refused = !("error" in reply) && reply.status === 422 && Boolean(answer?.report);
620
+ if ("error" in reply || (reply.status !== 200 && !refused)) {
533
621
  return explain(reply, args, deps, {
534
622
  verb: "Not undone",
535
623
  target,
624
+ editId,
536
625
  notFound: `Not undone (not_found): ${target} has no edit ${editId}, or it is not yours.`,
537
626
  });
538
627
  }
539
628
  if (args.json) {
540
629
  out(JSON.stringify(reply.body, null, 2));
541
- return 0;
630
+ return refused ? 1 : 0;
542
631
  }
543
- const b = reply.body;
544
- const entry = b?.edit;
545
- const revision = b?.chainRevision ?? entry?.chainRevisionAfter;
632
+ if (answer?.report)
633
+ renderReport(out, answer.report, { brief: true, undo: true });
634
+ if (refused && answer?.report) {
635
+ // The undo would break a later step or the answer (D9): the same check a
636
+ // save runs, and the same way past it.
637
+ refusal(out, answer.report, "Not undone", ' To undo it anyway, on purpose: add --force --note "why".');
638
+ return 1;
639
+ }
640
+ const entry = answer?.edit;
641
+ const revision = answer?.chainRevision ?? entry?.chainRevisionAfter;
546
642
  out(`Step ${entry?.stepIndex ?? "?"} is back as it was before ${entry?.revertsEditId ?? editId} (revision ${revision ?? "?"}).` +
547
643
  (entry?.id ? ` Redo: bir scenario undo ${target} --edit ${entry.id}` : ""));
644
+ // Undo walks back (D9): the same command again goes one further. An undo
645
+ // asked for by id names its step, so "further back" stays on that step.
646
+ const further = args.step !== undefined
647
+ ? ` --step ${args.step}`
648
+ : args.edit !== undefined && typeof entry?.stepIndex === "number"
649
+ ? ` --step ${entry.stepIndex}`
650
+ : "";
651
+ out(` One further back: bir scenario undo ${target}${further}`);
652
+ if (entry?.forced) {
653
+ out(" Undone on purpose (--force), although the check refused it.");
654
+ for (const p of answer?.report?.problems ?? [])
655
+ out(` The check said: ${sentence(p)}`);
656
+ }
548
657
  return 0;
549
658
  }
550
659
  // ── calc ──────────────────────────────────────────────────────────────────────
@@ -552,6 +661,13 @@ async function undo(args, deps) {
552
661
  * `calc` lives here because of one answer: a recalculation rebuilds every
553
662
  * step from the recording, so the service refuses `--force` on a plan with
554
663
  * hand edits (D4) unless `--discard-edits` says to throw them away.
664
+ *
665
+ * A `run_` id posts `/recordings/runs/:id/calculate`, which reaches only the
666
+ * run's **whole-run** scenario. A `scn_` id posts `/scenarios/:id/recalculate`
667
+ * — the only way to recalculate a sub-task (segment) scenario from here, and
668
+ * so the only way to take a segment's hand edits back once undo cannot (C12).
669
+ * A scenario id always names one that exists, so it is always a
670
+ * recalculation in place, and asks for `--force` as the run form does.
555
671
  */
556
672
  async function calc(args, deps) {
557
673
  const { out } = deps;
@@ -561,7 +677,13 @@ async function calc(args, deps) {
561
677
  if (args.discardEdits && !args.force) {
562
678
  return usageError(deps, "calc", "--discard-edits only means something with --force (a recalculation in place).");
563
679
  }
564
- const reply = await deps.service("POST", `/recordings/runs/${seg(target)}/calculate`, args.force ? { force: true, ...(args.discardEdits ? { discardEdits: true } : {}) } : {});
680
+ const byScenario = target.startsWith("scn_");
681
+ if (byScenario && !args.force) {
682
+ return usageError(deps, "calc", `${target} is a scenario already, so calculating it means recalculating it in place: add --force (and --discard-edits to replace steps edited by hand).`);
683
+ }
684
+ const reply = byScenario
685
+ ? await deps.service("POST", `/scenarios/${seg(target)}/recalculate`, args.discardEdits ? { discardEdits: true } : {})
686
+ : await deps.service("POST", `/recordings/runs/${seg(target)}/calculate`, args.force ? { force: true, ...(args.discardEdits ? { discardEdits: true } : {}) } : {});
565
687
  if ("error" in reply)
566
688
  return explain(reply, args, deps, { verb: "Could not start calculation", target });
567
689
  if (args.json) {
@@ -572,9 +694,13 @@ async function calc(args, deps) {
572
694
  const code = body?.error;
573
695
  if (status === 202) {
574
696
  const b = body;
575
- out(`Calculating ${b.scenarioId ?? ""} — poll with \`bir scenario show ${target}\`.`);
697
+ out(`${byScenario ? "Recalculating" : "Calculating"} ${b.scenarioId ?? target} — poll with \`bir scenario show ${target}\`.`);
576
698
  return 0;
577
699
  }
700
+ if (status === 404) {
701
+ out(byScenario ? `No such scenario, or it is not yours: ${target}.` : `No such run, or it is not yours: ${target}.`);
702
+ return 1;
703
+ }
578
704
  if (status === 409 && code === "scenario_has_edits") {
579
705
  const steps = body.editedSteps;
580
706
  const list = Array.isArray(steps) ? steps.map(String) : [];
@@ -588,15 +714,26 @@ async function calc(args, deps) {
588
714
  out("Another calculation of yours is running, and the service calculates one at a time. Try again when it is ready.");
589
715
  return 1;
590
716
  }
591
- if (status === 409) {
717
+ // With --force the run route answers `scenario_exists` for one reason only:
718
+ // that scenario is being calculated right now. `scenario_calculating` is the
719
+ // scenario route's word for the same thing (C16).
720
+ if (status === 409 && (code === "scenario_calculating" || (code === "scenario_exists" && args.force))) {
721
+ out(`A calculation of ${target} is already running. Wait for it to finish (\`bir scenario show ${target}\`).`);
722
+ return 1;
723
+ }
724
+ if (status === 409 && code === "scenario_exists") {
592
725
  out("That run already has a scenario. Re-derive it in place with --force.");
593
726
  return 1;
594
727
  }
595
- if (status === 503) {
728
+ if (status === 503 && code === "calculation_paused_today") {
729
+ out("Calculation is paused for today: the service has reached its daily calculation budget. Try again tomorrow.");
730
+ return 1;
731
+ }
732
+ if (status === 503 && code === "anthropic_not_configured") {
596
733
  out("The service has no Anthropic configuration, so it cannot calculate scenarios.");
597
734
  return 1;
598
735
  }
599
- out(`Could not start calculation (HTTP ${status}): ${JSON.stringify(body)}`);
736
+ out(`Could not start calculation (HTTP ${status}${code ? `, ${code}` : ""}): ${JSON.stringify(body)}`);
600
737
  return 1;
601
738
  }
602
739
  // ── editing on|off|status ─────────────────────────────────────────────────────
@@ -654,15 +791,36 @@ function usageError(deps, sub, why) {
654
791
  deps.out(USAGE[sub] ?? "usage: bir scenario …");
655
792
  return 2;
656
793
  }
657
- /** A failure this side decided on; `--json` still gets JSON, in the service's `{error, details}` shape. */
658
- function localFailure(args, deps, code, text) {
659
- deps.out(args.json ? JSON.stringify({ error: code, details: text }, null, 2) : text);
794
+ /**
795
+ * A failure this side decided on; `--json` still gets JSON, in the service's
796
+ * `{error, details}` shape (with `extra` beside them when there is more to say).
797
+ */
798
+ function localFailure(args, deps, code, text, extra = {}) {
799
+ deps.out(args.json ? JSON.stringify({ error: code, details: text, ...extra }, null, 2) : text);
660
800
  return 1;
661
801
  }
802
+ /**
803
+ * A run with no whole-run scenario. When sub-task (segment) scenarios were
804
+ * calculated from it, they are listed with their `scn_` ids — the only ids
805
+ * they have — instead of suggesting a calculation, which would start a new
806
+ * whole-run plan beside them rather than reach the one the person meant (C21).
807
+ */
808
+ function noWholeRunScenario(target, segments, args, deps) {
809
+ const listed = (Array.isArray(segments) ? segments : []).filter((s) => typeof s?.id === "string");
810
+ if (listed.length === 0) {
811
+ return localFailure(args, deps, "no_scenario", `${target} has no calculated scenario yet — \`bir scenario calc ${target}\` first.`);
812
+ }
813
+ const range = (s) => typeof s.stepFrom === "number" && typeof s.stepTo === "number" ? ` steps ${s.stepFrom}–${s.stepTo} of the recording` : "";
814
+ const text = [
815
+ `${target} has no whole-run scenario, only sub-task scenarios — and a sub-task scenario is addressed by its own scn_ id:`,
816
+ ...listed.map((s) => ` ${s.id}${range(s)}`),
817
+ ].join("\n");
818
+ return localFailure(args, deps, "no_whole_run_scenario", text, { segments: listed.map((s) => s.id) });
819
+ }
662
820
  /**
663
821
  * A `run_` id is the recording's; the routes that change a scenario take its
664
- * `scn_` id (D7). A `scn_` id is used as it is — sub-task scenarios have
665
- * nothing else.
822
+ * `scn_` id (D7). A `run_` id reaches only the run's **whole-run** scenario; a
823
+ * `scn_` id is used as it is — sub-task scenarios have nothing else.
666
824
  */
667
825
  async function resolveTarget(target, args, deps) {
668
826
  if (target.startsWith("scn_"))
@@ -677,12 +835,10 @@ async function resolveTarget(target, args, deps) {
677
835
  }),
678
836
  };
679
837
  }
680
- const id = reply.body?.scenario?.id;
681
- if (typeof id !== "string") {
682
- return {
683
- code: localFailure(args, deps, "no_scenario", `${target} has no calculated scenario yet — \`bir scenario calc ${target}\` first.`),
684
- };
685
- }
838
+ const served = reply.body;
839
+ const id = served?.scenario?.id;
840
+ if (typeof id !== "string")
841
+ return { code: noWholeRunScenario(target, served?.segments, args, deps) };
686
842
  return { id };
687
843
  }
688
844
  /**
@@ -716,6 +872,12 @@ function explain(reply, args, deps, e) {
716
872
  out(`${e.verb}: the service did not accept this machine's session (HTTP 401) — run \`bir login\`.`);
717
873
  return 1;
718
874
  }
875
+ if (reply.status === 413) {
876
+ // By status, not code: a proxy in front of the service answers 413 with a
877
+ // page of its own, and the cause is the same.
878
+ out(`${e.verb} (payload_too_large): the request is larger than the service accepts. A logic body may be at most 20 000 characters, and a note 500.`);
879
+ return 1;
880
+ }
719
881
  switch (code) {
720
882
  case "invalid_input": {
721
883
  const details = brief(body.details);
@@ -726,7 +888,14 @@ function explain(reply, args, deps, e) {
726
888
  out(`${e.verb} (not_a_tool_step): step ${e.step ?? "?"} is a sub-task call row, not a tool step. Call rows belong to the service; only tool steps can be edited.`);
727
889
  return 1;
728
890
  case "nothing_to_change":
729
- out(`${e.verb} (nothing_to_change): give --input-logic, --output-logic, --freeze or --unfreeze.`);
891
+ // The empty command is refused on this side before any request, so a
892
+ // service `nothing_to_change` always means the change is already the
893
+ // step's — naming the flags again would tell the person to repeat
894
+ // themselves (C15, C27).
895
+ out("Nothing to change: the step already has this code and this mark.");
896
+ return 1;
897
+ case "logic_sandbox_unavailable":
898
+ out(`${e.verb} (logic_sandbox_unavailable): the service could not run step code just now, so nothing was checked, saved or undone. Try again in a minute.`);
730
899
  return 1;
731
900
  case "note_required":
732
901
  out(`${e.verb} (note_required): --force needs --note "why".`);
@@ -741,9 +910,19 @@ function explain(reply, args, deps, e) {
741
910
  return 1;
742
911
  }
743
912
  case "not_latest_edit": {
913
+ // Undo walks back (D9), so this is a real way through now: undoing the
914
+ // newer entry puts the step back as this one left it, and then this one
915
+ // can be undone. It used to loop, when undo only toggled (C23).
744
916
  const latest = typeof body.latestEditId === "string" ? body.latestEditId : "<the newer edit>";
745
- out(`${e.verb} (not_latest_edit): a newer edit of the same step follows it. Undo that one first:`);
917
+ out(`${e.verb} (not_latest_edit): the step has changed since that edit — ${latest} changed it after. Undo the newer one first, then this one:`);
746
918
  out(` bir scenario undo ${target} --edit ${latest}`);
919
+ out(` bir scenario undo ${target} --edit ${e.editId ?? "<this edit>"}`);
920
+ return 1;
921
+ }
922
+ case "already_undone": {
923
+ const by = typeof body.revertedById === "string" ? body.revertedById : undefined;
924
+ out(`${e.verb} (already_undone): ${e.editId ?? "that edit"} was undone already${by ? `, by ${by}` : ""}. To redo it, undo that undo:`);
925
+ out(` bir scenario undo ${target} --edit ${by ?? "<the undo's sedit_ id, from bir scenario edits>"}`);
747
926
  return 1;
748
927
  }
749
928
  case "edit_replaced":
@@ -28,7 +28,7 @@ import { createHash, randomBytes, randomUUID } from "node:crypto";
28
28
  import { FINGERPRINT_WINDOW_MS, fingerprint, newCallId, parseQualifiedName, qualifyToolName, } from "./correlation.js";
29
29
  import { StepIndexAllocator } from "./ordering.js";
30
30
  import { contextForToolUse, intentForToolUse, markTranscriptUsage, recentToolResults, settledLastAssistantText, usageSince, } from "./transcript.js";
31
- import { isHousekeeping } from "../record/housekeeping.js";
31
+ import { isHousekeepingCall } from "../record/housekeeping.js";
32
32
  import { redact } from "../record/redact.js";
33
33
  import { serializeCapped } from "../record/truncate.js";
34
34
  import { StepQueue } from "../record/queue.js";
@@ -732,7 +732,13 @@ export class ControlServer {
732
732
  // calculated scenario: its generated reasoning is injected into every
733
733
  // matched turn's steering directive, and because it is not an MCP tool it
734
734
  // drags the whole scenario out of `direct` mode. Observed in production.
735
- if (isHousekeeping(toolName)) {
735
+ // The runner's own `bir` — its MCP tools and a Bash command that only runs
736
+ // it — is housekeeping too (D13): a session that fixes a plan must never
737
+ // become a scenario that edits plans when it is replayed. Returning here
738
+ // also keeps it out of intent matching, the fragment and the plan.
739
+ if (isHousekeepingCall(toolName, modelArgs)) {
740
+ if (toolUseId)
741
+ (run.housekeepingUses ??= new Set()).add(toolUseId);
736
742
  logDetail("tool.pre.skipped", { run: run.runId, tool: toolName, why: "host housekeeping" });
737
743
  return {};
738
744
  }
@@ -761,7 +767,7 @@ export class ControlServer {
761
767
  // Runs before anything else, because two of its four answers end the call.
762
768
  let pinned;
763
769
  if (run.replay?.plan && !run.replay.retired) {
764
- const action = await this.replay.preTool(run.replay, toolName, toolUseId);
770
+ const action = await this.replay.preTool(run.replay, toolName, toolUseId, modelArgs);
765
771
  // A hand-over inside `preTool` (an input logic that threw) must still
766
772
  // schedule the fragment, even though this call ends here.
767
773
  this.noteHandover(run);
@@ -1266,8 +1272,15 @@ export class ControlServer {
1266
1272
  const toolUseId = payload.tool_use_id ?? "";
1267
1273
  const toolName = payload.tool_name ?? "unknown";
1268
1274
  // `onToolPre` opened no step for these, so closing one here would record a
1269
- // `tool_response` with no `tool_selected` before it.
1270
- if (isHousekeeping(toolName))
1275
+ // `tool_response` with no `tool_selected` before it. The id it remembered
1276
+ // decides first; the call itself is judged again only when no step was
1277
+ // opened for it — a post whose pre never reached this server. A step the
1278
+ // pre did open (a plan pinned the model's call to a command that happens
1279
+ // to be `bir`) is closed as usual: its output is what the plan threads.
1280
+ const skippedAtPre = toolUseId !== "" && run.housekeepingUses?.delete(toolUseId) === true;
1281
+ if (skippedAtPre)
1282
+ return {};
1283
+ if (!run.builtIns.has(toolUseId) && isHousekeepingCall(toolName, payload.tool_input))
1271
1284
  return {};
1272
1285
  // Nor for an unarmed `run_scenario`. The plan may have retired since the
1273
1286
  // pre hook, so the test is whether that hook opened a step, not the plan.
@@ -27,8 +27,79 @@
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
  */
31
57
  /** Host tools that are never recorded as steps and never count as divergence. */
32
58
  export declare const HOUSEKEEPING_TOOLS: ReadonlySet<string>;
59
+ /** By name alone — the host's own bookkeeping tools. See {@link isHousekeepingCall} for the full rule. */
33
60
  export declare function isHousekeeping(toolName: string): boolean;
61
+ /**
62
+ * The direct plan's delivery vehicle. Must equal `DIRECT_TOOL_NAME` in the
63
+ * replay controller (a test holds them together); spelled out here because
64
+ * the controller imports this file.
65
+ */
66
+ export declare const RUN_SCENARIO_TOOL = "mcp__bir__run_scenario";
67
+ /**
68
+ * Whether one call is host housekeeping: never recorded as a step, never a
69
+ * divergence from a plan. `toolInput` is what the hook saw (`tool_input`); only
70
+ * a `Bash` call's `command` is read from it.
71
+ */
72
+ export declare function isHousekeepingCall(toolName: string, toolInput?: unknown): boolean;
73
+ /**
74
+ * {@link isBirOnlyCommand} for PowerShell, whose rules differ enough that the
75
+ * Bash reader would get it wrong: a backslash is a path separator, not an
76
+ * escape; the escape is the backtick; `$(…)` runs code even inside double
77
+ * quotes; `&` at the start of a part is the call operator. Deliberately
78
+ * narrower than the Bash reader: parts split on `;`, `&&`, `||` and newlines
79
+ * (outside quotes); each is `bir …` (as {@link isBirInvocation} reads it, also
80
+ * after a leading `&`) or a plain `cd`/`Set-Location`/`Push-Location <dir>`.
81
+ * A pipe, a backtick, `$(` or `@(` anywhere, a brace or parenthesis outside
82
+ * quotes, an `@` starting a word, or a quote left open makes the answer false — for the same
83
+ * reason as there: when unsure, record it.
84
+ */
85
+ export declare function isBirOnlyPowerShell(command: string): boolean;
86
+ /**
87
+ * True when a shell command runs `bir` and nothing else: split on `&&`, `||`,
88
+ * `;`, `|`, `&` and newlines (outside quotes), every part is a `bir`
89
+ * invocation or a plain `cd <dir>`, and at least one is `bir`.
90
+ *
91
+ * A `bir` invocation is `bir` / `bir.cmd` / a path ending in `bir`, `bir.cmd`,
92
+ * `bir.js` or `bir.ps1`; `npx [-y] [-p @basein/runner…] [@basein/runner…] bir …`;
93
+ * or `node <path>/bir.js …` — each optionally after `NAME=value` assignments,
94
+ * which only set `bir`'s own environment. A heredoc is read as the data it is,
95
+ * because `bir scenario check … --input-logic - <<'EOF'` is the natural way to
96
+ * hand `bir` a logic body, and its lines are JavaScript, not commands.
97
+ *
98
+ * Anything that could run other code while looking like `bir` makes the answer
99
+ * false: a command substitution (`$(…)`, backticks) outside single quotes and
100
+ * quoted heredocs, and a subshell, group or process substitution (`(`, `{`).
101
+ * So does a quote or heredoc left open — a parse this side is not sure of is
102
+ * not one to hide a step on.
103
+ */
104
+ export declare function isBirOnlyCommand(command: string): boolean;
34
105
  //# sourceMappingURL=housekeeping.d.ts.map