@basein/runner 0.1.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 (100) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +276 -0
  3. package/dist/auth/client.d.ts +85 -0
  4. package/dist/auth/client.js +284 -0
  5. package/dist/bin/bir-hooks.d.ts +48 -0
  6. package/dist/bin/bir-hooks.js +201 -0
  7. package/dist/bin/bir-proxy.d.ts +45 -0
  8. package/dist/bin/bir-proxy.js +207 -0
  9. package/dist/bin/bir-scenario.d.ts +24 -0
  10. package/dist/bin/bir-scenario.js +177 -0
  11. package/dist/bin/bir.d.ts +21 -0
  12. package/dist/bin/bir.js +876 -0
  13. package/dist/config/adapters/claude-code.d.ts +76 -0
  14. package/dist/config/adapters/claude-code.js +181 -0
  15. package/dist/config/adapters/generic.d.ts +17 -0
  16. package/dist/config/adapters/generic.js +36 -0
  17. package/dist/config/generate.d.ts +127 -0
  18. package/dist/config/generate.js +114 -0
  19. package/dist/config/resolve.d.ts +68 -0
  20. package/dist/config/resolve.js +132 -0
  21. package/dist/control/client.d.ts +56 -0
  22. package/dist/control/client.js +86 -0
  23. package/dist/control/correlation.d.ts +86 -0
  24. package/dist/control/correlation.js +0 -0
  25. package/dist/control/discovery.d.ts +50 -0
  26. package/dist/control/discovery.js +123 -0
  27. package/dist/control/ordering.d.ts +38 -0
  28. package/dist/control/ordering.js +44 -0
  29. package/dist/control/paths.d.ts +32 -0
  30. package/dist/control/paths.js +56 -0
  31. package/dist/control/server.d.ts +272 -0
  32. package/dist/control/server.js +1131 -0
  33. package/dist/control/transcript.d.ts +75 -0
  34. package/dist/control/transcript.js +241 -0
  35. package/dist/index.d.ts +37 -0
  36. package/dist/index.js +32 -0
  37. package/dist/jsonrpc/framing.d.ts +49 -0
  38. package/dist/jsonrpc/framing.js +143 -0
  39. package/dist/jsonrpc/types.d.ts +52 -0
  40. package/dist/jsonrpc/types.js +46 -0
  41. package/dist/proxy/intercept.d.ts +55 -0
  42. package/dist/proxy/intercept.js +147 -0
  43. package/dist/proxy/relay.d.ts +97 -0
  44. package/dist/proxy/relay.js +166 -0
  45. package/dist/proxy/session.d.ts +116 -0
  46. package/dist/proxy/session.js +319 -0
  47. package/dist/record/housekeeping.d.ts +34 -0
  48. package/dist/record/housekeeping.js +39 -0
  49. package/dist/record/queue.d.ts +48 -0
  50. package/dist/record/queue.js +96 -0
  51. package/dist/record/recorder.d.ts +111 -0
  52. package/dist/record/recorder.js +39 -0
  53. package/dist/record/redact.d.ts +37 -0
  54. package/dist/record/redact.js +119 -0
  55. package/dist/record/remote-recorder.d.ts +110 -0
  56. package/dist/record/remote-recorder.js +301 -0
  57. package/dist/record/truncate.d.ts +36 -0
  58. package/dist/record/truncate.js +85 -0
  59. package/dist/replay/bundle.d.ts +36 -0
  60. package/dist/replay/bundle.js +89 -0
  61. package/dist/replay/controller.d.ts +300 -0
  62. package/dist/replay/controller.js +807 -0
  63. package/dist/replay/coverage.d.ts +41 -0
  64. package/dist/replay/coverage.js +56 -0
  65. package/dist/replay/derive.d.ts +58 -0
  66. package/dist/replay/derive.js +166 -0
  67. package/dist/replay/executor.d.ts +78 -0
  68. package/dist/replay/executor.js +233 -0
  69. package/dist/replay/logic.d.ts +31 -0
  70. package/dist/replay/logic.js +50 -0
  71. package/dist/replay/plan.d.ts +181 -0
  72. package/dist/replay/plan.js +397 -0
  73. package/dist/replay/pricing.d.ts +41 -0
  74. package/dist/replay/pricing.js +76 -0
  75. package/dist/replay/source-run.d.ts +50 -0
  76. package/dist/replay/source-run.js +98 -0
  77. package/dist/replay/tool-error.d.ts +22 -0
  78. package/dist/replay/tool-error.js +60 -0
  79. package/dist/replay/types.d.ts +116 -0
  80. package/dist/replay/types.js +35 -0
  81. package/dist/upstream/client.d.ts +78 -0
  82. package/dist/upstream/client.js +114 -0
  83. package/dist/upstream/http-client.d.ts +78 -0
  84. package/dist/upstream/http-client.js +261 -0
  85. package/dist/upstream/lazy-client.d.ts +31 -0
  86. package/dist/upstream/lazy-client.js +53 -0
  87. package/dist/upstream/stdio-client.d.ts +57 -0
  88. package/dist/upstream/stdio-client.js +203 -0
  89. package/dist/util/log.d.ts +27 -0
  90. package/dist/util/log.js +51 -0
  91. package/dist/util/version.d.ts +2 -0
  92. package/dist/util/version.js +40 -0
  93. package/docs/BaseInstRunner.md +621 -0
  94. package/docs/calculatedReplay.md +1185 -0
  95. package/docs/calculatedReplayGuide.md +448 -0
  96. package/docs/installRun.md +413 -0
  97. package/docs/mcpmark.md +752 -0
  98. package/docs/quickstart.md +201 -0
  99. package/docs/t-bench.md +394 -0
  100. package/package.json +56 -0
@@ -0,0 +1,1185 @@
1
+ # Calculated Replay — running a scenario on a similar-meaning match
2
+
3
+ > Design doc for BaseInstRunnerMCP **v2** ([BaseInstRunner.md](BaseInstRunner.md) §12), the
4
+ > milestone the v1 design deferred: *"Serve recorded results; port RRepeat's divergence handling."*
5
+ >
6
+ > **What it adds.** When the user's prompt means the same thing as a prompt they have run before,
7
+ > the BaseIn service says so and hands back the *calculated scenario* derived from that earlier run.
8
+ > This document specifies what BaseInstRunnerMCP does with it: derive the new prompt's parameters,
9
+ > run the scenario's tool sequence against live data, and let the model answer from genuine results —
10
+ > instead of rediscovering the same plan token by token.
11
+ >
12
+ > **Status:** implemented. Phases R0–R7 (§17) are in `src/replay/`, wired through the control server
13
+ > and the proxies, covered by `test/replay.test.ts` and `test/replay-server.test.ts`, and proved end
14
+ > to end with real processes by `test/smoke-replay.mjs`. Three notes below are marked **[built]**
15
+ > where the implementation settled something this design left open.
16
+ > **Reference implementation:** RRepeat's [`docs/replay.md`](../../../BasInstRunner/RRepeat/docs/replay.md)
17
+ > §3, §12, §13 and [`docs/savingCalcRunner.md`](../../../BasInstRunner/RRepeat/docs/savingCalcRunner.md).
18
+ > **Server contract:** BaseIn `src/recordings/routes.ts`, `src/scenarios/{routes,repo,replay,logic}.ts`.
19
+
20
+ ---
21
+
22
+ ## 0. The one fact that makes this different from RRepeat
23
+
24
+ RRepeat and BaseInstRunnerMCP are solving the same problem from opposite sides of the tool call.
25
+
26
+ RRepeat **observes** a Claude Code session through hooks. It has no execution path of its own, so
27
+ when a replay diverges it must spawn a *second* Claude Agent SDK session to run the remaining tools
28
+ for real (`sdk-tool-session.ts`, `replay.md` §13). Its own savings doc names the consequence:
29
+
30
+ > *"the **dominant** cost of today's calculated run is a full second agent session, and it is the one
31
+ > cost nobody counts."* — `savingCalcRunner.md` §4
32
+
33
+ BaseInstRunnerMCP **is** the execution path for MCP. Every wrapped server is reached through a
34
+ `bir-proxy` process that already holds an initialized, live `UpstreamClient` to it
35
+ ([`src/upstream/client.ts`](../src/upstream/client.ts)). Issuing a `tools/call` on that connection
36
+ costs **zero tokens** and requires no second agent, no subprocess, and no model in the loop.
37
+
38
+ Everything below follows from that. Where RRepeat has one replay mode and an expensive fallback,
39
+ this design has a ladder whose cheapest rung is free — and the more of a stack sits behind
40
+ `bir-proxy`, the further down the ladder a scenario lands.
41
+
42
+ | | RRepeat | BaseInstRunnerMCP |
43
+ |---|---|---|
44
+ | Runs MCP tools itself | No (`replay.md` §12: *"rrepeat never executes tools — not even MCP"*) | **Yes**, on the connection it already owns |
45
+ | Divergence fallback | A second Agent SDK session — the dominant cost | Direct `tools/call` through the proxies — **$0** |
46
+ | Reaches built-ins (`Bash`, `Read`) | Only by steering the live session | Same — only by steering the live session |
47
+ | Needs `@anthropic-ai/*` at runtime | Yes | No (§16.3) |
48
+
49
+ ---
50
+
51
+ ## 1. Decisions locked
52
+
53
+ Continuing v1's D1–D8.
54
+
55
+ | # | Decision | Choice | Consequence |
56
+ |---|---|---|---|
57
+ | **D9** | What a match buys | **Execute the scenario**, do not merely stop recording | v1's `watchForMatch` already detects the hit and stops; this replaces the no-op with work |
58
+ | **D10** | Who decides the mode | **Tool coverage**, computed per scenario at arm time (§5) | A fully-wrapped scenario replays for free; a mixed one steers the live session |
59
+ | **D11** | Execution ownership for MCP steps | **The proxy that owns the upstream** | No second connection, no double-spawned browser, no Agent SDK dependency |
60
+ | **D12** | Control → proxy channel | **Long-poll**, proxy dials out | No new listeners, no new ports, no new tokens (§13.3, §19.2) |
61
+ | **D13** | Result delivery in direct mode | A **first-party MCP server** (`bir`), one tool | The model reads a genuine `tool_result`, never a `deny` reason it distrusts (§19.1) |
62
+ | **D14** | Default state | **Off.** `BIR_REPLAY=1` plus `bir install --replay` | Replay bypasses permission prompts (§13.2); opt-in is the only defensible default |
63
+ | **D15** | Parameter derivation | One Haiku call over raw `fetch`; **no API key ⇒ recorded sample values** | Preserves v1's zero-runtime-dependency property, and degrades to a free replay |
64
+ | **D16** | Savings reporting | Every armed match reports to `POST /scenarios/:id/executions`, including declines | A decline is a *baseline sample*, not silence — it is what keeps the ledger honest |
65
+
66
+ ### 1.1 Non-goals
67
+
68
+ - **Replay in Tier 2.** A match is a match *on the prompt*, and a standalone proxy never sees one
69
+ (§0.1 of the v1 design). Tier 2 gets the manual `bir replay` command (§12.1) and nothing automatic.
70
+ - **Chaining scenarios.** RRepeat's `next`/`getScenarioChain` has no counterpart in BaseIn's
71
+ `serializeScenario` payload, which serializes exactly one scenario. Out of scope.
72
+ - **Editing tool descriptions to steer selection.** Still a non-goal, unchanged. §6's `bir` server
73
+ adds one *first-party* tool and touches no upstream schema — and it costs context in every session,
74
+ which is why D14 makes it opt-in.
75
+ - **Recording the replayed turn.** The service created no run for it, by design. §12.
76
+
77
+ ---
78
+
79
+ ## 2. The match, and where it already works
80
+
81
+ Nothing in this section is new. It is already implemented and already runs on every prompt.
82
+
83
+ ```
84
+ UserPromptSubmit
85
+ └─ ControlServer.onPrompt src/control/server.ts
86
+ └─ ensureRun → recorder.startRun(input, metadata)
87
+ └─ RemoteRecorder.startRun POST /recordings/runs { id, framework, input, metadata }
88
+ └─ BaseIn recordings/routes.ts:
89
+ findSimilarRun(userId, input) ← embed + cosine over the user's runs
90
+ ├─ miss → 201 { id, matched: null }
91
+ └─ hit → 200 { id: null, matched: {...} } ← no run is created
92
+ ```
93
+
94
+ On a hit the server answers with the whole thing — there is no follow-up fetch:
95
+
96
+ ```jsonc
97
+ {
98
+ "id": null,
99
+ "matched": {
100
+ "runId": "run_…", // the canonical earlier run
101
+ "scenarioId": "scn_…", // its calculated scenario, when one is ready
102
+ "similarity": 0.94, // cosine, ≥ SIMILARITY_THRESHOLD (server default 0.9)
103
+ "executionTicket": "tkt_…", // redeem exactly once at POST /scenarios/:id/executions
104
+ "scenario": { /* serializeScenario(): intent, paramsObject, paramsLogic,
105
+ responseParamsLogic, steps[{ stepIndex, toolName,
106
+ toolInputLogic, toolOutputLogic, reasoning }] */ }
107
+ }
108
+ }
109
+ ```
110
+
111
+ [`RemoteRecorder.getMatch(runId)`](../src/record/remote-recorder.ts) already exposes it, and
112
+ [`ControlServer.watchForMatch`](../src/control/server.ts) already consumes it — to do exactly one
113
+ thing:
114
+
115
+ ```ts
116
+ run.recording = false;
117
+ logLine("run.matched", { …, why: "similar prompt — the service kept its own run; not recording this one" });
118
+ ```
119
+
120
+ **That is the whole of v1's replay support: a correct stop.** This design replaces the stop with a
121
+ plan, and keeps the stop.
122
+
123
+ ### 2.1 What "similar meaning" means, precisely
124
+
125
+ Server-side, and worth stating because the threshold in §4 is a judgement about it:
126
+
127
+ - The embedding is of the run's **`input`** — the user's prompt — computed by
128
+ `Xenova/all-MiniLM-L6-v2` (`EMBEDDING_MODEL`), mean-pooled and normalized
129
+ (`recordings/embeddings.ts`).
130
+ - Candidates are **the caller's own canonical runs only** (`listUserRunEmbeddings(userId)`);
131
+ duplicates and sub-threshold runs are excluded from the pool at record time.
132
+ - The match is the single best cosine ≥ `SIMILARITY_THRESHOLD` (default **0.9**).
133
+ - A matched run only carries a `scenarioId` when its scenario exists **and** is `state: 'ready'`.
134
+
135
+ So "similar meaning" is: *the same user asked for something that embeds within 0.1 cosine of this,
136
+ and we finished calculating a reusable plan for it.* It is not "the same words", and it is not
137
+ cross-tenant.
138
+
139
+ ---
140
+
141
+ ## 3. Architecture
142
+
143
+ ```mermaid
144
+ graph TB
145
+ subgraph host["Claude Code session"]
146
+ M[model loop]
147
+ H[hooks]
148
+ end
149
+
150
+ subgraph bir["BaseInstRunnerMCP"]
151
+ C["bir-hooks — control server<br/>ScenarioReplayPlan lives here"]
152
+ S["bir-scenario<br/>MCP server: run_scenario"]
153
+ P1["bir-proxy — chrome-devtools"]
154
+ P2["bir-proxy — github"]
155
+ end
156
+
157
+ U1[chrome-devtools-mcp]
158
+ U2[github mcp]
159
+ R[("BaseIn /recordings/* /scenarios/*")]
160
+
161
+ H -->|"UserPromptSubmit → directive"| C
162
+ H -->|"PreToolUse → pinned updatedInput"| C
163
+ H -->|"PostToolUse → real output"| C
164
+ M -->|"mcp__bir__run_scenario"| S
165
+ S -->|"POST /scenario/run"| C
166
+ C -.->|"work: tools/call"| P1
167
+ C -.->|"work: tools/call"| P2
168
+ P1 -->|"bir: id request"| U1
169
+ P2 -->|"bir: id request"| U2
170
+ C -->|"match + execution report"| R
171
+ ```
172
+
173
+ Three additions to the v1 topology, and nothing else moves:
174
+
175
+ | Component | New? | Role in replay |
176
+ |---|---|---|
177
+ | `bir-hooks` control server | extended | Holds the `ScenarioReplayPlan` on `RunState`. Arms it, pins inputs, threads outputs, dispatches direct steps, redeems the ticket |
178
+ | `bir-proxy` | extended | Long-polls for work; executes a `tools/call` on the upstream it already owns |
179
+ | `bir-scenario` | **new**, ~120 lines | A stdio MCP server exposing one tool, `run_scenario`. The delivery channel for direct mode (§6) |
180
+
181
+ ---
182
+
183
+ ## 4. The gate ladder
184
+
185
+ Ported from RRepeat's `applyMatch`, with one gate this repo needs and RRepeat cannot have.
186
+ Every gate that declines logs **one** `replay.decision` line carrying the reason — because
187
+ *"a scenario existed and was not used"* is a fact an auditor needs as much as *"a scenario ran"*.
188
+
189
+ | # | Gate | Declines when | Outcome floor |
190
+ |---|---|---|---|
191
+ | 1 | **A match arrived** | `getMatch()` resolved `null`, or the budget expired (§10) | (no report — nothing matched) |
192
+ | 2 | **Replay enabled** | `BIR_REPLAY !== "1"` | `not_steered` |
193
+ | 3 | **Scenario ready** | `scenario == null`, `state !== "ready"`, or `steps.length === 0` | `not_steered` |
194
+ | 4 | **Similarity** | `similarity < BIR_MIN_STEER_SIMILARITY` (default **0.92**) | `not_steered` |
195
+ | 5 | **Tool coverage** | No step is executable anywhere (§5 → `mode: "none"`) | `not_steered` |
196
+
197
+ Gate 4 deserves its own note. The server's own detection threshold is 0.9, and it uses it to decide
198
+ *"do not record this again"* — a cheap claim: the worst case is a lost recording. Steering makes a
199
+ much stronger claim: *"do not think about this again."* A prompt that is 0.90-similar usually covers
200
+ only part of the live task, and steering it produces a confident, thin, wrong answer. So the steer
201
+ threshold sits **above** the detection threshold by default, and `BIR_MIN_STEER_SIMILARITY` is the
202
+ single knob an operator turns after watching `replay.decision` lines for a week.
203
+
204
+ `not_steered` as the floor is not a failure code. It is the **control group**: the prompt matched,
205
+ the agent ran the task the ordinary way, and the cost of doing so is a fresh measurement of what this
206
+ task costs — which is exactly what a baseline is made of. BaseIn's
207
+ `POST /scenarios/:id/executions` routes `not_steered` to `addBaselineSample` rather than the
208
+ savings ledger, on purpose (`scenarios/routes.ts`). §11.
209
+
210
+ ---
211
+
212
+ ## 5. Tool coverage decides the mode
213
+
214
+ The classification is a pure function of the scenario's steps and the set of server keys registered
215
+ with this control server (`ControlServer.wrapped`, grown by `/proxy/register`).
216
+
217
+ ```ts
218
+ // src/replay/coverage.ts
219
+ export type StepReach = "direct" | "live";
220
+ export type ReplayMode = "direct" | "steer" | "none";
221
+
222
+ export function reachOf(toolName: string, wrapped: ReadonlySet<string>): StepReach {
223
+ const mcp = parseQualifiedName(toolName); // src/control/correlation.ts
224
+ return mcp && wrapped.has(mcp.serverName) ? "direct" : "live";
225
+ }
226
+
227
+ export function modeFor(steps: SerializedScenarioStep[], wrapped: ReadonlySet<string>): ReplayMode {
228
+ if (steps.length === 0) return "none";
229
+ return steps.every((s) => reachOf(s.toolName, wrapped) === "direct") ? "direct" : "steer";
230
+ }
231
+ ```
232
+
233
+ | Every step | Mode | Where the tools run | Model spend |
234
+ |---|---|---|---|
235
+ | a wrapped MCP tool | **`direct`** (§6) | The proxies, on connections already open | one Haiku derivation + one turn that reads the results |
236
+ | anything else, or mixed | **`steer`** (§7) | The live session, inputs pinned | one Haiku derivation + the live turn |
237
+ | — | `none` | nowhere | declines at gate 5 |
238
+
239
+ `live` covers three genuinely different cases and treats them identically, correctly: a built-in
240
+ (`Bash`, `Read`, `Edit`, `Grep`, `Task`, `WebFetch`), an MCP server the user chose not to wrap, and
241
+ `claude-in-chrome` — which is `scope: "dynamic"`, appears in no config file, and therefore can never
242
+ be wrapped (v1 design §1). None of them can be reached except through the session.
243
+
244
+ A **mixed** scenario in `steer` mode still gets direct execution for its wrapped steps once it
245
+ diverges (§8), so coverage is not all-or-nothing at the divergence boundary — only at the arm-time
246
+ mode choice.
247
+
248
+ ---
249
+
250
+ ## 6. Mode A — direct execution
251
+
252
+ The cheap rung. Every step is a wrapped MCP tool, so the model never has to emit a single tool call.
253
+
254
+ ### 6.1 Sequence
255
+
256
+ ```mermaid
257
+ sequenceDiagram
258
+ participant CC as Claude Code (model)
259
+ participant CS as bir-hooks (control server)
260
+ participant SC as bir-scenario (MCP)
261
+ participant PX as bir-proxy
262
+ participant UP as upstream MCP server
263
+
264
+ CC->>CS: UserPromptSubmit
265
+ CS->>CS: gates pass, mode=direct → arm plan; start derivation (not awaited)
266
+ CS-->>CC: additionalContext: "call mcp__bir__run_scenario once"
267
+ CC->>SC: tools/call run_scenario {}
268
+ SC->>CS: POST /scenario/run
269
+ CS->>CS: await plan.ready() (derivation lands)
270
+ loop each step
271
+ CS->>CS: toolInputForCurrentStep()
272
+ CS-->>PX: work { workId, toolName, arguments } (answers an open long-poll)
273
+ PX->>UP: tools/call (bir: id, on the open connection)
274
+ UP-->>PX: CallToolResult
275
+ PX->>CS: POST /proxy/result { workId, result }
276
+ CS->>CS: applyOutput(JSON.stringify(result)) → respParams
277
+ end
278
+ CS->>CS: responseParamsLogic → response model; assembleBundle()
279
+ CS-->>SC: { text, responseModel, steps }
280
+ SC-->>CC: tool_result (genuine)
281
+ CC->>CC: answers from the results
282
+ CC->>CS: Stop → seal, report execution (ticket)
283
+ ```
284
+
285
+ ### 6.2 Why a first-party MCP server, and not `additionalContext`
286
+
287
+ Executing inside `UserPromptSubmit` and returning the results as `additionalContext` is the obvious
288
+ shortcut, and it is wrong for three separate reasons:
289
+
290
+ 1. **Budget.** `UserPromptSubmit` has a hook timeout (§10). N live MCP round trips do not fit inside
291
+ one, and if it expires Claude Code proceeds *without* the context — so the work is burned and the
292
+ user waits for nothing.
293
+ 2. **Trust.** RRepeat learned this expensively. Its `deny`-with-a-reason delivery is *"inherently
294
+ read as a denial by the model"* (`hook-server.ts`), which is why it grew a Bash-rewrite path and
295
+ then a whole MCP bridge (`mcp-bridge.ts`) to hand the same bytes back as a genuine `tool_result`.
296
+ A first-party MCP tool is that bridge, without the shadowing.
297
+ 3. **Truth.** A `tool_result` in the transcript is a real record of a real call. `additionalContext`
298
+ asserting that tools ran is a claim the transcript cannot corroborate.
299
+
300
+ The cost is honest and worth naming: one extra tool in the model's tool list, in **every** session
301
+ where `bir install --replay` ran. That is why it is a separate install flag and why `bir status`
302
+ prints it.
303
+
304
+ ### 6.3 `run_scenario`
305
+
306
+ ```jsonc
307
+ // tools/list, from bir-scenario
308
+ {
309
+ "name": "run_scenario",
310
+ "description":
311
+ "Run the pre-calculated tool sequence BaseInstRunner has armed for the current request, and return its results. Call once, with no arguments, when instructed to.",
312
+ "inputSchema": { "type": "object", "properties": {}, "additionalProperties": false }
313
+ }
314
+ ```
315
+
316
+ No arguments **by design**: the armed plan is server-side state keyed by the control server's open
317
+ run, so there is nothing for the model to get wrong and nothing it can point at a different scenario.
318
+ When no plan is armed the tool returns `isError: true` with `"no scenario is armed for this turn"`,
319
+ which is a tool failure the model reacts to normally.
320
+
321
+ `bir-scenario` is a client of the control server, discovered the same way a proxy discovers it —
322
+ `~/.baseinstrunner/control/<key>.json`, mode `0600`, bearer token
323
+ ([`src/control/discovery.ts`](../src/control/discovery.ts)). It holds no state, no credentials and
324
+ no upstream connections.
325
+
326
+ ### 6.4 The steering directive (direct)
327
+
328
+ ```
329
+ [BaseInstRunner] A known-good tool sequence was recorded for this request
330
+ (intent: <scenario.intent>). Call mcp__bir__run_scenario once, with no
331
+ arguments, before any other tool. It runs the sequence and returns the
332
+ results. Answer the user from those results.
333
+ ```
334
+
335
+ If the model calls something else first, the turn falls to §8 — the plan is still armed and the
336
+ divergence path still executes it.
337
+
338
+ ---
339
+
340
+ ## 7. Mode B — model-steered live replay
341
+
342
+ The mixed rung. Ported from RRepeat's `replay.md` §3/§12, which is the right design and is already
343
+ proven: the model chooses *which* tool and *when*; the control server overrides the *arguments* and
344
+ threads state between steps. The session never leaves the model's hands, so every
345
+ `tool_use`/`tool_result` in the transcript is genuine and continuation is automatic.
346
+
347
+ **Arm** at `UserPromptSubmit` — plan on `run.plan`, `run.planToolUse = new Set()`, directive via
348
+ `additionalContext`, **no `decision`** so the prompt still reaches the model:
349
+
350
+ ```
351
+ [BaseInstRunner calculated replay] A known-good tool sequence was previously
352
+ recorded for this request. Call exactly these tools, in order, one at a time:
353
+
354
+ 1. mcp__chrome-devtools__navigate_page — open the fleet dashboard
355
+ 2. mcp__chrome-devtools__take_snapshot — read the device table
356
+ 3. Read — load the alert thresholds
357
+
358
+ The system supplies the exact arguments for each call — you do not need to
359
+ compute them. Do not call any other tools until this sequence is complete,
360
+ then answer the user's request from the tool results.
361
+ ```
362
+
363
+ **Pin** at `PreToolUse`, while a plan is active and the call is not `mcp__bir__run_scenario`:
364
+
365
+ ```ts
366
+ if (toolName === plan.expectedTool()) {
367
+ const input = plan.toolInputForCurrentStep(); // may throw → §8
368
+ run.planToolUse.add(toolUseId);
369
+ return { hookSpecificOutput: { hookEventName: "PreToolUse",
370
+ permissionDecision: "allow",
371
+ updatedInput: withCallId(input) } }; // §7.1
372
+ }
373
+ return divergence(…); // §8
374
+ ```
375
+
376
+ **Thread** at `PostToolUse`, for a `tool_use_id ∈ run.planToolUse` — but *from the right source*
377
+ (§7.2). When `plan.isDone()`, retire the plan; the model then writes its answer from the real tool
378
+ history.
379
+
380
+ ### 7.1 Steering and correlation both write `updatedInput` — they must merge
381
+
382
+ This collision does not exist in RRepeat and is easy to miss here.
383
+
384
+ `onToolPre` already returns an `updatedInput` for every **wrapped MCP** call: it is how the
385
+ correlation id rides along to the proxy ([`src/control/correlation.ts`](../src/control/correlation.ts)).
386
+ A pinned step whose tool is a wrapped MCP tool therefore needs *both* payloads in one object, and the
387
+ order is not arbitrary:
388
+
389
+ ```ts
390
+ /** Pinned arguments plus, for a wrapped MCP tool, the correlation id. */
391
+ function withCallId(pinned: Record<string, unknown>): Record<string, unknown> {
392
+ const mcp = parseQualifiedName(toolName);
393
+ if (!mcp || !this.wrapped.has(mcp.serverName) || this.opts.noCorrelation) return pinned;
394
+ const correlation = mintCorrelation(run, mcp, pinned, context); // hookArgs = the PINNED args
395
+ return { ...pinned, [BIR_CALL_ID]: correlation.callId };
396
+ }
397
+ ```
398
+
399
+ Three consequences, all load-bearing:
400
+
401
+ - **The correlation's `hookArgs` must be the pinned arguments, not the model's.** `settle()` records
402
+ `correlation.hookArgs ?? report.args`; recording the arguments the model *proposed* rather than the
403
+ ones that *ran* would put a lie in the corpus. (In a replayed turn nothing is recorded — §12 — but
404
+ the same code path serves recorded turns, and the invariant must hold there.)
405
+ - **Schema relaxation must already be active**, or the host may reject the extra key. It is, whenever
406
+ Tier 1 correlation is on (`ProxySession.correlationActive`). Under `--no-correlation`, `withCallId`
407
+ is a no-op and pinning still works — only the *result* join degrades to fingerprints.
408
+ - **`permissionDecision: "allow"` auto-approves.** Already true for every correlated call today
409
+ (`BIR_CORRELATION_DECISION`), and now true for pinned built-ins too. §13.2.
410
+
411
+ ### 7.2 Thread from the same source that recorded
412
+
413
+ `toolOutputLogic` was authored by BaseIn's `analyzeRun` against the exact bytes stored in that step's
414
+ `tool_output`. Feeding it a differently-shaped rendering of the same result does not fail loudly — it
415
+ silently derives nothing, and every later step reading `respParams` produces a wrong input.
416
+
417
+ In this repo those bytes have **two different origins**:
418
+
419
+ | Step's tool | What was recorded | Therefore thread from |
420
+ |---|---|---|
421
+ | Wrapped MCP | `serializeCapped(redact(report.result))` — the proxy's whole `CallToolResult`, via `settle()` → `record()` | the **proxy's `/proxy/step` report** |
422
+ | Built-in / unwrapped MCP | `serializeCapped(redact(payload.tool_response))` — Claude Code's rendering, via `onToolPost` | the **hook's `tool_response`** |
423
+
424
+ So `/tool/post` must not thread unconditionally. It threads for a `live` step, and for a `direct`
425
+ step it does what it already does for a correlated call: **arm the existing
426
+ `PROXY_REPORT_GRACE_MS` fallback**, and let `onProxyStep` thread when the report lands.
427
+
428
+ ```ts
429
+ // onToolPost, for a pinned step
430
+ if (reachOf(step.toolName, this.wrapped) === "live") {
431
+ plan.applyOutput(serializeCapped(payload.tool_response ?? {}));
432
+ } else {
433
+ correlation.threadOnReport = true; // onProxyStep calls plan.applyOutput(report.result)
434
+ correlation.fallback = setTimeout(() => { // …unless the proxy never reports
435
+ logLine("replay.thread_fallback", { why: "no proxy report — threading the hook's view; shapes may differ" });
436
+ plan.applyOutput(serializeCapped(payload.tool_response ?? {}));
437
+ }, PROXY_REPORT_GRACE_MS);
438
+ }
439
+ ```
440
+
441
+ This reuses machinery that exists and is already correct. It also means the §7.3 ordering constraint
442
+ binds a little harder: the proxy report must land before the next `PreToolUse`, and
443
+ `PROXY_REPORT_GRACE_MS` (1500 ms) is the bound on how long that can take before we degrade.
444
+
445
+ ### 7.3 Synchronous `PostToolUse` is a requirement, not a preference
446
+
447
+ Step *N*'s `/tool/post` must finish updating `respParams` before step *N+1*'s `/tool/pre` reads it.
448
+ In [`HOOK_ROUTES`](../src/config/adapters/claude-code.ts) neither `PreToolUse` nor `PostToolUse`
449
+ carries `async: true`, so this already holds — **and must never be relaxed**. Add it to the comment
450
+ block there, next to the `SessionEnd` note that records the same class of lesson.
451
+
452
+ ---
453
+
454
+ ## 8. Divergence
455
+
456
+ The model called a tool the plan did not expect, or `toolInputLogic` threw, or the tool had no
457
+ `tool_use_id`. Three tiers, tried in order. Here is where the §0 asymmetry pays.
458
+
459
+ **Tier 1 — direct-execute the remainder (new; the common case).**
460
+ Take every not-yet-run step, execute it, and hand the composed result back. Steps with
461
+ `reach === "direct"` run through their proxy. Steps with `reach === "live"` cannot run here at all —
462
+ a hook process is not the session — and fall back to the step's **recorded output** (§8.1), or are
463
+ skipped with a logged warning. RRepeat's rule, and its reasoning holds verbatim: a *skipped* step
464
+ also stops threading `respParams`, so every later step reading from it fails too, and a five-step
465
+ scenario degrades to nothing. A recorded result is marked as such in the bundle, so the model is
466
+ never told a stale result is fresh.
467
+
468
+ Delivery, best channel first:
469
+
470
+ 1. **The `bir` tool** — if the divergent call *is* `mcp__bir__run_scenario`, or the model can be
471
+ nudged to it. Genuine `tool_result`.
472
+ 2. **Bash-clean** — if the divergent call is `Bash`, rewrite the command to `cat` a heredoc of the
473
+ bundle. The model sees ordinary command output. (RRepeat's `bashCleanPath`; neutralize the
474
+ delimiter first.)
475
+ 3. **Deny-inject** — `permissionDecision: "deny"`, bundle as `permissionDecisionReason`, capped at
476
+ `MAX_REPLAY_REASON` (60 000 chars) with the same fair-share truncation
477
+ (`assembleBundle`/`truncate`, portable verbatim from `payload-executor.ts`). Works for any tool,
478
+ and is read as adversarial — hence last.
479
+
480
+ **Tier 2 — abort.** Composition itself threw. Retire the plan, return `{}`, continue as an ordinary
481
+ turn. Outcome `failed`.
482
+
483
+ **The cost line.** RRepeat's Tier 1 is an Agent SDK session, and its own doc calls that the dominant
484
+ cost of a calculated run. Here Tier 1 is N `tools/call`s on connections that are already open:
485
+ **`fallbackCostUsd` is 0.00 by construction**, for every step the proxies can reach.
486
+
487
+ > **Do not port the unconditional-divergence line.** RRepeat's `steerPreTool` ends with a deliberate
488
+ > `return await injectBundle(...)` that bypasses the pin branch on *every* armed plan
489
+ > (`savingCalcRunner.md` §4 documents the consequence: `stepsPinned` is 0 on every row). That was a
490
+ > choice made when the bundle was the only path that ran real tools. It is not this design's choice:
491
+ > pinning is tried first, always, and divergence is what its name says.
492
+
493
+ ### 8.1 Where a recorded output actually comes from
494
+
495
+ RRepeat reads it from `SerializedScenarioStep.recordedOutput`, whose own comment says the field is
496
+ optional because *"it is the server's payload that decides whether to send it, and older payloads do
497
+ not."* Verify this before relying on it: **no payload does.** BaseIn's `scenario_steps` table has
498
+ columns `tool_input_logic`, `tool_output_logic`, `reasoning`, `reasoning_vector` — and no
499
+ `recorded_output`; `serializeScenario` emits no such field. RRepeat's fallback is unreachable in
500
+ production today.
501
+
502
+ There is a path that needs no server change, and the match payload already carries its key:
503
+
504
+ ```
505
+ GET /recordings/runs/:runId → { run, steps } // steps carry tool_name + tool_output
506
+ ↑ matched.runId — the canonical source run this scenario was calculated from
507
+ ```
508
+
509
+ So: fetch the source run **lazily**, on the first `live` step of the first divergence, and index its
510
+ `tool_response` steps by tool name into the same FIFO queue BaseIn's own `dryReplay` builds
511
+ (`recordedOutputsByTool` in `scenarios/replay.ts` — a `Map<toolName, string[]>` drawn in order, with
512
+ a per-tool cursor). One authenticated GET, cached for the turn, and the queue semantics match the
513
+ server's dry replay exactly — which matters, because a scenario that behaves one way under
514
+ `bir scenario replay --dry` and another way here would be untestable.
515
+
516
+ Adding `recorded_output` to `scenario_steps` would make this a field read instead of a fetch, and is
517
+ a reasonable server-side follow-up. It is not a prerequisite.
518
+
519
+ > **[built]** [`src/replay/source-run.ts`](../src/replay/source-run.ts). The type still carries the
520
+ > optional `recordedOutput` field and prefers it when present, so a server that starts sending it
521
+ > needs no change here — but nothing depends on it.
522
+
523
+ ---
524
+
525
+ ## 9. The plan, and state threading across hook calls
526
+
527
+ `ScenarioReplayPlan` ports from RRepeat's [`payload-executor.ts`](../../../BasInstRunner/RRepeat/src/server/payload-executor.ts)
528
+ with three changes.
529
+
530
+ ```ts
531
+ // src/replay/plan.ts
532
+ export class ScenarioReplayPlan {
533
+ readonly scenarioId: string;
534
+ readonly mode: ReplayMode;
535
+ readonly stepCount: number;
536
+
537
+ /** Resolves when derivation has landed and paramsLogic has been applied. */
538
+ ready(): Promise<void>;
539
+
540
+ currentStepIndex: number;
541
+ expectedTool(): string | undefined;
542
+ isDone(): boolean;
543
+
544
+ /** Pinned input for the current step. Throws → divergence. */
545
+ toolInputForCurrentStep(): Record<string, unknown>;
546
+ /** Thread a real output into respParams and advance. Throws → abort. */
547
+ applyOutput(realOutput: string): void;
548
+
549
+ steeringDirective(): string;
550
+ /** Execute every remaining step through `execute`, and lay the results out under `maxChars`. */
551
+ composeBundle(maxChars: number, execute: ExecuteStep): Promise<BundleResult>;
552
+ /** responseParamsLogic over the accumulated respParams. */
553
+ responseModel(): Record<string, unknown>;
554
+ }
555
+ ```
556
+
557
+ **Change 1 — lifetime is a run, not a session.** The plan hangs off `RunState`, not `SessionState`.
558
+ In this codebase a run *is* a turn (`UserPromptSubmit → Stop`, `server.ts` §onPrompt), which is
559
+ exactly the plan's lifetime. `sealRun` retires it; a second prompt in the same session cannot inherit
560
+ a stale plan, which in RRepeat requires care.
561
+
562
+ **Change 2 — `params` are a promise.** The constructor takes `Promise<Record<string, unknown>>`, not
563
+ a value, so arming does not wait for the Haiku call (§10). `paramsLogic` is applied once, inside
564
+ `ready()`, the first time it resolves.
565
+
566
+ **Change 3 — `execute` is a step, not a tool name.** `composeBundle` takes
567
+ `(step, input) => Promise<string>` rather than `(toolName, input)`, because the executor must know
568
+ the step's `reach` to decide between the proxy, the recorded output, and skipping.
569
+
570
+ The four `new Function` shims (`evalParamsLogic`, `evalToolInputLogic`, `evalToolOutputLogic`,
571
+ `evalResponseParamsLogic`) go in `src/replay/logic.ts`, ported from BaseIn's
572
+ [`src/scenarios/logic.ts`](../../../BaseIntSever/BaseIn/src/scenarios/logic.ts) — which is the same
573
+ code with better comments and an `assertPlainObject` guard the other copies lack. **Port BaseIn's
574
+ version**, not RRepeat's: it is the one the server evaluates against, so a divergence between them is
575
+ a divergence between what a scenario does in a dry replay and what it does here.
576
+
577
+ ### 9.1 What flows between steps
578
+
579
+ - **`params`** — computed once (derivation → `paramsLogic`), read by every `toolInputLogic`.
580
+ - **`respParams`** — starts `{}`; each step's `toolOutputLogic` merges keys in
581
+ (`respParams = { ...respParams, ...derived }`); later steps' `toolInputLogic` read the accumulation.
582
+ This is how step 1's `highestRiskDeviceId` reaches step 2's input.
583
+
584
+ In direct mode both live inside one `for` loop. In steer mode they are split across *separate HTTP
585
+ requests* — which is what §7.3's ordering rule protects.
586
+
587
+ ---
588
+
589
+ ## 10. Timing: what may block, and for how long
590
+
591
+ The hook is on the user's critical path. Every await here is a stall the user watches.
592
+
593
+ | Moment | Route | Hook timeout | What may be awaited | Budget |
594
+ |---|---|---|---|---|
595
+ | `UserPromptSubmit` | `/session/prompt` | **5 s → raise to 15 s** | `getMatch()` only | `BIR_MATCH_BUDGET_MS` = **2500** |
596
+ | `PreToolUse` | `/tool/pre` | 30 s (unchanged) | `plan.ready()` (derivation) | `BIR_DERIVE_BUDGET_MS` = **8000** |
597
+ | `run_scenario` | `/scenario/run` | MCP client's own | `ready()` + N steps | `BIR_REPLAY_BUDGET_MS` = **120000**, `BIR_STEP_TIMEOUT_MS` = **60000** |
598
+ | `Stop` | `/session/stop` | 10 s | nothing (report is queued) | — |
599
+
600
+ **Derivation is deliberately not awaited at the prompt.** The steering directive needs only the
601
+ scenario's ordered tool names and reasonings — it does not need parameter values. So the prompt hook
602
+ returns the directive the moment the match lands, and the Haiku call runs un-awaited; the *first*
603
+ `PreToolUse` awaits it, inside a 30-second budget instead of a 15-second one. On a mode-`direct` turn
604
+ the wait lands inside `run_scenario` instead, where there is no hook timeout at all.
605
+
606
+ **Budget expiry is never an error.** Match budget expired → no plan, ordinary turn, one
607
+ `replay.decision` line. Derivation budget expired → fall back to the scenario's recorded
608
+ `sampleValue`s (D15) and keep going; a replay with stale parameters is worth more than no replay, and
609
+ the audit line says which happened.
610
+
611
+ **The one hook-config change** is `UserPromptSubmit`'s timeout, `5` → `15`, in
612
+ [`HOOK_ROUTES`](../src/config/adapters/claude-code.ts). It must stay non-`async`: an async hook is
613
+ fire-and-forget and cannot return `additionalContext` at all.
614
+
615
+ ---
616
+
617
+ ## 11. Reporting: outcome, ticket, cost
618
+
619
+ This is the half that makes replay *provable* rather than merely fast, and v1 does none of it.
620
+
621
+ ### 11.1 The outcome ladder
622
+
623
+ ```ts
624
+ // Upgrade-only. `not_steered` is the floor and the safe answer if the turn dies mid-flight.
625
+ const OUTCOME_RANK = { not_steered: 0, failed: 1, diverged: 2, steered_full: 3 } as const;
626
+ ```
627
+
628
+ | Outcome | Means | Server-side effect |
629
+ |---|---|---|
630
+ | `not_steered` | Matched, declined at a gate; the agent ran the task | **baseline sample** (`addBaselineSample`) |
631
+ | `failed` | Armed, then the machinery gave up; the agent ran the task | **baseline sample** |
632
+ | `diverged` | The model went off-script; the bundle answered | ledger row, `saved_usd` computed |
633
+ | `steered_full` | Every planned step ran with scenario-derived inputs | ledger row, `saved_usd` computed |
634
+
635
+ Direct mode reports `steered_full`. BaseIn's zod enum
636
+ (`['steered_full','diverged','not_steered','failed']`) has no `direct` member, and `steered_full`
637
+ means exactly what happened: every planned step executed under the scenario's control and the
638
+ response model was produced. A `direct` discriminator would be a *server* change and is §20's
639
+ question 2, not a blocker.
640
+
641
+ ### 11.2 The ticket
642
+
643
+ `matched.executionTicket` is the server's claim token, and **it is the execution row's id** — which
644
+ is what makes a doubled report book once (`scenarios/routes.ts`: a `claimed` ticket returns
645
+ `200 { duplicate: true }`, not an error). Redeem it exactly once, in `sealRun`, pushed onto
646
+ `pendingSeals` alongside the answer resolution so `Stop` never blocks on the network:
647
+
648
+ ```
649
+ POST /scenarios/:scenarioId/executions
650
+ { ticket, outcome, deriveCostUsd, sessionCostUsd, fallbackCostUsd,
651
+ durationMs, stepsPlanned, stepsPinned, measured, pricingVersion, prompt,
652
+ steps, error, errorStage, errorStepIndex, errorToolName }
653
+ ```
654
+
655
+ An **unredeemed** ticket leaves no execution row at all. That asymmetry is intentional and worth
656
+ preserving: silence loses a saving; it never invents one.
657
+
658
+ ### 11.2a What each step did (the service's errorshandling.md)
659
+
660
+ The last five fields are the diagnosis, and none of the money depends on them. The service's
661
+ recording page draws the scenario's chain step by step; without these, a scenario whose step 2
662
+ throws on every replay renders as four healthy-looking steps beside a saving that never grows. The
663
+ error exists — in a log line, on the operator's machine. The person who owns the scenario is looking
664
+ at a web page.
665
+
666
+ ```ts
667
+ steps: Array<{
668
+ stepIndex: number; // the scenario's own index, not a position in this array
669
+ toolName: string;
670
+ status: "ok" | "recorded" | "skipped" | "failed";
671
+ stage?: ExecutionStage; // where in the step, for `failed` / `skipped`
672
+ error?: string; // message only — this is rendered in a web page
673
+ durationMs?: number;
674
+ }>
675
+ ```
676
+
677
+ `ok` and `recorded` are passes (`recorded` with an asterisk: the values came from the source run).
678
+ `skipped` did not run. **`failed` is the only one that means the scenario is broken** rather than
679
+ the environment — it is the scenario's own logic throwing, and it is what `Recalculate` fixes.
680
+
681
+ Three rules this must keep:
682
+
683
+ 1. **A step the turn never reached is absent**, not `skipped`. The page shows those as "not run",
684
+ and a turn that dies at step 2 of 5 must not paint steps 3-5 red.
685
+ 2. **Every field is optional on the wire.** An older service ignores them; the costs still book.
686
+ 3. **The headline and the steps are one statement.** `error*` is lifted from the first `failed`
687
+ step (`blameStep`), never assembled independently, so the page's card cannot contradict the step
688
+ list underneath it.
689
+
690
+ The verdicts come from `plan.ts`'s `onStep`, which has always known them — with one addition: a
691
+ logic failure is announced *before* the throw propagates (`reportingStage`), because after it
692
+ nothing knows which step the stack trace belonged to. In steer mode there is no loop to observe, so
693
+ the controller records the verdict at both hooks: `PreToolUse` for an input-logic throw, `PostToolUse`
694
+ for the pass or the output-logic throw. `ReplayState.stepResults` is keyed by step index so the
695
+ second word on a step replaces the first.
696
+
697
+ One consequence for the rule that a costless decline is not worth sending (`buildReport`, and the
698
+ service's matching `202 { recorded: false }`): it now *is* worth sending when a step broke. No cost
699
+ **and** no error remains nothing to report.
700
+
701
+ ### 11.3 Cost, and a live pricing hazard
702
+
703
+ | Component | Source | Notes |
704
+ |---|---|---|
705
+ | `deriveCostUsd` | our own Haiku call's `usage`, priced locally | the only tokens replay itself spends |
706
+ | `sessionCostUsd` | transcript usage **delta** for this run (§11.4) | the live turn: prompt, tool calls, answer |
707
+ | `fallbackCostUsd` | **0.00** | direct execution spends no tokens (§8) |
708
+
709
+ > ### ⚠ Verify the pricing table before copying it
710
+ >
711
+ > A saving is `baseline − actual`, where the baseline is priced by BaseIn's
712
+ > [`src/scenarios/cost.ts`](../../../BaseIntSever/BaseIn/src/scenarios/cost.ts) and the actual by the
713
+ > runner's copy. Both currently declare `PRICING_VERSION = '2026-08-30'`, and **the two tables
714
+ > already disagree**: RRepeat prices `claude-opus-4-6` at `3.0 / 15.0`, BaseIn at `5.0 / 25.0`. Equal
715
+ > versions are supposed to mean *"these are comparable"*. Right now, for that row, they are not.
716
+ >
717
+ > So: **copy BaseIn's table, not RRepeat's** — the server owns the baseline, and the baseline is the
718
+ > number a saving is measured *against*. Copy `PRICING_VERSION` with it. Treat any price change as a
719
+ > three-repository change, and file the drift above against RRepeat rather than reproducing it.
720
+ >
721
+ > Practical exposure here is small — derivation runs on `claude-haiku-4-5-20251001`, which both
722
+ > tables price identically at `1.0 / 5.0` — but the invariant is what matters, not today's blast
723
+ > radius.
724
+
725
+ ### 11.4 The per-run usage watermark
726
+
727
+ `savingCalcRunner.md` §3 documents this trap in RRepeat, and **BaseInstRunnerMCP has it too**, for
728
+ the same structural reason: Claude Code writes one transcript per *session*, and `onPrompt` rolls a
729
+ new run on every prompt. Summing the whole file gives run 3 the cost of runs 1+2+3.
730
+
731
+ Every one of those numbers is a **baseline** on the server. Do not ship the whole-file version.
732
+
733
+ ```ts
734
+ // src/control/transcript.ts — two new exports beside contextForToolUse / settledLastAssistantText
735
+ /** Per-model cumulative usage, captured when a run starts. */
736
+ export function markTranscriptUsage(path: string | undefined): UsageMark;
737
+ /** Per-model delta since `mark`, clamped at 0 per field. */
738
+ export function usageSince(path: string | undefined, mark: UsageMark): ModelUsage[];
739
+ ```
740
+
741
+ Take the mark in `ensureRun`, beside `startedAtMs`, so the cost window and the duration window are
742
+ the same window. Clamp per field, so a compacted or rotated transcript degrades to *"no usage"*
743
+ rather than to a negative cost. A run with **no mark** (one opened lazily by a proxy step before any
744
+ prompt) reports `measured: false` — better to say "unmeasured" than to hand the ledger an inflated
745
+ baseline.
746
+
747
+ `measured` is `true` only when a mark existed **and** `pricingVersion` matches the server's. The
748
+ server enforces the second half itself and stores a mismatched report as unmeasured rather than
749
+ trusting it.
750
+
751
+ ---
752
+
753
+ ## 12. What the replayed turn records
754
+
755
+ **Nothing, and that is correct.** The service answered `{ id: null }`; no run exists; every step post
756
+ would 404. `run.recording = false` already handles it — `record()` early-returns and `sealRun` skips
757
+ both the final answer and `finishRun`.
758
+
759
+ Three consequences to keep straight:
760
+
761
+ 1. **Step indices are still allocated.** `ordering` runs regardless, correlations are still minted
762
+ and settled, and `bir doctor` still shows a live step count. Only the *sending* stops.
763
+ 2. **The matched run is the canonical one.** The server bumped its `iterations` at match time. The
764
+ corpus does not grow on a replay, by design — that is what stops one popular task from flooding
765
+ the similarity pool with near-duplicates.
766
+ 3. **Direct-mode calls bypass the recorder entirely.** They are issued via `UpstreamClient.request`,
767
+ which allocates a `bir:` id and consumes its own response in `accept()` before the relay's
768
+ handlers ever see it ([`src/upstream/client.ts`](../src/upstream/client.ts)). So
769
+ `RecordingInterceptor` never observes them and cannot double-record. That interface comment says
770
+ `request()` is *"Never used by the relay"* — it was built for `bir doctor` and tests, and this is
771
+ its second, load-bearing user.
772
+
773
+ ### 12.1 `bir replay` — the Tier 2 and debugging path
774
+
775
+ ```bash
776
+ bir replay --scenario scn_… --prompt "check the fleet for high-risk devices" # run it
777
+ bir replay --scenario scn_… --prompt "…" --dry # derive + plan only
778
+ ```
779
+
780
+ Runs the same plan through the same executor, against the same live proxies, printing the per-step
781
+ input/output and the response model. It is how you test a scenario without a session, how a Tier 2
782
+ or non-Claude-Code client gets any replay at all, and the first thing to reach for when a steered
783
+ turn behaves oddly.
784
+
785
+ ---
786
+
787
+ ## 13. Security
788
+
789
+ v1 §9 holds unchanged. Replay adds two exposures, and neither is small.
790
+
791
+ ### 13.1 The scenario payload is executable code
792
+
793
+ `toolInputLogic`, `toolOutputLogic`, `paramsLogic` and `responseParamsLogic` are JavaScript function
794
+ *bodies*, compiled with `new Function` and run **in the control server process** — a process holding
795
+ the user's BaseIn credentials and, through the proxies, live authenticated access to every wrapped
796
+ MCP server.
797
+
798
+ BaseIn's own `logic.ts` argues the eval is safe because *"these run only over data the owner already
799
+ produced… so the dynamic eval is scoped to first-party content."* That argument holds here too, and
800
+ the blast radius does not: BaseIn evaluates them against a database row, we evaluate them next to a
801
+ browser and a GitHub token.
802
+
803
+ What actually constrains it:
804
+
805
+ - The payload arrives **only** in the response to an authenticated `POST /recordings/runs` against
806
+ the configured `BIR_AUTH_URL`, over the user's own token, describing the user's own runs. It is not
807
+ attacker-supplied in any ordinary threat model.
808
+ - A `new Function` body gets its declared arguments **and the global scope**. `require` and `import`
809
+ are not reachable (ESM, no CommonJS wrapper), but `fetch`, `process` and `globalThis` are. Say this
810
+ plainly rather than implying a sandbox: **there is no sandbox.**
811
+ - `BIR_REPLAY` is opt-in (D14), so a compromised or misconfigured `BIR_AUTH_URL` cannot start
812
+ executing logic on a machine whose operator never asked for replay.
813
+
814
+ If that is not enough for a deployment, the mitigation is a real one and is out of scope here:
815
+ evaluate the logic in a `node:vm` context with a frozen, minimal global. Filed as §20 question 3.
816
+
817
+ ### 13.2 Replay bypasses permission prompts
818
+
819
+ This is the sharpest edge in the whole design, and it must not be buried.
820
+
821
+ - **Steer mode** answers `PreToolUse` with `permissionDecision: "allow"`, so a pinned call is
822
+ auto-approved. The `BIR_CORRELATION_DECISION` note in `bir-hooks.ts` already documents this for
823
+ correlated MCP calls; steering extends it to **built-ins** — including `Bash` and `Edit`.
824
+ - **Direct mode** never reaches `PreToolUse` at all. A `tools/call` dispatched to a proxy is not
825
+ visible to Claude Code's permission system in any form.
826
+
827
+ So a scenario is, in effect, a **pre-approved list of tool calls with computed arguments**. That is
828
+ precisely what makes it valuable, and precisely what makes it dangerous. Four mitigations, all
829
+ required:
830
+
831
+ 1. `BIR_REPLAY` off by default; `bir install --replay` a distinct, non-default flag.
832
+ 2. `BIR_REPLAY_ALLOW_SERVERS` — a comma-separated allowlist of server keys eligible for **direct**
833
+ execution. Unset means *all wrapped servers*; the guide (§4 of `calculatedReplayGuide.md`)
834
+ recommends setting it.
835
+ 3. `bir doctor` prints `replay: on` and the effective allowlist, loudly, at the top. A capability this
836
+ sharp must never be discoverable only by reading a log.
837
+ 4. Every armed plan logs `plan.armed` with the scenario id, mode, step count and the ordered tool
838
+ names **before** the first call runs. The audit line precedes the action.
839
+
840
+ ### 13.3 The control plane
841
+
842
+ `/scenario/run`, `/proxy/poll` and `/proxy/result` are ordinary control-server routes: loopback only,
843
+ bearer token from the `0600` discovery file, same as `/proxy/step`. `/proxy/poll` holds a connection
844
+ open for up to 25 s — a long-lived authenticated request, not a new trust boundary.
845
+
846
+ ---
847
+
848
+ ## 14. Degradation matrix
849
+
850
+ The governing rule is unchanged and outranks every optimisation here: **the host session must never
851
+ fail because of BaseInstRunner.**
852
+
853
+ | Failure | Behaviour |
854
+ |---|---|
855
+ | `getMatch()` slower than the match budget | No plan. Ordinary turn. One `replay.decision` line |
856
+ | No `ANTHROPIC_API_KEY` | Derivation skipped; the scenario's recorded `sampleValue`s are used. `deriveCostUsd: 0` |
857
+ | Derivation call fails or times out | Same as above, plus a `replay.derive_failed` line |
858
+ | `toolInputLogic` throws | Divergence (§8) — never a crash |
859
+ | `toolOutputLogic` throws | Retire the plan, abort to an ordinary turn, outcome `failed` |
860
+ | Model calls an unexpected tool | Divergence (§8) |
861
+ | A proxy has no open long-poll (dead, or `BIR_REPLAY` unset in its env) | That step falls back to `recordedOutput`, else is skipped with a warning |
862
+ | A direct step exceeds `BIR_STEP_TIMEOUT_MS` | Same as above; the work item is abandoned server-side |
863
+ | Whole plan exceeds `BIR_REPLAY_BUDGET_MS` | Return what completed, marked partial; outcome `diverged` |
864
+ | `bir-scenario` cannot find the control server | `run_scenario` returns `isError: true`; the model proceeds normally |
865
+ | Ticket redemption fails (offline, 409) | Logged; the turn is unaffected. An unredeemed ticket books nothing |
866
+ | Two reports for one ticket (retry, `Stop` racing `SessionEnd`) | Server books once, answers `200 { duplicate: true }` |
867
+ | `BIR_REPLAY` unset | Exactly v1: stop recording on a match, do nothing else |
868
+
869
+ ---
870
+
871
+ ## 15. Solution layout
872
+
873
+ ```
874
+ src/
875
+ ├─ replay/ ← new
876
+ │ ├─ plan.ts # ScenarioReplayPlan (§9)
877
+ │ ├─ logic.ts # the four new Function shims — port BaseIn's (§9)
878
+ │ ├─ derive.ts # Haiku parameter derivation over raw fetch (§16.3)
879
+ │ ├─ pricing.ts # MODEL_PRICING + PRICING_VERSION — copy BaseIn's (§11.3)
880
+ │ ├─ coverage.ts # reachOf / modeFor (§5)
881
+ │ ├─ bundle.ts # assembleBundle / truncate — port verbatim
882
+ │ ├─ executor.ts # direct step dispatch + the work queue (§16.2)
883
+ │ ├─ source-run.ts # lazy GET /recordings/runs/:id → recorded outputs by tool (§8.1)
884
+ │ └─ types.ts # SerializedScenario, SerializedScenarioStep
885
+ ├─ bin/
886
+ │ ├─ bir-scenario.ts ← new: the one-tool MCP server (§6.3)
887
+ │ └─ bir.ts # + `--replay`, `bir scenario …`, `bir replay` (§16.5)
888
+ ├─ control/
889
+ │ ├─ server.ts # + arm / pin / thread / dispatch / report
890
+ │ └─ transcript.ts # + markTranscriptUsage / usageSince (§11.4)
891
+ ├─ proxy/
892
+ │ └─ session.ts # + the long-poll work loop (§16.2)
893
+ ├─ record/
894
+ │ ├─ recorder.ts # + reportExecution() on the Recorder surface
895
+ │ └─ remote-recorder.ts # + POST /scenarios/:id/executions
896
+ └─ config/adapters/claude-code.ts # UserPromptSubmit timeout 5 → 15; the `bir` MCP entry
897
+ ```
898
+
899
+ `package.json` gains one bin: `"bir-scenario": "dist/bin/bir-scenario.js"`. Runtime dependencies stay
900
+ at **zero** (§16.3).
901
+
902
+ ---
903
+
904
+ ## 16. Contracts
905
+
906
+ ### 16.1 Control-plane routes (new)
907
+
908
+ ```ts
909
+ // bir-scenario → control server
910
+ POST /scenario/run {}
911
+ → 200 { ok: true, text: string, responseModel: object, steps: number, partial?: boolean }
912
+ → 200 { ok: false, why: "no_plan" | "not_ready" | "budget_exceeded" }
913
+
914
+ // proxy → control server (long-poll; holds up to 25 s, then answers empty)
915
+ POST /proxy/poll { serverName: string }
916
+ → 200 {} | 200 { work: { workId: string; toolName: string; arguments: unknown; timeoutMs: number } }
917
+
918
+ // proxy → control server
919
+ POST /proxy/result { workId: string, result?: unknown, error?: string }
920
+ → 200 { ok: true }
921
+ ```
922
+
923
+ ### 16.2 The direct executor
924
+
925
+ ```ts
926
+ // src/replay/executor.ts
927
+ export type ExecuteStep = (step: SerializedScenarioStep, input: Record<string, unknown>) => Promise<string>;
928
+
929
+ /** Hands work to the proxy that owns `serverName` and awaits its result. */
930
+ export class ProxyWorkQueue {
931
+ /** Called by POST /proxy/poll. Resolves with work, or with undefined at the poll deadline. */
932
+ waitForWork(serverName: string, pollDeadlineMs: number): Promise<Work | undefined>;
933
+ /** Called by POST /proxy/result. */
934
+ complete(workId: string, result?: unknown, error?: string): void;
935
+ /** Dispatch and await one call. Rejects on timeout or on no registered proxy. */
936
+ call(serverName: string, toolName: string, args: unknown, timeoutMs: number): Promise<unknown>;
937
+ }
938
+ ```
939
+
940
+ > **[built] "No poller parked" is not the same as "no proxy".** The first implementation rejected
941
+ > `call` immediately whenever no poller was waiting, on the reasoning that queued work could stall a
942
+ > turn past its budget. That reasoning holds for a server no proxy serves — and is wrong for the case
943
+ > that actually dominates: a proxy is *between* polls precisely while it is POSTing one step's result
944
+ > and re-polling, which is exactly the gap between two steps of a multi-step plan. The end-to-end test
945
+ > caught it as "step 2 of 2 never ran".
946
+ >
947
+ > So the queue tracks `lastSeen` per server. Work dispatched while a recently-seen proxy is
948
+ > mid-round-trip is **held** for its next poll; the per-call timeout still bounds the wait, and a
949
+ > server nothing has served for a minute still fails fast into the recorded-output fallback.
950
+
951
+ Proxy side, in `ProxySession` — started after a successful `register()`, only when `BIR_REPLAY=1`:
952
+
953
+ ```ts
954
+ while (!closed) {
955
+ const work = await control.poll(this.serverName); // 25 s long-poll
956
+ if (!work) continue;
957
+ try {
958
+ // The reserved `bir:` id space, built for exactly this and never used by the relay.
959
+ const result = await this.upstream.request("tools/call",
960
+ { name: work.toolName, arguments: work.arguments },
961
+ AbortSignal.timeout(work.timeoutMs));
962
+ await control.result(work.workId, result);
963
+ } catch (err) {
964
+ await control.result(work.workId, undefined, errText(err));
965
+ }
966
+ }
967
+ ```
968
+
969
+ Note `work.toolName` is the **upstream-local** name (`navigate_page`), not the qualified one —
970
+ `parseQualifiedName` splits it at dispatch. Storing both parts is what v1 §5 bought.
971
+
972
+ > **[built] The proxy needs no replay configuration of its own.** `/proxy/register` answers
973
+ > `{ replay: boolean, pollHoldMs }`, and the loop opens only if that says so. `BIR_REPLAY` is
974
+ > therefore a single switch on the control server, with no second copy in each wrapped entry's `env`
975
+ > to drift out of step with it.
976
+
977
+ ### 16.3 Derivation without a dependency
978
+
979
+ ```ts
980
+ // src/replay/derive.ts — no SDK; ~50 lines of fetch.
981
+ export async function deriveParameters(opts: {
982
+ prompt: string;
983
+ intent: string;
984
+ paramsObject: Record<string, { sampleValue: unknown; description: string }> | null;
985
+ apiKey?: string; // ANTHROPIC_API_KEY
986
+ model?: string; // default claude-haiku-4-5-20251001
987
+ signal?: AbortSignal;
988
+ }): Promise<{ params: Record<string, unknown>; costUsd: number; derived: boolean }>;
989
+ ```
990
+
991
+ Behaviour, matching RRepeat's `deriveParametersFromScenarioPayload` exactly so the two produce the
992
+ same parameters from the same prompt:
993
+
994
+ - No `paramsObject` keys → `{}`, `costUsd: 0`.
995
+ - **No `apiKey` → return every key's recorded `sampleValue`**, `costUsd: 0`, `derived: false`. A plain
996
+ replay of the recorded parameters, free, and the documented default for anyone without a key.
997
+ - Otherwise `POST https://api.anthropic.com/v1/messages` with the same extraction prompt, tolerant
998
+ JSON extraction (first balanced `{…}`, `undefined` → `null`), and any `null`/missing key filled
999
+ from its `sampleValue`.
1000
+
1001
+ Keeping this dependency-free is not purity. `bir-proxy` is spawned inside the host's process tree for
1002
+ *every* wrapped server; an npm install that pulls a model SDK into that path is a startup-latency and
1003
+ supply-chain cost paid N times per session, for a feature most sessions never arm.
1004
+
1005
+ ### 16.4 `Recorder` gains one method
1006
+
1007
+ ```ts
1008
+ export interface ScenarioReporter {
1009
+ reportExecution(r: {
1010
+ scenarioId: string; ticket?: string;
1011
+ outcome: "steered_full" | "diverged" | "not_steered" | "failed";
1012
+ deriveCostUsd: number; sessionCostUsd: number; fallbackCostUsd: number;
1013
+ durationMs: number; stepsPlanned: number; stepsPinned: number;
1014
+ measured: boolean; pricingVersion: string; prompt?: string;
1015
+ }): void;
1016
+ }
1017
+ export function isScenarioReporter(r: Recorder): r is Recorder & ScenarioReporter;
1018
+ ```
1019
+
1020
+ Optional capability, discovered the same way `isMatchAware` is — so `NullRecorder` needs no change
1021
+ and a session with no BaseIn credentials still replays (it just books nothing).
1022
+
1023
+ ### 16.5 CLI surface
1024
+
1025
+ `bir` gains one flag and one command group. Everything here is a thin client over endpoints that
1026
+ already exist server-side, except `bir replay`, which drives §16.2's executor directly.
1027
+
1028
+ ```
1029
+ bir install --replay register the `bir` MCP server; raise the prompt-hook timeout to 15 s
1030
+ bir uninstall --replay reverse exactly that, leaving the rest of the install alone
1031
+
1032
+ bir scenario list GET /recordings/runs runs, iterations, scenario state
1033
+ bir scenario show <runId> GET /recordings/runs/:id/scenario intent, params, steps, logic
1034
+ bir scenario calc <runId> POST /recordings/runs/:id/calculate [--force to re-derive in place]
1035
+ bir scenario replay <scnId> --prompt "…" [--dry]
1036
+
1037
+ bir replay --scenario <scnId> --prompt "…" [--dry] alias; the Tier 2 / debugging path (§12.1)
1038
+ ```
1039
+
1040
+ `--dry` maps to BaseIn's `POST /scenarios/:id/replay`, which evaluates the stored logic against the
1041
+ source run's *recorded* outputs — no real tools, no side effects, one Haiku call. Without `--dry`,
1042
+ `bir replay` runs the same plan through the same executor against the live proxies. `bir doctor`
1043
+ gains a `replay` block: on/off, the effective `BIR_REPLAY_ALLOW_SERVERS` allowlist,
1044
+ `BIR_MIN_STEER_SIMILARITY`, and whether an `ANTHROPIC_API_KEY` was found (§13.2, mitigation 3).
1045
+
1046
+ ---
1047
+
1048
+ ## 17. Implementation phases
1049
+
1050
+ Each phase ends in something runnable. Do not start a phase before its predecessor's acceptance test
1051
+ passes.
1052
+
1053
+ ### Phase R0 — Plan, logic, coverage (no wiring)
1054
+ Port `logic.ts` from BaseIn, `bundle.ts` and `ScenarioReplayPlan` from RRepeat, write `coverage.ts`.
1055
+ Pure, dependency-free, no I/O.
1056
+ **Acceptance:** unit tests replay a fixture `SerializedScenario` end to end against a stub executor
1057
+ and reproduce the recorded outputs byte for byte.
1058
+
1059
+ ### Phase R1 — Derivation and pricing
1060
+ `derive.ts` + `pricing.ts`. Both fully testable against a fake `fetch`.
1061
+ **Acceptance:** with no key, derivation returns sample values at zero cost; with a stubbed key, it
1062
+ parses fenced JSON, bare JSON and prose-wrapped JSON, and prices a known `usage` to the same cent as
1063
+ BaseIn's `cost.ts`.
1064
+
1065
+ ### Phase R2 — Arming and the decision log
1066
+ `onPrompt` awaits `getMatch()` under budget, runs the gate ladder, computes the mode, arms the plan,
1067
+ returns the directive. **No execution yet.**
1068
+ **Acceptance:** a fake recorder returns a match; assert exactly one `plan.armed` or one
1069
+ `replay.decision` line per prompt, the right mode, and that the prompt hook returns inside the
1070
+ budget when `getMatch` never resolves.
1071
+
1072
+ ### Phase R3 — Steer mode
1073
+ `/tool/pre` pinning (with the §7.1 `updatedInput` merge), `/tool/post` threading (with the §7.2
1074
+ source split), retire on done.
1075
+ **Acceptance:** a scripted hook sequence drives a 3-step plan to `steered_full`; a wrapped-MCP step
1076
+ threads from `/proxy/step` and not from the hook; reordering the model's calls produces exactly one
1077
+ divergence and no crash.
1078
+
1079
+ ### Phase R4 — The work queue and direct execution
1080
+ `ProxyWorkQueue`, `/proxy/poll`, `/proxy/result`, the proxy-side loop.
1081
+ **Acceptance:** against `test/fake-upstream.ts`, a 3-step direct plan executes with `respParams`
1082
+ threaded between steps; killing the proxy mid-plan degrades to `recordedOutput`/skip and the session
1083
+ survives.
1084
+
1085
+ ### Phase R5 — `bir-scenario` and direct delivery
1086
+ The MCP server, `/scenario/run`, `bir install --replay`.
1087
+ **Acceptance:** a real Claude Code session on a fully-wrapped scenario answers from one
1088
+ `run_scenario` call, and the transcript shows a genuine `tool_result`.
1089
+
1090
+ ### Phase R6 — Divergence
1091
+ Direct-execute the remainder; the lazy source-run fetch for `live` steps (§8.1); Bash-clean,
1092
+ `bir`-tool and deny delivery; the fair-share bundle cap.
1093
+ **Acceptance:** forcing an off-script call produces a bundle under `MAX_REPLAY_REASON` containing
1094
+ every remaining step; a `live` step is served its recorded output, marked `(recorded)`, and threads
1095
+ `respParams` for the steps after it; a `live` step with no recorded output is skipped with a warning
1096
+ and later steps still resolve.
1097
+
1098
+ ### Phase R7 — Reporting
1099
+ The usage watermark, `reportExecution`, ticket redemption in `sealRun`, `bir doctor` fields.
1100
+ **Acceptance:** three prompts in one session produce three *non-cumulative* `sessionCostUsd` values;
1101
+ a declined match books a baseline sample; a doubled report books once.
1102
+
1103
+ ---
1104
+
1105
+ ## 18. Testing
1106
+
1107
+ Extends v1 §11; same `node:test` harness, same `test/fake-upstream.ts`.
1108
+
1109
+ 1. **Plan semantics** — `respParams` threading, `paramsLogic` applied once, `isDone` after the last
1110
+ step, throw-propagation for each of the four logic hooks.
1111
+ 2. **Coverage** — every combination of wrapped/unwrapped MCP and built-in maps to the right mode,
1112
+ including the empty-steps and all-live cases.
1113
+ 3. **The `updatedInput` merge (§7.1)** — a pinned wrapped-MCP call carries both the pinned arguments
1114
+ and `__bir_call_id__`; the correlation's `hookArgs` are the *pinned* ones; `--no-correlation`
1115
+ still pins.
1116
+ 4. **The threading source (§7.2)** — a wrapped step threads the proxy's `result`, not the hook's
1117
+ `tool_response`, and threads the hook's view only after the grace window, with the warning.
1118
+ 5. **Budgets** — a `getMatch` that never resolves still returns the prompt hook inside its budget;
1119
+ a derivation that never resolves still pins, using sample values.
1120
+ 6. **Direct execution** — end to end through `fake-upstream`, including a step timeout, a dead proxy
1121
+ and a `tools/call` that returns `isError: true` (which is a *result*, not a failure — it must
1122
+ thread).
1123
+ 7. **Divergence** — each of the three delivery channels; delimiter neutralisation in the Bash path;
1124
+ the fair-share cap with one enormous response and four small ones.
1125
+ 8. **Reporting** — the watermark across three prompts; each outcome's server-side effect against a
1126
+ fake BaseIn; ticket idempotence.
1127
+ 9. **Integration** — a real Claude Code session against `fake-upstream` with a hand-written scenario,
1128
+ asserting a `steered_full` report. The only test that catches host-behaviour drift.
1129
+
1130
+ ---
1131
+
1132
+ ## 19. Rejected alternatives
1133
+
1134
+ **19.1 Deliver direct-mode results as `additionalContext`.** No extra MCP server, no extra tool in the
1135
+ list. Rejected: it must execute inside `UserPromptSubmit`'s timeout (§10), and it asserts to the model
1136
+ that tools ran without a transcript record that they did. §6.2.
1137
+
1138
+ **19.2 A callback URL per proxy instead of long-polling.** Faster dispatch, no idle connection.
1139
+ Rejected: N new loopback listeners, N new tokens, N new things `bir doctor` must verify — for a
1140
+ latency saving of milliseconds on a path already dominated by the upstream's own round trip. Dialling
1141
+ out is also the posture v1 §9 already chose everywhere else. D12.
1142
+
1143
+ **19.3 Give `bir-scenario` its own upstream connections.** Then no work queue at all. Rejected
1144
+ outright: it double-spawns every upstream. Two `chrome-devtools-mcp` processes means two browsers,
1145
+ two profiles, and a scenario that navigates one and screenshots the other.
1146
+
1147
+ **19.4 Port RRepeat's Agent-SDK divergence fallback.** Rejected: it is the dominant cost of RRepeat's
1148
+ calculated run (`savingCalcRunner.md` §4), it adds `@anthropic-ai/claude-agent-sdk` to a
1149
+ zero-dependency package, it needs anti-recursion guards, and here it buys nothing the proxies do not
1150
+ already buy for free. §8.
1151
+
1152
+ **19.5 Block the prompt and answer from the scenario alone.** The original RRepeat design, superseded
1153
+ there (`replay.md` §1, §10). Rejected for the same reasons plus one of ours: it forfeits the live
1154
+ session's built-in tools entirely, which is exactly the half of a mixed scenario the proxy cannot
1155
+ reach.
1156
+
1157
+ **19.6 Match on the whole conversation rather than the prompt.** Arguably better "similar meaning".
1158
+ Rejected as a *server* concern: `findSimilarRun` embeds `run.input`, and changing what is embedded
1159
+ changes the corpus, the threshold and every stored vector. §20 question 1.
1160
+
1161
+ ---
1162
+
1163
+ ## 20. Open questions
1164
+
1165
+ 1. **Should the match embed more than the first prompt?** A turn's meaning often lives in the
1166
+ preceding turn. Changing it is a BaseIn-side decision with a corpus migration attached, and it
1167
+ would let `bir` match mid-conversation rather than only at a turn boundary.
1168
+ 2. **Does BaseIn want a `direct` outcome?** Today direct mode reports `steered_full`, which is
1169
+ accurate but hides that the run cost no session tokens at all. A fifth enum member would let the
1170
+ ledger separate "the model was steered cheaply" from "the model did not run the task."
1171
+ 3. **`node:vm` for the logic bodies?** §13.1 states plainly that there is no sandbox. A frozen,
1172
+ minimal context would cost little and close the gap between "first-party by provenance" and
1173
+ "first-party by construction."
1174
+ 4. **Should a declined match still arm a *dry* plan?** Deriving parameters and computing the first
1175
+ step's input without executing anything would make `bir doctor` able to say *"this scenario would
1176
+ have run these three calls"* — excellent for tuning `BIR_MIN_STEER_SIMILARITY`, at the price of a
1177
+ Haiku call on turns that replay nothing.
1178
+ 5. **Should `scenario_steps` carry `recorded_output`?** §8.1 works around its absence with one lazy
1179
+ `GET /recordings/runs/:runId`. A column would make it a field read, would let a scenario outlive
1180
+ the deletion of its source run, and would finally make RRepeat's `recordedOutput` field mean
1181
+ something. Cost: the scenario row grows by the size of every step's output.
1182
+ 6. **Cwd fit.** A scenario recorded in project A may pin paths that do not exist in project B, and
1183
+ nothing currently checks. A cheap sixth gate — compare `run.metadata.cwd` — would catch the common
1184
+ case; the general case (relative paths, monorepos) is harder and probably belongs in
1185
+ `toolInputLogic` generation server-side.