@basein/runner 0.3.17 → 0.3.19

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 (51) hide show
  1. package/dist/bin/bir-hooks.d.ts +11 -8
  2. package/dist/bin/bir-hooks.js +11 -8
  3. package/dist/bin/bir-scenario.d.ts +5 -1
  4. package/dist/bin/bir-scenario.js +155 -5
  5. package/dist/bin/bir.js +22 -9
  6. package/dist/bin/investigate.d.ts +9 -0
  7. package/dist/bin/investigate.js +26 -12
  8. package/dist/bin/scenario-edit.d.ts +7 -0
  9. package/dist/bin/scenario-edit.js +19 -2
  10. package/dist/bin/scenario-liveness.d.ts +12 -1
  11. package/dist/bin/scenario-liveness.js +8 -4
  12. package/dist/bin/setup.js +3 -3
  13. package/dist/control/client.d.ts +5 -0
  14. package/dist/control/client.js +7 -0
  15. package/dist/control/ensure-hook.d.ts +14 -3
  16. package/dist/control/ensure-hook.js +18 -5
  17. package/dist/control/recorder-main.d.ts +15 -0
  18. package/dist/control/recorder-main.js +33 -8
  19. package/dist/control/server.d.ts +192 -13
  20. package/dist/control/server.js +819 -41
  21. package/dist/control/transcript.d.ts +19 -0
  22. package/dist/control/transcript.js +57 -0
  23. package/dist/proxy/intercept.js +13 -0
  24. package/dist/proxy/session.d.ts +23 -0
  25. package/dist/proxy/session.js +71 -3
  26. package/dist/record/recorder.d.ts +156 -2
  27. package/dist/record/recorder.js +9 -0
  28. package/dist/record/remote-recorder.d.ts +59 -1
  29. package/dist/record/remote-recorder.js +297 -20
  30. package/dist/record/switchable-recorder.js +6 -0
  31. package/dist/replay/bundle.d.ts +8 -0
  32. package/dist/replay/bundle.js +20 -6
  33. package/dist/replay/condition-words.d.ts +28 -0
  34. package/dist/replay/condition-words.js +59 -0
  35. package/dist/replay/controller.d.ts +78 -7
  36. package/dist/replay/controller.js +318 -62
  37. package/dist/replay/derive.d.ts +15 -0
  38. package/dist/replay/derive.js +5 -0
  39. package/dist/replay/handover.d.ts +7 -0
  40. package/dist/replay/handover.js +89 -51
  41. package/dist/replay/plan.d.ts +101 -5
  42. package/dist/replay/plan.js +260 -18
  43. package/dist/replay/source-run.d.ts +2 -2
  44. package/dist/replay/source-run.js +3 -3
  45. package/dist/replay/targets.d.ts +40 -1
  46. package/dist/replay/targets.js +105 -3
  47. package/dist/replay/types.d.ts +25 -8
  48. package/docs/calculatedReplay.md +45 -5
  49. package/docs/calculatedReplayGuide.md +13 -4
  50. package/package.json +1 -1
  51. package/production.md +21 -7
@@ -101,15 +101,18 @@
101
101
  * Separate from BIR_INTENT_MATCHES_PER_TURN: arming
102
102
  * is a claim on the turn, counting hits is not
103
103
  * BIR_INTENT_MATCH_BUDGET_MS one intent probe's own ceiling (default 4000).
104
- * Not a wait: PreToolUse does not wait for a probe
105
- * unless arming is on (BIR_SEGMENT_ARM=1 or
106
- * BIR_INTENT_ARM=1), and then at most
104
+ * Not a wait of its own: PreToolUse waits for a
105
+ * probe only while arming is on (the default from
106
+ * 0.3.19; off with BIR_SEGMENT_ARM=0 or
107
+ * BIR_INTENT_ARM=0), and then at most
107
108
  * BIR_INTENT_ARM_BUDGET_MS (default 500)
108
- * BIR_SEGMENT_ARM 1 lets a handed-out segment actually run mid-task.
109
- * Unset is observe-only: what would have armed is
110
- * logged and nothing is replaced
111
- * BIR_MIN_SEGMENT_STEER_SIMILARITY minimum similarity to steer a segment
112
- * (default 0.95)
109
+ * BIR_SEGMENT_ARM unset or 1 (the default from 0.3.19): a segment
110
+ * the service hands out runs mid-task — one found
111
+ * by repeated work, or a half after a plan's
112
+ * decision — and so may a whole plan whose intent
113
+ * a step matches (BIR_INTENT_ARM follows it unless
114
+ * set). 0 is observe-only: what would have armed
115
+ * is logged and nothing is replaced
113
116
  * BIR_REPLAY_BUDGET_MS whole-plan ceiling, direct mode (unset: the
114
117
  * controller's 100000, BIR_REPLAY_PLAN_BUDGET_MS)
115
118
  * BIR_STEP_TIMEOUT_MS one direct tools/call (default 60000)
@@ -101,15 +101,18 @@
101
101
  * Separate from BIR_INTENT_MATCHES_PER_TURN: arming
102
102
  * is a claim on the turn, counting hits is not
103
103
  * BIR_INTENT_MATCH_BUDGET_MS one intent probe's own ceiling (default 4000).
104
- * Not a wait: PreToolUse does not wait for a probe
105
- * unless arming is on (BIR_SEGMENT_ARM=1 or
106
- * BIR_INTENT_ARM=1), and then at most
104
+ * Not a wait of its own: PreToolUse waits for a
105
+ * probe only while arming is on (the default from
106
+ * 0.3.19; off with BIR_SEGMENT_ARM=0 or
107
+ * BIR_INTENT_ARM=0), and then at most
107
108
  * BIR_INTENT_ARM_BUDGET_MS (default 500)
108
- * BIR_SEGMENT_ARM 1 lets a handed-out segment actually run mid-task.
109
- * Unset is observe-only: what would have armed is
110
- * logged and nothing is replaced
111
- * BIR_MIN_SEGMENT_STEER_SIMILARITY minimum similarity to steer a segment
112
- * (default 0.95)
109
+ * BIR_SEGMENT_ARM unset or 1 (the default from 0.3.19): a segment
110
+ * the service hands out runs mid-task — one found
111
+ * by repeated work, or a half after a plan's
112
+ * decision — and so may a whole plan whose intent
113
+ * a step matches (BIR_INTENT_ARM follows it unless
114
+ * set). 0 is observe-only: what would have armed
115
+ * is logged and nothing is replaced
113
116
  * BIR_REPLAY_BUDGET_MS whole-plan ceiling, direct mode (unset: the
114
117
  * controller's 100000, BIR_REPLAY_PLAN_BUDGET_MS)
115
118
  * BIR_STEP_TIMEOUT_MS one direct tools/call (default 60000)
@@ -68,7 +68,11 @@
68
68
  * WHICH TOOLS ARE OFFERED. `run_scenario`, always — unless the runner is
69
69
  * switched off (`bir off`), when nothing is: an inert runner adds nothing to
70
70
  * the model's context, and a call from a list taken before the switch starts
71
- * nothing and asks nobody. The rest come with scenario editing, which
71
+ * nothing and asks nobody. `report_wrong_result` comes with it: the agent that
72
+ * reads a replay's results is the one who can tell they do not answer the
73
+ * request, and says so in one call that holds the plan and undoes nothing
74
+ * (the service's review of calcscenarioautocorrect.md, item 19); the control
75
+ * server knows which session asks, and so which plan. The rest come with scenario editing, which
72
76
  * `bir setup` turns on (a person's `bir scenario editing off` is kept): the
73
77
  * reading tools — also with `BIR_SCENARIO_TOOLS=read` in this server's
74
78
  * environment — and the tools that CHANGE a scenario (D2). A project wired by
@@ -68,7 +68,11 @@
68
68
  * WHICH TOOLS ARE OFFERED. `run_scenario`, always — unless the runner is
69
69
  * switched off (`bir off`), when nothing is: an inert runner adds nothing to
70
70
  * the model's context, and a call from a list taken before the switch starts
71
- * nothing and asks nobody. The rest come with scenario editing, which
71
+ * nothing and asks nobody. `report_wrong_result` comes with it: the agent that
72
+ * reads a replay's results is the one who can tell they do not answer the
73
+ * request, and says so in one call that holds the plan and undoes nothing
74
+ * (the service's review of calcscenarioautocorrect.md, item 19); the control
75
+ * server knows which session asks, and so which plan. The rest come with scenario editing, which
72
76
  * `bir setup` turns on (a person's `bir scenario editing off` is kept): the
73
77
  * reading tools — also with `BIR_SCENARIO_TOOLS=read` in this server's
74
78
  * environment — and the tools that CHANGE a scenario (D2). A project wired by
@@ -184,6 +188,39 @@ const TOOL = {
184
188
  "never otherwise.",
185
189
  inputSchema: { type: "object", properties: {}, additionalProperties: false },
186
190
  };
191
+ /**
192
+ * The agent's way to say a replay was wrong (the service's review of
193
+ * calcscenarioautocorrect.md, item 19). A plan that took the wrong half of an
194
+ * "if … otherwise" was caught only when the agent said so in its answer, or
195
+ * did the task again — and the results it was handed told it not to check.
196
+ * Offered wherever `run_scenario` is: the agent that reads a replay's results
197
+ * is the one who can tell they are wrong. It holds the plan on the service and
198
+ * undoes nothing. Must match `REPORT_TOOL_NAME`'s suffix in the replay
199
+ * controller.
200
+ */
201
+ const REPORT_TOOL_NAME = "report_wrong_result";
202
+ /** What the agent may say, in characters; the control server and the service cut it there too. */
203
+ const REPORT_REASON_MAX = 2_000;
204
+ /** The whole report's request: the control server waits up to 10 s for the service. */
205
+ const REPORT_TIMEOUT_MS = 15_000;
206
+ const REPORT_TOOL = {
207
+ name: REPORT_TOOL_NAME,
208
+ description: "Call this when the results of a replayed scenario (run_scenario, or a plan bir ran for you) do not answer " +
209
+ "the user's request — for example, the wrong branch of an if/otherwise was taken, or the wrong item was acted " +
210
+ "on. Say why in one or two sentences. It does not undo anything.",
211
+ inputSchema: {
212
+ type: "object",
213
+ properties: {
214
+ reason: {
215
+ type: "string",
216
+ maxLength: REPORT_REASON_MAX,
217
+ description: "Why the results do not answer the request, in one or two sentences.",
218
+ },
219
+ },
220
+ required: ["reason"],
221
+ additionalProperties: false,
222
+ },
223
+ };
187
224
  /**
188
225
  * Ask the control server to run the armed plan. Never throws. The reasons it
189
226
  * returns are for the audit log; {@link toolResult} decides what the model reads.
@@ -213,8 +250,14 @@ async function runArmedPlan(cwd, flight, toolUseId) {
213
250
  "content-type": "application/json",
214
251
  authorization: `Bearer ${found.token}`,
215
252
  },
216
- // `ppid`: the host that started this server and the session's proxies (WHOSE PROXIES above).
217
- body: JSON.stringify(toolUseId ? { toolUseId, ppid: process.ppid } : { ppid: process.ppid }),
253
+ // `ppid`: the host that started this server and the session's proxies
254
+ // (WHOSE PROXIES above). `reports`: this server offers
255
+ // `report_wrong_result`, so the results may say the agent can use it.
256
+ body: JSON.stringify({
257
+ ...(toolUseId ? { toolUseId } : {}),
258
+ ppid: process.ppid,
259
+ reports: listTools(cwd).some((t) => t.name === REPORT_TOOL_NAME),
260
+ }),
218
261
  signal: controller.signal,
219
262
  });
220
263
  const raw = await res.text();
@@ -333,6 +376,60 @@ function toolResult(result) {
333
376
  return said(ALREADY_RUNNING_TEXT);
334
377
  return said(UNAVAILABLE_TEXT);
335
378
  }
379
+ /**
380
+ * Tell the control server the agent said a replay was wrong (review item 19):
381
+ * it knows the session — by the host's id for this call, as for
382
+ * `run_scenario` — and so the plan, and it reports it to the service. Never
383
+ * throws; every failure is an answer.
384
+ */
385
+ async function reportWrongResult(cwd, flight, reason, toolUseId) {
386
+ const found = await resolveControl(cwd, DISCOVERY_GRACE_MS, 50);
387
+ if (!found?.url || !found.token)
388
+ return { ok: false, why: "no_plan" };
389
+ if (flight.cancelled)
390
+ return { ok: false, why: "cancelled" };
391
+ const controller = new AbortController();
392
+ flight.abort = controller;
393
+ const timer = setTimeout(() => controller.abort(), REPORT_TIMEOUT_MS);
394
+ timer.unref?.();
395
+ try {
396
+ const res = await fetch(`${found.url.replace(/\/+$/, "")}/scenario/dispute`, {
397
+ method: "POST",
398
+ headers: { "content-type": "application/json", authorization: `Bearer ${found.token}` },
399
+ body: JSON.stringify({ reason, ...(toolUseId ? { toolUseId } : {}), ppid: process.ppid }),
400
+ signal: controller.signal,
401
+ });
402
+ const raw = await res.text();
403
+ // An older recorder has no such route (404): it cannot take the report.
404
+ if (!res.ok)
405
+ return { ok: false, why: res.status === 404 ? "unsupported" : `HTTP ${res.status}` };
406
+ const body = JSON.parse(raw);
407
+ return body && typeof body === "object" && !Array.isArray(body) ? body : { ok: false };
408
+ }
409
+ catch (err) {
410
+ return { ok: false, why: flight.cancelled ? "cancelled" : errText(err) };
411
+ }
412
+ finally {
413
+ clearTimeout(timer);
414
+ flight.abort = undefined;
415
+ }
416
+ }
417
+ /**
418
+ * The one sentence the agent reads back. Never an error: the report is a side
419
+ * note to its answer, and whatever happened to it, the agent's next move is to
420
+ * tell the user.
421
+ */
422
+ function reportAnswer(result) {
423
+ if (result.ok === true) {
424
+ return said(result.held === true
425
+ ? "Reported. bir will not use this plan again until it is checked."
426
+ : "Reported. Now tell the user what is wrong with these results.");
427
+ }
428
+ if (result.why === "no_plan") {
429
+ return said("Nothing was reported: no scenario was replayed for this session's latest request.");
430
+ }
431
+ return said("The report could not be sent right now. Tell the user what is wrong with these results.");
432
+ }
336
433
  // ── the scenario tools: `bir scenario …`, spawned ─────────────────────────────
337
434
  /** How long a `bir` that may change a scenario may take. A check replays the chain on the service. */
338
435
  const BIR_TIMEOUT_MS = 120_000;
@@ -720,7 +817,11 @@ function readToolsOptedIn() {
720
817
  const value = (process.env.BIR_SCENARIO_TOOLS ?? "").trim().toLowerCase();
721
818
  return value === "read" || value === "1" || value === "on" || value === "true";
722
819
  }
723
- /** The tools to list for `cwd`, read now: the switches may have moved since the last list. */
820
+ /**
821
+ * The tools to list for `cwd`, read now: the switches may have moved since the
822
+ * last list. `report_wrong_result` comes with `run_scenario`, editing on or off:
823
+ * whoever reads a replay's results may need to say they are wrong.
824
+ */
724
825
  function listTools(cwd) {
725
826
  if (runnerOff(cwd))
726
827
  return [];
@@ -728,6 +829,7 @@ function listTools(cwd) {
728
829
  const reading = editing || readToolsOptedIn();
729
830
  return [
730
831
  TOOL,
832
+ REPORT_TOOL,
731
833
  ...SCENARIO_TOOLS.filter((t) => (t.changes ? editing : reading)).map(({ name, description, inputSchema }) => ({
732
834
  name,
733
835
  description,
@@ -968,7 +1070,8 @@ function main() {
968
1070
  cwd,
969
1071
  state: () => {
970
1072
  const off = runnerOff(cwd);
971
- return { tools: off ? 0 : listTools(cwd).length, off };
1073
+ const tools = off ? [] : listTools(cwd);
1074
+ return { tools: tools.length, off, report: tools.some((t) => t.name === REPORT_TOOL_NAME) };
972
1075
  },
973
1076
  });
974
1077
  /**
@@ -1035,6 +1138,27 @@ function main() {
1035
1138
  }
1036
1139
  reply({ jsonrpc: "2.0", id, result: answer });
1037
1140
  };
1141
+ /** `report_wrong_result`: tell the control server, and answer exactly once — unless cancelled. */
1142
+ const answerReport = async (id, flight, reason, toolUseId) => {
1143
+ let result;
1144
+ try {
1145
+ result = await reportWrongResult(cwd, flight, reason, toolUseId);
1146
+ }
1147
+ catch (err) {
1148
+ result = { ok: false, why: errText(err) };
1149
+ }
1150
+ if (inFlight.get(id) === flight)
1151
+ inFlight.delete(id);
1152
+ if (flight.cancelled || closing)
1153
+ return;
1154
+ logLine("scenario.reported", {
1155
+ ok: result.ok === true,
1156
+ held: result.held === true || undefined,
1157
+ why: typeof result.why === "string" ? result.why.slice(0, 200) : undefined,
1158
+ bound: toolUseId !== undefined || undefined,
1159
+ });
1160
+ reply({ jsonrpc: "2.0", id, result: reportAnswer(result) });
1161
+ };
1038
1162
  /** A scenario tool: run `bir`, and answer exactly once — unless cancelled. */
1039
1163
  const answerTool = async (id, tool, args, flight) => {
1040
1164
  let result;
@@ -1136,6 +1260,32 @@ function main() {
1136
1260
  void answerTool(id, scenarioTool, paramsObject(params.arguments), flight);
1137
1261
  return;
1138
1262
  }
1263
+ if (name === REPORT_TOOL_NAME) {
1264
+ // Off: nothing ran a plan, so there is nothing to report — said
1265
+ // plainly, as the reading tools say it.
1266
+ if (runnerOff(cwd)) {
1267
+ logLine("scenario.reported", { ok: false, why: "runner is off" });
1268
+ reply({
1269
+ jsonrpc: "2.0",
1270
+ id,
1271
+ result: said("BaseInstRunner is switched off here, so there is nothing to report."),
1272
+ });
1273
+ return;
1274
+ }
1275
+ const reason = paramsObject(params.arguments).reason;
1276
+ if (typeof reason !== "string" || reason.trim() === "") {
1277
+ reply({
1278
+ jsonrpc: "2.0",
1279
+ id,
1280
+ result: { content: [{ type: "text", text: "`reason` is required: say in a sentence or two why the results are wrong." }], isError: true },
1281
+ });
1282
+ return;
1283
+ }
1284
+ const flight = { tool: REPORT_TOOL_NAME, cancelled: false };
1285
+ inFlight.set(id, flight);
1286
+ void answerReport(id, flight, reason.trim().slice(0, REPORT_REASON_MAX), toolUseIdOf(params));
1287
+ return;
1288
+ }
1139
1289
  if (name !== TOOL_NAME) {
1140
1290
  reply(errorResponse(id, JsonRpcErrorCode.MethodNotFound, `unknown tool: ${params.name}`));
1141
1291
  return;
package/dist/bin/bir.js CHANGED
@@ -3118,13 +3118,20 @@ async function doctor(args) {
3118
3118
  "For headless runs (`claude -p`) allow them in permissions.allow or --allowedTools, or set " +
3119
3119
  "BIR_CORRELATION_DECISION=allow where the recorder starts");
3120
3120
  }
3121
- // Whether a recurring sub-task the service found may actually run mid-task
3122
- // (segmented.md R-LIFE-8). Observe-only is the default and must be visible
3123
- // without reading a log: an operator turns arming on by reading what *would*
3124
- // have armed, and needs to know which of the two states this machine is in.
3125
- notes.push(health.segmentArm === true
3126
- ? "recurring sub-tasks MAY run mid-task (BIR_SEGMENT_ARM=1)"
3127
- : "recurring sub-tasks are observed only — nothing is replaced (BIR_SEGMENT_ARM unset)");
3121
+ // Whether a segment the service hands out may run mid-task (segmented.md
3122
+ // R-LIFE-8). On by default from 0.3.19 (the service's segmentsbreakdown.md
3123
+ // SB-21); either state must be visible without reading a log, and an older
3124
+ // recorder still running here keeps the old default until it is replaced.
3125
+ // A segment starts only where the probe is waited on, so `intentArm: false`
3126
+ // (BIR_INTENT_ARM=0) starts nothing mid-task, segments included; a
3127
+ // recorder older than that field says nothing of it.
3128
+ notes.push(health.segmentArm === true && health.intentArm !== false
3129
+ ? "segments run mid-task — recurring sub-tasks, and the halves after a plan's decision — and so may whole plans (the default; BIR_SEGMENT_ARM=0 turns this off)"
3130
+ : health.segmentArm === true
3131
+ ? "segments are observed only — nothing starts mid-task (BIR_INTENT_ARM=0 where the recorder started)"
3132
+ : theirs && theirs !== VERSION
3133
+ ? `segments are observed only — this recorder (${theirs}) is older than this bir, which runs them by default: \`bir up --restart\``
3134
+ : `segments are observed only — nothing is replaced (BIR_SEGMENT_ARM=0 where the recorder started)${health.intentArm === true ? "; whole plans may still start mid-task (BIR_INTENT_ARM=1)" : ""}`);
3128
3135
  const sessions = health.sessions ?? [];
3129
3136
  const probes = sessions.reduce((n, s) => n + (s.intent?.probes ?? 0), 0);
3130
3137
  const armed = sessions.reduce((n, s) => n + (s.intent?.armed ?? 0), 0);
@@ -3269,8 +3276,8 @@ async function doctor(args) {
3269
3276
  // lets Claude Code run them without asking; said only when that is not so.
3270
3277
  if (scenarioEntry && isScenarioServer(scenarioEntry.config)) {
3271
3278
  if (!editingEnabled(sidecar, cwd)) {
3272
- notes.push("the bir MCP server offers run_scenario only: scenario editing is off here — `bir scenario editing on` " +
3273
- "adds investigate and the scenario tools (restart Claude Code after)");
3279
+ notes.push("the bir MCP server offers run_scenario and report_wrong_result only: scenario editing is off here — " +
3280
+ "`bir scenario editing on` adds investigate and the scenario tools (restart Claude Code after)");
3274
3281
  }
3275
3282
  else if (![claudeUserSettingsPath(), paths.settings, paths.settingsLocal].some((file) => allowRules(file).some((rule) => rule === BIR_TOOLS_RULE || rule === `${BIR_TOOLS_RULE}__*`))) {
3276
3283
  notes.push("Claude Code asks before investigate and the scenario tools run here — `bir setup` allows every bir tool " +
@@ -3714,6 +3721,12 @@ async function replayCommand(args) {
3714
3721
  }
3715
3722
  out(`response ${JSON.stringify(result.responseModel ?? {})}`);
3716
3723
  out(`steps ${result.steps ?? 0}${result.partial ? " (partial — budget reached)" : ""}`);
3724
+ // A plan's decision (the service's segmentsbreakdown.md SB-6): the steps
3725
+ // from there are the half the recording took, so the run stops before them.
3726
+ if (result.stopped?.reason === "decision") {
3727
+ out(`stopped before step ${result.stopped.step}${result.stopped.tool ? ` (${result.stopped.tool})` : ""}: ` +
3728
+ "the plan's decision, which the agent makes — the steps from there did not run");
3729
+ }
3717
3730
  return 0;
3718
3731
  }
3719
3732
  // ── wrap ───────────────────────────────────────────────────────────────────
@@ -245,6 +245,13 @@ interface ServiceInvestigation {
245
245
  runId?: string | null;
246
246
  } | null;
247
247
  corrections?: ServiceCorrection[];
248
+ /**
249
+ * The step the plan stops in front of so that the agent decides, and the
250
+ * condition in words (the service's segmentsbreakdown.md SB-6). Null when
251
+ * the plan has none; absent from an older service.
252
+ */
253
+ decisionStep?: number | null;
254
+ decisionText?: string | null;
248
255
  } | null;
249
256
  executions?: Array<{
250
257
  id: string;
@@ -265,6 +272,8 @@ interface ServiceInvestigation {
265
272
  status: string;
266
273
  error?: string;
267
274
  }> | null;
275
+ /** Where a `fell_back` run handed over, and why (`decision`, …): null for any other run. */
276
+ fallbackKind?: string | null;
268
277
  /** The repeat the agent redid the task in, and its own calls there (AC1). */
269
278
  redoneByRunId?: string | null;
270
279
  redoneOwnCalls?: number | null;
@@ -203,8 +203,8 @@ export function findTurn(turns, id) {
203
203
  /** The runner's own gate vocabulary, with the cause and the fix (guide §9). */
204
204
  export const DECLINES = {
205
205
  replay_disabled: {
206
- cause: "calculated replay was off in this bir-hooks (BIR_REPLAY=0), or a sub-task was handed out with BIR_SEGMENT_ARM off.",
207
- fix: "start bir-hooks with BIR_REPLAY=1 (the default); for sub-tasks set BIR_SEGMENT_ARM=1.",
206
+ cause: "calculated replay was off in this bir-hooks (BIR_REPLAY=0), or a sub-task was handed out to a recorder that only observes sub-tasks (BIR_SEGMENT_ARM=0, or a recorder older than 0.3.19).",
207
+ fix: "start bir-hooks with BIR_REPLAY=1 (the default); for sub-tasks leave BIR_SEGMENT_ARM unset (on from 0.3.19), then `bir up --restart`.",
208
208
  },
209
209
  not_ready: {
210
210
  cause: "the matched recording had no ready scenario at that moment.",
@@ -434,9 +434,11 @@ export function localFindings(turn) {
434
434
  }
435
435
  for (const i of turn.incidents) {
436
436
  const fields = Object.fromEntries(Object.entries(i).filter(([k]) => k !== "at" && k !== "event"));
437
+ // A stop at the plan's decision is the plan working as split: no fault.
438
+ const decisionStop = i.event === "replay.handover" && i.kind === "decision";
437
439
  out.push({
438
440
  code: i.event.replace(/\./g, "_"),
439
- severity: i.event.endsWith("_failed") || i.event === "replay.failed" ? "problem" : "warn",
441
+ severity: i.event.endsWith("_failed") || i.event === "replay.failed" ? "problem" : decisionStop ? "info" : "warn",
440
442
  title: `${i.event}${i.error ? `: ${String(i.error)}` : ""}`,
441
443
  cause: Object.entries(fields)
442
444
  .map(([k, v]) => `${k}=${String(v)}`)
@@ -447,13 +449,15 @@ export function localFindings(turn) {
447
449
  : "the turn's cost could not be read from the transcript, or the runner has no price for the model: update it. Its report is unmeasured; run bir-hooks where the Claude Code transcript is readable."
448
450
  : i.event === "replay.handover" && i.kind === "other_branch"
449
451
  ? "this request was the other half of the task the plan holds (a step's test said so), and the agent did it; that run is how the service learns the other half."
450
- : i.event === "replay.diverge" || i.event === "replay.handover"
451
- ? "the model left the script or the runner handed the turn back; the service section shows what the fallback cost."
452
- : i.event === "run.incomplete"
453
- ? "the recording is not the whole run, so it is never calculated and no longer catches its prompt; run the task again to record it whole."
454
- : i.event === "run.host_failed"
455
- ? "the application's run failed; its recording is closed with that error and never becomes a plan."
456
- : "see the guide's troubleshooting table for this line.",
452
+ : decisionStop
453
+ ? "nothing to fix: the plan stopped at its decision, as planned. It ran the steps before it, and the agent chose the way. A segment of the half it chose may have run next (a `replay.intent_armed` line with kind segment); a `run.fragment` line with decision=true is the agent's own work, from which the service makes the other half."
454
+ : i.event === "replay.diverge" || i.event === "replay.handover"
455
+ ? "the model left the script or the runner handed the turn back; the service section shows what the fallback cost."
456
+ : i.event === "run.incomplete"
457
+ ? "the recording is not the whole run, so it is never calculated and no longer catches its prompt; run the task again to record it whole."
458
+ : i.event === "run.host_failed"
459
+ ? "the application's run failed; its recording is closed with that error and never becomes a plan."
460
+ : "see the guide's troubleshooting table for this line.",
457
461
  });
458
462
  }
459
463
  const r = turn.reported;
@@ -536,6 +540,12 @@ export function savedPct(turn) {
536
540
  return baseline > 0 ? r.savedUsd / baseline : null;
537
541
  }
538
542
  const short = (id) => (id ? id.slice(0, 13) : "—");
543
+ /**
544
+ * An execution's outcome, and where it handed the task over when the service
545
+ * says (`fell_back · decision`). The service sends `fallbackKind: null` for a
546
+ * run that did not hand over, and an older one sends nothing.
547
+ */
548
+ const outcomeOf = (e) => typeof e.fallbackKind === "string" && e.fallbackKind !== "" ? `${e.outcome} · ${e.fallbackKind}` : e.outcome;
539
549
  /** 4624 → "4,624", whatever the machine's locale. */
540
550
  const count = (n) => String(n).replace(/\B(?=(\d{3})+(?!\d))/g, ",");
541
551
  const money = (v) => (v == null ? "—" : usd(v));
@@ -638,6 +648,10 @@ export function renderService(out, s, viewerIsOwner) {
638
648
  if (sc.held) {
639
649
  out(` HELD since ${when(sc.held.at ?? undefined)}${sc.held.reason ? ` (${sc.held.reason})` : ""}: not handed out — the agent does the task until the plan is corrected or let go${sc.held.runId ? `; the redo is ${sc.held.runId}` : ""}`);
640
650
  }
651
+ if (typeof sc.decisionStep === "number") {
652
+ const text = typeof sc.decisionText === "string" ? sc.decisionText.replace(/\s+/g, " ").trim() : "";
653
+ out(` decision at step ${sc.decisionStep}: the plan stops in front of it, and the agent decides${text ? ` (${text.length > 160 ? `${text.slice(0, 160)}…` : text})` : ""}`);
654
+ }
641
655
  for (const line of stepLines(sc.steps))
642
656
  out(` ${line}`);
643
657
  const correction = newestCorrection(sc.corrections);
@@ -655,7 +669,7 @@ export function renderService(out, s, viewerIsOwner) {
655
669
  if (ex.length > 0) {
656
670
  out(` executions (${ex.length}, newest first)`);
657
671
  for (const e of ex.slice(0, 10)) {
658
- out(` ${when(e.createdAt)} ${short(e.id)} ${e.outcome.padEnd(13)} cost ${usd(e.costUsd)} saved ${money(e.savedUsd)}${e.savedPct != null ? ` (${pct(e.savedPct)})` : ""}${e.measured ? "" : " unmeasured"}`);
672
+ out(` ${when(e.createdAt)} ${short(e.id)} ${outcomeOf(e).padEnd(13)} cost ${usd(e.costUsd)} saved ${money(e.savedUsd)}${e.savedPct != null ? ` (${pct(e.savedPct)})` : ""}${e.measured ? "" : " unmeasured"}`);
659
673
  const failed = e.steps?.filter((st) => st.status === "failed") ?? [];
660
674
  for (const st of failed)
661
675
  out(` step ${st.stepIndex} ${st.toolName ?? ""} failed${st.error ? `: ${st.error.slice(0, 120)}` : ""}`);
@@ -790,7 +804,7 @@ export async function investigateCommand(args, deps) {
790
804
  if (rows.length === 0)
791
805
  out(" none yet");
792
806
  for (const e of rows) {
793
- out(` ${when(e.createdAt)} ${e.id} ${e.outcome.padEnd(13)} cost ${usd(e.costUsd)} saved ${money(e.savedUsd)}${e.savedPct != null ? ` (${pct(e.savedPct)})` : ""}${e.measured ? "" : " unmeasured"}`);
807
+ out(` ${when(e.createdAt)} ${e.id} ${outcomeOf(e).padEnd(13)} cost ${usd(e.costUsd)} saved ${money(e.savedUsd)}${e.savedPct != null ? ` (${pct(e.savedPct)})` : ""}${e.measured ? "" : " unmeasured"}`);
794
808
  out(` scenario ${e.scenarioId}${e.runId ? ` run ${e.runId}` : ""}${e.intent ? ` "${e.intent.slice(0, 60)}"` : ""}`);
795
809
  }
796
810
  out();
@@ -222,6 +222,13 @@ export declare const SCENARIO_EDIT_SUBCOMMANDS: ReadonlySet<string>;
222
222
  export declare const READ_TOOLS: readonly ["scenario_show", "scenario_edits", "investigate"];
223
223
  /** The ones that change a scenario: offered only after `bir scenario editing on` (D2). */
224
224
  export declare const EDIT_TOOLS: readonly ["scenario_check", "scenario_edit", "scenario_undo"];
225
+ /**
226
+ * The ones every session with the `bir` server has, editing on or off: the
227
+ * delivery of a direct plan, and the agent's way to say a replay's results do
228
+ * not answer the request (the service's review of calcscenarioautocorrect.md,
229
+ * item 19). `bir setup`'s allow rule, `mcp__bir`, covers every one.
230
+ */
231
+ export declare const BASE_TOOLS: readonly ["run_scenario", "report_wrong_result"];
225
232
  export declare function scenarioEditCommand(args: ScenarioEditArgs, deps: ScenarioEditDeps): Promise<number>;
226
233
  /**
227
234
  * A logic file's text. UTF-8, with a byte-order mark dropped — and UTF-16 with
@@ -59,6 +59,13 @@ export const SCENARIO_EDIT_SUBCOMMANDS = new Set([
59
59
  export const READ_TOOLS = ["scenario_show", "scenario_edits", "investigate"];
60
60
  /** The ones that change a scenario: offered only after `bir scenario editing on` (D2). */
61
61
  export const EDIT_TOOLS = ["scenario_check", "scenario_edit", "scenario_undo"];
62
+ /**
63
+ * The ones every session with the `bir` server has, editing on or off: the
64
+ * delivery of a direct plan, and the agent's way to say a replay's results do
65
+ * not answer the request (the service's review of calcscenarioautocorrect.md,
66
+ * item 19). `bir setup`'s allow rule, `mcp__bir`, covers every one.
67
+ */
68
+ export const BASE_TOOLS = ["run_scenario", "report_wrong_result"];
62
69
  const USAGE = {
63
70
  show: "usage: bir scenario show <runId|scnId> [--step <n>]",
64
71
  check: "usage: bir scenario check <runId|scnId> --step <n> [--input-logic <file|->] [--output-logic <file|->] [--unfreeze] [--json]",
@@ -1067,11 +1074,11 @@ function describeEditing(out, sidecar, cwd) {
1067
1074
  // working on a scenario, and every other session would pay for their
1068
1075
  // definitions on every request without ever calling them.
1069
1076
  if (on) {
1070
- out(` the bir MCP server offers run_scenario, ${READ_TOOLS.join(", ")} (they read)`);
1077
+ out(` the bir MCP server offers ${[...BASE_TOOLS, ...READ_TOOLS].join(", ")} (they read)`);
1071
1078
  out(` and ${EDIT_TOOLS.join(", ")} (they change a scenario)`);
1072
1079
  }
1073
1080
  else {
1074
- out(" the bir MCP server offers run_scenario");
1081
+ out(` the bir MCP server offers ${BASE_TOOLS.join(", ")}`);
1075
1082
  out(` \`bir scenario editing on\` adds ${EDIT_TOOLS.join(", ")} (they change a scenario)`);
1076
1083
  out(` and ${READ_TOOLS.join(", ")} (they read)`);
1077
1084
  out(" (BIR_SCENARIO_TOOLS=read in the server's environment offers the reading ones alone)");
@@ -1224,6 +1231,16 @@ function explain(reply, args, deps, e) {
1224
1231
  // newer entry puts the step back as this one left it, and then this one
1225
1232
  // can be undone. It used to loop, when undo only toggled (C23).
1226
1233
  const latest = typeof body.latestEditId === "string" ? body.latestEditId : "<the newer edit>";
1234
+ if (latest.startsWith("ssplit_")) {
1235
+ // The plan was split at its decision since (the service's
1236
+ // segmentsbreakdown.md). No `--edit` takes a split back: its Undo is
1237
+ // on the recording page's Split card, and only then can this go.
1238
+ const what = aboutCorrection ? "correction" : "edit";
1239
+ out(`${e.verb} (not_latest_edit): the plan was split at its decision after that ${what} (${latest}). ` +
1240
+ `It cannot be taken back while the split is in use: undo the split first (Undo on the Split card of the plan's recording page), then this ${what}:`);
1241
+ out(` bir scenario undo ${target} --edit ${e.editId ?? "<this edit>"}`);
1242
+ return 1;
1243
+ }
1227
1244
  out(aboutCorrection
1228
1245
  ? `${e.verb} (not_latest_edit): the plan has changed since that correction — ${latest} changed it after. Undo the newer one first, then this one:`
1229
1246
  : `${e.verb} (not_latest_edit): the ${aboutParam ? "parameter" : "step"} has changed since that edit — ${latest} changed it after. Undo the newer one first, then this one:`);
@@ -75,6 +75,13 @@ export interface HelloBody {
75
75
  /** How many tools this server lists now (0 when off). */
76
76
  tools: number;
77
77
  off: boolean;
78
+ /**
79
+ * It lists `report_wrong_result` (runner 0.3.18 on; the service's review of
80
+ * calcscenarioautocorrect.md, item 19): a replay's results may then tell the
81
+ * agent it can say they are wrong. Sent only when true; an older recorder
82
+ * ignores it.
83
+ */
84
+ report?: boolean;
78
85
  }
79
86
  /** A discovery record as written, its pid not yet checked. */
80
87
  export interface RecorderRecord {
@@ -100,10 +107,14 @@ export declare function launchRevive(cwd: string): Promise<void>;
100
107
  export interface LivenessOptions {
101
108
  /** The project this server runs for. */
102
109
  cwd: string;
103
- /** What this server lists now. Called for every hello. */
110
+ /**
111
+ * What this server lists now. Called for every hello. `report`: the list
112
+ * holds `report_wrong_result` (review item 19).
113
+ */
104
114
  state: () => {
105
115
  tools: number;
106
116
  off: boolean;
117
+ report?: boolean;
107
118
  };
108
119
  now?: () => number;
109
120
  random?: () => number;
@@ -314,10 +314,14 @@ export class ScenarioLiveness {
314
314
  state() {
315
315
  try {
316
316
  const s = this.opts.state();
317
- return { tools: Number.isInteger(s.tools) && s.tools >= 0 ? s.tools : 0, off: s.off === true };
317
+ return {
318
+ tools: Number.isInteger(s.tools) && s.tools >= 0 ? s.tools : 0,
319
+ off: s.off === true,
320
+ report: s.report === true && s.off !== true,
321
+ };
318
322
  }
319
323
  catch {
320
- return { tools: 0, off: false };
324
+ return { tools: 0, off: false, report: false };
321
325
  }
322
326
  }
323
327
  async sendHello(why) {
@@ -330,8 +334,8 @@ export class ScenarioLiveness {
330
334
  const target = this.target();
331
335
  if (!target)
332
336
  return;
333
- const { tools, off } = this.state();
334
- const body = { pid: process.pid, version: packageVersion(), tools, off };
337
+ const { tools, off, report } = this.state();
338
+ const body = { pid: process.pid, version: packageVersion(), tools, off, ...(report ? { report } : {}) };
335
339
  this.tried = target.identity;
336
340
  this.lastHelloAt = this.now();
337
341
  let reach;
package/dist/bin/setup.js CHANGED
@@ -43,7 +43,7 @@ import { normalizePath } from "../control/paths.js";
43
43
  import { allowRules, BIR_TOOLS_RULE, claudeCodePaths, claudeCodeSetUp } from "../config/adapters/claude-code.js";
44
44
  import { allocateReplacementPort, editingEnabled, isScenarioServer, isWrapped, LOOPBACK_NO_PROXY, probeFreePort, proxyConfigured, projectRecord, readSidecar, SCENARIO_SERVER_KEY, setProjectRecord, settleReplaySwitch, writeSidecar, allocateFreeProjectPort, } from "../config/generate.js";
45
45
  import { resolveServers } from "../config/resolve.js";
46
- import { EDIT_TOOLS, READ_TOOLS } from "./scenario-edit.js";
46
+ import { BASE_TOOLS, EDIT_TOOLS, READ_TOOLS } from "./scenario-edit.js";
47
47
  import { errText } from "../util/log.js";
48
48
  import { offSource } from "../util/off-switch.js";
49
49
  import { packageVersion } from "../util/version.js";
@@ -508,8 +508,8 @@ export async function runSetup(args, deps) {
508
508
  if (resolveServers(project).some((s) => s.name === SCENARIO_SERVER_KEY && isScenarioServer(s.config))) {
509
509
  const asks = !allowRules(claudeCodePaths(project).settingsLocal).includes(BIR_TOOLS_RULE);
510
510
  out(editingEnabled(readSidecar(), project)
511
- ? ` tools ${["run_scenario", ...READ_TOOLS, ...EDIT_TOOLS].join(", ")} — ${asks ? "Claude Code asks before each" : "Claude Code runs them without asking, except a forced change"}`
512
- : " tools run_scenario only — scenario editing is off here (`bir scenario editing on` adds investigate and the scenario tools)");
511
+ ? ` tools ${[...BASE_TOOLS, ...READ_TOOLS, ...EDIT_TOOLS].join(", ")} — ${asks ? "Claude Code asks before each" : "Claude Code runs them without asking, except a forced change"}`
512
+ : ` tools ${BASE_TOOLS.join(" and ")} only — scenario editing is off here (\`bir scenario editing on\` adds investigate and the scenario tools)`);
513
513
  }
514
514
  if (ensured) {
515
515
  const { info, health: h } = ensured.status;
@@ -68,6 +68,11 @@ export declare class ControlClient {
68
68
  }): Promise<RegisterResult | undefined>;
69
69
  /** Report one completed MCP call. Resolves false when the send was dropped. */
70
70
  report(step: ProxyStepReport): Promise<boolean>;
71
+ /**
72
+ * The tool list of the server this proxy wraps, as it answered `tools/list`
73
+ * (the service's autocorrect.md AC12). Resolves false when the send was dropped.
74
+ */
75
+ tools(serverName: string, tools: readonly unknown[]): Promise<boolean>;
71
76
  health(): Promise<Record<string, unknown> | undefined>;
72
77
  /**
73
78
  * Ask the control server to shut down. Resolves true when it agreed; the
@@ -26,6 +26,13 @@ export class ControlClient {
26
26
  const body = this.identity ? { ...step, proxyId: this.identity.proxyId } : step;
27
27
  return (await this.post("/proxy/step", body)) !== undefined;
28
28
  }
29
+ /**
30
+ * The tool list of the server this proxy wraps, as it answered `tools/list`
31
+ * (the service's autocorrect.md AC12). Resolves false when the send was dropped.
32
+ */
33
+ async tools(serverName, tools) {
34
+ return (await this.post("/proxy/tools", { ...this.identity, serverName, tools })) !== undefined;
35
+ }
29
36
  async health() {
30
37
  return (await this.request("GET", "/health"));
31
38
  }