@basein/runner 0.2.11 → 0.3.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.
Files changed (144) hide show
  1. package/README.md +540 -350
  2. package/dist/auth/client.d.ts +65 -3
  3. package/dist/auth/client.js +305 -53
  4. package/dist/bin/bir-hooks.d.ts +72 -31
  5. package/dist/bin/bir-hooks.js +176 -284
  6. package/dist/bin/bir-proxy.d.ts +17 -2
  7. package/dist/bin/bir-proxy.js +222 -28
  8. package/dist/bin/bir-scenario.d.ts +61 -10
  9. package/dist/bin/bir-scenario.js +704 -135
  10. package/dist/bin/bir.d.ts +14 -8
  11. package/dist/bin/bir.js +2937 -488
  12. package/dist/bin/investigate.js +41 -16
  13. package/dist/bin/scenario-edit.d.ts +43 -10
  14. package/dist/bin/scenario-edit.js +281 -80
  15. package/dist/bin/scenario-liveness.d.ts +194 -0
  16. package/dist/bin/scenario-liveness.js +459 -0
  17. package/dist/bin/scenario-stderr.d.ts +43 -0
  18. package/dist/bin/scenario-stderr.js +76 -0
  19. package/dist/bin/service-read.d.ts +88 -0
  20. package/dist/bin/service-read.js +128 -0
  21. package/dist/bin/setup.d.ts +19 -0
  22. package/dist/bin/setup.js +146 -20
  23. package/dist/config/adapters/claude-code.d.ts +92 -14
  24. package/dist/config/adapters/claude-code.js +291 -101
  25. package/dist/config/adapters/generic.d.ts +4 -1
  26. package/dist/config/adapters/generic.js +13 -14
  27. package/dist/config/generate.d.ts +392 -14
  28. package/dist/config/generate.js +666 -19
  29. package/dist/config/mcp-approval.d.ts +76 -0
  30. package/dist/config/mcp-approval.js +210 -0
  31. package/dist/config/node-path.d.ts +42 -0
  32. package/dist/config/node-path.js +102 -0
  33. package/dist/config/overrides.d.ts +62 -0
  34. package/dist/config/overrides.js +136 -0
  35. package/dist/config/resolve.d.ts +70 -8
  36. package/dist/config/resolve.js +186 -15
  37. package/dist/control/client.d.ts +22 -0
  38. package/dist/control/client.js +26 -7
  39. package/dist/control/correlation.d.ts +23 -5
  40. package/dist/control/correlation.js +0 -0
  41. package/dist/control/daemon.d.ts +400 -18
  42. package/dist/control/daemon.js +1105 -102
  43. package/dist/control/discovery.d.ts +77 -5
  44. package/dist/control/discovery.js +186 -11
  45. package/dist/control/ensure-hook.d.ts +61 -0
  46. package/dist/control/ensure-hook.js +160 -8
  47. package/dist/control/paths.d.ts +26 -0
  48. package/dist/control/paths.js +34 -0
  49. package/dist/control/pin-guard.d.ts +83 -0
  50. package/dist/control/pin-guard.js +534 -0
  51. package/dist/control/recorder-keeper.d.ts +229 -0
  52. package/dist/control/recorder-keeper.js +721 -0
  53. package/dist/control/recorder-main.d.ts +73 -0
  54. package/dist/control/recorder-main.js +778 -0
  55. package/dist/control/server.d.ts +707 -31
  56. package/dist/control/server.js +2803 -306
  57. package/dist/control/transcript.d.ts +21 -1
  58. package/dist/control/transcript.js +378 -112
  59. package/dist/index.d.ts +5 -1
  60. package/dist/index.js +5 -1
  61. package/dist/jsonrpc/framing.d.ts +76 -12
  62. package/dist/jsonrpc/framing.js +216 -59
  63. package/dist/proxy/intercept.d.ts +49 -5
  64. package/dist/proxy/intercept.js +188 -59
  65. package/dist/proxy/relay.d.ts +16 -0
  66. package/dist/proxy/relay.js +77 -5
  67. package/dist/proxy/revive.d.ts +122 -0
  68. package/dist/proxy/revive.js +251 -0
  69. package/dist/proxy/session.d.ts +222 -14
  70. package/dist/proxy/session.js +708 -99
  71. package/dist/record/housekeeping.d.ts +71 -0
  72. package/dist/record/housekeeping.js +417 -0
  73. package/dist/record/recorder.d.ts +51 -3
  74. package/dist/record/recorder.js +3 -0
  75. package/dist/record/redact.d.ts +103 -9
  76. package/dist/record/redact.js +659 -30
  77. package/dist/record/remote-recorder.d.ts +327 -32
  78. package/dist/record/remote-recorder.js +1517 -187
  79. package/dist/record/switchable-recorder.d.ts +126 -0
  80. package/dist/record/switchable-recorder.js +351 -0
  81. package/dist/record/truncate.d.ts +40 -1
  82. package/dist/record/truncate.js +111 -4
  83. package/dist/replay/bash-channel.d.ts +35 -0
  84. package/dist/replay/bash-channel.js +133 -0
  85. package/dist/replay/bundle.d.ts +10 -0
  86. package/dist/replay/bundle.js +10 -0
  87. package/dist/replay/controller.d.ts +371 -20
  88. package/dist/replay/controller.js +1185 -111
  89. package/dist/replay/derive.d.ts +1 -0
  90. package/dist/replay/derive.js +81 -24
  91. package/dist/replay/executor.d.ts +51 -16
  92. package/dist/replay/executor.js +114 -41
  93. package/dist/replay/handover.d.ts +54 -1
  94. package/dist/replay/handover.js +55 -5
  95. package/dist/replay/logic.d.ts +62 -10
  96. package/dist/replay/logic.js +140 -22
  97. package/dist/replay/plan.d.ts +201 -27
  98. package/dist/replay/plan.js +581 -141
  99. package/dist/replay/safety.d.ts +72 -0
  100. package/dist/replay/safety.js +303 -0
  101. package/dist/replay/source-run.d.ts +10 -0
  102. package/dist/replay/source-run.js +80 -35
  103. package/dist/replay/targets.d.ts +15 -1
  104. package/dist/replay/targets.js +62 -4
  105. package/dist/replay/tool-error.d.ts +15 -1
  106. package/dist/replay/tool-error.js +33 -1
  107. package/dist/replay/types.d.ts +7 -4
  108. package/dist/upstream/client.d.ts +56 -3
  109. package/dist/upstream/client.js +61 -4
  110. package/dist/upstream/headers.d.ts +43 -0
  111. package/dist/upstream/headers.js +90 -0
  112. package/dist/upstream/http-client.d.ts +213 -22
  113. package/dist/upstream/http-client.js +1034 -168
  114. package/dist/upstream/http-transport.d.ts +143 -0
  115. package/dist/upstream/http-transport.js +391 -0
  116. package/dist/upstream/lazy-client.d.ts +8 -0
  117. package/dist/upstream/lazy-client.js +29 -1
  118. package/dist/upstream/sse.d.ts +78 -0
  119. package/dist/upstream/sse.js +189 -0
  120. package/dist/upstream/stdio-client.d.ts +61 -1
  121. package/dist/upstream/stdio-client.js +356 -33
  122. package/dist/util/atomic-write.d.ts +29 -0
  123. package/dist/util/atomic-write.js +96 -0
  124. package/dist/util/journal.d.ts +15 -0
  125. package/dist/util/journal.js +48 -3
  126. package/dist/util/log.d.ts +10 -0
  127. package/dist/util/log.js +28 -1
  128. package/dist/util/off-switch.d.ts +46 -0
  129. package/dist/util/off-switch.js +91 -0
  130. package/dist/util/service-health.d.ts +133 -0
  131. package/dist/util/service-health.js +217 -0
  132. package/docs/BaseInstRunner.md +694 -621
  133. package/docs/calculatedReplay.md +1625 -1244
  134. package/docs/calculatedReplayGuide.md +1212 -879
  135. package/docs/installRun.md +1013 -772
  136. package/docs/loginWeb.md +627 -607
  137. package/docs/mcpmark.md +49 -16
  138. package/docs/my-first-sample.md +569 -545
  139. package/docs/quickstart.md +384 -271
  140. package/docs/t-bench.md +406 -394
  141. package/package.json +58 -57
  142. package/production.md +1371 -0
  143. package/scripts/install.ps1 +97 -6
  144. package/scripts/install.sh +104 -7
@@ -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
@@ -31,6 +31,7 @@
31
31
  import { normalizePath } from "../control/paths.js";
32
32
  import { SCENARIO_SERVER_KEY, editingEnabled, projectRecord, readSidecar, setEditing, writeSidecar, } from "../config/generate.js";
33
33
  import { errText } from "../util/log.js";
34
+ import { ReadClock, jsonText, readWithin, serviceProblem } from "./service-read.js";
34
35
  /** The `bir scenario` words this module answers; `list` and `replay` stay in bir.ts. */
35
36
  export const SCENARIO_EDIT_SUBCOMMANDS = new Set([
36
37
  "show",
@@ -41,7 +42,13 @@ export const SCENARIO_EDIT_SUBCOMMANDS = new Set([
41
42
  "calc",
42
43
  "editing",
43
44
  ]);
44
- /** The `bir` MCP server's tools that only read: always offered (D2). */
45
+ /**
46
+ * The `bir` MCP server's tools that only read. Offered with scenario editing
47
+ * on, or where the server's environment has `BIR_SCENARIO_TOOLS=read` — no
48
+ * longer in every session: their definitions ride along with every request a
49
+ * session makes, and only somebody working on a scenario ever calls them (the
50
+ * same answers are `bir scenario show` and `bir investigate` in a terminal).
51
+ */
45
52
  export const READ_TOOLS = ["scenario_show", "scenario_edits", "investigate"];
46
53
  /** The ones that change a scenario: offered only after `bir scenario editing on` (D2). */
47
54
  export const EDIT_TOOLS = ["scenario_check", "scenario_edit", "scenario_undo"];
@@ -51,8 +58,8 @@ const USAGE = {
51
58
  edit: "usage: bir scenario edit <runId|scnId> --step <n> [--input-logic <file|->] [--output-logic <file|->]\n" +
52
59
  ' [--freeze | --unfreeze] [--note "why"] [--force --note "why"] [--revision <n>] [--json]',
53
60
  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]]",
61
+ undo: 'usage: bir scenario undo <runId|scnId> [--edit <sedit_id> | --step <n>] [--force --note "why"] [--json]',
62
+ calc: "usage: bir scenario calc <runId|scnId> [--force [--discard-edits]]",
56
63
  editing: "usage: bir scenario editing on|off|status",
57
64
  };
58
65
  export async function scenarioEditCommand(args, deps) {
@@ -79,7 +86,11 @@ export async function scenarioEditCommand(args, deps) {
79
86
  /**
80
87
  * The raw JSON, as it always was — scripts and the guide read it. A `scn_` id
81
88
  * 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.
89
+ * no run of their own); `--step` narrows the print to that one step object,
90
+ * with the scenario's id and `chainRevision` put in front of it. The revision
91
+ * is a scenario's, not a step's, and it is what `edit --revision` (and the MCP
92
+ * `scenario_edit`'s `revision`) takes: a step printed without it sent the model
93
+ * that had just read the step off to guess one (C20).
83
94
  */
84
95
  async function show(args, deps) {
85
96
  const { out } = deps;
@@ -87,7 +98,7 @@ async function show(args, deps) {
87
98
  if (!target)
88
99
  return usageError(deps, "show");
89
100
  const byScenario = target.startsWith("scn_");
90
- const reply = await deps.service("GET", byScenario ? `/scenarios/${seg(target)}` : `/recordings/runs/${seg(target)}/scenario`);
101
+ const reply = await readWithin(deps.service("GET", byScenario ? `/scenarios/${seg(target)}` : `/recordings/runs/${seg(target)}/scenario`));
91
102
  if ("error" in reply || reply.status !== 200) {
92
103
  return explain(reply, args, deps, {
93
104
  verb: "Could not read the scenario",
@@ -98,14 +109,13 @@ async function show(args, deps) {
98
109
  });
99
110
  }
100
111
  if (args.step === undefined) {
101
- out(JSON.stringify(reply.body, null, 2));
112
+ out(jsonText(reply.body));
102
113
  return 0;
103
114
  }
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
- }
115
+ const served = reply.body;
116
+ const scenario = served?.scenario;
117
+ if (!scenario)
118
+ return noWholeRunScenario(target, served?.segments, args, deps);
109
119
  if (!Array.isArray(scenario.steps)) {
110
120
  out(`${scenario.id ?? target} is ${scenario.state ?? "not ready"}, so it has no steps to show yet.`);
111
121
  return 1;
@@ -116,7 +126,7 @@ async function show(args, deps) {
116
126
  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
127
  return 1;
118
128
  }
119
- out(JSON.stringify(step, null, 2));
129
+ out(jsonText({ scenarioId: scenario.id ?? null, chainRevision: scenario.chainRevision ?? null, ...step }));
120
130
  return 0;
121
131
  }
122
132
  /**
@@ -181,19 +191,28 @@ async function checkOrEdit(args, deps, save) {
181
191
  return explain(reply, args, deps, { verb: "Not checked", ...explained });
182
192
  const report = reply.body?.report;
183
193
  if (args.json) {
184
- out(JSON.stringify(reply.body, null, 2));
185
- return report?.ok ? 0 : 1;
194
+ out(jsonText(reply.body));
195
+ return report?.ok || report?.noChange ? 0 : 1;
186
196
  }
187
197
  if (!report) {
188
198
  out("The service answered the check without a report.");
189
199
  return 1;
190
200
  }
191
201
  renderReport(out, report);
202
+ // Before `ok`: a change that is already the step's would pass the check and
203
+ // then be answered `nothing_to_change` by `edit` — so `check` says so
204
+ // itself, instead of printing an `edit` command that cannot save (C15).
205
+ if (report.noChange) {
206
+ out("Nothing would change: the step already has this code and mark.");
207
+ return 0;
208
+ }
192
209
  if (report.ok) {
193
210
  out(`Nothing saved. To save: ${editCommand(target, args)}`);
194
211
  return 0;
195
212
  }
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".');
213
+ refusal(out, report, "Nothing saved, and `bir scenario edit` would refuse it", report.budgetExceeded
214
+ ? ' 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".'
215
+ : ' Fix the code and check again. If the recording itself was wrong, edit can save it on purpose: add --force --note "why".');
197
216
  return 1;
198
217
  }
199
218
  if (args.freeze)
@@ -213,13 +232,15 @@ async function checkOrEdit(args, deps, save) {
213
232
  return explain(reply, args, deps, { verb: "Not saved", ...explained });
214
233
  }
215
234
  if (args.json) {
216
- out(JSON.stringify(reply.body, null, 2));
235
+ out(jsonText(reply.body));
217
236
  return refused ? 1 : 0;
218
237
  }
219
238
  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".');
239
+ renderReport(out, answer.report, { brief: true });
240
+ if (refused && answer?.report) {
241
+ refusal(out, answer.report, "Not saved", answer.report.budgetExceeded
242
+ ? ' Make the code cheaper to run and try again, or save it on purpose: add --force --note "why".'
243
+ : ' Fix the code and try again, or save it on purpose: add --force --note "why".');
223
244
  return 1;
224
245
  }
225
246
  const edit = answer?.edit;
@@ -283,12 +304,28 @@ const label = (name, text) => ` ${name.padEnd(9)}${text}`;
283
304
  * The report in the lines editSteps.md "What a person sees" shows:
284
305
  * `input`, `output` (when the output logic changed), `later`, `answer` (when
285
306
  * the final answer read anything before) and `mark`.
307
+ *
308
+ * `check` prints every line: it is where a person explores a change, and "step
309
+ * 5 still reproduces" is an answer there. `edit` and `undo` are acts, and print
310
+ * `brief`ly — what changed and what went wrong: `input` always, `output` when
311
+ * it changed, `later` only for a step that broke, `answer` only when it lost its
312
+ * values, `mark` only when it moves. The report is the same one either way (D11);
313
+ * only the lines that say "nothing to see" are left out.
314
+ *
315
+ * An `undo` does not judge the step itself — its code and mark go back exactly
316
+ * as they were (editSteps.md, "Undo") — so its report's input status is
317
+ * `not_checked` by design, and printing the usual reason for that ("the
318
+ * recorded call is plain text") would be false. It says what happened instead.
286
319
  */
287
- export function renderReport(out, r) {
320
+ export function renderReport(out, r, opts = {}) {
321
+ const brief = opts.brief === true;
288
322
  out(`Step ${r.stepIndex} (${r.toolName ?? "?"}) — checked against ${r.sourceRunId}`);
289
323
  const i = r.input ?? { changed: false, before: "not_checked", after: "not_checked" };
290
324
  const unchanged = i.changed ? "" : " (input logic unchanged)";
291
- if (i.after === "differs") {
325
+ if (opts.undo) {
326
+ out(label("input", i.changed ? "back as it was before that edit" : "unchanged"));
327
+ }
328
+ else if (i.after === "differs") {
292
329
  const d = i.firstDifference ?? firstDifference(i.computed, i.recorded);
293
330
  out(label("input", `differs from the recorded call${d ? ` at character ${d.at}` : ""}${unchanged}`));
294
331
  if (d) {
@@ -314,8 +351,18 @@ export function renderReport(out, r) {
314
351
  }
315
352
  const later = r.later ?? [];
316
353
  const regressed = later.filter((l) => l.regressed);
317
- if (later.length === 0) {
318
- out(label("later", "no later steps"));
354
+ if (brief && regressed.length === 0) {
355
+ // Nothing broke: an act has nothing to say about later steps.
356
+ }
357
+ else if (later.length === 0) {
358
+ // The service judges later steps only when what they read can change: an
359
+ // input-only change (or a mark) leaves them alone, and marked or
360
+ // unverifiable later steps are never judged (editSteps.md rule 6).
361
+ out(label("later", r.output?.changed
362
+ ? "none to check"
363
+ : r.input?.changed
364
+ ? "not affected: only the input logic changed"
365
+ : "not affected: the code did not change"));
319
366
  }
320
367
  else if (regressed.length === 0) {
321
368
  const still = later.filter((l) => l.after === "reproduces").map((l) => l.stepIndex);
@@ -330,18 +377,21 @@ export function renderReport(out, r) {
330
377
  out(label("later", `step ${l.stepIndex} (${l.toolName ?? "?"}) reproduced before; now it ${laterPhrase(l.after)}`));
331
378
  }
332
379
  }
333
- if (r.answer?.informativeBefore) {
380
+ if (r.answer?.informativeBefore && !(brief && r.answer.informativeAfter)) {
334
381
  out(label("answer", r.answer.informativeAfter
335
382
  ? "still gets its values"
336
383
  : "no longer gets its values: the final answer's logic stops finding what it reads"));
337
384
  }
338
385
  if (r.mark?.before && r.mark.after) {
339
386
  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)}`));
387
+ const moves = before.marked !== after.marked || before.why !== after.why;
388
+ if (moves || !brief) {
389
+ out(label("mark", moves
390
+ ? `${markWord(before)} → ${markWord(after)}`
391
+ : before.marked
392
+ ? `${markWord(before)} → stays marked`
393
+ : "runs by itself"));
394
+ }
345
395
  }
346
396
  }
347
397
  function inputPhrase(status, verifiable) {
@@ -413,8 +463,27 @@ function sentence(problem) {
413
463
  const lowered = /^[A-Z][a-z]/.test(text) ? text[0].toLowerCase() + text.slice(1) : text;
414
464
  return /[.!?]$/.test(lowered) ? lowered : `${lowered}.`;
415
465
  }
416
- function refusal(out, problems, opening, hint) {
417
- const list = problems.length ? problems : ["the check did not pass"];
466
+ /**
467
+ * The verdict under a refused report. When the one thing wrong is that the
468
+ * changed input logic does not compute the recorded call, the `input` line
469
+ * above has already said where it parts, and the verdict says it in the words
470
+ * editSteps.md shows: "the change does not reproduce the recording". Anything
471
+ * else — a copy, a later step, the answer, the mark, *too costly to check* —
472
+ * is the service's own sentence, one per problem.
473
+ */
474
+ function refusal(out, report, opening, hint) {
475
+ const problems = report.problems ?? [];
476
+ const inputOnly = problems.length === 1 &&
477
+ report.input?.changed === true &&
478
+ (report.input.after === "differs" || report.input.after === "throws") &&
479
+ !report.budgetExceeded &&
480
+ !(report.later ?? []).some((l) => l.regressed) &&
481
+ !(report.answer?.informativeBefore && !report.answer.informativeAfter);
482
+ const list = inputOnly
483
+ ? ["the change does not reproduce the recording"]
484
+ : problems.length
485
+ ? problems
486
+ : ["the check did not pass"];
418
487
  if (list.length === 1) {
419
488
  out(`${opening}: ${sentence(list[0])}`);
420
489
  }
@@ -427,7 +496,7 @@ function refusal(out, problems, opening, hint) {
427
496
  }
428
497
  // ── edits / undo ──────────────────────────────────────────────────────────────
429
498
  async function fetchEdits(scenarioId, deps) {
430
- const reply = await deps.service("GET", `/scenarios/${seg(scenarioId)}/edits`);
499
+ const reply = await readWithin(deps.service("GET", `/scenarios/${seg(scenarioId)}/edits`));
431
500
  if ("error" in reply || reply.status !== 200)
432
501
  return { reply };
433
502
  const edits = reply.body?.edits;
@@ -438,15 +507,18 @@ async function listEdits(args, deps) {
438
507
  const target = args.positionals[1];
439
508
  if (!target)
440
509
  return usageError(deps, "edits");
441
- const resolved = await resolveTarget(target, args, deps);
510
+ // A `run_` id is two reads — which scenario, then its history — inside one
511
+ // bound: the `bir` MCP server gives a reading tool one read's worth of time.
512
+ const clock = new ReadClock();
513
+ const resolved = await resolveTarget(target, args, deps, clock);
442
514
  if ("code" in resolved)
443
515
  return resolved.code;
444
- const reply = await deps.service("GET", `/scenarios/${seg(resolved.id)}/edits`);
516
+ const reply = await clock.within(deps.service("GET", `/scenarios/${seg(resolved.id)}/edits`));
445
517
  if ("error" in reply || reply.status !== 200) {
446
518
  return explain(reply, args, deps, { verb: "Could not read the history", target });
447
519
  }
448
520
  if (args.json) {
449
- out(JSON.stringify(reply.body, null, 2));
521
+ out(jsonText(reply.body));
450
522
  return 0;
451
523
  }
452
524
  const edits = reply.body?.edits ?? [];
@@ -489,17 +561,28 @@ function editSummary(e) {
489
561
  return parts.length ? parts.join("; ") : "no change to the code or the mark";
490
562
  }
491
563
  /**
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.
564
+ * What `undo` without `--edit` takes back (D9): the newest **edit** — never an
565
+ * undo entry — that nothing has put back and no recalculation replaced; of
566
+ * step `step` when it is given.
567
+ *
568
+ * Undo entries are skipped on purpose. They used to be picked like any other,
569
+ * so a second `undo` redid the first one, and a step edited twice toggled
570
+ * between its two edits for ever and never got back to the calculation's code
571
+ * (C12). Now a second `undo` goes one further back; a redo is asked for by
572
+ * name, `--edit <the undo's id>`, which the undo prints.
573
+ *
574
+ * Whether the step is still exactly as that edit left it is the service's
575
+ * question, not this list's: it answers `not_latest_edit` when it is not.
496
576
  */
497
- export function newestUndoable(edits) {
577
+ export function newestUndoable(edits, step) {
498
578
  // Newest first, as the service lists them; sorted again so a reordering
499
579
  // there cannot make this undo the wrong one.
500
580
  return [...edits]
501
581
  .sort((x, y) => (x.createdAt < y.createdAt ? 1 : x.createdAt > y.createdAt ? -1 : 0))
502
- .find((e) => !e.revertedAt && !e.replacedAt);
582
+ .find((e) => e.kind !== "revert" &&
583
+ !e.revertedAt &&
584
+ !e.replacedAt &&
585
+ (step === undefined || e.stepIndex === step));
503
586
  }
504
587
  async function undo(args, deps) {
505
588
  const { out } = deps;
@@ -509,6 +592,15 @@ async function undo(args, deps) {
509
592
  if (args.edit !== undefined && !/^sedit_[\w-]+$/.test(args.edit)) {
510
593
  return usageError(deps, "undo", `--edit takes a sedit_… id, as \`bir scenario edits\` prints it; got ${args.edit}.`);
511
594
  }
595
+ if (args.edit !== undefined && args.step !== undefined) {
596
+ return usageError(deps, "undo", "--edit names one entry and --step picks one; give one of them.");
597
+ }
598
+ const note = args.note?.trim() ? args.note : undefined;
599
+ if (args.force && !note) {
600
+ // As for `edit`: an undo the check refused is taken back only on purpose,
601
+ // and the note is the only record of why.
602
+ return usageError(deps, "undo", '--force needs --note "why" (note_required): an undo made on purpose says why, and the history keeps it.');
603
+ }
512
604
  const resolved = await resolveTarget(target, args, deps);
513
605
  if ("code" in resolved)
514
606
  return resolved.code;
@@ -517,34 +609,61 @@ async function undo(args, deps) {
517
609
  const listed = await fetchEdits(resolved.id, deps);
518
610
  if ("reply" in listed)
519
611
  return explain(listed.reply, args, deps, { verb: "Could not read the history", target });
520
- const pick = newestUndoable(listed.edits);
612
+ const pick = newestUndoable(listed.edits, args.step);
521
613
  if (!pick) {
522
- return localFailure(args, deps, "nothing_to_undo", `Nothing to undo: ${target} has no hand edit that can be undone.`);
614
+ return localFailure(args, deps, "nothing_to_undo", args.step === undefined
615
+ ? `Nothing to undo: ${target} has no hand edit that can be undone.`
616
+ : `Nothing to undo: step ${args.step} of ${target} has no hand edit that can be undone.`);
523
617
  }
524
618
  editId = pick.id;
525
619
  }
526
620
  const body = {};
527
- if (args.note?.trim())
528
- body.note = args.note;
621
+ if (note)
622
+ body.note = note;
623
+ if (args.force)
624
+ body.force = true;
529
625
  if (args.revision !== undefined)
530
626
  body.expectedRevision = args.revision;
531
627
  const reply = await deps.service("POST", `/scenarios/${seg(resolved.id)}/edits/${seg(editId)}/revert`, body);
532
- if ("error" in reply || reply.status !== 200) {
628
+ const answer = ("error" in reply ? undefined : reply.body);
629
+ const refused = !("error" in reply) && reply.status === 422 && Boolean(answer?.report);
630
+ if ("error" in reply || (reply.status !== 200 && !refused)) {
533
631
  return explain(reply, args, deps, {
534
632
  verb: "Not undone",
535
633
  target,
634
+ editId,
536
635
  notFound: `Not undone (not_found): ${target} has no edit ${editId}, or it is not yours.`,
537
636
  });
538
637
  }
539
638
  if (args.json) {
540
- out(JSON.stringify(reply.body, null, 2));
541
- return 0;
639
+ out(jsonText(reply.body));
640
+ return refused ? 1 : 0;
542
641
  }
543
- const b = reply.body;
544
- const entry = b?.edit;
545
- const revision = b?.chainRevision ?? entry?.chainRevisionAfter;
642
+ if (answer?.report)
643
+ renderReport(out, answer.report, { brief: true, undo: true });
644
+ if (refused && answer?.report) {
645
+ // The undo would break a later step or the answer (D9): the same check a
646
+ // save runs, and the same way past it.
647
+ refusal(out, answer.report, "Not undone", ' To undo it anyway, on purpose: add --force --note "why".');
648
+ return 1;
649
+ }
650
+ const entry = answer?.edit;
651
+ const revision = answer?.chainRevision ?? entry?.chainRevisionAfter;
546
652
  out(`Step ${entry?.stepIndex ?? "?"} is back as it was before ${entry?.revertsEditId ?? editId} (revision ${revision ?? "?"}).` +
547
653
  (entry?.id ? ` Redo: bir scenario undo ${target} --edit ${entry.id}` : ""));
654
+ // Undo walks back (D9): the same command again goes one further. An undo
655
+ // asked for by id names its step, so "further back" stays on that step.
656
+ const further = args.step !== undefined
657
+ ? ` --step ${args.step}`
658
+ : args.edit !== undefined && typeof entry?.stepIndex === "number"
659
+ ? ` --step ${entry.stepIndex}`
660
+ : "";
661
+ out(` One further back: bir scenario undo ${target}${further}`);
662
+ if (entry?.forced) {
663
+ out(" Undone on purpose (--force), although the check refused it.");
664
+ for (const p of answer?.report?.problems ?? [])
665
+ out(` The check said: ${sentence(p)}`);
666
+ }
548
667
  return 0;
549
668
  }
550
669
  // ── calc ──────────────────────────────────────────────────────────────────────
@@ -552,6 +671,13 @@ async function undo(args, deps) {
552
671
  * `calc` lives here because of one answer: a recalculation rebuilds every
553
672
  * step from the recording, so the service refuses `--force` on a plan with
554
673
  * hand edits (D4) unless `--discard-edits` says to throw them away.
674
+ *
675
+ * A `run_` id posts `/recordings/runs/:id/calculate`, which reaches only the
676
+ * run's **whole-run** scenario. A `scn_` id posts `/scenarios/:id/recalculate`
677
+ * — the only way to recalculate a sub-task (segment) scenario from here, and
678
+ * so the only way to take a segment's hand edits back once undo cannot (C12).
679
+ * A scenario id always names one that exists, so it is always a
680
+ * recalculation in place, and asks for `--force` as the run form does.
555
681
  */
556
682
  async function calc(args, deps) {
557
683
  const { out } = deps;
@@ -561,20 +687,30 @@ async function calc(args, deps) {
561
687
  if (args.discardEdits && !args.force) {
562
688
  return usageError(deps, "calc", "--discard-edits only means something with --force (a recalculation in place).");
563
689
  }
564
- const reply = await deps.service("POST", `/recordings/runs/${seg(target)}/calculate`, args.force ? { force: true, ...(args.discardEdits ? { discardEdits: true } : {}) } : {});
690
+ const byScenario = target.startsWith("scn_");
691
+ if (byScenario && !args.force) {
692
+ 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).`);
693
+ }
694
+ const reply = byScenario
695
+ ? await deps.service("POST", `/scenarios/${seg(target)}/recalculate`, args.discardEdits ? { discardEdits: true } : {})
696
+ : await deps.service("POST", `/recordings/runs/${seg(target)}/calculate`, args.force ? { force: true, ...(args.discardEdits ? { discardEdits: true } : {}) } : {});
565
697
  if ("error" in reply)
566
698
  return explain(reply, args, deps, { verb: "Could not start calculation", target });
567
699
  if (args.json) {
568
- out(JSON.stringify(reply.body ?? null, null, 2));
700
+ out(jsonText(reply.body ?? null));
569
701
  return reply.status === 202 ? 0 : 1;
570
702
  }
571
703
  const { status, body } = reply;
572
704
  const code = body?.error;
573
705
  if (status === 202) {
574
706
  const b = body;
575
- out(`Calculating ${b.scenarioId ?? ""} — poll with \`bir scenario show ${target}\`.`);
707
+ out(`${byScenario ? "Recalculating" : "Calculating"} ${b.scenarioId ?? target} — poll with \`bir scenario show ${target}\`.`);
576
708
  return 0;
577
709
  }
710
+ if (status === 404) {
711
+ out(byScenario ? `No such scenario, or it is not yours: ${target}.` : `No such run, or it is not yours: ${target}.`);
712
+ return 1;
713
+ }
578
714
  if (status === 409 && code === "scenario_has_edits") {
579
715
  const steps = body.editedSteps;
580
716
  const list = Array.isArray(steps) ? steps.map(String) : [];
@@ -588,15 +724,26 @@ async function calc(args, deps) {
588
724
  out("Another calculation of yours is running, and the service calculates one at a time. Try again when it is ready.");
589
725
  return 1;
590
726
  }
591
- if (status === 409) {
727
+ // With --force the run route answers `scenario_exists` for one reason only:
728
+ // that scenario is being calculated right now. `scenario_calculating` is the
729
+ // scenario route's word for the same thing (C16).
730
+ if (status === 409 && (code === "scenario_calculating" || (code === "scenario_exists" && args.force))) {
731
+ out(`A calculation of ${target} is already running. Wait for it to finish (\`bir scenario show ${target}\`).`);
732
+ return 1;
733
+ }
734
+ if (status === 409 && code === "scenario_exists") {
592
735
  out("That run already has a scenario. Re-derive it in place with --force.");
593
736
  return 1;
594
737
  }
595
- if (status === 503) {
738
+ if (status === 503 && code === "calculation_paused_today") {
739
+ out("Calculation is paused for today: the service has reached its daily calculation budget. Try again tomorrow.");
740
+ return 1;
741
+ }
742
+ if (status === 503 && code === "anthropic_not_configured") {
596
743
  out("The service has no Anthropic configuration, so it cannot calculate scenarios.");
597
744
  return 1;
598
745
  }
599
- out(`Could not start calculation (HTTP ${status}): ${JSON.stringify(body)}`);
746
+ out(`Could not start calculation (HTTP ${status}${code ? `, ${code}` : ""}): ${JSON.stringify(body)}`);
600
747
  return 1;
601
748
  }
602
749
  // ── editing on|off|status ─────────────────────────────────────────────────────
@@ -634,11 +781,19 @@ function describeEditing(out, sidecar, cwd) {
634
781
  if (!hasScenarioServer(sidecar, cwd)) {
635
782
  out(" (no `bir` MCP server is installed here, so no tool is offered at all — `bir install --replay` adds it)");
636
783
  }
637
- out(` the bir MCP server offers ${READ_TOOLS.join(", ")} (they read)`);
638
- if (on)
784
+ // The reading tools come with the switch too: they are for whoever is
785
+ // working on a scenario, and every other session would pay for their
786
+ // definitions on every request without ever calling them.
787
+ if (on) {
788
+ out(` the bir MCP server offers run_scenario, ${READ_TOOLS.join(", ")} (they read)`);
639
789
  out(` and ${EDIT_TOOLS.join(", ")} (they change a scenario)`);
640
- else
790
+ }
791
+ else {
792
+ out(" the bir MCP server offers run_scenario");
641
793
  out(` \`bir scenario editing on\` adds ${EDIT_TOOLS.join(", ")} (they change a scenario)`);
794
+ out(` and ${READ_TOOLS.join(", ")} (they read)`);
795
+ out(" (BIR_SCENARIO_TOOLS=read in the server's environment offers the reading ones alone)");
796
+ }
642
797
  out(" The `bir scenario` commands work either way: this switch decides only the MCP tools.");
643
798
  }
644
799
  function hasScenarioServer(sidecar, cwd) {
@@ -654,20 +809,41 @@ function usageError(deps, sub, why) {
654
809
  deps.out(USAGE[sub] ?? "usage: bir scenario …");
655
810
  return 2;
656
811
  }
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);
812
+ /**
813
+ * A failure this side decided on; `--json` still gets JSON, in the service's
814
+ * `{error, details}` shape (with `extra` beside them when there is more to say).
815
+ */
816
+ function localFailure(args, deps, code, text, extra = {}) {
817
+ deps.out(args.json ? jsonText({ error: code, details: text, ...extra }) : text);
660
818
  return 1;
661
819
  }
820
+ /**
821
+ * A run with no whole-run scenario. When sub-task (segment) scenarios were
822
+ * calculated from it, they are listed with their `scn_` ids — the only ids
823
+ * they have — instead of suggesting a calculation, which would start a new
824
+ * whole-run plan beside them rather than reach the one the person meant (C21).
825
+ */
826
+ function noWholeRunScenario(target, segments, args, deps) {
827
+ const listed = (Array.isArray(segments) ? segments : []).filter((s) => typeof s?.id === "string");
828
+ if (listed.length === 0) {
829
+ return localFailure(args, deps, "no_scenario", `${target} has no calculated scenario yet — \`bir scenario calc ${target}\` first.`);
830
+ }
831
+ const range = (s) => typeof s.stepFrom === "number" && typeof s.stepTo === "number" ? ` steps ${s.stepFrom}–${s.stepTo} of the recording` : "";
832
+ const text = [
833
+ `${target} has no whole-run scenario, only sub-task scenarios — and a sub-task scenario is addressed by its own scn_ id:`,
834
+ ...listed.map((s) => ` ${s.id}${range(s)}`),
835
+ ].join("\n");
836
+ return localFailure(args, deps, "no_whole_run_scenario", text, { segments: listed.map((s) => s.id) });
837
+ }
662
838
  /**
663
839
  * 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.
840
+ * `scn_` id (D7). A `run_` id reaches only the run's **whole-run** scenario; a
841
+ * `scn_` id is used as it is — sub-task scenarios have nothing else.
666
842
  */
667
- async function resolveTarget(target, args, deps) {
843
+ async function resolveTarget(target, args, deps, clock = new ReadClock()) {
668
844
  if (target.startsWith("scn_"))
669
845
  return { id: target };
670
- const reply = await deps.service("GET", `/recordings/runs/${seg(target)}/scenario`);
846
+ const reply = await clock.within(deps.service("GET", `/recordings/runs/${seg(target)}/scenario`));
671
847
  if ("error" in reply || reply.status !== 200) {
672
848
  return {
673
849
  code: explain(reply, args, deps, {
@@ -677,12 +853,10 @@ async function resolveTarget(target, args, deps) {
677
853
  }),
678
854
  };
679
855
  }
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
- }
856
+ const served = reply.body;
857
+ const id = served?.scenario?.id;
858
+ if (typeof id !== "string")
859
+ return { code: noWholeRunScenario(target, served?.segments, args, deps) };
686
860
  return { id };
687
861
  }
688
862
  /**
@@ -693,14 +867,18 @@ async function resolveTarget(target, args, deps) {
693
867
  function explain(reply, args, deps, e) {
694
868
  const { out } = deps;
695
869
  if ("error" in reply) {
870
+ // In words, not the transport's ("fetch failed"): this is what reaches a
871
+ // model through the `bir` MCP server. A person at a terminal also gets the
872
+ // raw reason in brackets, for a bug report.
873
+ const words = serviceProblem(reply.error);
696
874
  if (args.json)
697
- out(JSON.stringify({ error: "service_unreachable", details: reply.error }, null, 2));
875
+ out(jsonText({ error: "service_unreachable", details: words }));
698
876
  else
699
- out(`Could not reach the service: ${reply.error}`);
877
+ out(`Could not reach the service: ${words}${words === reply.error ? "" : ` (${reply.error})`}`);
700
878
  return 1;
701
879
  }
702
880
  if (args.json) {
703
- out(JSON.stringify(reply.body ?? null, null, 2));
881
+ out(jsonText(reply.body ?? null));
704
882
  return 1;
705
883
  }
706
884
  const body = (reply.body && typeof reply.body === "object" ? reply.body : {});
@@ -716,6 +894,12 @@ function explain(reply, args, deps, e) {
716
894
  out(`${e.verb}: the service did not accept this machine's session (HTTP 401) — run \`bir login\`.`);
717
895
  return 1;
718
896
  }
897
+ if (reply.status === 413) {
898
+ // By status, not code: a proxy in front of the service answers 413 with a
899
+ // page of its own, and the cause is the same.
900
+ 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.`);
901
+ return 1;
902
+ }
719
903
  switch (code) {
720
904
  case "invalid_input": {
721
905
  const details = brief(body.details);
@@ -726,7 +910,14 @@ function explain(reply, args, deps, e) {
726
910
  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
911
  return 1;
728
912
  case "nothing_to_change":
729
- out(`${e.verb} (nothing_to_change): give --input-logic, --output-logic, --freeze or --unfreeze.`);
913
+ // The empty command is refused on this side before any request, so a
914
+ // service `nothing_to_change` always means the change is already the
915
+ // step's — naming the flags again would tell the person to repeat
916
+ // themselves (C15, C27).
917
+ out("Nothing to change: the step already has this code and this mark.");
918
+ return 1;
919
+ case "logic_sandbox_unavailable":
920
+ 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
921
  return 1;
731
922
  case "note_required":
732
923
  out(`${e.verb} (note_required): --force needs --note "why".`);
@@ -741,9 +932,19 @@ function explain(reply, args, deps, e) {
741
932
  return 1;
742
933
  }
743
934
  case "not_latest_edit": {
935
+ // Undo walks back (D9), so this is a real way through now: undoing the
936
+ // newer entry puts the step back as this one left it, and then this one
937
+ // can be undone. It used to loop, when undo only toggled (C23).
744
938
  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:`);
939
+ 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
940
  out(` bir scenario undo ${target} --edit ${latest}`);
941
+ out(` bir scenario undo ${target} --edit ${e.editId ?? "<this edit>"}`);
942
+ return 1;
943
+ }
944
+ case "already_undone": {
945
+ const by = typeof body.revertedById === "string" ? body.revertedById : undefined;
946
+ out(`${e.verb} (already_undone): ${e.editId ?? "that edit"} was undone already${by ? `, by ${by}` : ""}. To redo it, undo that undo:`);
947
+ out(` bir scenario undo ${target} --edit ${by ?? "<the undo's sedit_ id, from bir scenario edits>"}`);
747
948
  return 1;
748
949
  }
749
950
  case "edit_replaced":