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/CHANGELOG.md +143 -0
- package/bareloop.context.md +312 -0
- package/package.json +2 -2
- package/src/codeversion.js +155 -0
- package/src/index.js +12 -0
- package/src/ledger.js +1 -1
- package/src/promptregisters.js +93 -0
- package/src/replay.js +1212 -0
- package/src/run.js +14 -0
- package/types/codeversion.d.ts +18 -0
- package/types/index.d.ts +2 -0
- package/types/ledger.d.ts +14 -0
- package/types/promptregisters.d.ts +31 -0
- package/types/replay.d.ts +195 -0
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
|
+
}
|