bareloop 0.28.0 → 0.30.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 CHANGED
@@ -128,6 +128,45 @@ function parseTs(ts) {
128
128
  return Number.isFinite(n) ? n : null;
129
129
  }
130
130
 
131
+ /**
132
+ * `[startTs, endTs]` (epoch ms; `endTs` possibly `Infinity`) bounding the
133
+ * gate-audit rows that actually belong to ONE run's own spine. A sidecar
134
+ * file can be SHARED across several runs whose spines happen to reuse the
135
+ * same filename (measured directly on a real archived patient —
136
+ * pulselog-person-live-2, run mu2p83go: its sidecar carries rows from 7
137
+ * distinct `run_id`s spanning 06:56Z..13:25Z on one day, but this run's own
138
+ * `job-start`..`job-end` window is 13:19:40Z..13:25:48Z; every row before
139
+ * that window belongs to an earlier, unrelated run sharing the sidecar
140
+ * path). No `step`/`run_id` field on a gate-audit row is a reliable enough
141
+ * key on its own (see this file's header) — bounding by `ts` against this
142
+ * run's own `job-start`/`job-end` is the only honest scope.
143
+ *
144
+ * `startTs` is this run's own `job-start.ts` (`-Infinity` when absent —
145
+ * never a guessed lower bound). `endTs` is `job-end.ts` when a `job-end`
146
+ * was actually reached, else `Infinity` (a died/still-running run — never
147
+ * clamped to the spine's own last record's `ts`, which would wrongly
148
+ * exclude a real tool call the gate logged just AFTER the spine's own last
149
+ * record landed: spine and gate-audit are written by different seams and
150
+ * are not guaranteed to interleave exactly).
151
+ *
152
+ * ONE shared owner for this rule (F195): `replayRun` (this file) uses it to
153
+ * scope every audit row it hands to `runBehaviour`, so `bareloop replay`
154
+ * and every caller of `replayRun`/`replayOne` (including the panel,
155
+ * `src/panel/server.js`) read the same, honest figure — never two places
156
+ * independently re-deriving (and risking drifting from) the same rule.
157
+ * @param {{ts?: string}|null} [jobStart]
158
+ * @param {{ts?: string}|null} [jobEnd]
159
+ * @returns {{startTs: number, endTs: number}}
160
+ */
161
+ export function auditWindow(jobStart, jobEnd) {
162
+ const startTs = jobStart ? parseTs(jobStart.ts) : null;
163
+ const endTs = jobEnd ? parseTs(jobEnd.ts) : null;
164
+ return {
165
+ startTs: startTs !== null ? startTs : -Infinity,
166
+ endTs: endTs !== null ? endTs : Infinity,
167
+ };
168
+ }
169
+
131
170
  /**
132
171
  * Trim a string to `max` chars with an explicit `…[+N chars]` marker —
133
172
  * NEVER a silent truncation, and never a trailing ellipsis alone
@@ -356,10 +395,18 @@ function resolveClose(spine) {
356
395
  *
357
396
  * @param {any[]} spineEvents parsed records from `u-<id>.jsonl`
358
397
  * @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)
398
+ * @param {{runId?: string|null, auditAvailable?: boolean}} [opts] `runId`:
399
+ * caller-supplied (the spine carries no run-id field of its own — see the
400
+ * file header). `auditAvailable` (default `true`, back-compat: every
401
+ * direct caller in this codebase that hands a literal `[]` here means a
402
+ * REAL, known-empty audit, not "unknown") — set `false` only when the
403
+ * caller could not even determine whether a gate-audit sidecar exists (no
404
+ * file found on disk, or deliberately not read). When `false`, every
405
+ * tool-call figure this function derives (`behaviour`, each occurrence's
406
+ * `toolCalls`) reads `null` (unknown), never `0` — doctrine: unknown is
407
+ * reported as unknown, never rendered as zero.
361
408
  */
362
- export function replayRun(spineEvents, auditEvents = [], { runId = null } = {}) {
409
+ export function replayRun(spineEvents, auditEvents = [], { runId = null, auditAvailable = true } = {}) {
363
410
  let skipped = 0;
364
411
 
365
412
  /** @type {any[]} */
@@ -369,7 +416,7 @@ export function replayRun(spineEvents, auditEvents = [], { runId = null } = {})
369
416
  else skipped += 1;
370
417
  }
371
418
  /** @type {any[]} */
372
- const audit = [];
419
+ let audit = [];
373
420
  for (const e of Array.isArray(auditEvents) ? auditEvents : []) {
374
421
  if (isRecord(e)) audit.push(e);
375
422
  else skipped += 1;
@@ -377,6 +424,30 @@ export function replayRun(spineEvents, auditEvents = [], { runId = null } = {})
377
424
 
378
425
  const jobStart = spine.find((e) => e.type === 'job-start') ?? null;
379
426
  const jobEnd = spine.findLast((e) => e.type === 'job-end') ?? null;
427
+
428
+ // F195 fix: a gate-audit sidecar CAN be shared across several runs whose
429
+ // spines happen to reuse the same filename (measured on a real archived
430
+ // patient — pulselog-person-live-2's mu2p83go: its sidecar carries rows
431
+ // from 7 distinct run_ids spanning 06:56Z..13:25Z on one day, while this
432
+ // run's own job-start..job-end window is only 13:19:40Z..13:25:48Z).
433
+ // Scope `audit` to THIS run's own window (see {@link auditWindow}'s own
434
+ // doc) before any downstream use — the top-level `behaviour` field below
435
+ // (previously fed the raw, unscoped array: `bareloop replay` printed 142
436
+ // tool calls on mu2p83go where the real figure for this run alone is 82)
437
+ // AND every per-occurrence/per-attempt window `buildOccurrenceMetrics`
438
+ // computes further down, which is a strict subset of this run's own
439
+ // window anyway and so is unaffected in the common (single-owner sidecar)
440
+ // case. This is now the ONE place that applies the rule — the panel
441
+ // (`src/panel/server.js`) calls this same {@link auditWindow} rather than
442
+ // keeping its own copy.
443
+ {
444
+ const { startTs, endTs } = auditWindow(jobStart, jobEnd);
445
+ audit = audit.filter((a) => {
446
+ const t = parseTs(a.ts);
447
+ return t !== null && t >= startTs && t <= endTs;
448
+ });
449
+ }
450
+
380
451
  const outcome = jobEnd ? (jobEnd.outcome ?? null) : null;
381
452
  const isGreen = outcome === 'green' || outcome === 'already-green';
382
453
 
@@ -537,7 +608,7 @@ export function replayRun(spineEvents, auditEvents = [], { runId = null } = {})
537
608
  const { spentUsd, unpricedRounds } = windowSpend(roundsInWindow);
538
609
  return {
539
610
  rounds: roundsInWindow.length,
540
- toolCalls: runBehaviour(windowed).totalCalls,
611
+ toolCalls: auditAvailable ? runBehaviour(windowed).totalCalls : null,
541
612
  wallMs: windowWallMs(startTs, endTs),
542
613
  spentUsd,
543
614
  unpricedRounds,
@@ -546,6 +617,33 @@ export function replayRun(spineEvents, auditEvents = [], { runId = null } = {})
546
617
  };
547
618
  }
548
619
 
620
+ // ── SCOUT+PLAN (panel build item 1, 2026-09-26): a PLAN run's own
621
+ // pre-step drafting work (`phase:'scout'` and `phase:'plan'` worker rounds,
622
+ // src/planrun.js:2735,2515) — the OTHER half of the same invisibility
623
+ // fixLoop above closes: before this, only the plan's own steps ever showed
624
+ // on the Run tab, so a run's first ~10 rounds (drafting materials, scouting
625
+ // the repo, validating the plan) had no box of their own at all. Scoped to
626
+ // seq/ts strictly BEFORE the first step-start (the only place scout/plan
627
+ // rounds occur on a steps-shape run — measured on mu2p83go: scout×8 + plan×1
628
+ // all precede the run's one step-start at seq 34). `null` when this run has
629
+ // no steps (nothing to be "pre-" to) or no scout/plan rounds at all (an
630
+ // older archived spine, or a run resumed past drafting).
631
+ let scoutPlan = null;
632
+ if (stepStarts.length > 0) {
633
+ const firstStepStart = stepStarts.reduce((first, ss) => (typeof ss.seq === 'number' && (first === null || ss.seq < first.seq) ? ss : first), /** @type {any} */ (null));
634
+ const firstSeq = firstStepStart && typeof firstStepStart.seq === 'number' ? firstStepStart.seq : Infinity;
635
+ const firstTs = firstStepStart ? parseTs(firstStepStart.ts) : null;
636
+ const roundsInWindow = spendRecords.filter((r) => (r.phase === 'scout' || r.phase === 'plan') && typeof r.seq === 'number' && r.seq < firstSeq);
637
+ if (roundsInWindow.length > 0) {
638
+ const jobStartTs = parseTs(jobStart?.ts);
639
+ scoutPlan = {
640
+ scoutRounds: roundsInWindow.filter((r) => r.phase === 'scout').length,
641
+ planRounds: roundsInWindow.filter((r) => r.phase === 'plan').length,
642
+ ...buildOccurrenceMetrics(-Infinity, jobStartTs, firstSeq, firstTs, roundsInWindow),
643
+ };
644
+ }
645
+ }
646
+
549
647
  const usedEndSeqs = new Set();
550
648
  const idOccurrence = new Map();
551
649
 
@@ -574,14 +672,66 @@ export function replayRun(spineEvents, auditEvents = [], { runId = null } = {})
574
672
  let passed = 0;
575
673
  let failed = 0;
576
674
  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;
675
+ // `attempts` (panel item 4, 2026-09-25): one entry per exit-eval that
676
+ // actually ran inside this step's own window — an "attempt" being one
677
+ // iteration of the step's own micro-loop (worker works, then the step's
678
+ // check runs, src/planrun.js's `ralph({judge,...})`). An attempt's own
679
+ // outcome is GREEN only when EVERY result the exit-eval carries passed
680
+ // (not just `check-passes` — `tree-changed` failing is also a real
681
+ // attempt failure); an exit-eval with an EMPTY `results` array is
682
+ // reported RED, never a fabricated green (nothing passed). Ordered by
683
+ // `seq` (the array is already built in spine order, but sorted
684
+ // defensively since a caller could hand in an out-of-order array).
685
+ /** @type {Array<{n: number, iteration: number|null, outcome: 'green'|'red'}>} */
686
+ const attempts = [];
687
+ // `attemptWindows` (panel item 5, 2026-09-25): the SAME per-attempt list
688
+ // as `attempts` above, plus each attempt's own seq/ts sub-window
689
+ // (`(prevBoundarySeq, thisExitEvalSeq]`, first attempt's lower bound is
690
+ // the step's own `startSeq`/`startTs`) — never a second windowing pass:
691
+ // this is the ONE loop that decides attempt boundaries; `attempts` is
692
+ // just this array's public-facing projection. Consumed by the
693
+ // `/api/runs/:runid/rounds` endpoint (`src/panel/server.js`) to slice
694
+ // this attempt's own rounds/tool-calls out of the full spine+gate-audit,
695
+ // never recomputed a second way there either.
696
+ /** @type {Array<{n: number, iteration: number|null, outcome: 'green'|'red', startSeq: number, endSeq: number, startTs: number|null, endTs: number|null, exitEvalDetail: string|null}>} */
697
+ const attemptWindows = [];
698
+ const stepExitEvals = exitEvals
699
+ .filter((ee) => ee.step === id && typeof ee.seq === 'number' && ee.seq > startSeq && ee.seq < endSeq)
700
+ .sort((a, b) => a.seq - b.seq);
701
+ let prevBoundSeq = startSeq;
702
+ let prevBoundTs = startTs;
703
+ for (const ee of stepExitEvals) {
704
+ const results = Array.isArray(ee.results) ? ee.results.filter(isRecord) : [];
705
+ for (const r of results) {
582
706
  if (r.type === 'check-passes') { if (r.pass) passed += 1; else failed += 1; }
583
707
  if (r.type === 'tree-changed' && r.pass) treeChanged = true;
584
708
  }
709
+ const allPass = results.length > 0 && results.every((r) => r.pass === true);
710
+ const eeSeq = typeof ee.seq === 'number' ? ee.seq : prevBoundSeq;
711
+ const eeTs = parseTs(ee.ts);
712
+ // the failing (or, on an all-pass attempt, the first) result's own
713
+ // `detail` — the closest thing a step-level exit-eval carries to a
714
+ // "gap line" (the outer close's own `gap` field has no per-step
715
+ // equivalent; see the file header's `close-verdict` doc).
716
+ const failing = results.find((r) => r.pass !== true);
717
+ const detailSource = failing ?? results[0] ?? null;
718
+ attempts.push({
719
+ n: attempts.length + 1,
720
+ iteration: typeof ee.iteration === 'number' ? ee.iteration : null,
721
+ outcome: allPass ? 'green' : 'red',
722
+ });
723
+ attemptWindows.push({
724
+ n: attemptWindows.length + 1,
725
+ iteration: typeof ee.iteration === 'number' ? ee.iteration : null,
726
+ outcome: allPass ? 'green' : 'red',
727
+ startSeq: prevBoundSeq,
728
+ endSeq: eeSeq,
729
+ startTs: prevBoundTs,
730
+ endTs: eeTs,
731
+ exitEvalDetail: detailSource && typeof detailSource.detail === 'string' ? detailSource.detail : null,
732
+ });
733
+ prevBoundSeq = eeSeq;
734
+ prevBoundTs = eeTs ?? prevBoundTs;
585
735
  }
586
736
 
587
737
  idOccurrence.set(id, (idOccurrence.get(id) ?? 0) + 1);
@@ -593,6 +743,19 @@ export function replayRun(spineEvents, auditEvents = [], { runId = null } = {})
593
743
  ...buildOccurrenceMetrics(startSeq, startTs, endSeq, endTs, roundsInWindow),
594
744
  checks: { passed, failed },
595
745
  treeChanged,
746
+ attempts,
747
+ attemptWindows,
748
+ stepStartSeq: startSeq,
749
+ stepEndSeq: endSeq,
750
+ // `stepStartTs`/`stepEndTs` (panel item 2 follow-up, F195/F-audit-step):
751
+ // this step's own ts bounds, the same `startTs`/`endTs` already
752
+ // computed above for `buildOccurrenceMetrics` — exposed (never a
753
+ // second windowing pass) so a ts-keyed lookup (the Audit tab's `step`
754
+ // column) can place a row without re-deriving step boundaries a
755
+ // second way. `stepEndTs` is `null` when this occurrence never
756
+ // reached its own `step-end` (a died/still-running/escalated step).
757
+ stepStartTs: startTs,
758
+ stepEndTs: endTs,
596
759
  };
597
760
  });
598
761
 
@@ -604,6 +767,177 @@ export function replayRun(spineEvents, auditEvents = [], { runId = null } = {})
604
767
  const timelineKind = /** @type {'steps'|'iterations'} */ (stepStarts.length > 0 ? 'steps' : (iterationStarts.length > 0 ? 'iterations' : 'steps'));
605
768
  const boundaries = spine.filter((e) => e.type === 'close-verdict' || e.type === 'run-end' || e.type === 'escalation');
606
769
 
770
+ // ── FIX LOOP (panel build item 1, 2026-09-26): a PLAN run (timelineKind
771
+ // 'steps') can carry a SECOND loop after every step already ended — the
772
+ // operator's real close runs an `outer-close` PRECHECK, and when that
773
+ // comes back non-green a `fix-loop` (src/planrun.js:3539's `runCloseStages`
774
+ // loop) re-runs the close against fresh worker rounds (`phase:'fix'`)
775
+ // until it's satisfied or the run stops. Before this, every one of those
776
+ // rounds/iterations was invisible on the Run tab: `steps` only carries the
777
+ // plan's OWN steps, and the `iterations` timeline above is built ONLY when
778
+ // `timelineKind==='iterations'` (no step-start at all) — a plan run with a
779
+ // fix loop has BOTH a step-start AND post-step iteration-start records,
780
+ // and the latter were simply never read.
781
+ //
782
+ // Measured on the real archived run this was built against (mu2p83go):
783
+ // step-end at seq 61, then `outer-close` (needs_revision) at seq 62,
784
+ // `fix-loop`/run-start/iteration-start 1 at seq 63-65, two more
785
+ // needs_revision iterations, then iteration 3 satisfied at seq 108,
786
+ // run-end/job-end at 109/112. `fixLoop.attempts` reports this as 4
787
+ // attempts: the outer-close precheck first (attempt 1, red), then the
788
+ // fix-loop's own 3 iterations (2 red, 1 green) — the SAME "✗ ✗ ✗ ✓"
789
+ // sequence a person reading the raw spine by hand gets.
790
+ //
791
+ // Scoped by seq strictly AFTER the last step-end this spine reached (never
792
+ // before — the step's OWN micro-loop, src/planrun.js's `ralph({judge,...})`,
793
+ // also emits `iteration-start`/`close-verdict` and must not be double
794
+ // counted here; that loop is already fully captured by each step's own
795
+ // `attempts`/`attemptWindows` above, keyed off `exit-eval`, a completely
796
+ // separate emit type). `null` (never an empty-but-present object) when
797
+ // this run has no steps at all, OR no outer-close/fix-loop iteration
798
+ // exists after the last step ended (the overwhelmingly common case: a
799
+ // plan run whose close was satisfied on the first outer-close precheck
800
+ // never enters a fix loop, and this field must read exactly as it did
801
+ // before this addition for that run).
802
+ // F196 (2026-09-27, live-observed on mujjtrvd): a step still RUNNING (no
803
+ // `step-end` on the spine yet) has its own in-flight micro-loop
804
+ // (`ralph({judge,...})`, src/planrun.js) that emits its OWN
805
+ // `iteration-start` — e.g. step 2's attempt 1 on mujjtrvd (seq 71,
806
+ // `strict-clean-whole-src`'s own step-start at seq 69, no step-end). The
807
+ // old `lastStepEndSeq` (the LAST *ended* step's own boundary — seq 68,
808
+ // step 1 `type-checks-jsdoc`'s step-end) left that still-running step's
809
+ // seq range wide open, so its iteration-start leaked in below as a fake
810
+ // fix-loop attempt: the Run tab showed step 2 as `[done]` with 0
811
+ // calls/`?` (`end` never found for it in the `steps` map) plus a phantom
812
+ // `FIX [running]` box after it. An un-ended step is its own right
813
+ // boundary: the fix loop can only start once the run's LAST-STARTED step
814
+ // (`steps[steps.length-1]` — `steps` preserves spine order, so this is
815
+ // the step with the highest `stepStartSeq` across the whole file,
816
+ // including every concatenated leg of a resumed chain) has reached its
817
+ // own step-end.
818
+ //
819
+ // Deliberately checks only the LAST step, never "every step" (measured
820
+ // and rejected: a first pass checking `steps.some(s => s.stepEndSeq ===
821
+ // Infinity)` broke a genuinely FINISHED resumed-chain archive,
822
+ // litectx-u-bareloop/reuse-msc6w93z.jsonl — its first leg's
823
+ // `fix-index-js-strict` attempt (step-start seq 34) crashed with no
824
+ // step-end before the chain resumed and re-ran that same step from seq
825
+ // 77 onward to a real step-end at seq 115; the file's LAST step
826
+ // (`achieve-strict-typecheck`, step-end seq 611) did end, and this run's
827
+ // real fixLoop must still be read). Checking only the last-started step
828
+ // preserves that file's output unchanged while still nulling fixLoop on
829
+ // mujjtrvd (last step `strict-clean-whole-src` never step-ends in the
830
+ // mid-run slice) and on every other archived spine this fix was diffed
831
+ // against that turned out to be a truncated/killed file with no
832
+ // `job-end` at all (its last step-start likewise never step-ended) —
833
+ // see the F196 commit's report for the full before/after list.
834
+ let fixLoop = null;
835
+ if (timelineKind === 'steps' && stepStarts.length > 0 && steps[steps.length - 1].stepEndSeq !== Infinity) {
836
+ const lastStepEndSeq = stepEnds.reduce((max, se) => (typeof se.seq === 'number' && se.seq > max ? se.seq : max), -Infinity);
837
+ const outerClosesAfter = spine
838
+ .filter((e) => e.type === 'outer-close' && typeof e.seq === 'number' && e.seq > lastStepEndSeq)
839
+ .sort((a, b) => a.seq - b.seq);
840
+ const iterationStartsAfter = iterationStarts
841
+ .filter((e) => typeof e.seq === 'number' && e.seq > lastStepEndSeq)
842
+ .sort((a, b) => a.seq - b.seq);
843
+
844
+ if (outerClosesAfter.length > 0 || iterationStartsAfter.length > 0) {
845
+ /** @type {any[]} */
846
+ const attempts = [];
847
+
848
+ // The outer-close precheck(s): each is a single point-in-time verdict,
849
+ // never its own round-producing window on its own (the rounds that led
850
+ // to it already belong to the LAST STEP, already reported there) —
851
+ // except the (rare) gap between step-end and the precheck itself,
852
+ // which this window still honestly captures rather than assuming zero.
853
+ let prevSeq = lastStepEndSeq;
854
+ let prevTs = stepEnds.length ? (stepEnds.reduce((latest, se) => {
855
+ const t = parseTs(se.ts);
856
+ return typeof se.seq === 'number' && se.seq === lastStepEndSeq && t !== null ? t : latest;
857
+ }, /** @type {number|null} */ (null))) : null;
858
+ for (const oc of outerClosesAfter) {
859
+ const seq = typeof oc.seq === 'number' ? oc.seq : Infinity;
860
+ const ts = parseTs(oc.ts);
861
+ const roundsInWindow = spendRecords.filter((r) => typeof r.seq === 'number' && r.seq > prevSeq && r.seq < seq);
862
+ const verdict = oc.verdict ?? null;
863
+ attempts.push({
864
+ n: attempts.length + 1,
865
+ source: /** @type {'outer-close'} */ ('outer-close'),
866
+ iteration: null,
867
+ verdict,
868
+ stages: Array.isArray(oc.stages) ? oc.stages : null,
869
+ outcome: /** @type {'green'|'red'} */ (verdict === 'satisfied' || verdict === 'green' || verdict === 'already-green' ? 'green' : 'red'),
870
+ ...buildOccurrenceMetrics(prevSeq, prevTs, seq, ts, roundsInWindow),
871
+ windowStartSeq: prevSeq,
872
+ windowEndSeq: seq,
873
+ windowStartTs: prevTs,
874
+ windowEndTs: ts,
875
+ });
876
+ prevSeq = seq;
877
+ prevTs = ts;
878
+ }
879
+
880
+ // The fix-loop's own iterations: same windowing rule the `iterations`
881
+ // timeline above already uses (iteration-start .. next close-verdict/
882
+ // run-end/escalation), scoped to `> lastStepEndSeq` so the step's own
883
+ // micro-loop iterations never leak in.
884
+ for (const is of iterationStartsAfter) {
885
+ const startSeq = typeof is.seq === 'number' ? is.seq : -Infinity;
886
+ const startTs = parseTs(is.ts);
887
+ const end = boundaries
888
+ .filter((b) => typeof b.seq === 'number' && b.seq > startSeq)
889
+ .sort((a, b) => a.seq - b.seq)[0] ?? null;
890
+ const endSeq = end && typeof end.seq === 'number' ? end.seq : Infinity;
891
+ const endTs = end ? parseTs(end.ts) : null;
892
+ const roundsInWindow = spendRecords.filter((r) => typeof r.seq === 'number' && r.seq > startSeq && r.seq < endSeq);
893
+ const verdict = end
894
+ ? (end.type === 'close-verdict' ? (end.verdict ?? null)
895
+ : end.type === 'run-end' ? (end.outcome ?? null)
896
+ : end.type === 'escalation' ? (end.category ?? null) : null)
897
+ : null;
898
+ attempts.push({
899
+ n: attempts.length + 1,
900
+ source: /** @type {'iteration'} */ ('iteration'),
901
+ iteration: typeof is.iteration === 'number' ? is.iteration : null,
902
+ verdict,
903
+ stages: end && end.type === 'close-verdict' && Array.isArray(end.stages) ? end.stages : null,
904
+ // `end === null` means this iteration's own window hasn't closed
905
+ // yet (no close-verdict/run-end/escalation boundary has landed on
906
+ // the spine after its iteration-start) — a LIVE run reading this
907
+ // mid-iteration, never a finished one (a finished spine's last
908
+ // iteration always has a boundary). Fabricating 'red' here (as
909
+ // `verdict === null` used to fall through to) reads a still-running
910
+ // fix-loop attempt as an already-failed one; `null` (the same
911
+ // "unresolved" value every other in-progress outcome in this file
912
+ // uses) is the honest read. Fixed here, the one place this outcome
913
+ // is minted, rather than in the panel's `partBoxState` (which reads
914
+ // this field but has no ordering info to tell "genuinely red" from
915
+ // "still open" apart if this field itself already lies).
916
+ outcome: end
917
+ ? /** @type {'green'|'red'} */ (verdict === 'satisfied' || verdict === 'green' || verdict === 'already-green' ? 'green' : 'red')
918
+ : /** @type {null} */ (null),
919
+ ...buildOccurrenceMetrics(startSeq, startTs, endSeq, endTs, roundsInWindow),
920
+ windowStartSeq: startSeq,
921
+ windowEndSeq: endSeq,
922
+ windowStartTs: startTs,
923
+ windowEndTs: endTs,
924
+ });
925
+ }
926
+
927
+ const allToolsKnown = attempts.every((a) => typeof a.toolCalls === 'number');
928
+ const allWallKnown = attempts.every((a) => typeof a.wallMs === 'number');
929
+ const unpricedRounds = attempts.reduce((acc, a) => acc + a.unpricedRounds, 0);
930
+ fixLoop = {
931
+ attempts,
932
+ rounds: attempts.reduce((acc, a) => acc + a.rounds, 0),
933
+ toolCalls: allToolsKnown ? attempts.reduce((acc, a) => acc + (a.toolCalls ?? 0), 0) : null,
934
+ wallMs: allWallKnown ? attempts.reduce((acc, a) => acc + (a.wallMs ?? 0), 0) : null,
935
+ spentUsd: unpricedRounds > 0 ? null : attempts.reduce((acc, a) => acc + (a.spentUsd ?? 0), 0),
936
+ unpricedRounds,
937
+ };
938
+ }
939
+ }
940
+
607
941
  const iterations = timelineKind === 'iterations' ? iterationStarts.map((is) => {
608
942
  const startSeq = typeof is.seq === 'number' ? is.seq : -Infinity;
609
943
  const startTs = parseTs(is.ts);
@@ -634,6 +968,13 @@ export function replayRun(spineEvents, auditEvents = [], { runId = null } = {})
634
968
  ? { verdict: end.verdict ?? null, stages: Array.isArray(end.stages) ? end.stages : null }
635
969
  : null,
636
970
  ...buildOccurrenceMetrics(startSeq, startTs, endSeq, endTs, roundsInWindow),
971
+ // exposed for panel item 5's `/api/runs/:runid/rounds` endpoint, which
972
+ // slices this iteration's own rounds/tool-calls the same way a plan
973
+ // step's `attemptWindows` does — never a second windowing pass.
974
+ windowStartSeq: startSeq,
975
+ windowEndSeq: endSeq,
976
+ windowStartTs: startTs,
977
+ windowEndTs: endTs,
637
978
  };
638
979
  }) : [];
639
980
 
@@ -668,6 +1009,280 @@ export function replayRun(spineEvents, auditEvents = [], { runId = null } = {})
668
1009
  const shown = new Set([last, ...before]);
669
1010
  const escalationOutsideWindow = lastEscalation && !shown.has(lastEscalation) ? lastEscalation : null;
670
1011
 
1012
+ // ── PARTS (panel build item 6, 2026-09-26): ONE ordered list of this run's
1013
+ // own high-level pieces — scout, plan, each step occurrence in run order,
1014
+ // a replan window sitting between two steps, the fix loop, and (synthetic
1015
+ // only — no real archive carries this yet) a leftover judge-round bucket.
1016
+ // Computed ONCE here, server-side, so the Run tab's map+cards and the
1017
+ // Audit tab's groups read off the exact same list and can never disagree
1018
+ // on order or counts. A run with no phase data at all (no step-start: the
1019
+ // pre-phase archive shape, or the older loop-tier `iterations` shape,
1020
+ // neither of which carries a scout/plan/step split of its own) collapses
1021
+ // to one box, `{kind:'run'}` — matching the build spec's "old runs with no
1022
+ // phase data = one box 'run'".
1023
+ /**
1024
+ * @param {number|null} startTs
1025
+ * @param {number|null} endTs
1026
+ */
1027
+ function partBehaviour(startTs, endTs) {
1028
+ if (!auditAvailable) return null;
1029
+ const lo = startTs ?? -Infinity;
1030
+ const hi = endTs ?? Infinity;
1031
+ const windowed = audit.filter((a) => {
1032
+ const t = parseTs(a.ts);
1033
+ return t !== null && t >= lo && t <= hi;
1034
+ });
1035
+ return runBehaviour(windowed);
1036
+ }
1037
+ /**
1038
+ * One synthetic "whole part" attempt — used by every part kind that has no
1039
+ * attempt/iteration sub-loop of its own (scout, plan, replan, judge, run):
1040
+ * the Audit tab expands these straight to their rounds table (build spec
1041
+ * item C), never through a fake attempt list.
1042
+ * @param {number} startSeq
1043
+ * @param {number|null} startTs
1044
+ * @param {number} endSeq
1045
+ * @param {number|null} endTs
1046
+ */
1047
+ function wholePartAttempt(startSeq, startTs, endSeq, endTs) {
1048
+ return [{
1049
+ n: 1, outcome: /** @type {null} */ (null), verdict: null, stages: null, detail: null, startSeq, endSeq, startTs, endTs,
1050
+ }];
1051
+ }
1052
+
1053
+ /** @type {Array<any>} */
1054
+ const parts = [];
1055
+
1056
+ if (stepStarts.length === 0) {
1057
+ const jobStartTs = parseTs(jobStart?.ts);
1058
+ const jobEndTs = parseTs(jobEnd?.ts);
1059
+ const b = partBehaviour(jobStartTs, jobEndTs);
1060
+ parts.push({
1061
+ id: 'run',
1062
+ kind: 'run',
1063
+ label: 'run',
1064
+ number: null,
1065
+ occurrence: null,
1066
+ rounds: spendRecords.length,
1067
+ toolCalls: b ? b.totalCalls : null,
1068
+ byTool: b ? b.byTool : null,
1069
+ blocked: b ? b.denied : null,
1070
+ wallMs,
1071
+ spentUsd: thisFileSpend.value,
1072
+ unpricedRounds: thisFileSpend.unpricedRounds,
1073
+ outcome,
1074
+ attempts: wholePartAttempt(-Infinity, jobStartTs, Infinity, jobEndTs),
1075
+ });
1076
+ } else {
1077
+ const firstStepStart = stepStarts.reduce((first, ss) => (typeof ss.seq === 'number' && (first === null || ss.seq < first.seq) ? ss : first), /** @type {any} */ (null));
1078
+ const firstSeq = firstStepStart && typeof firstStepStart.seq === 'number' ? firstStepStart.seq : Infinity;
1079
+ const firstTs = firstStepStart ? parseTs(firstStepStart.ts) : null;
1080
+ const jobStartTs = parseTs(jobStart?.ts);
1081
+
1082
+ const scoutRoundsArr = spendRecords.filter((r) => r.phase === 'scout' && typeof r.seq === 'number' && r.seq < firstSeq);
1083
+ const planRoundsInitialArr = spendRecords.filter((r) => r.phase === 'plan' && typeof r.seq === 'number' && r.seq < firstSeq);
1084
+ // `scoutLastSeq` is the ONE seq boundary shared by both windows below —
1085
+ // scout's own upper bound and plan's own lower bound — so a round can
1086
+ // never land in both (or neither): each is a real spend record's own
1087
+ // `seq`, never a placeholder Infinity that would make the two windows
1088
+ // overlap (a `phase-lookup` consumer keying purely off seq needs this).
1089
+ const scoutLastSeq = scoutRoundsArr.length ? scoutRoundsArr[scoutRoundsArr.length - 1].seq : -Infinity;
1090
+ const scoutTsHi = planRoundsInitialArr.length ? parseTs(planRoundsInitialArr[0].ts) : firstTs;
1091
+
1092
+ if (scoutRoundsArr.length > 0) {
1093
+ const { spentUsd, unpricedRounds } = windowSpend(scoutRoundsArr);
1094
+ const b = partBehaviour(jobStartTs, scoutTsHi);
1095
+ parts.push({
1096
+ id: 'scout',
1097
+ kind: 'scout',
1098
+ label: 'scout',
1099
+ number: null,
1100
+ occurrence: null,
1101
+ rounds: scoutRoundsArr.length,
1102
+ toolCalls: b ? b.totalCalls : null,
1103
+ byTool: b ? b.byTool : null,
1104
+ blocked: b ? b.denied : null,
1105
+ wallMs: windowWallMs(jobStartTs, scoutTsHi),
1106
+ spentUsd,
1107
+ unpricedRounds,
1108
+ outcome: null,
1109
+ attempts: wholePartAttempt(-Infinity, jobStartTs, scoutLastSeq, scoutTsHi),
1110
+ });
1111
+ }
1112
+ if (planRoundsInitialArr.length > 0) {
1113
+ const { spentUsd, unpricedRounds } = windowSpend(planRoundsInitialArr);
1114
+ const b = partBehaviour(scoutTsHi, firstTs);
1115
+ parts.push({
1116
+ id: 'plan',
1117
+ kind: 'plan',
1118
+ label: 'plan',
1119
+ number: null,
1120
+ occurrence: null,
1121
+ rounds: planRoundsInitialArr.length,
1122
+ toolCalls: b ? b.totalCalls : null,
1123
+ byTool: b ? b.byTool : null,
1124
+ blocked: b ? b.denied : null,
1125
+ wallMs: windowWallMs(scoutTsHi, firstTs),
1126
+ spentUsd,
1127
+ unpricedRounds,
1128
+ outcome: null,
1129
+ attempts: wholePartAttempt(scoutLastSeq, scoutTsHi, firstSeq, firstTs),
1130
+ });
1131
+ }
1132
+
1133
+ // replans (panel build item 6): a `materials` record stamped
1134
+ // `phase:'replan'` between two consecutive steps marks a redraft — its
1135
+ // own worker rounds carry `phase:'plan'` (measured on a real archived
1136
+ // run, bareagent-u-bareloop/u-mshcpdg4.jsonl: `materials{phase:replan}`
1137
+ // at seq 72, then a `worker-round{phase:plan}` at seq 73, then the next
1138
+ // step's own `step-start` at seq 76 — the identical shape the run's
1139
+ // FIRST scout/plan window uses, just sitting later in the spine).
1140
+ const replanMarkers = spine
1141
+ .filter((e) => e.type === 'materials' && e.phase === 'replan' && typeof e.seq === 'number' && e.seq > firstSeq)
1142
+ .sort((a, b) => a.seq - b.seq);
1143
+
1144
+ let stepIdx = 0;
1145
+ for (const step of steps) {
1146
+ stepIdx += 1;
1147
+ // a replan marker sitting between the PREVIOUS step's own end and
1148
+ // THIS step's own start belongs here, immediately before this step's
1149
+ // box — matching run order.
1150
+ const prevStep = stepIdx > 1 ? steps[stepIdx - 2] : null;
1151
+ const windowLo = prevStep ? prevStep.stepEndSeq : firstSeq;
1152
+ const marker = replanMarkers.find((m) => typeof m.seq === 'number' && m.seq > windowLo && m.seq < step.stepStartSeq);
1153
+ if (marker) {
1154
+ const markerSeq = marker.seq;
1155
+ const planRoundsHere = spendRecords.filter((r) => r.phase === 'plan' && typeof r.seq === 'number' && r.seq > windowLo && r.seq < step.stepStartSeq);
1156
+ const loTs = prevStep ? prevStep.stepEndTs : firstTs;
1157
+ const hiTs = step.stepStartTs;
1158
+ const { spentUsd, unpricedRounds } = windowSpend(planRoundsHere);
1159
+ const b = partBehaviour(loTs, hiTs);
1160
+ parts.push({
1161
+ id: `replan:${parts.filter((p) => p.kind === 'replan').length + 1}`,
1162
+ kind: 'replan',
1163
+ label: 'replan',
1164
+ number: null,
1165
+ occurrence: null,
1166
+ rounds: planRoundsHere.length,
1167
+ toolCalls: b ? b.totalCalls : null,
1168
+ byTool: b ? b.byTool : null,
1169
+ blocked: b ? b.denied : null,
1170
+ wallMs: windowWallMs(loTs, hiTs),
1171
+ spentUsd,
1172
+ unpricedRounds,
1173
+ outcome: null,
1174
+ attempts: wholePartAttempt(windowLo, loTs, step.stepStartSeq, hiTs),
1175
+ markerSeq,
1176
+ });
1177
+ }
1178
+
1179
+ const stepBehaviour = partBehaviour(step.stepStartTs, step.stepEndTs);
1180
+ // a step that died before its own first exit-eval ever ran (e.g. a
1181
+ // crash mid-attempt — measured on the real archived
1182
+ // spines-poc-openai/poc-p2ocuxj8.jsonl, whose 2nd step crashed with no
1183
+ // check ever firing) carries an EMPTY `attemptWindows` — fall back to
1184
+ // one synthetic whole-step attempt spanning the step's own bounds so
1185
+ // the Audit tab still has something to expand into a rounds table,
1186
+ // same as a scout/plan/replan/judge/run part already does.
1187
+ const stepAttempts = step.attemptWindows.length > 0
1188
+ ? step.attemptWindows.map((aw) => ({
1189
+ n: aw.n, outcome: aw.outcome, verdict: null, stages: null, detail: aw.exitEvalDetail, startSeq: aw.startSeq, endSeq: aw.endSeq, startTs: aw.startTs, endTs: aw.endTs,
1190
+ }))
1191
+ : wholePartAttempt(step.stepStartSeq, step.stepStartTs, step.stepEndSeq, step.stepEndTs);
1192
+ parts.push({
1193
+ id: `step:${step.id}:${step.occurrence}`,
1194
+ kind: 'step',
1195
+ label: step.id,
1196
+ number: stepIdx,
1197
+ occurrence: step.occurrence,
1198
+ rounds: step.rounds,
1199
+ toolCalls: step.toolCalls,
1200
+ byTool: stepBehaviour ? stepBehaviour.byTool : null,
1201
+ blocked: stepBehaviour ? stepBehaviour.denied : null,
1202
+ wallMs: step.wallMs,
1203
+ spentUsd: step.spentUsd,
1204
+ unpricedRounds: step.unpricedRounds,
1205
+ outcome: step.outcome,
1206
+ attempts: stepAttempts,
1207
+ });
1208
+ }
1209
+
1210
+ if (fixLoop) {
1211
+ const firstA = fixLoop.attempts[0];
1212
+ const lastA = fixLoop.attempts[fixLoop.attempts.length - 1];
1213
+ const fixBehaviour = partBehaviour(firstA ? firstA.windowStartTs : null, lastA ? lastA.windowEndTs : null);
1214
+ parts.push({
1215
+ id: 'fix',
1216
+ kind: 'fix',
1217
+ label: 'fix',
1218
+ number: null,
1219
+ occurrence: null,
1220
+ rounds: fixLoop.rounds,
1221
+ toolCalls: fixLoop.toolCalls,
1222
+ byTool: fixBehaviour ? fixBehaviour.byTool : null,
1223
+ blocked: fixBehaviour ? fixBehaviour.denied : null,
1224
+ wallMs: fixLoop.wallMs,
1225
+ spentUsd: fixLoop.spentUsd,
1226
+ unpricedRounds: fixLoop.unpricedRounds,
1227
+ outcome: fixLoop.attempts.length ? fixLoop.attempts[fixLoop.attempts.length - 1].outcome : null,
1228
+ attempts: fixLoop.attempts.map((a) => ({
1229
+ n: a.n, outcome: a.outcome, verdict: a.verdict, stages: a.stages, detail: null, startSeq: a.windowStartSeq, endSeq: a.windowEndSeq, startTs: a.windowStartTs, endTs: a.windowEndTs,
1230
+ })),
1231
+ });
1232
+ }
1233
+
1234
+ // judge (panel build item 6): a bare `judge-round` spend record — a
1235
+ // close's own paid seam (`onJudgeCost`, src/planrun.js:1333) — that
1236
+ // falls OUTSIDE every window already built above (every step, every
1237
+ // replan, the fix loop). No real archived run measured against this
1238
+ // build carries one: every judge-round seen so far already lands inside
1239
+ // a step or fix-loop window. Kept SYNTHETIC-ONLY (a fixture test builds
1240
+ // one by hand) so a future soft-green judged close still gets its own
1241
+ // box instead of silently vanishing into "unaccounted for".
1242
+ const consumedSeqs = new Set();
1243
+ for (const p of parts) {
1244
+ for (const a of p.attempts) {
1245
+ if (typeof a.startSeq === 'number' && typeof a.endSeq === 'number') {
1246
+ for (const r of spendRecords) {
1247
+ if (typeof r.seq === 'number' && r.seq > a.startSeq && r.seq <= a.endSeq) consumedSeqs.add(r.seq);
1248
+ }
1249
+ }
1250
+ }
1251
+ }
1252
+ const leftoverJudgeRounds = spendRecords.filter((r) => r.type === 'judge-round' && typeof r.seq === 'number' && !consumedSeqs.has(r.seq));
1253
+ if (leftoverJudgeRounds.length > 0) {
1254
+ const jTsLo = parseTs(leftoverJudgeRounds[0].ts);
1255
+ const jTsHi = parseTs(leftoverJudgeRounds[leftoverJudgeRounds.length - 1].ts);
1256
+ const { spentUsd, unpricedRounds } = windowSpend(leftoverJudgeRounds);
1257
+ const b = partBehaviour(jTsLo, jTsHi);
1258
+ parts.push({
1259
+ id: 'judge',
1260
+ kind: 'judge',
1261
+ label: 'judge',
1262
+ number: null,
1263
+ occurrence: null,
1264
+ rounds: leftoverJudgeRounds.length,
1265
+ toolCalls: b ? b.totalCalls : null,
1266
+ byTool: b ? b.byTool : null,
1267
+ blocked: b ? b.denied : null,
1268
+ wallMs: windowWallMs(jTsLo, jTsHi),
1269
+ spentUsd,
1270
+ unpricedRounds,
1271
+ outcome: null,
1272
+ // startSeq is one less than the first leftover round's own seq (an
1273
+ // exclusive lower bound, matching every other part's convention) so
1274
+ // that round itself still falls INSIDE this window rather than being
1275
+ // excluded by its own boundary.
1276
+ attempts: wholePartAttempt(
1277
+ leftoverJudgeRounds[0].seq - 1,
1278
+ jTsLo,
1279
+ leftoverJudgeRounds[leftoverJudgeRounds.length - 1].seq,
1280
+ jTsHi,
1281
+ ),
1282
+ });
1283
+ }
1284
+ }
1285
+
671
1286
  return {
672
1287
  runId,
673
1288
  job: jobStart?.job ?? null,
@@ -680,6 +1295,11 @@ export function replayRun(spineEvents, auditEvents = [], { runId = null } = {})
680
1295
  // neither, and this reader reports that honestly rather than guessing.
681
1296
  verdictType: typeof jobStart?.verdictType === 'string' ? jobStart.verdictType : null,
682
1297
  model: typeof jobStart?.model === 'string' ? jobStart.model : null,
1298
+ // PRD item 28 / F156's own field (src/run.js:368) — WHICH provider ran
1299
+ // this model (e.g. `openai-api` for a DeepSeek run). `null` on any spine
1300
+ // older than that landing, or a native/clipipe call path with no such
1301
+ // binding — never fabricated, never defaulted to "anthropic-api".
1302
+ provider: typeof jobStart?.provider === 'string' ? jobStart.provider : null,
683
1303
  // F118 (parked-half landed): `job-start.code` — absent on any spine older
684
1304
  // than that landing (this run predates the field entirely, distinct from
685
1305
  // `sha` being null because the run happened to run from an npm install
@@ -727,12 +1347,28 @@ export function replayRun(spineEvents, auditEvents = [], { runId = null } = {})
727
1347
  ? { spentUsd, thisFileUsd: thisFileSpend.value, diffUsd: spentUsd - thisFileSpend.value }
728
1348
  : null,
729
1349
  timelineKind,
1350
+ // scoutPlan (panel build item 1, 2026-09-26): this run's own pre-step
1351
+ // scout/plan rounds — see the field's own construction comment above.
1352
+ // `null` on every run with no steps, or no scout/plan rounds recorded.
1353
+ scoutPlan,
730
1354
  steps,
731
1355
  iterations,
1356
+ // fixLoop (panel build item 1, 2026-09-26): the post-step outer-close
1357
+ // precheck + fix-loop iterations a PLAN run can carry after its last
1358
+ // step-end — see the field's own construction comment above. `null` on
1359
+ // every run with no such loop (the common case) or with no steps at all.
1360
+ fixLoop,
1361
+ // parts (panel build item 6, 2026-09-26): the ONE ordered part list —
1362
+ // see its own construction comment above. Drives the Run tab's map+cards
1363
+ // and the Audit tab's groups; never a second computation of run order.
1364
+ parts,
732
1365
  replans,
733
1366
  close,
734
1367
  ending: { record: last, before, escalation: escalationOutsideWindow },
735
- behaviour: runBehaviour(audit),
1368
+ // `null` (never a 0-call object) when the caller could not even
1369
+ // determine whether a gate-audit sidecar exists — see the `auditAvailable`
1370
+ // param doc above.
1371
+ behaviour: auditAvailable ? runBehaviour(audit) : null,
736
1372
  memoryCache,
737
1373
  skipped,
738
1374
  };
@@ -761,6 +1397,18 @@ function rowMoney(row) {
761
1397
  : money(row.spentUsd);
762
1398
  }
763
1399
 
1400
+ /**
1401
+ * The `tools` column for one occurrence/iteration row: `null` (no
1402
+ * gate-audit sidecar available for this run) reads `unknown tools` — never
1403
+ * `0 tools`, which would silently discard real tool-call activity the
1404
+ * sidecar just wasn't there to count. A real zero-call occurrence still
1405
+ * reads `0 tools` honestly.
1406
+ * @param {number|null} n
1407
+ */
1408
+ function toolsCell(n) {
1409
+ return n === null ? 'unknown tools' : `${n} tool${n === 1 ? '' : 's'}`;
1410
+ }
1411
+
764
1412
  /** @param {number|null} ms */
765
1413
  function duration(ms) {
766
1414
  if (ms === null) return 'unknown';
@@ -1018,7 +1666,7 @@ export function formatReplay(summary) {
1018
1666
  `iteration ${it.iteration ?? '?'}`,
1019
1667
  resultWord(it.verdict),
1020
1668
  `${it.rounds} round${it.rounds === 1 ? '' : 's'}`,
1021
- `${it.toolCalls} tool${it.toolCalls === 1 ? '' : 's'}`,
1669
+ toolsCell(it.toolCalls),
1022
1670
  duration(it.wallMs),
1023
1671
  rowMoney(it),
1024
1672
  ]);
@@ -1048,7 +1696,7 @@ export function formatReplay(summary) {
1048
1696
  `${s.id}${s.occurrence > 1 ? ` (${ordinal(s.occurrence)})` : ''}`,
1049
1697
  resultWord(s.outcome),
1050
1698
  `${s.rounds} round${s.rounds === 1 ? '' : 's'}`,
1051
- `${s.toolCalls} tool${s.toolCalls === 1 ? '' : 's'}`,
1699
+ toolsCell(s.toolCalls),
1052
1700
  `${s.checks.passed}/${s.checks.failed}`,
1053
1701
  s.treeChanged ? 'changed' : 'unchanged',
1054
1702
  duration(s.wallMs),
@@ -1067,6 +1715,23 @@ export function formatReplay(summary) {
1067
1715
  }
1068
1716
  }
1069
1717
 
1718
+ // FIX LOOP (panel build item 1, 2026-09-26): printed ONLY when this run
1719
+ // actually carries one (see `replayRun`'s own construction comment) —
1720
+ // additive, never replacing any existing line above, so a run with no
1721
+ // fix loop prints byte-identical to before this addition.
1722
+ if (summary.fixLoop) {
1723
+ lines.push('');
1724
+ lines.push('FIX LOOP (post-step outer-close precheck + fix-loop iterations)');
1725
+ const seq = summary.fixLoop.attempts.map((a) => (a.source === 'outer-close' ? 'precheck' : `iter ${a.iteration ?? '?'}`) + (a.outcome === 'green' ? ' ✓' : ' ✗')).join(' · ');
1726
+ lines.push(` ${seq}`);
1727
+ lines.push(` ${summary.fixLoop.rounds} round${summary.fixLoop.rounds === 1 ? '' : 's'} · ${toolsCell(summary.fixLoop.toolCalls)} · ${duration(summary.fixLoop.wallMs)} · ${rowMoney(summary.fixLoop)}`);
1728
+ }
1729
+ if (summary.scoutPlan) {
1730
+ lines.push('');
1731
+ lines.push('SCOUT+PLAN (this run\'s own pre-step drafting)');
1732
+ lines.push(` ${summary.scoutPlan.scoutRounds} scout round${summary.scoutPlan.scoutRounds === 1 ? '' : 's'} + ${summary.scoutPlan.planRounds} plan round${summary.scoutPlan.planRounds === 1 ? '' : 's'} · ${toolsCell(summary.scoutPlan.toolCalls)} · ${duration(summary.scoutPlan.wallMs)} · ${rowMoney(summary.scoutPlan)}`);
1733
+ }
1734
+
1070
1735
  lines.push('');
1071
1736
  lines.push('CLOSE');
1072
1737
  lines.push(` ${closeLine(summary.close)}`);