bareloop 0.14.0 → 0.15.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.
package/src/replay.js ADDED
@@ -0,0 +1,1212 @@
1
+ // Generic run replay (PRD build-list TODO #6): today no failed run can be
2
+ // reconstructed in under five minutes — every byte is on disk (the spine
3
+ // JSONL + its gate-audit sidecar) but only hand-slicing JSONL reads it. This
4
+ // is a report-only reader: point it at a run's spine records, get the whole
5
+ // story back as one object and one printable page. Reads only, writes
6
+ // NOTHING, mints no verdict, touches nothing in the arbiter — same posture
7
+ // as `runBehaviour` (src/behaviour.js), which this reuses rather than
8
+ // re-implementing.
9
+ //
10
+ // Field provenance (every field this module reads, grepped to its `emit(`
11
+ // site so nothing here is a guessed shape):
12
+ // job-start {job, specHash, budgetUsd, shape, goal, verdictType, model?, ...}
13
+ // src/run.js:227-256,303
14
+ // `verdictType`/`model` (F117, PRD TODO #20, landed 2026-08-25): `verdictType` is
15
+ // a REQUIRED spec field (src/job.js:552 reds `missing-required` before job-start
16
+ // ever fires), so it is always real on every spine carrying it — `null` here means
17
+ // only "older than the field", never "unset". `model` rides along only when the
18
+ // shell-owned provider binding itself carried a `.model` string at job-start time
19
+ // (src/planrun.js:76's own comment: bare-agent's Loop reads `baseProvider.model`
20
+ // directly) — absent on a native/clipipe call path, never guessed.
21
+ // `code` (F118, the run→code direction, landed same day): `{version, sha}`
22
+ // from `codeVersion()` (src/codeversion.js) — `sha` is null when the
23
+ // process ran from an npm install with no `.git`; the whole `code` key is
24
+ // absent on any spine older than this landing.
25
+ // job-end {outcome, spentUsd, spendComplete, engagementSpentUsd}
26
+ // src/run.js:288,378-413
27
+ // escalation {category, decisionReady, verdicts?, spend?, decision, options, detail?}
28
+ // src/ralph.js:576,648,693;
29
+ // src/planrun.js (many sites)
30
+ // step-start {step, rounds, tools} src/planrun.js:2699
31
+ // step-end {step, outcome} src/planrun.js:2973
32
+ // worker-round {phase, iteration, kind, costUsd, pricing, tokens, usage}
33
+ // src/planrun.js:2288-2297
34
+ // `phase` is `step:<step.id>` for a plan step's own attempts, `scout` /
35
+ // `plan` / `fix` for the other worker phases — src/planrun.js:2735,2515,3359.
36
+ // `costUsd` is `arg?.costUsd ?? null` — a null is an HONEST unpriced round
37
+ // (F6), never summed as $0 by this module.
38
+ // exit-eval {step, iteration, results: [{type, pass, detail?}]}
39
+ // src/planrun.js:2897
40
+ // each `results[i].type` is one of plan.js's EXIT_TYPES; `check-passes`
41
+ // and `tree-changed` are the two this module reads.
42
+ // transport-retry {phase, attempt, error, recovered} src/planrun.js:126
43
+ // (F119, live-proven 2026-08-25 on u-mt8yk53k, two records): `attempt` is
44
+ // always `1` (`withTransportRetry`'s own comment there — one retry,
45
+ // transport-only, hamr's ruling); `recovered` is knowable only once the
46
+ // retried attempt settles, so it is never speculative. src/run.js:266,393
47
+ // treats the record's PRESENCE alone (never `recovered`) as a one-way
48
+ // floor on `spendComplete` — the first attempt may already have been
49
+ // billed and threw before returning any usage figure.
50
+ // memory-cache {pointered, capped, bytesWithheld, approxTokens} src/planrun.js:3619
51
+ // work-branch {branch, created, resumed, from, base, repo, collided?}
52
+ // src/planrun.js:1589
53
+ // absent on any archived spine older than PRD v1.57 §3's work-branch
54
+ // rung (measured: 0 records in bareagent-u-bareloop's Aug-3/4 archive) —
55
+ // reads `none recorded`, never a fabricated/derived branch name.
56
+ // plan-executed {steps, replanned, replans} src/planrun.js:2688
57
+ // `replans` (a number) is likewise absent on an older archived spine
58
+ // (same vintage as work-branch's gap) — this module falls back to
59
+ // counting `materials` records with `phase:'replan'` (src/planrun.js:2520,
60
+ // one per replan draft) when the field itself is missing, never guesses 0.
61
+ // run-start {capRuns|strikeLimit, close} src/ralph.js:554
62
+ // iteration-start {iteration} src/ralph.js:583
63
+ // close-verdict {iteration, verdict, gap?, detail?, stages?, judgedCount?}
64
+ // src/ralph.js:674
65
+ // A close-verdict with an ARRAY `stages` field came from `runCloseStages`
66
+ // (src/ralph.js:328's `runStages`, which always sets `stages` on its
67
+ // return — src/planrun.js:1496's `judgeClose`, the operator's REAL close).
68
+ // A close-verdict with NO `stages` field came from a PLAN STEP's own
69
+ // micro-loop, judged by the exit evaluator instead of a command
70
+ // (src/planrun.js:2907's `ralph({judge, ...})` — ralph.js's own docs:
71
+ // "`judge`... replaces runClose for this loop"). These are two DIFFERENT
72
+ // verdict sources sharing one emit type; only the `stages`-carrying kind
73
+ // is "the close" this module's CLOSE section reports (see `resolveClose`).
74
+ // outer-close {verdict, stage?, stages?, gap?, detail?, judgedCount?}
75
+ // src/planrun.js:3296
76
+ // the FINAL close-fix-loop verdict — always the operator's real close (no
77
+ // step-level equivalent of this emit type exists).
78
+ // run-end {outcome, iterations} src/ralph.js:577,657,676,698
79
+ // wall-clock/wall-halt see chainClock below.
80
+ // (every record's own) ts, seq, type src/spine.js
81
+ // Gate-audit rows (the tool-call sidecar) are handed to `runBehaviour`
82
+ // unmodified — see src/behaviour.js for ITS field provenance (`run_id`,
83
+ // `phase`, `action.{type,path,args}`, `decision`); this module additionally
84
+ // reads each row's own `ts` (present on every gate-audit row emitted by
85
+ // src/tools.js's gate) to window tool calls into the step/iteration that was
86
+ // running when they happened — gate-audit rows carry no `step` field and no
87
+ // `seq` shared with the spine (their own `seq` is a separate per-audit-file
88
+ // counter), so `ts` is the only windowing key available for them.
89
+ //
90
+ // `runId` is NOT a spine field: nowhere does a job's own run id (the `u-<id>`
91
+ // in its filename) land inside a record. It only exists as the filename, so
92
+ // it is accepted as caller-supplied metadata via the third `opts` argument —
93
+ // the same shape `runBehaviour(events, {runId})` already uses for its own
94
+ // (different) runId — never invented as a fabricated record field.
95
+ //
96
+ // Explicitly NOT surfaced, and why: the signed verdict CLASS (green/softgreen)
97
+ // and the worker MODEL name live only in the signed spec — no spine record
98
+ // carries either (a `judgedCount` on a close-verdict is not a reliable stand-in:
99
+ // it appears on plain green closes too, not only softgreen ones). Adding them
100
+ // would be a spine-WRITER change to `job-start` (src/run.js), which this
101
+ // report-only reader does not make on its own authority — parked for hamr's
102
+ // explicit word (PRD TODO #20, F117).
103
+
104
+ import { runBehaviour, formatBehaviour } from './behaviour.js';
105
+ import { shortSha } from './codeversion.js';
106
+ import { SPEND_RECORD_TYPES } from './ledger.js';
107
+
108
+ /**
109
+ * @param {unknown} e
110
+ * @returns {e is Record<string, any>}
111
+ */
112
+ function isRecord(e) {
113
+ return e !== null && typeof e === 'object' && !Array.isArray(e);
114
+ }
115
+
116
+ /**
117
+ * @param {unknown} e
118
+ * @returns {e is {type: string, [k: string]: any}}
119
+ */
120
+ function isSpineEvent(e) {
121
+ return isRecord(e) && typeof e.type === 'string';
122
+ }
123
+
124
+ /** @param {string|undefined} ts @returns {number|null} */
125
+ function parseTs(ts) {
126
+ if (typeof ts !== 'string') return null;
127
+ const n = Date.parse(ts);
128
+ return Number.isFinite(n) ? n : null;
129
+ }
130
+
131
+ /**
132
+ * Trim a string to `max` chars with an explicit `…[+N chars]` marker —
133
+ * NEVER a silent truncation, and never a trailing ellipsis alone
134
+ * (indistinguishable from a detail that just happened to end there). Used
135
+ * for the full-report `stopReason` (400 chars) and the per-occurrence
136
+ * `↳ tripped:` line (120 chars) — both multi-line contexts where the fuller
137
+ * honesty marker earns its keep. The header block's `goal:` line and the
138
+ * `--all` table's `reason` column use the plainer {@link hardTrim} instead,
139
+ * because THEIR job is staying exactly one line.
140
+ * @param {unknown} s
141
+ * @param {number} max
142
+ * @returns {string|null}
143
+ */
144
+ function trimTo(s, max) {
145
+ if (typeof s !== 'string' || s.length === 0) return null;
146
+ const trimmed = s.trimEnd(); // trailing newlines (common in captured stderr) never open the marker on a blank line
147
+ if (trimmed.length <= max) return trimmed;
148
+ const cut = trimmed.slice(0, max);
149
+ return `${cut}…[+${trimmed.length - max} chars]`;
150
+ }
151
+
152
+ /**
153
+ * Plain one-line trim: normalizes whitespace/newlines to single spaces, then
154
+ * hard-cuts at `max` chars with a bare `…` — no `[+N chars]` marker (unlike
155
+ * {@link trimTo}). Feeds the header's `goal:` line and the `--all` table's
156
+ * `reason` column, both single-line-by-design.
157
+ * @param {unknown} s
158
+ * @param {number} max
159
+ * @returns {string|null}
160
+ */
161
+ function hardTrim(s, max) {
162
+ if (typeof s !== 'string') return null;
163
+ const oneLine = s.replace(/\s+/g, ' ').trim();
164
+ if (oneLine.length === 0) return null;
165
+ if (oneLine.length <= max) return oneLine;
166
+ return `${oneLine.slice(0, max)}…`;
167
+ }
168
+
169
+ const STOP_REASON_DETAIL_MAX = 400;
170
+ const TRIP_DETAIL_MAX = 120;
171
+ const GOAL_MAX = 100;
172
+ const ALL_REASON_MAX = 60;
173
+
174
+ /** A field the header expects but this particular spine never recorded
175
+ * (an older archive predating the field, or a run that died before the
176
+ * record fired) — printed literally, never blank, never a fabricated 0. */
177
+ const NONE_RECORDED = 'none recorded';
178
+
179
+ /**
180
+ * `stopReason` for a non-green outcome: `category — decision — detail`, every
181
+ * part present that the escalation actually carries (see the field-site
182
+ * comment at its one call site in {@link replayRun}). Falls back to the bare
183
+ * outcome string when the run stopped before any escalation record exists.
184
+ * @param {any} lastEscalation
185
+ * @param {string|null} outcome
186
+ * @returns {string|null}
187
+ */
188
+ function buildStopReason(lastEscalation, outcome) {
189
+ if (!lastEscalation) return outcome;
190
+ // `category` is dropped when it is IDENTICAL to the job-end outcome (the
191
+ // common case: outcome 'provider-red' from an escalation category
192
+ // 'provider-red') — the header already prints the outcome immediately
193
+ // before this string, so repeating it verbatim is noise, not information.
194
+ // A run where they genuinely differ (outcome 'escalated' from category
195
+ // 'cap-halt', e.g.) keeps the category — that IS new information.
196
+ const category = lastEscalation.category !== outcome ? lastEscalation.category : null;
197
+ const parts = [category, lastEscalation.decision, trimTo(lastEscalation.detail, STOP_REASON_DETAIL_MAX)]
198
+ .filter((p) => typeof p === 'string' && p.length > 0);
199
+ return parts.length > 0 ? parts.join(' — ') : outcome;
200
+ }
201
+
202
+ /**
203
+ * The LAST escalation whose `seq` falls inside `(startSeq, endSeq]` — an
204
+ * occurrence/iteration "tripped" by an escalation. Inclusive on the upper
205
+ * bound (unlike the round/check windowing below, which is exclusive on both
206
+ * ends) because on the ITERATIONS timeline the escalation record can BE the
207
+ * boundary itself. Harmless on the STEPS timeline: no escalation record ever
208
+ * shares a step-end's exact `seq`.
209
+ * @param {any[]} escalations
210
+ * @param {number} startSeq
211
+ * @param {number} endSeq
212
+ * @returns {any|null}
213
+ */
214
+ function findTrip(escalations, startSeq, endSeq) {
215
+ const inWindow = escalations.filter((e) => typeof e.seq === 'number' && e.seq > startSeq && e.seq <= endSeq);
216
+ return inWindow.length ? inWindow[inWindow.length - 1] : null;
217
+ }
218
+
219
+ /**
220
+ * Sum `costUsd` over an already seq-windowed slice of `worker-round` records.
221
+ * Doctrine (F6, same rule as the run-level spend floor): a `null`/non-finite
222
+ * `costUsd` on ANY round in the window makes the whole occurrence's spend
223
+ * `null` (unknown) — NEVER 0, and never a partial sum stamped as exact. Zero
224
+ * rounds in the window is a REAL zero (nothing ran, nothing was spent), not
225
+ * an unknown one, so that case alone returns `0`.
226
+ * @param {any[]} rounds
227
+ * @returns {{spentUsd: number|null, unpricedRounds: number}}
228
+ */
229
+ function windowSpend(rounds) {
230
+ if (rounds.length === 0) return { spentUsd: 0, unpricedRounds: 0 };
231
+ let sum = 0;
232
+ let unpriced = 0;
233
+ for (const r of rounds) {
234
+ if (typeof r.costUsd === 'number' && Number.isFinite(r.costUsd)) sum += r.costUsd;
235
+ else unpriced += 1;
236
+ }
237
+ return { spentUsd: unpriced > 0 ? null : sum, unpricedRounds: unpriced };
238
+ }
239
+
240
+ /**
241
+ * @param {number|null} startTs
242
+ * @param {number|null} endTs
243
+ * @returns {number|null}
244
+ */
245
+ function windowWallMs(startTs, endTs) {
246
+ if (startTs === null || endTs === null) return null;
247
+ const d = endTs - startTs;
248
+ return d >= 0 ? d : null; // an out-of-order ts pair reads as unknown, never a negative duration
249
+ }
250
+
251
+ /**
252
+ * `transport-retry` records (F119) whose `seq` falls inside `(startSeq,
253
+ * endSeq)` — same open-interval windowing as {@link windowSpend}'s rounds.
254
+ * Multiple retries in one window all count; the displayed error text is the
255
+ * FIRST record's (every retry is `attempt:1` per src/planrun.js:126's own
256
+ * comment — one transport-only retry — so the first record represents the
257
+ * group even when more than one fired in the same occurrence). `label` is
258
+ * `recovered` only when EVERY retry in the window recovered, `not recovered`
259
+ * when none did, and `partially recovered` on a genuine mix — never
260
+ * collapsed to one word that hides a real split.
261
+ * @param {any[]} retries
262
+ * @param {number} startSeq
263
+ * @param {number} endSeq
264
+ * @returns {{count: number, label: string, error: string|null}|null}
265
+ */
266
+ function windowTransportRetries(retries, startSeq, endSeq) {
267
+ const inWindow = retries.filter((r) => typeof r.seq === 'number' && r.seq > startSeq && r.seq < endSeq);
268
+ if (inWindow.length === 0) return null;
269
+ const recoveredCount = inWindow.filter((r) => r.recovered === true).length;
270
+ const label = recoveredCount === inWindow.length ? 'recovered' : recoveredCount === 0 ? 'not recovered' : 'partially recovered';
271
+ return { count: inWindow.length, label, error: hardTrim(inWindow[0].error, 80) };
272
+ }
273
+
274
+ /**
275
+ * The REASON(S) behind a `spendComplete:false` job-end, derived from spine
276
+ * records — never guessed, never picking just one when more than one truly
277
+ * applies. Mirrors src/run.js:229-276's four one-way floor flags plus the
278
+ * resume fold's own fifth: transport retry (any `transport-retry` record's
279
+ * presence, src/run.js:266,393 — `recovered` is irrelevant to the flag),
280
+ * unpriced round (a `worker-round`/`judge-round` with non-finite `costUsd`;
281
+ * `ACCOUNTED_ROUND_TYPES`, src/run.js:38,380), cut mid-call (a `wall-halt`
282
+ * record with `cutMidCall:true`, src/run.js:257,389), stall (a `stall`
283
+ * record, src/run.js:246,385), prior leg floor (`job-start.priorSpendComplete
284
+ * === false`, src/run.js:240,328). Returns `[]` when `spendComplete` is true
285
+ * (nothing to explain) OR when it is false but this spine carries no
286
+ * derivable evidence at all (an older archive, or a floor inherited from a
287
+ * resumed leg not in this file) — the caller prints the honest
288
+ * "reason not in spine" fallback for that empty case, never a guess.
289
+ * @param {any[]} spine
290
+ * @param {any} jobStart
291
+ * @param {boolean} spendComplete
292
+ * @returns {string[]}
293
+ */
294
+ function floorReasons(spine, jobStart, spendComplete) {
295
+ if (spendComplete) return [];
296
+ const reasons = [];
297
+ const transportRetryCount = spine.filter((e) => e.type === 'transport-retry').length;
298
+ if (transportRetryCount > 0) reasons.push(`transport retry ×${transportRetryCount}`);
299
+ const unpricedRoundCount = spine.filter((e) => (e.type === 'worker-round' || e.type === 'judge-round')
300
+ && !(typeof e.costUsd === 'number' && Number.isFinite(e.costUsd))).length;
301
+ if (unpricedRoundCount > 0) reasons.push('unpriced round(s)');
302
+ if (spine.some((e) => e.type === 'wall-halt' && e.cutMidCall === true)) reasons.push('cut mid-call');
303
+ if (spine.some((e) => e.type === 'stall')) reasons.push('stall');
304
+ if (jobStart && jobStart.priorSpendComplete === false) reasons.push('prior leg floor');
305
+ return reasons;
306
+ }
307
+
308
+ /**
309
+ * Which `close-verdict`/`outer-close` record is "the close" — the
310
+ * OPERATOR's real, signed close, never a plan step's own exit-eval judge
311
+ * loop (see the file header's `close-verdict` entry for why `stages` is the
312
+ * reliable discriminator). Picks the LAST qualifying record BY SEQ — never a
313
+ * type preference: `outer-close` (src/planrun.js:3296's `judgeClose()`) is
314
+ * the PRECHECK against the real close, fired ONCE before the fix loop even
315
+ * starts, so when it comes back red the fix loop's OWN staged close-verdicts
316
+ * (src/planrun.js:3539, `runCloseStages`-backed like `outer-close` itself)
317
+ * run strictly AFTER it and are the true final answer — measured on
318
+ * u-msf70nei: `outer-close` at seq 14 reads `needs_revision`, but the fix
319
+ * loop's own close-verdict at seq 97 (LATER) reads `satisfied`, and that is
320
+ * what the run actually ended on (`job-end.outcome:'green'`). Picking
321
+ * `outer-close` unconditionally would have reported a run that went green as
322
+ * still needing revision.
323
+ * @param {any[]} spine
324
+ * @returns {{source: 'close-verdict'|'outer-close', iteration: number|null,
325
+ * verdict: string|null, stages: any[]|null}|null}
326
+ */
327
+ function resolveClose(spine) {
328
+ const candidates = spine.filter((e) => (e.type === 'close-verdict' && Array.isArray(e.stages)) || e.type === 'outer-close');
329
+ const picked = candidates.length ? candidates[candidates.length - 1] : null;
330
+ if (!picked) return null;
331
+ return {
332
+ source: picked.type === 'outer-close' ? 'outer-close' : 'close-verdict',
333
+ iteration: typeof picked.iteration === 'number' ? picked.iteration : null,
334
+ verdict: picked.verdict ?? null,
335
+ stages: Array.isArray(picked.stages) ? picked.stages : null,
336
+ };
337
+ }
338
+
339
+ /**
340
+ * Reconstruct one run's whole story from its spine + gate-audit. Pure: no
341
+ * IO, no spine writes, no verdict. Malformed/non-object entries in either
342
+ * array are skipped and counted, never thrown on.
343
+ *
344
+ * A step id can recur (a replan re-executes the same step): `steps[].occurrence`
345
+ * is 1 for that id's first step-start..step-end pair, 2 for its second, etc.
346
+ * Every field on an occurrence — rounds, toolCalls, checks, treeChanged, wallMs,
347
+ * spentUsd — is scoped to records strictly between ITS OWN start/end `seq`,
348
+ * never pooled across every occurrence sharing the id.
349
+ *
350
+ * A run whose spine carries no `step-start` at all (the plan's only step was
351
+ * already-green-skipped on a resumed leg, or a genuinely legacy loop-only
352
+ * shape) has no STEPS timeline to build — `timelineKind` reads `'iterations'`
353
+ * and `iterations[]` is populated from `iteration-start`/`close-verdict`/
354
+ * `run-end`/`escalation` instead. `steps`/`iterations` are never both
355
+ * populated: exactly one is non-empty, selected by `timelineKind`.
356
+ *
357
+ * @param {any[]} spineEvents parsed records from `u-<id>.jsonl`
358
+ * @param {any[]} [auditEvents] parsed records from the sibling `-gate-audit.jsonl`
359
+ * @param {{runId?: string|null}} [opts] `runId`: caller-supplied (the spine
360
+ * carries no run-id field of its own — see the file header)
361
+ */
362
+ export function replayRun(spineEvents, auditEvents = [], { runId = null } = {}) {
363
+ let skipped = 0;
364
+
365
+ /** @type {any[]} */
366
+ const spine = [];
367
+ for (const e of Array.isArray(spineEvents) ? spineEvents : []) {
368
+ if (isSpineEvent(e)) spine.push(e);
369
+ else skipped += 1;
370
+ }
371
+ /** @type {any[]} */
372
+ const audit = [];
373
+ for (const e of Array.isArray(auditEvents) ? auditEvents : []) {
374
+ if (isRecord(e)) audit.push(e);
375
+ else skipped += 1;
376
+ }
377
+
378
+ const jobStart = spine.find((e) => e.type === 'job-start') ?? null;
379
+ const jobEnd = spine.findLast((e) => e.type === 'job-end') ?? null;
380
+ const outcome = jobEnd ? (jobEnd.outcome ?? null) : null;
381
+ const isGreen = outcome === 'green' || outcome === 'already-green';
382
+
383
+ const escalations = spine.filter((e) => e.type === 'escalation');
384
+ const lastEscalation = escalations.length ? escalations[escalations.length - 1] : null;
385
+ // detail field sites (grepped): src/run.js:342 (smoke-red), src/ralph.js:656
386
+ // (the DECISIONS passthrough — `detail: String(e.message || e)`),
387
+ // src/planrun.js:2427 (the `relay()` transport/cap/wall relay), plus every
388
+ // declared-close-red site (src/planrun.js:1111,1129,1656,1693,1742,3315)
389
+ // and the wall-halt sites (src/planrun.js:2990,3208-3209).
390
+ const stopReason = isGreen ? null : buildStopReason(lastEscalation, outcome);
391
+
392
+ const spentUsd = jobEnd && typeof jobEnd.spentUsd === 'number' && Number.isFinite(jobEnd.spentUsd) ? jobEnd.spentUsd : null;
393
+ const spendComplete = jobEnd ? jobEnd.spendComplete === true && spentUsd !== null : false;
394
+ const floorReasonList = floorReasons(spine, jobStart, spendComplete);
395
+
396
+ const wallMs = windowWallMs(parseTs(jobStart?.ts), parseTs(jobEnd?.ts));
397
+
398
+ // chainClock: the SIGNED wall, read off the run's own clock record —
399
+ // src/planrun.js:1203 (`wall-clock`) or `emitWallHalt` (src/planrun.js:1269)
400
+ // — both spread `clock.report()` (src/clock.js:235-247: `bounded,
401
+ // requestedMs, closeStages, enforcedMs, elapsedMs, remainingMs`).
402
+ // `elapsedMs` on EITHER is chain-scoped by construction (`createClock`'s
403
+ // `priorElapsedMs` fold, src/clock.js:174). wall-halt is preferred: it is
404
+ // the TERMINAL reading, taken at the moment the wall actually stopped the
405
+ // run. wall-clock (emitted once, near the top of every run) is the
406
+ // fallback — it reads the chain's folded elapsed at THIS ENGAGEMENT'S
407
+ // START, never this run's own end.
408
+ /** @type {{source: 'wall-halt'|'wall-clock', elapsedMs: number, requestedMs: number|null, enforcedMs: number|null, bounded: boolean}|null} */
409
+ let chainClock = null;
410
+ {
411
+ const wallHalts = spine.filter((e) => e.type === 'wall-halt');
412
+ const wallClocks = spine.filter((e) => e.type === 'wall-clock');
413
+ const source = wallHalts.length ? wallHalts[wallHalts.length - 1]
414
+ : wallClocks.length ? wallClocks[wallClocks.length - 1] : null;
415
+ if (source && typeof source.elapsedMs === 'number' && Number.isFinite(source.elapsedMs)) {
416
+ chainClock = {
417
+ source: /** @type {'wall-halt'|'wall-clock'} */ (wallHalts.length ? 'wall-halt' : 'wall-clock'),
418
+ elapsedMs: source.elapsedMs,
419
+ requestedMs: typeof source.requestedMs === 'number' && Number.isFinite(source.requestedMs) ? source.requestedMs : null,
420
+ enforcedMs: typeof source.enforcedMs === 'number' && Number.isFinite(source.enforcedMs) ? source.enforcedMs : null,
421
+ bounded: source.bounded === true,
422
+ };
423
+ }
424
+ }
425
+
426
+ // work-branch: src/planrun.js:1589. Absent on any archive older than the
427
+ // work-branch rung (measured, see file header) — `null`, never a derived name.
428
+ const workBranchRecords = spine.filter((e) => e.type === 'work-branch');
429
+ const branch = workBranchRecords.length && typeof workBranchRecords[workBranchRecords.length - 1].branch === 'string'
430
+ ? workBranchRecords[workBranchRecords.length - 1].branch : null;
431
+
432
+ const stepStarts = spine.filter((e) => e.type === 'step-start');
433
+ const stepEnds = spine.filter((e) => e.type === 'step-end');
434
+ // F1 fix (2026-08-26 review of PR #23): was `e.type === 'worker-round'` only,
435
+ // silently dropping `judge-round` (a close's own paid seam) and `worker-turn`
436
+ // (native per-turn attribution) out of every spend total below — the same
437
+ // canonical set `spendProvenance` uses (src/ledger.js:482, `SPEND_RECORD_TYPES`
438
+ // = `['worker-round', 'judge-round', 'worker-turn']`), imported rather than
439
+ // re-spelled so the two can never drift apart.
440
+ const spendRecords = spine.filter((e) => SPEND_RECORD_TYPES.includes(e.type));
441
+ const exitEvals = spine.filter((e) => e.type === 'exit-eval');
442
+
443
+ // F119: every `transport-retry` record on this spine, and the (derived,
444
+ // never guessed) reason(s) behind a `spendComplete:false` job-end — see
445
+ // {@link floorReasons}'s own doc for the full mapping back to src/run.js.
446
+ const transportRetries = spine.filter((e) => e.type === 'transport-retry');
447
+
448
+ // resumed / resumeSeed / thisFileSpend — a RESUMED run's `job-end.spentUsd`
449
+ // is the CHAIN total (prior legs folded in: src/run.js:227's `chainFoldUsd`
450
+ // seeds `spentUsd` from `job-start.priorSpentUsd`), so it can NEVER equal
451
+ // the sum of THIS file's own worker-rounds, and the header must say so
452
+ // rather than let the two numbers silently disagree (measured:
453
+ // u-msf70nei's header read $5.3389 while its own 55 rounds sum to
454
+ // $1.2127). `resumed` mirrors the SAME condition src/run.js:328 itself uses
455
+ // to decide whether to fold — the key's PRESENCE, not `priorSpentUsd > 0`
456
+ // (PR #23 review, 2026-08-26): src/run.js:328 emits the field whenever
457
+ // `spentUsd > 0 || priorFloor`, and its own comment states "a fold of $0
458
+ // that is NOT exact is still a fold" — a resumed run whose prior leg spent
459
+ // exactly $0 (a genuine floor, `priorSpendComplete: false`) still carries
460
+ // `priorSpentUsd: 0`, and reading `> 0` here would have silently read that
461
+ // real resume as `resumed: no`. Not off `resume-seed`'s presence either:
462
+ // resume-seed (src/planrun.js:2622) is a NARROWER record that fires only
463
+ // when a plan is actually reloaded mid-flight, and its absence does NOT
464
+ // mean "not resumed". Measured directly: 4 real archived runs
465
+ // (litectx-maintainer's msx7xoe0, msx87qqs, msxf9129, msxf2mwi) carry
466
+ // `job-start.priorSpentUsd` — a real chain fold — with NO resume-seed
467
+ // record at all (resumed before any plan reload was needed); keying
468
+ // "resumed" on resume-seed alone would have printed `resumed: no` on
469
+ // genuinely resumed runs and mis-explained their spend.
470
+ const resumed = !!jobStart && 'priorSpentUsd' in jobStart;
471
+
472
+ // resumeSeed: the DETAIL fields (phase/completed/skipping/divergence) —
473
+ // present only on the narrower resume-seed record; `resumed` above stays
474
+ // true even when this is null (see above).
475
+ const resumeSeedRecords = spine.filter((e) => e.type === 'resume-seed');
476
+ const lastResumeSeed = resumeSeedRecords.length ? resumeSeedRecords[resumeSeedRecords.length - 1] : null;
477
+ const resumeSeed = lastResumeSeed ? {
478
+ phase: lastResumeSeed.phase ?? null,
479
+ completed: Array.isArray(lastResumeSeed.completed) ? lastResumeSeed.completed.length : null,
480
+ skipping: typeof lastResumeSeed.skipping === 'number' ? lastResumeSeed.skipping : null,
481
+ divergence: typeof lastResumeSeed.divergence === 'string' ? lastResumeSeed.divergence : null,
482
+ } : null;
483
+
484
+ // thisFileSpend: prefer `job-end.engagementSpentUsd` (F103, src/run.js:284
485
+ // — THIS engagement's own spend, folded out of the chain total) when the
486
+ // record carries it; older archived spines can predate the field (measured
487
+ // absent on u-msf70nei itself, an Aug-4 run) — fall back to summing this
488
+ // file's own `worker-round.costUsd`. Either way, an unpriced round anywhere
489
+ // in THIS FILE makes the figure `null` (never a partial sum stamped exact,
490
+ // F6's rule) — checked directly against this file's rounds regardless of
491
+ // which source supplies the value, because `engagementSpentUsd` is built
492
+ // from the SAME priced-only sum and can carry the identical floor.
493
+ const totalRoundsInFile = spendRecords.length;
494
+ const unpricedRoundsInFile = spendRecords.filter((r) => !(typeof r.costUsd === 'number' && Number.isFinite(r.costUsd))).length;
495
+ const summedRounds = spendRecords.reduce((acc, r) => (typeof r.costUsd === 'number' && Number.isFinite(r.costUsd) ? acc + r.costUsd : acc), 0);
496
+ const hasEngagementField = jobEnd && typeof jobEnd.engagementSpentUsd === 'number' && Number.isFinite(jobEnd.engagementSpentUsd);
497
+ const thisFileSpend = {
498
+ value: unpricedRoundsInFile > 0 ? null : (hasEngagementField ? jobEnd.engagementSpentUsd : summedRounds),
499
+ source: /** @type {'engagementSpentUsd'|'summed rounds'} */ (hasEngagementField ? 'engagementSpentUsd' : 'summed rounds'),
500
+ totalRounds: totalRoundsInFile,
501
+ unpricedRounds: unpricedRoundsInFile,
502
+ };
503
+
504
+ /** @param {any} e */
505
+ const tripOf = (e) => (e ? {
506
+ category: e.category ?? null,
507
+ // `decision` is the fallback, not an addition: some escalation sites
508
+ // (e.g. ralph.js's ladder-exhaustion `cap-halt`) carry no `detail` at
509
+ // all, and the useful text lives in `decision` instead — the same
510
+ // fallback chain `buildStopReason` already uses above, so a tripped
511
+ // occurrence is never left with just a bare category word when the
512
+ // record actually explains itself.
513
+ detail: trimTo(e.detail, TRIP_DETAIL_MAX) ?? trimTo(e.decision, TRIP_DETAIL_MAX),
514
+ } : null);
515
+
516
+ /**
517
+ * The metrics ONE occurrence window (one step, or one loop iteration) shares
518
+ * regardless of which timeline it belongs to — extracted (PR #23 review item
519
+ * 6, 2026-08-26) so a fix to how spend/tool-calls/trips get windowed lands
520
+ * ONCE for both the steps and iterations blocks below, rather than needing
521
+ * to be re-applied twice and risking the two silently drifting apart (the
522
+ * original bug in item 1 above was exactly a fix that had to touch two
523
+ * near-identical windowing call sites by hand).
524
+ * @param {number} startSeq
525
+ * @param {number|null} startTs
526
+ * @param {number} endSeq
527
+ * @param {number|null} endTs
528
+ * @param {any[]} roundsInWindow
529
+ */
530
+ function buildOccurrenceMetrics(startSeq, startTs, endSeq, endTs, roundsInWindow) {
531
+ const startTsForAudit = startTs ?? -Infinity;
532
+ const endTsForAudit = endTs ?? Infinity;
533
+ const windowed = audit.filter((a) => {
534
+ const t = parseTs(a.ts);
535
+ return t !== null && t >= startTsForAudit && t <= endTsForAudit;
536
+ });
537
+ const { spentUsd, unpricedRounds } = windowSpend(roundsInWindow);
538
+ return {
539
+ rounds: roundsInWindow.length,
540
+ toolCalls: runBehaviour(windowed).totalCalls,
541
+ wallMs: windowWallMs(startTs, endTs),
542
+ spentUsd,
543
+ unpricedRounds,
544
+ tripped: tripOf(findTrip(escalations, startSeq, endSeq)),
545
+ transportRetries: windowTransportRetries(transportRetries, startSeq, endSeq),
546
+ };
547
+ }
548
+
549
+ const usedEndSeqs = new Set();
550
+ const idOccurrence = new Map();
551
+
552
+ const steps = stepStarts.map((ss) => {
553
+ const id = ss.step;
554
+ const startSeq = typeof ss.seq === 'number' ? ss.seq : -Infinity;
555
+ const startTs = parseTs(ss.ts);
556
+
557
+ const end = stepEnds
558
+ .filter((se) => se.step === id && typeof se.seq === 'number' && se.seq >= startSeq && !usedEndSeqs.has(se.seq))
559
+ .sort((a, b) => a.seq - b.seq)[0] ?? null;
560
+ if (end) usedEndSeqs.add(end.seq);
561
+ const endSeq = end && typeof end.seq === 'number' ? end.seq : Infinity;
562
+ const endTs = end ? parseTs(end.ts) : null;
563
+
564
+ const phaseTag = `step:${id}`;
565
+ // `judge-round` (a close's paid seam) carries no `.phase` at all
566
+ // (src/planrun.js:1333's `onJudgeCost` emit — stage/path/attempt/label/model,
567
+ // never phase); a phase check that only ever runs against `worker-round`/
568
+ // `worker-turn` records would silently exclude it from every step forever,
569
+ // so a record with no `.phase` field is windowed by seq alone, same as the
570
+ // iterations timeline below already does unconditionally.
571
+ const roundsInWindow = spendRecords.filter((r) => (typeof r.phase === 'string' ? r.phase === phaseTag : true)
572
+ && typeof r.seq === 'number' && r.seq > startSeq && r.seq < endSeq);
573
+
574
+ let passed = 0;
575
+ let failed = 0;
576
+ let treeChanged = false;
577
+ for (const ee of exitEvals) {
578
+ if (ee.step !== id) continue;
579
+ if (!(typeof ee.seq === 'number' && ee.seq > startSeq && ee.seq < endSeq)) continue;
580
+ for (const r of Array.isArray(ee.results) ? ee.results : []) {
581
+ if (!isRecord(r)) continue;
582
+ if (r.type === 'check-passes') { if (r.pass) passed += 1; else failed += 1; }
583
+ if (r.type === 'tree-changed' && r.pass) treeChanged = true;
584
+ }
585
+ }
586
+
587
+ idOccurrence.set(id, (idOccurrence.get(id) ?? 0) + 1);
588
+
589
+ return {
590
+ id,
591
+ occurrence: idOccurrence.get(id),
592
+ outcome: end ? (end.outcome ?? null) : null,
593
+ ...buildOccurrenceMetrics(startSeq, startTs, endSeq, endTs, roundsInWindow),
594
+ checks: { passed, failed },
595
+ treeChanged,
596
+ };
597
+ });
598
+
599
+ // ── ITERATIONS timeline (loop-shape runs: no step-start at all). No
600
+ // `phase` filter on the rounds: the seq window between one iteration-start
601
+ // and the next close-verdict/run-end/escalation already isolates that
602
+ // iteration's own rounds.
603
+ const iterationStarts = spine.filter((e) => e.type === 'iteration-start');
604
+ const timelineKind = /** @type {'steps'|'iterations'} */ (stepStarts.length > 0 ? 'steps' : (iterationStarts.length > 0 ? 'iterations' : 'steps'));
605
+ const boundaries = spine.filter((e) => e.type === 'close-verdict' || e.type === 'run-end' || e.type === 'escalation');
606
+
607
+ const iterations = timelineKind === 'iterations' ? iterationStarts.map((is) => {
608
+ const startSeq = typeof is.seq === 'number' ? is.seq : -Infinity;
609
+ const startTs = parseTs(is.ts);
610
+
611
+ const end = boundaries
612
+ .filter((b) => typeof b.seq === 'number' && b.seq > startSeq)
613
+ .sort((a, b) => a.seq - b.seq)[0] ?? null;
614
+ const endSeq = end && typeof end.seq === 'number' ? end.seq : Infinity;
615
+ const endTs = end ? parseTs(end.ts) : null;
616
+
617
+ const roundsInWindow = spendRecords.filter((r) => typeof r.seq === 'number' && r.seq > startSeq && r.seq < endSeq);
618
+
619
+ const verdict = end
620
+ ? (end.type === 'close-verdict' ? (end.verdict ?? null)
621
+ : end.type === 'run-end' ? (end.outcome ?? null)
622
+ : end.type === 'escalation' ? (end.category ?? null) : null)
623
+ : null;
624
+
625
+ return {
626
+ iteration: typeof is.iteration === 'number' ? is.iteration : null,
627
+ verdict,
628
+ boundary: end ? end.type : null,
629
+ // this iteration's OWN close-verdict, when that is what ended it —
630
+ // distinct from the top-level `close` (the run's FINAL close), because
631
+ // an escalated/red iteration's own close-verdict is still worth
632
+ // showing inline even when the run never reached a final close.
633
+ closeStage: end && end.type === 'close-verdict'
634
+ ? { verdict: end.verdict ?? null, stages: Array.isArray(end.stages) ? end.stages : null }
635
+ : null,
636
+ ...buildOccurrenceMetrics(startSeq, startTs, endSeq, endTs, roundsInWindow),
637
+ };
638
+ }) : [];
639
+
640
+ // Retries whose seq fell OUTSIDE every occurrence window built above (a
641
+ // retry during scout/plan/fix-loop phases the steps/iterations timelines
642
+ // don't cover, or a spine with no windows at all) — counted against
643
+ // whichever timeline is actually populated, never both (see `timelineKind`'s
644
+ // own doc: exactly one of steps/iterations is non-empty).
645
+ const windowedRetryCount = (timelineKind === 'iterations' ? iterations : steps)
646
+ .reduce((acc, u) => acc + (u.transportRetries ? u.transportRetries.count : 0), 0);
647
+ const transportRetriesOutsideWindows = transportRetries.length - windowedRetryCount;
648
+
649
+ // replans: `plan-executed.replans` (src/planrun.js:2688) when present;
650
+ // an older archived spine can predate that field (measured — see file
651
+ // header), so this falls back to counting `materials` records stamped
652
+ // `phase:'replan'` (src/planrun.js:2520, one per replan draft) rather than
653
+ // guessing 0. Both are real counts of a discrete recorded event, never
654
+ // "unknown" — a run that never replanned really did replan 0 times.
655
+ const planExecutedRecords = spine.filter((e) => e.type === 'plan-executed');
656
+ const lastPlanExecuted = planExecutedRecords.length ? planExecutedRecords[planExecutedRecords.length - 1] : null;
657
+ const replans = lastPlanExecuted && typeof lastPlanExecuted.replans === 'number'
658
+ ? lastPlanExecuted.replans
659
+ : spine.filter((e) => e.type === 'materials' && e.phase === 'replan').length;
660
+
661
+ const close = resolveClose(spine);
662
+
663
+ const memoryCacheRecords = spine.filter((e) => e.type === 'memory-cache');
664
+ const memoryCache = memoryCacheRecords.length ? memoryCacheRecords[memoryCacheRecords.length - 1] : null;
665
+
666
+ const last = spine.length ? spine[spine.length - 1] : null;
667
+ const before = spine.length > 1 ? spine.slice(Math.max(0, spine.length - 4), spine.length - 1) : [];
668
+ const shown = new Set([last, ...before]);
669
+ const escalationOutsideWindow = lastEscalation && !shown.has(lastEscalation) ? lastEscalation : null;
670
+
671
+ return {
672
+ runId,
673
+ job: jobStart?.job ?? null,
674
+ // F117 (PRD TODO #20, landed): `verdictType` (src/run.js:303's job-start
675
+ // emit) — `null` on any spine older than that landing (measured:
676
+ // u-msdsmkid, an Aug-3 archived run, predates the field). `model` is
677
+ // narrower still: only present when the shell-owned provider binding
678
+ // itself carried a `.model` string at job-start time (src/run.js's own
679
+ // comment there) — a native/clipipe call path can legitimately have
680
+ // neither, and this reader reports that honestly rather than guessing.
681
+ verdictType: typeof jobStart?.verdictType === 'string' ? jobStart.verdictType : null,
682
+ model: typeof jobStart?.model === 'string' ? jobStart.model : null,
683
+ // F118 (parked-half landed): `job-start.code` — absent on any spine older
684
+ // than that landing (this run predates the field entirely, distinct from
685
+ // `sha` being null because the run happened to run from an npm install
686
+ // with no `.git`). `code: null` here means "not recorded (pre-F118
687
+ // spine)"; `code: {version, sha}` with `sha: null` means "recorded, but
688
+ // this process had no `.git` to read a sha from" — the two are never
689
+ // conflated.
690
+ code: jobStart && jobStart.code && typeof jobStart.code === 'object'
691
+ ? {
692
+ version: typeof jobStart.code.version === 'string' ? jobStart.code.version : null,
693
+ sha: typeof jobStart.code.sha === 'string' ? jobStart.code.sha : null,
694
+ }
695
+ : null,
696
+ goal: typeof jobStart?.goal === 'string' ? jobStart.goal : null,
697
+ // the RAW last escalation record (or null) — the source `stopReason` was
698
+ // built from, exposed directly rather than only reachable through
699
+ // `ending` (which carries it conditionally, only when it falls OUTSIDE
700
+ // the 3-before display window — a display concern, not "did an
701
+ // escalation happen at all"). `summarizeForAllLine`'s `reason` column
702
+ // needs the latter question answered unconditionally.
703
+ lastEscalation,
704
+ budgetUsd: jobStart && typeof jobStart.budgetUsd === 'number' && Number.isFinite(jobStart.budgetUsd) ? jobStart.budgetUsd : null,
705
+ specHash: typeof jobStart?.specHash === 'string' ? jobStart.specHash : null,
706
+ branch,
707
+ outcome,
708
+ stopReason,
709
+ spentUsd,
710
+ spendComplete,
711
+ floorReasons: floorReasonList,
712
+ transportRetryCount: transportRetries.length,
713
+ transportRetriesOutsideWindows,
714
+ wallMs,
715
+ chainClock,
716
+ resumed,
717
+ resumeSeed,
718
+ thisFileSpend,
719
+ // spendMismatch: a NON-resumed run's own `job-end.spentUsd` IS this
720
+ // file's whole spend (no fold to explain a gap) — so on a cold run,
721
+ // `spentUsd` and `thisFileSpend.value` disagreeing by more than a cent's
722
+ // rounding noise is a REAL finding, not an expected honest split, and is
723
+ // never hidden. `null` when there is nothing to compare (a resumed run,
724
+ // where a gap is expected and already explained; or `thisFileSpend`
725
+ // itself unknown) or when the two already agree within $0.0001.
726
+ spendMismatch: (!resumed && spentUsd !== null && thisFileSpend.value !== null && Math.abs(spentUsd - thisFileSpend.value) > 0.0001)
727
+ ? { spentUsd, thisFileUsd: thisFileSpend.value, diffUsd: spentUsd - thisFileSpend.value }
728
+ : null,
729
+ timelineKind,
730
+ steps,
731
+ iterations,
732
+ replans,
733
+ close,
734
+ ending: { record: last, before, escalation: escalationOutsideWindow },
735
+ behaviour: runBehaviour(audit),
736
+ memoryCache,
737
+ skipped,
738
+ };
739
+ }
740
+
741
+ /** @param {number|null} n 4-decimal money — spend/cost figures. */
742
+ function money(n) {
743
+ return n === null ? 'unknown' : `$${n.toFixed(4)}`;
744
+ }
745
+
746
+ /** @param {number|null} n 2-decimal money — a signed BUDGET figure, never a spend one. */
747
+ function money2(n) {
748
+ return n === null ? 'unknown' : `$${n.toFixed(2)}`;
749
+ }
750
+
751
+ /**
752
+ * The `$`/`spend` column for one occurrence/iteration row: `unpricedRounds >
753
+ * 0` reads `unknown (N unpriced rounds)` — never `$0`, never a partial sum
754
+ * stamped as exact (F6's rule, in a row's coat). Zero rounds is a real
755
+ * `$0.0000`.
756
+ * @param {{spentUsd: number|null, unpricedRounds: number}} row
757
+ */
758
+ function rowMoney(row) {
759
+ return row.unpricedRounds > 0
760
+ ? `unknown (${row.unpricedRounds} unpriced round${row.unpricedRounds === 1 ? '' : 's'})`
761
+ : money(row.spentUsd);
762
+ }
763
+
764
+ /** @param {number|null} ms */
765
+ function duration(ms) {
766
+ if (ms === null) return 'unknown';
767
+ if (ms < 0) return 'unknown'; // a malformed/out-of-order ts pair — never render a negative time
768
+ const s = ms / 1000;
769
+ if (s < 60) return `${s.toFixed(1)}s`;
770
+ let m = Math.floor(s / 60);
771
+ let rem = Math.round(s - m * 60);
772
+ if (rem === 60) { m += 1; rem = 0; } // a remainder that rounds up to a full minute carries, never prints "Xm60s"
773
+ return `${m}m${String(rem).padStart(2, '0')}s`;
774
+ }
775
+
776
+ /** 1 → '1st', 2 → '2nd', 3 → '3rd', 4 → '4th', 11/12/13 → '11th'/'12th'/'13th', … */
777
+ function ordinal(n) {
778
+ const v = n % 100;
779
+ if (v >= 11 && v <= 13) return `${n}th`;
780
+ switch (n % 10) {
781
+ case 1: return `${n}st`;
782
+ case 2: return `${n}nd`;
783
+ case 3: return `${n}rd`;
784
+ default: return `${n}th`;
785
+ }
786
+ }
787
+
788
+ /** GREEN/RED/UNKNOWN — visible outcome word for a step row, an iteration
789
+ * row, or the run's own outcome. `satisfied`/`green`/`already-green` are the
790
+ * only green-equivalent values across every source this reads (step-end
791
+ * outcome, close-verdict verdict, run-end outcome); everything else non-null
792
+ * is RED; `null` (nothing recorded yet) is UNKNOWN.
793
+ * @param {string|null} v
794
+ */
795
+ function resultWord(v) {
796
+ if (v === null) return 'UNKNOWN';
797
+ return (v === 'green' || v === 'already-green' || v === 'satisfied') ? 'GREEN' : 'RED';
798
+ }
799
+
800
+ /**
801
+ * The `↳ tripped:`/`↳ close:` line under one row, or `null` when neither applies.
802
+ * @param {{category: string|null, detail: string|null}|null} tripped
803
+ */
804
+ function trippedLine(tripped) {
805
+ if (!tripped) return null;
806
+ const bits = [tripped.category, tripped.detail].filter((p) => typeof p === 'string' && p.length > 0);
807
+ return bits.length ? `tripped: ${bits.join(' — ')}` : null;
808
+ }
809
+
810
+ /**
811
+ * `<verdict>[ — <failing stage names>]` for an iteration's OWN close-verdict
812
+ * (`iterations[].closeStage`) — distinct from the top-level CLOSE section,
813
+ * which reports the run's FINAL close.
814
+ * @param {{verdict: string|null, stages: any[]|null}|null} closeStage
815
+ */
816
+ function closeStageLine(closeStage) {
817
+ if (!closeStage || !closeStage.verdict) return null;
818
+ const failing = Array.isArray(closeStage.stages) ? closeStage.stages.filter((s) => s.verdict !== 'satisfied').map((s) => s.name) : [];
819
+ return `close: ${closeStage.verdict}${failing.length ? ` — ${failing.join(', ')}` : ''}`;
820
+ }
821
+
822
+ /**
823
+ * The `↳ transport retry ×N (…)` line under one step/iteration row (F119),
824
+ * or `null` when no retry fell inside that occurrence's window.
825
+ * @param {{count: number, label: string, error: string|null}|null} tr
826
+ */
827
+ function transportRetryLine(tr) {
828
+ if (!tr) return null;
829
+ return `transport retry ×${tr.count} (${tr.label}) — ${tr.error ?? 'no error text recorded'}`;
830
+ }
831
+
832
+ /**
833
+ * Render one close's stages, red stage FIRST (marked `✗`), the rest `name
834
+ * OK` in their original order. There is at most one non-satisfied stage in
835
+ * any one `stages` array (`runStages` stops at the first red — src/ralph.js's
836
+ * `runStages`), so "first" here is really "the only one, moved to the front".
837
+ * @param {any[]|null} stages
838
+ * @returns {string|null}
839
+ */
840
+ function renderStages(stages) {
841
+ if (!Array.isArray(stages) || stages.length === 0) return null;
842
+ const redIdx = stages.findIndex((s) => s.verdict !== 'satisfied');
843
+ const ordered = redIdx === -1 ? stages : [stages[redIdx], ...stages.filter((_, i) => i !== redIdx)];
844
+ return ordered.map((s) => (s.verdict !== 'satisfied' ? `✗ ${s.name} FAIL(${s.verdict})` : `${s.name} OK`)).join(' · ');
845
+ }
846
+
847
+ /**
848
+ * The CLOSE section body (one line, or the `none` fallback — never omitted).
849
+ * @param {ReturnType<typeof resolveClose>} close
850
+ */
851
+ function closeLine(close) {
852
+ if (!close || !close.verdict) return 'none — the run ended before any close ran';
853
+ const prefix = close.iteration !== null ? `iteration ${close.iteration} · ` : '';
854
+ const stagesText = renderStages(close.stages);
855
+ const stagesPart = stagesText ? ` · ${/** @type {any[]} */ (close.stages).length} stages: ${stagesText}` : '';
856
+ return `${prefix}${close.verdict}${stagesPart}`;
857
+ }
858
+
859
+ /**
860
+ * Left-pad a table: given rows (arrays of cell strings) and a header row,
861
+ * compute per-column widths across header + rows and return every row
862
+ * (header included) padEnd-joined with a 2-space gap. The LAST column is
863
+ * never padded (a trailing pad on free text like `reason`/`goal` is
864
+ * pointless whitespace).
865
+ * @param {string[]} header
866
+ * @param {string[][]} rows
867
+ * @returns {string[]} formatted lines, header first
868
+ */
869
+ function alignTable(header, rows) {
870
+ const nCols = header.length;
871
+ const widths = Array.from({ length: nCols }, (_, i) => Math.max(header[i].length, ...rows.map((r) => (r[i] ?? '').length)));
872
+ const render = (/** @type {string[]} */ cells) => cells.map((c, i) => (i === nCols - 1 ? c : c.padEnd(widths[i]))).join(' ');
873
+ return [render(header), ...rows.map(render)];
874
+ }
875
+
876
+ /**
877
+ * Render a {@link replayRun} summary as one printable page: a header block
878
+ * (goal/shape/signed/branch/outcome/spent), a timeline (steps or iterations,
879
+ * whichever the spine has), the CLOSE section (never omitted), the ending
880
+ * (last record + 3 before it, plus an out-of-window escalation), the
881
+ * behaviour block, and the MEMORY-CACHE line (always printed — `not armed on
882
+ * this run` when there is no record, never silently absent).
883
+ * @param {ReturnType<typeof replayRun>} summary
884
+ * @returns {string}
885
+ */
886
+ export function formatReplay(summary) {
887
+ const lines = [];
888
+ lines.push(`RUN ${summary.runId ?? '(unknown id)'} · ${summary.job ?? NONE_RECORDED}`);
889
+
890
+ lines.push(`goal: ${hardTrim(summary.goal, GOAL_MAX) ?? NONE_RECORDED}`);
891
+
892
+ const shapeLabel = summary.timelineKind === 'iterations' ? 'loop' : 'plan';
893
+ const shapeCount = summary.timelineKind === 'iterations'
894
+ ? `${summary.iterations.length} iteration${summary.iterations.length === 1 ? '' : 's'}`
895
+ : `${summary.steps.length} step${summary.steps.length === 1 ? '' : 's'}, ${summary.replans} replan${summary.replans === 1 ? '' : 's'}`;
896
+ lines.push(`shape: ${shapeLabel} · ${shapeCount}`);
897
+
898
+ // F117 (PRD TODO #20): a spine older than the field lands reads
899
+ // "not recorded (pre-F117 spine)" — distinct wording from `model`'s bare
900
+ // "not recorded", because a missing `verdictType` is always an archive-age
901
+ // gap (the field is REQUIRED on every job — src/job.js:552), while a
902
+ // missing `model` can happen on ANY spine, old or new, whenever the
903
+ // provider binding itself carries none (a native/clipipe call path).
904
+ lines.push(`class: ${summary.verdictType ?? 'not recorded (pre-F117 spine)'}`);
905
+ lines.push(`model: ${summary.model ?? 'not recorded'}`);
906
+
907
+ // F118: the run→code direction (the commit→run half landed same day as
908
+ // the prompt-commit check). `code` absent entirely = pre-F118 spine; a
909
+ // present `code` with `sha: null` is a real, honest reading (an
910
+ // npm-installed copy with no `.git` at run time), never conflated with
911
+ // "not recorded".
912
+ if (summary.code) {
913
+ const versionPart = summary.code.version ?? 'unknown version';
914
+ const shaPart = shortSha(summary.code.sha) ?? 'sha unknown';
915
+ lines.push(`code: bareloop ${versionPart} @ ${shaPart}`);
916
+ } else {
917
+ lines.push('code: not recorded (pre-F118 spine)');
918
+ }
919
+
920
+ const signedWall = summary.chainClock && typeof summary.chainClock.requestedMs === 'number' ? duration(summary.chainClock.requestedMs) : NONE_RECORDED;
921
+ const specShort = summary.specHash ? summary.specHash.slice(0, 8) : NONE_RECORDED;
922
+ lines.push(`signed: ${money2(summary.budgetUsd)} budget · ${signedWall} wall · spec ${specShort}`);
923
+
924
+ lines.push(`branch: ${summary.branch ?? NONE_RECORDED}`);
925
+
926
+ // resumed: read off `job-start.priorSpentUsd` (the general fold signal —
927
+ // see `replayRun`'s own comment), never off `resume-seed` alone (a
928
+ // narrower record: measured absent on real resumed archived runs).
929
+ // `resumeSeed`'s detail (phase/completed/skipping/divergence) rides along
930
+ // when it exists; a resumed leg with no resume-seed record still reads
931
+ // `resumed: yes`, honestly, just without that extra clause.
932
+ if (summary.resumed) {
933
+ if (summary.resumeSeed) {
934
+ const { phase, completed, skipping, divergence } = summary.resumeSeed;
935
+ const counts = completed !== null && skipping !== null ? `${skipping} of ${completed} completed step(s) skipped` : 'no step count recorded';
936
+ lines.push(`resumed: yes — phase ${phase ?? 'unknown'}, ${counts}${divergence ? `, divergence: ${divergence}` : ''}`);
937
+ } else {
938
+ lines.push('resumed: yes (no resume-seed detail recorded)');
939
+ }
940
+ } else {
941
+ lines.push('resumed: no');
942
+ }
943
+
944
+ lines.push(`outcome: ${summary.outcome ?? 'unknown'}${summary.stopReason ? ` — ${summary.stopReason}` : ''}`);
945
+
946
+ // `wall:` is explicitly labeled "this file" — job-start→job-end of the ONE
947
+ // spine handed in, which on a RESUMED leg is only the latest engagement,
948
+ // not the whole signed chain. `clock:` is the other true number: chain-
949
+ // scoped by construction (src/clock.js's `priorElapsedMs` fold) — never
950
+ // one printed as if it were the other. `spent:` carries the SAME split for
951
+ // money: a resumed run's `job-end.spentUsd` is the CHAIN total (prior legs
952
+ // folded in), which can never equal this file's own rounds, so both figures
953
+ // print, each labeled with its source — never one silently standing in for
954
+ // the other (measured: u-msf70nei's header used to read $5.3389 while its
955
+ // own 55 rounds sum to $1.2127).
956
+ const tf = summary.thisFileSpend;
957
+ const thisFileText = tf.unpricedRounds > 0
958
+ ? `this file unknown (${tf.unpricedRounds} of ${tf.totalRounds} rounds unpriced; ${tf.source})`
959
+ : `this file ${money(tf.value)} (${tf.totalRounds} round${tf.totalRounds === 1 ? '' : 's'}, all priced; ${tf.source})`;
960
+ let spentLine;
961
+ if (summary.resumed) {
962
+ spentLine = `spent: ${money(summary.spentUsd)} chain total (job-end; prior legs folded) · ${thisFileText} · of ${money2(summary.budgetUsd)} signed`;
963
+ } else {
964
+ spentLine = `spent: ${money(summary.spentUsd)}${summary.spentUsd !== null && !summary.spendComplete ? ' (floor, not exact)' : ''} of ${money2(summary.budgetUsd)} signed`;
965
+ // a NON-resumed run has nothing to fold — `job-end.spentUsd` IS this
966
+ // file's whole spend, so a real disagreement here is a FINDING, printed,
967
+ // never hidden (measured: 0 of 47 non-resumed archived runs trip this).
968
+ if (summary.spendMismatch) {
969
+ spentLine += ` · ${thisFileText} — MISMATCH vs job-end (diff $${summary.spendMismatch.diffUsd.toFixed(4)}), never hidden`;
970
+ }
971
+ }
972
+ // F119: WHY a `spendComplete:false` figure is a floor, derived from this
973
+ // spine's own records (never guessed) — see {@link floorReasons}. A floor
974
+ // with no derivable evidence (an older archive, or a resumed leg's floor
975
+ // inherited from a file not in hand) prints the honest fallback, never a
976
+ // blank or a fabricated single cause.
977
+ if (!summary.spendComplete) {
978
+ spentLine += summary.floorReasons.length
979
+ ? ` · floor because: ${summary.floorReasons.join(', ')}`
980
+ : ' · floor (reason not in spine)';
981
+ }
982
+ spentLine += ` · wall ${duration(summary.wallMs)} (this file)`;
983
+ if (summary.chainClock) {
984
+ const { elapsedMs, requestedMs, source } = summary.chainClock;
985
+ const signed = typeof requestedMs === 'number' ? `${duration(requestedMs)} signed` : 'no wall signed';
986
+ // `wall-halt` is a TERMINAL reading — elapsedMs was taken at the moment the
987
+ // wall actually stopped THIS run, so "X of Y signed" is an honest end-of-run
988
+ // figure. `wall-clock` fires once, at the TOP of the engagement, before this
989
+ // run did anything — its elapsedMs is whatever chain time a resumed leg
990
+ // already inherited, not this run's own duration. Printing it as "0.0s of
991
+ // 30m00s signed" reads as "this run took 0 seconds" — the unknown/partial-
992
+ // rendered-as-zero honesty class this reader exists to refuse — so the
993
+ // wall-clock line names what the number IS instead of implying an
994
+ // end-of-run total that was never taken.
995
+ const clockLine = source === 'wall-halt'
996
+ ? `clock: ${duration(elapsedMs)} of ${signed} (chain, from wall-halt)`
997
+ : `clock: ${signed} · ${duration(elapsedMs)} inherited from prior legs at start (from wall-clock; no end-of-run reading in this file)`;
998
+ spentLine += ` · ${clockLine}`;
999
+ }
1000
+ lines.push(spentLine);
1001
+
1002
+ lines.push('');
1003
+ if (summary.timelineKind === 'iterations') {
1004
+ lines.push('TIMELINE (iterations — loop-shape run)');
1005
+ // F119: retries whose seq fell outside every iteration window (scout/plan
1006
+ // phases the iterations timeline doesn't cover) — printed only when there
1007
+ // ARE any; a run with every retry accounted for inside a row has nothing
1008
+ // extra to say here.
1009
+ if (summary.transportRetriesOutsideWindows > 0) {
1010
+ lines.push(` transport retries outside steps: ${summary.transportRetriesOutsideWindows}`);
1011
+ }
1012
+ if (summary.iterations.length === 0) {
1013
+ lines.push(' (no iterations reached)');
1014
+ } else {
1015
+ const header = ['#', 'iteration', 'result', 'rounds', 'tools', 'wall', 'spend'];
1016
+ const rows = summary.iterations.map((it, i) => [
1017
+ String(i + 1),
1018
+ `iteration ${it.iteration ?? '?'}`,
1019
+ resultWord(it.verdict),
1020
+ `${it.rounds} round${it.rounds === 1 ? '' : 's'}`,
1021
+ `${it.toolCalls} tool${it.toolCalls === 1 ? '' : 's'}`,
1022
+ duration(it.wallMs),
1023
+ rowMoney(it),
1024
+ ]);
1025
+ const rendered = alignTable(header, rows);
1026
+ const indent = ' '.repeat(String(summary.iterations.length).length + 2 + 2);
1027
+ for (let i = 0; i < rendered.length; i += 1) {
1028
+ lines.push(` ${rendered[i]}`);
1029
+ if (i === 0) continue; // header row carries no tripped/close note
1030
+ const it = summary.iterations[i - 1];
1031
+ const note = it.boundary === 'close-verdict' ? closeStageLine(it.closeStage) : trippedLine(it.tripped);
1032
+ if (note) lines.push(`${indent}↳ ${note}`);
1033
+ const retryNote = transportRetryLine(it.transportRetries);
1034
+ if (retryNote) lines.push(`${indent}↳ ${retryNote}`);
1035
+ }
1036
+ }
1037
+ } else {
1038
+ lines.push('TIMELINE (steps)');
1039
+ if (summary.transportRetriesOutsideWindows > 0) {
1040
+ lines.push(` transport retries outside steps: ${summary.transportRetriesOutsideWindows}`);
1041
+ }
1042
+ if (summary.steps.length === 0) {
1043
+ lines.push(' (no steps reached)');
1044
+ } else {
1045
+ const header = ['#', 'step', 'result', 'rounds', 'tools', 'checks', 'tree', 'wall', 'spend'];
1046
+ const rows = summary.steps.map((s, i) => [
1047
+ String(i + 1),
1048
+ `${s.id}${s.occurrence > 1 ? ` (${ordinal(s.occurrence)})` : ''}`,
1049
+ resultWord(s.outcome),
1050
+ `${s.rounds} round${s.rounds === 1 ? '' : 's'}`,
1051
+ `${s.toolCalls} tool${s.toolCalls === 1 ? '' : 's'}`,
1052
+ `${s.checks.passed}/${s.checks.failed}`,
1053
+ s.treeChanged ? 'changed' : 'unchanged',
1054
+ duration(s.wallMs),
1055
+ rowMoney(s),
1056
+ ]);
1057
+ const rendered = alignTable(header, rows);
1058
+ const indent = ' '.repeat(String(summary.steps.length).length + 2 + 2);
1059
+ for (let i = 0; i < rendered.length; i += 1) {
1060
+ lines.push(` ${rendered[i]}`);
1061
+ if (i === 0) continue;
1062
+ const note = trippedLine(summary.steps[i - 1].tripped);
1063
+ if (note) lines.push(`${indent}↳ ${note}`);
1064
+ const retryNote = transportRetryLine(summary.steps[i - 1].transportRetries);
1065
+ if (retryNote) lines.push(`${indent}↳ ${retryNote}`);
1066
+ }
1067
+ }
1068
+ }
1069
+
1070
+ lines.push('');
1071
+ lines.push('CLOSE');
1072
+ lines.push(` ${closeLine(summary.close)}`);
1073
+
1074
+ lines.push('');
1075
+ lines.push('ENDING');
1076
+ if (summary.ending.escalation) {
1077
+ lines.push(` ! last escalation (seq ${summary.ending.escalation.seq ?? '?'}, outside the 3-before window): ${JSON.stringify(summary.ending.escalation)}`);
1078
+ }
1079
+ for (const r of summary.ending.before) lines.push(` ${JSON.stringify(r)}`);
1080
+ if (summary.ending.record) lines.push(`> ${JSON.stringify(summary.ending.record)}`);
1081
+ else lines.push(' (empty spine)');
1082
+
1083
+ lines.push('');
1084
+ lines.push('BEHAVIOUR');
1085
+ lines.push(formatBehaviour(summary.behaviour));
1086
+ // ALWAYS printed — an absent record must never read as silence (the same
1087
+ // rule as the CLOSE section's `none` fallback above).
1088
+ if (summary.memoryCache) {
1089
+ const mc = summary.memoryCache;
1090
+ const kb = (mc.bytesWithheld / 1024).toFixed(1);
1091
+ const kTokens = (mc.approxTokens / 1000).toFixed(1);
1092
+ lines.push(`MEMORY-CACHE ${mc.pointered} re-reads answered from memory · ${mc.capped} reads capped · ${kb} KB withheld (~${kTokens}k tokens not re-sent)`);
1093
+ } else {
1094
+ lines.push('MEMORY-CACHE not armed on this run');
1095
+ }
1096
+ if (summary.skipped > 0) lines.push(`\n(${summary.skipped} malformed line${summary.skipped === 1 ? '' : 's'} skipped)`);
1097
+ return lines.join('\n');
1098
+ }
1099
+
1100
+ /**
1101
+ * One `--all` row's raw field strings for one real spine:
1102
+ * `id job shape outcome spend wall steps reason`. `reason` is
1103
+ * `category — <first clause of detail, cut at 60 chars>` off the last
1104
+ * escalation — never the generic `decision` prose. `-` on a green row;
1105
+ * `no job-end (killed mid-run)` when the spine never reached a `job-end` at
1106
+ * all (outcome itself is `null`) — a distinct, named case, not folded into
1107
+ * the ordinary escalation reason.
1108
+ * @param {ReturnType<typeof replayRun>} summary
1109
+ * @returns {{id: string, resumed: boolean, job: string, shape: string, class: string, model: string, outcome: string, hadTransportRetries: boolean, spend: string, wall: string, steps: string, reason: string}}
1110
+ */
1111
+ export function summarizeForAllLine(summary) {
1112
+ const isGreen = summary.outcome === 'green' || summary.outcome === 'already-green';
1113
+ const shape = summary.timelineKind === 'iterations' ? 'loop' : 'plan';
1114
+ const units = summary.timelineKind === 'iterations' ? summary.iterations : summary.steps;
1115
+ const stepsCol = summary.timelineKind === 'iterations' ? `${units.length} it` : `${units.length}`;
1116
+
1117
+ let reason = '-';
1118
+ if (summary.outcome === null) {
1119
+ reason = 'no job-end (killed mid-run)';
1120
+ } else if (!isGreen && summary.lastEscalation) {
1121
+ // Re-derive `category`/`detail` from the escalation itself rather than
1122
+ // re-parsing the already-composed `stopReason` string, which may have
1123
+ // DROPPED the category when it duplicated the outcome (`buildStopReason`)
1124
+ // — this compact row has no adjacent "outcome:" line to lean on the way
1125
+ // the full report's header does, so the category is always shown here.
1126
+ // `detail` ONLY — never `decision` (the coordinator's own ruling for
1127
+ // this column specifically: `decision` is the generic per-category
1128
+ // sentence every run in that category shares, and this row has no room
1129
+ // to show both). A `cap-halt` whose escalation carries no `detail` at
1130
+ // all (ralph.js's ladder-exhaustion site) prints the bare category —
1131
+ // still real information, just without a clause. Contrast the full
1132
+ // report's `↳ tripped:` line ({@link tripOf}), which DOES fall back to
1133
+ // `decision` there, because that view has the room and hamr's own
1134
+ // signed illustration showed it doing so.
1135
+ const esc = summary.lastEscalation;
1136
+ const category = esc.category ?? null;
1137
+ const clause = hardTrim(esc.detail, ALL_REASON_MAX);
1138
+ reason = [category, clause].filter((p) => typeof p === 'string' && p.length > 0).join(' — ') || '-';
1139
+ }
1140
+
1141
+ return {
1142
+ // `*` marks a resumed row directly on the id — `--all`'s spend column
1143
+ // stays the CHAIN total (that is what a reader scanning the whole
1144
+ // directory asks first), but a chain total on a resumed row is a
1145
+ // DIFFERENT claim than a cold run's spend, and the asterisk plus
1146
+ // {@link formatAllLines}'s footnote say so rather than let the two kinds
1147
+ // of number sit in one column looking identical.
1148
+ id: `${summary.runId ?? '(unknown)'}${summary.resumed ? '*' : ''}`,
1149
+ resumed: summary.resumed === true,
1150
+ job: summary.job ?? NONE_RECORDED,
1151
+ shape,
1152
+ // `-` (never `NONE_RECORDED`'s longer prose) — this is a compact table
1153
+ // column, same posture as `reason`'s own `-` for a green row.
1154
+ class: summary.verdictType ?? '-',
1155
+ // model strings (`claude-sonnet-5`) are kept FULL, never truncated — a
1156
+ // wrong/uncertain model reading is exactly the kind of thing a reader
1157
+ // scanning the whole directory needs to see in full, not guess at from
1158
+ // a cut string.
1159
+ model: summary.model ?? '-',
1160
+ // F119: no new column — the retry count rides on the outcome word itself,
1161
+ // `⟲N`, the same posture as the `*` resumed marker riding on `id`. Never
1162
+ // shown on a run with zero retries.
1163
+ outcome: `${summary.outcome ?? 'unknown'}${summary.transportRetryCount > 0 ? ` ⟲${summary.transportRetryCount}` : ''}`,
1164
+ hadTransportRetries: summary.transportRetryCount > 0,
1165
+ spend: money(summary.spentUsd),
1166
+ wall: duration(summary.wallMs),
1167
+ steps: stepsCol,
1168
+ reason,
1169
+ };
1170
+ }
1171
+
1172
+ /**
1173
+ * Render every row {@link summarizeForAllLine} built for one directory's
1174
+ * spines as a fixed-column, aligned table WITH a header row (plus any
1175
+ * `<name> not-a-spine` lines interleaved at their original position — those
1176
+ * are not part of the aligned columns, since a filename carries no outcome/
1177
+ * spend/wall of its own). Never CSV: columns are padded, not delimited.
1178
+ * @param {Array<{kind: 'spine', row: ReturnType<typeof summarizeForAllLine>}|{kind: 'not-a-spine', name: string}>} entries
1179
+ * @returns {string}
1180
+ */
1181
+ export function formatAllLines(entries) {
1182
+ const header = ['id', 'job', 'shape', 'class', 'model', 'outcome', 'spend', 'wall', 'steps', 'reason'];
1183
+ const spineEntries = entries.filter((e) => e.kind === 'spine');
1184
+ const rows = spineEntries.map((e) => {
1185
+ const r = /** @type {any} */ (e).row;
1186
+ return [r.id, r.job, r.shape, r.class, r.model, r.outcome, r.spend, r.wall, r.steps, r.reason];
1187
+ });
1188
+ // No header at all when there is nothing tabular to head — a directory of
1189
+ // ONLY `not-a-spine` files gets its plain filename lines with no bare
1190
+ // column header floating above them.
1191
+ if (rows.length === 0) return entries.map((e) => `${/** @type {any} */ (e).name} not-a-spine`).join('\n');
1192
+
1193
+ const rendered = alignTable(header, rows);
1194
+ // Re-interleave: `alignTable` only saw the spine rows, so walk the ORIGINAL
1195
+ // entry order and pull each formatted spine line back into its real
1196
+ // position among the `not-a-spine` lines (rendered separately, not padded
1197
+ // into the same columns — a filename carries no outcome/spend/wall).
1198
+ const out = [rendered[0]]; // header
1199
+ let spineIdx = 1;
1200
+ for (const e of entries) {
1201
+ if (e.kind === 'not-a-spine') out.push(`${e.name} not-a-spine`);
1202
+ else { out.push(rendered[spineIdx]); spineIdx += 1; }
1203
+ }
1204
+ if (spineEntries.some((e) => /** @type {any} */ (e).row.resumed)) {
1205
+ out.push('* resumed run — spend is the chain total, see detail');
1206
+ }
1207
+ // F119: same footnote posture as the resumed `*` above.
1208
+ if (spineEntries.some((e) => /** @type {any} */ (e).row.hadTransportRetries)) {
1209
+ out.push('⟲N transport retry(ies), recovered inline — see the full replay for detail');
1210
+ }
1211
+ return out.join('\n');
1212
+ }