bareloop 0.27.0 → 0.29.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,131 @@ 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
+ let fixLoop = null;
803
+ if (timelineKind === 'steps' && stepStarts.length > 0) {
804
+ const lastStepEndSeq = stepEnds.reduce((max, se) => (typeof se.seq === 'number' && se.seq > max ? se.seq : max), -Infinity);
805
+ const outerClosesAfter = spine
806
+ .filter((e) => e.type === 'outer-close' && typeof e.seq === 'number' && e.seq > lastStepEndSeq)
807
+ .sort((a, b) => a.seq - b.seq);
808
+ const iterationStartsAfter = iterationStarts
809
+ .filter((e) => typeof e.seq === 'number' && e.seq > lastStepEndSeq)
810
+ .sort((a, b) => a.seq - b.seq);
811
+
812
+ if (outerClosesAfter.length > 0 || iterationStartsAfter.length > 0) {
813
+ /** @type {any[]} */
814
+ const attempts = [];
815
+
816
+ // The outer-close precheck(s): each is a single point-in-time verdict,
817
+ // never its own round-producing window on its own (the rounds that led
818
+ // to it already belong to the LAST STEP, already reported there) —
819
+ // except the (rare) gap between step-end and the precheck itself,
820
+ // which this window still honestly captures rather than assuming zero.
821
+ let prevSeq = lastStepEndSeq;
822
+ let prevTs = stepEnds.length ? (stepEnds.reduce((latest, se) => {
823
+ const t = parseTs(se.ts);
824
+ return typeof se.seq === 'number' && se.seq === lastStepEndSeq && t !== null ? t : latest;
825
+ }, /** @type {number|null} */ (null))) : null;
826
+ for (const oc of outerClosesAfter) {
827
+ const seq = typeof oc.seq === 'number' ? oc.seq : Infinity;
828
+ const ts = parseTs(oc.ts);
829
+ const roundsInWindow = spendRecords.filter((r) => typeof r.seq === 'number' && r.seq > prevSeq && r.seq < seq);
830
+ const verdict = oc.verdict ?? null;
831
+ attempts.push({
832
+ n: attempts.length + 1,
833
+ source: /** @type {'outer-close'} */ ('outer-close'),
834
+ iteration: null,
835
+ verdict,
836
+ stages: Array.isArray(oc.stages) ? oc.stages : null,
837
+ outcome: /** @type {'green'|'red'} */ (verdict === 'satisfied' || verdict === 'green' || verdict === 'already-green' ? 'green' : 'red'),
838
+ ...buildOccurrenceMetrics(prevSeq, prevTs, seq, ts, roundsInWindow),
839
+ windowStartSeq: prevSeq,
840
+ windowEndSeq: seq,
841
+ windowStartTs: prevTs,
842
+ windowEndTs: ts,
843
+ });
844
+ prevSeq = seq;
845
+ prevTs = ts;
846
+ }
847
+
848
+ // The fix-loop's own iterations: same windowing rule the `iterations`
849
+ // timeline above already uses (iteration-start .. next close-verdict/
850
+ // run-end/escalation), scoped to `> lastStepEndSeq` so the step's own
851
+ // micro-loop iterations never leak in.
852
+ for (const is of iterationStartsAfter) {
853
+ const startSeq = typeof is.seq === 'number' ? is.seq : -Infinity;
854
+ const startTs = parseTs(is.ts);
855
+ const end = boundaries
856
+ .filter((b) => typeof b.seq === 'number' && b.seq > startSeq)
857
+ .sort((a, b) => a.seq - b.seq)[0] ?? null;
858
+ const endSeq = end && typeof end.seq === 'number' ? end.seq : Infinity;
859
+ const endTs = end ? parseTs(end.ts) : null;
860
+ const roundsInWindow = spendRecords.filter((r) => typeof r.seq === 'number' && r.seq > startSeq && r.seq < endSeq);
861
+ const verdict = end
862
+ ? (end.type === 'close-verdict' ? (end.verdict ?? null)
863
+ : end.type === 'run-end' ? (end.outcome ?? null)
864
+ : end.type === 'escalation' ? (end.category ?? null) : null)
865
+ : null;
866
+ attempts.push({
867
+ n: attempts.length + 1,
868
+ source: /** @type {'iteration'} */ ('iteration'),
869
+ iteration: typeof is.iteration === 'number' ? is.iteration : null,
870
+ verdict,
871
+ stages: end && end.type === 'close-verdict' && Array.isArray(end.stages) ? end.stages : null,
872
+ outcome: /** @type {'green'|'red'} */ (verdict === 'satisfied' || verdict === 'green' || verdict === 'already-green' ? 'green' : 'red'),
873
+ ...buildOccurrenceMetrics(startSeq, startTs, endSeq, endTs, roundsInWindow),
874
+ windowStartSeq: startSeq,
875
+ windowEndSeq: endSeq,
876
+ windowStartTs: startTs,
877
+ windowEndTs: endTs,
878
+ });
879
+ }
880
+
881
+ const allToolsKnown = attempts.every((a) => typeof a.toolCalls === 'number');
882
+ const allWallKnown = attempts.every((a) => typeof a.wallMs === 'number');
883
+ const unpricedRounds = attempts.reduce((acc, a) => acc + a.unpricedRounds, 0);
884
+ fixLoop = {
885
+ attempts,
886
+ rounds: attempts.reduce((acc, a) => acc + a.rounds, 0),
887
+ toolCalls: allToolsKnown ? attempts.reduce((acc, a) => acc + (a.toolCalls ?? 0), 0) : null,
888
+ wallMs: allWallKnown ? attempts.reduce((acc, a) => acc + (a.wallMs ?? 0), 0) : null,
889
+ spentUsd: unpricedRounds > 0 ? null : attempts.reduce((acc, a) => acc + (a.spentUsd ?? 0), 0),
890
+ unpricedRounds,
891
+ };
892
+ }
893
+ }
894
+
607
895
  const iterations = timelineKind === 'iterations' ? iterationStarts.map((is) => {
608
896
  const startSeq = typeof is.seq === 'number' ? is.seq : -Infinity;
609
897
  const startTs = parseTs(is.ts);
@@ -634,6 +922,13 @@ export function replayRun(spineEvents, auditEvents = [], { runId = null } = {})
634
922
  ? { verdict: end.verdict ?? null, stages: Array.isArray(end.stages) ? end.stages : null }
635
923
  : null,
636
924
  ...buildOccurrenceMetrics(startSeq, startTs, endSeq, endTs, roundsInWindow),
925
+ // exposed for panel item 5's `/api/runs/:runid/rounds` endpoint, which
926
+ // slices this iteration's own rounds/tool-calls the same way a plan
927
+ // step's `attemptWindows` does — never a second windowing pass.
928
+ windowStartSeq: startSeq,
929
+ windowEndSeq: endSeq,
930
+ windowStartTs: startTs,
931
+ windowEndTs: endTs,
637
932
  };
638
933
  }) : [];
639
934
 
@@ -668,6 +963,280 @@ export function replayRun(spineEvents, auditEvents = [], { runId = null } = {})
668
963
  const shown = new Set([last, ...before]);
669
964
  const escalationOutsideWindow = lastEscalation && !shown.has(lastEscalation) ? lastEscalation : null;
670
965
 
966
+ // ── PARTS (panel build item 6, 2026-09-26): ONE ordered list of this run's
967
+ // own high-level pieces — scout, plan, each step occurrence in run order,
968
+ // a replan window sitting between two steps, the fix loop, and (synthetic
969
+ // only — no real archive carries this yet) a leftover judge-round bucket.
970
+ // Computed ONCE here, server-side, so the Run tab's map+cards and the
971
+ // Audit tab's groups read off the exact same list and can never disagree
972
+ // on order or counts. A run with no phase data at all (no step-start: the
973
+ // pre-phase archive shape, or the older loop-tier `iterations` shape,
974
+ // neither of which carries a scout/plan/step split of its own) collapses
975
+ // to one box, `{kind:'run'}` — matching the build spec's "old runs with no
976
+ // phase data = one box 'run'".
977
+ /**
978
+ * @param {number|null} startTs
979
+ * @param {number|null} endTs
980
+ */
981
+ function partBehaviour(startTs, endTs) {
982
+ if (!auditAvailable) return null;
983
+ const lo = startTs ?? -Infinity;
984
+ const hi = endTs ?? Infinity;
985
+ const windowed = audit.filter((a) => {
986
+ const t = parseTs(a.ts);
987
+ return t !== null && t >= lo && t <= hi;
988
+ });
989
+ return runBehaviour(windowed);
990
+ }
991
+ /**
992
+ * One synthetic "whole part" attempt — used by every part kind that has no
993
+ * attempt/iteration sub-loop of its own (scout, plan, replan, judge, run):
994
+ * the Audit tab expands these straight to their rounds table (build spec
995
+ * item C), never through a fake attempt list.
996
+ * @param {number} startSeq
997
+ * @param {number|null} startTs
998
+ * @param {number} endSeq
999
+ * @param {number|null} endTs
1000
+ */
1001
+ function wholePartAttempt(startSeq, startTs, endSeq, endTs) {
1002
+ return [{
1003
+ n: 1, outcome: /** @type {null} */ (null), verdict: null, stages: null, detail: null, startSeq, endSeq, startTs, endTs,
1004
+ }];
1005
+ }
1006
+
1007
+ /** @type {Array<any>} */
1008
+ const parts = [];
1009
+
1010
+ if (stepStarts.length === 0) {
1011
+ const jobStartTs = parseTs(jobStart?.ts);
1012
+ const jobEndTs = parseTs(jobEnd?.ts);
1013
+ const b = partBehaviour(jobStartTs, jobEndTs);
1014
+ parts.push({
1015
+ id: 'run',
1016
+ kind: 'run',
1017
+ label: 'run',
1018
+ number: null,
1019
+ occurrence: null,
1020
+ rounds: spendRecords.length,
1021
+ toolCalls: b ? b.totalCalls : null,
1022
+ byTool: b ? b.byTool : null,
1023
+ blocked: b ? b.denied : null,
1024
+ wallMs,
1025
+ spentUsd: thisFileSpend.value,
1026
+ unpricedRounds: thisFileSpend.unpricedRounds,
1027
+ outcome,
1028
+ attempts: wholePartAttempt(-Infinity, jobStartTs, Infinity, jobEndTs),
1029
+ });
1030
+ } else {
1031
+ const firstStepStart = stepStarts.reduce((first, ss) => (typeof ss.seq === 'number' && (first === null || ss.seq < first.seq) ? ss : first), /** @type {any} */ (null));
1032
+ const firstSeq = firstStepStart && typeof firstStepStart.seq === 'number' ? firstStepStart.seq : Infinity;
1033
+ const firstTs = firstStepStart ? parseTs(firstStepStart.ts) : null;
1034
+ const jobStartTs = parseTs(jobStart?.ts);
1035
+
1036
+ const scoutRoundsArr = spendRecords.filter((r) => r.phase === 'scout' && typeof r.seq === 'number' && r.seq < firstSeq);
1037
+ const planRoundsInitialArr = spendRecords.filter((r) => r.phase === 'plan' && typeof r.seq === 'number' && r.seq < firstSeq);
1038
+ // `scoutLastSeq` is the ONE seq boundary shared by both windows below —
1039
+ // scout's own upper bound and plan's own lower bound — so a round can
1040
+ // never land in both (or neither): each is a real spend record's own
1041
+ // `seq`, never a placeholder Infinity that would make the two windows
1042
+ // overlap (a `phase-lookup` consumer keying purely off seq needs this).
1043
+ const scoutLastSeq = scoutRoundsArr.length ? scoutRoundsArr[scoutRoundsArr.length - 1].seq : -Infinity;
1044
+ const scoutTsHi = planRoundsInitialArr.length ? parseTs(planRoundsInitialArr[0].ts) : firstTs;
1045
+
1046
+ if (scoutRoundsArr.length > 0) {
1047
+ const { spentUsd, unpricedRounds } = windowSpend(scoutRoundsArr);
1048
+ const b = partBehaviour(jobStartTs, scoutTsHi);
1049
+ parts.push({
1050
+ id: 'scout',
1051
+ kind: 'scout',
1052
+ label: 'scout',
1053
+ number: null,
1054
+ occurrence: null,
1055
+ rounds: scoutRoundsArr.length,
1056
+ toolCalls: b ? b.totalCalls : null,
1057
+ byTool: b ? b.byTool : null,
1058
+ blocked: b ? b.denied : null,
1059
+ wallMs: windowWallMs(jobStartTs, scoutTsHi),
1060
+ spentUsd,
1061
+ unpricedRounds,
1062
+ outcome: null,
1063
+ attempts: wholePartAttempt(-Infinity, jobStartTs, scoutLastSeq, scoutTsHi),
1064
+ });
1065
+ }
1066
+ if (planRoundsInitialArr.length > 0) {
1067
+ const { spentUsd, unpricedRounds } = windowSpend(planRoundsInitialArr);
1068
+ const b = partBehaviour(scoutTsHi, firstTs);
1069
+ parts.push({
1070
+ id: 'plan',
1071
+ kind: 'plan',
1072
+ label: 'plan',
1073
+ number: null,
1074
+ occurrence: null,
1075
+ rounds: planRoundsInitialArr.length,
1076
+ toolCalls: b ? b.totalCalls : null,
1077
+ byTool: b ? b.byTool : null,
1078
+ blocked: b ? b.denied : null,
1079
+ wallMs: windowWallMs(scoutTsHi, firstTs),
1080
+ spentUsd,
1081
+ unpricedRounds,
1082
+ outcome: null,
1083
+ attempts: wholePartAttempt(scoutLastSeq, scoutTsHi, firstSeq, firstTs),
1084
+ });
1085
+ }
1086
+
1087
+ // replans (panel build item 6): a `materials` record stamped
1088
+ // `phase:'replan'` between two consecutive steps marks a redraft — its
1089
+ // own worker rounds carry `phase:'plan'` (measured on a real archived
1090
+ // run, bareagent-u-bareloop/u-mshcpdg4.jsonl: `materials{phase:replan}`
1091
+ // at seq 72, then a `worker-round{phase:plan}` at seq 73, then the next
1092
+ // step's own `step-start` at seq 76 — the identical shape the run's
1093
+ // FIRST scout/plan window uses, just sitting later in the spine).
1094
+ const replanMarkers = spine
1095
+ .filter((e) => e.type === 'materials' && e.phase === 'replan' && typeof e.seq === 'number' && e.seq > firstSeq)
1096
+ .sort((a, b) => a.seq - b.seq);
1097
+
1098
+ let stepIdx = 0;
1099
+ for (const step of steps) {
1100
+ stepIdx += 1;
1101
+ // a replan marker sitting between the PREVIOUS step's own end and
1102
+ // THIS step's own start belongs here, immediately before this step's
1103
+ // box — matching run order.
1104
+ const prevStep = stepIdx > 1 ? steps[stepIdx - 2] : null;
1105
+ const windowLo = prevStep ? prevStep.stepEndSeq : firstSeq;
1106
+ const marker = replanMarkers.find((m) => typeof m.seq === 'number' && m.seq > windowLo && m.seq < step.stepStartSeq);
1107
+ if (marker) {
1108
+ const markerSeq = marker.seq;
1109
+ const planRoundsHere = spendRecords.filter((r) => r.phase === 'plan' && typeof r.seq === 'number' && r.seq > windowLo && r.seq < step.stepStartSeq);
1110
+ const loTs = prevStep ? prevStep.stepEndTs : firstTs;
1111
+ const hiTs = step.stepStartTs;
1112
+ const { spentUsd, unpricedRounds } = windowSpend(planRoundsHere);
1113
+ const b = partBehaviour(loTs, hiTs);
1114
+ parts.push({
1115
+ id: `replan:${parts.filter((p) => p.kind === 'replan').length + 1}`,
1116
+ kind: 'replan',
1117
+ label: 'replan',
1118
+ number: null,
1119
+ occurrence: null,
1120
+ rounds: planRoundsHere.length,
1121
+ toolCalls: b ? b.totalCalls : null,
1122
+ byTool: b ? b.byTool : null,
1123
+ blocked: b ? b.denied : null,
1124
+ wallMs: windowWallMs(loTs, hiTs),
1125
+ spentUsd,
1126
+ unpricedRounds,
1127
+ outcome: null,
1128
+ attempts: wholePartAttempt(windowLo, loTs, step.stepStartSeq, hiTs),
1129
+ markerSeq,
1130
+ });
1131
+ }
1132
+
1133
+ const stepBehaviour = partBehaviour(step.stepStartTs, step.stepEndTs);
1134
+ // a step that died before its own first exit-eval ever ran (e.g. a
1135
+ // crash mid-attempt — measured on the real archived
1136
+ // spines-poc-openai/poc-p2ocuxj8.jsonl, whose 2nd step crashed with no
1137
+ // check ever firing) carries an EMPTY `attemptWindows` — fall back to
1138
+ // one synthetic whole-step attempt spanning the step's own bounds so
1139
+ // the Audit tab still has something to expand into a rounds table,
1140
+ // same as a scout/plan/replan/judge/run part already does.
1141
+ const stepAttempts = step.attemptWindows.length > 0
1142
+ ? step.attemptWindows.map((aw) => ({
1143
+ 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,
1144
+ }))
1145
+ : wholePartAttempt(step.stepStartSeq, step.stepStartTs, step.stepEndSeq, step.stepEndTs);
1146
+ parts.push({
1147
+ id: `step:${step.id}:${step.occurrence}`,
1148
+ kind: 'step',
1149
+ label: step.id,
1150
+ number: stepIdx,
1151
+ occurrence: step.occurrence,
1152
+ rounds: step.rounds,
1153
+ toolCalls: step.toolCalls,
1154
+ byTool: stepBehaviour ? stepBehaviour.byTool : null,
1155
+ blocked: stepBehaviour ? stepBehaviour.denied : null,
1156
+ wallMs: step.wallMs,
1157
+ spentUsd: step.spentUsd,
1158
+ unpricedRounds: step.unpricedRounds,
1159
+ outcome: step.outcome,
1160
+ attempts: stepAttempts,
1161
+ });
1162
+ }
1163
+
1164
+ if (fixLoop) {
1165
+ const firstA = fixLoop.attempts[0];
1166
+ const lastA = fixLoop.attempts[fixLoop.attempts.length - 1];
1167
+ const fixBehaviour = partBehaviour(firstA ? firstA.windowStartTs : null, lastA ? lastA.windowEndTs : null);
1168
+ parts.push({
1169
+ id: 'fix',
1170
+ kind: 'fix',
1171
+ label: 'fix',
1172
+ number: null,
1173
+ occurrence: null,
1174
+ rounds: fixLoop.rounds,
1175
+ toolCalls: fixLoop.toolCalls,
1176
+ byTool: fixBehaviour ? fixBehaviour.byTool : null,
1177
+ blocked: fixBehaviour ? fixBehaviour.denied : null,
1178
+ wallMs: fixLoop.wallMs,
1179
+ spentUsd: fixLoop.spentUsd,
1180
+ unpricedRounds: fixLoop.unpricedRounds,
1181
+ outcome: fixLoop.attempts.length ? fixLoop.attempts[fixLoop.attempts.length - 1].outcome : null,
1182
+ attempts: fixLoop.attempts.map((a) => ({
1183
+ 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,
1184
+ })),
1185
+ });
1186
+ }
1187
+
1188
+ // judge (panel build item 6): a bare `judge-round` spend record — a
1189
+ // close's own paid seam (`onJudgeCost`, src/planrun.js:1333) — that
1190
+ // falls OUTSIDE every window already built above (every step, every
1191
+ // replan, the fix loop). No real archived run measured against this
1192
+ // build carries one: every judge-round seen so far already lands inside
1193
+ // a step or fix-loop window. Kept SYNTHETIC-ONLY (a fixture test builds
1194
+ // one by hand) so a future soft-green judged close still gets its own
1195
+ // box instead of silently vanishing into "unaccounted for".
1196
+ const consumedSeqs = new Set();
1197
+ for (const p of parts) {
1198
+ for (const a of p.attempts) {
1199
+ if (typeof a.startSeq === 'number' && typeof a.endSeq === 'number') {
1200
+ for (const r of spendRecords) {
1201
+ if (typeof r.seq === 'number' && r.seq > a.startSeq && r.seq <= a.endSeq) consumedSeqs.add(r.seq);
1202
+ }
1203
+ }
1204
+ }
1205
+ }
1206
+ const leftoverJudgeRounds = spendRecords.filter((r) => r.type === 'judge-round' && typeof r.seq === 'number' && !consumedSeqs.has(r.seq));
1207
+ if (leftoverJudgeRounds.length > 0) {
1208
+ const jTsLo = parseTs(leftoverJudgeRounds[0].ts);
1209
+ const jTsHi = parseTs(leftoverJudgeRounds[leftoverJudgeRounds.length - 1].ts);
1210
+ const { spentUsd, unpricedRounds } = windowSpend(leftoverJudgeRounds);
1211
+ const b = partBehaviour(jTsLo, jTsHi);
1212
+ parts.push({
1213
+ id: 'judge',
1214
+ kind: 'judge',
1215
+ label: 'judge',
1216
+ number: null,
1217
+ occurrence: null,
1218
+ rounds: leftoverJudgeRounds.length,
1219
+ toolCalls: b ? b.totalCalls : null,
1220
+ byTool: b ? b.byTool : null,
1221
+ blocked: b ? b.denied : null,
1222
+ wallMs: windowWallMs(jTsLo, jTsHi),
1223
+ spentUsd,
1224
+ unpricedRounds,
1225
+ outcome: null,
1226
+ // startSeq is one less than the first leftover round's own seq (an
1227
+ // exclusive lower bound, matching every other part's convention) so
1228
+ // that round itself still falls INSIDE this window rather than being
1229
+ // excluded by its own boundary.
1230
+ attempts: wholePartAttempt(
1231
+ leftoverJudgeRounds[0].seq - 1,
1232
+ jTsLo,
1233
+ leftoverJudgeRounds[leftoverJudgeRounds.length - 1].seq,
1234
+ jTsHi,
1235
+ ),
1236
+ });
1237
+ }
1238
+ }
1239
+
671
1240
  return {
672
1241
  runId,
673
1242
  job: jobStart?.job ?? null,
@@ -680,6 +1249,11 @@ export function replayRun(spineEvents, auditEvents = [], { runId = null } = {})
680
1249
  // neither, and this reader reports that honestly rather than guessing.
681
1250
  verdictType: typeof jobStart?.verdictType === 'string' ? jobStart.verdictType : null,
682
1251
  model: typeof jobStart?.model === 'string' ? jobStart.model : null,
1252
+ // PRD item 28 / F156's own field (src/run.js:368) — WHICH provider ran
1253
+ // this model (e.g. `openai-api` for a DeepSeek run). `null` on any spine
1254
+ // older than that landing, or a native/clipipe call path with no such
1255
+ // binding — never fabricated, never defaulted to "anthropic-api".
1256
+ provider: typeof jobStart?.provider === 'string' ? jobStart.provider : null,
683
1257
  // F118 (parked-half landed): `job-start.code` — absent on any spine older
684
1258
  // than that landing (this run predates the field entirely, distinct from
685
1259
  // `sha` being null because the run happened to run from an npm install
@@ -727,12 +1301,28 @@ export function replayRun(spineEvents, auditEvents = [], { runId = null } = {})
727
1301
  ? { spentUsd, thisFileUsd: thisFileSpend.value, diffUsd: spentUsd - thisFileSpend.value }
728
1302
  : null,
729
1303
  timelineKind,
1304
+ // scoutPlan (panel build item 1, 2026-09-26): this run's own pre-step
1305
+ // scout/plan rounds — see the field's own construction comment above.
1306
+ // `null` on every run with no steps, or no scout/plan rounds recorded.
1307
+ scoutPlan,
730
1308
  steps,
731
1309
  iterations,
1310
+ // fixLoop (panel build item 1, 2026-09-26): the post-step outer-close
1311
+ // precheck + fix-loop iterations a PLAN run can carry after its last
1312
+ // step-end — see the field's own construction comment above. `null` on
1313
+ // every run with no such loop (the common case) or with no steps at all.
1314
+ fixLoop,
1315
+ // parts (panel build item 6, 2026-09-26): the ONE ordered part list —
1316
+ // see its own construction comment above. Drives the Run tab's map+cards
1317
+ // and the Audit tab's groups; never a second computation of run order.
1318
+ parts,
732
1319
  replans,
733
1320
  close,
734
1321
  ending: { record: last, before, escalation: escalationOutsideWindow },
735
- behaviour: runBehaviour(audit),
1322
+ // `null` (never a 0-call object) when the caller could not even
1323
+ // determine whether a gate-audit sidecar exists — see the `auditAvailable`
1324
+ // param doc above.
1325
+ behaviour: auditAvailable ? runBehaviour(audit) : null,
736
1326
  memoryCache,
737
1327
  skipped,
738
1328
  };
@@ -761,6 +1351,18 @@ function rowMoney(row) {
761
1351
  : money(row.spentUsd);
762
1352
  }
763
1353
 
1354
+ /**
1355
+ * The `tools` column for one occurrence/iteration row: `null` (no
1356
+ * gate-audit sidecar available for this run) reads `unknown tools` — never
1357
+ * `0 tools`, which would silently discard real tool-call activity the
1358
+ * sidecar just wasn't there to count. A real zero-call occurrence still
1359
+ * reads `0 tools` honestly.
1360
+ * @param {number|null} n
1361
+ */
1362
+ function toolsCell(n) {
1363
+ return n === null ? 'unknown tools' : `${n} tool${n === 1 ? '' : 's'}`;
1364
+ }
1365
+
764
1366
  /** @param {number|null} ms */
765
1367
  function duration(ms) {
766
1368
  if (ms === null) return 'unknown';
@@ -1018,7 +1620,7 @@ export function formatReplay(summary) {
1018
1620
  `iteration ${it.iteration ?? '?'}`,
1019
1621
  resultWord(it.verdict),
1020
1622
  `${it.rounds} round${it.rounds === 1 ? '' : 's'}`,
1021
- `${it.toolCalls} tool${it.toolCalls === 1 ? '' : 's'}`,
1623
+ toolsCell(it.toolCalls),
1022
1624
  duration(it.wallMs),
1023
1625
  rowMoney(it),
1024
1626
  ]);
@@ -1048,7 +1650,7 @@ export function formatReplay(summary) {
1048
1650
  `${s.id}${s.occurrence > 1 ? ` (${ordinal(s.occurrence)})` : ''}`,
1049
1651
  resultWord(s.outcome),
1050
1652
  `${s.rounds} round${s.rounds === 1 ? '' : 's'}`,
1051
- `${s.toolCalls} tool${s.toolCalls === 1 ? '' : 's'}`,
1653
+ toolsCell(s.toolCalls),
1052
1654
  `${s.checks.passed}/${s.checks.failed}`,
1053
1655
  s.treeChanged ? 'changed' : 'unchanged',
1054
1656
  duration(s.wallMs),
@@ -1067,6 +1669,23 @@ export function formatReplay(summary) {
1067
1669
  }
1068
1670
  }
1069
1671
 
1672
+ // FIX LOOP (panel build item 1, 2026-09-26): printed ONLY when this run
1673
+ // actually carries one (see `replayRun`'s own construction comment) —
1674
+ // additive, never replacing any existing line above, so a run with no
1675
+ // fix loop prints byte-identical to before this addition.
1676
+ if (summary.fixLoop) {
1677
+ lines.push('');
1678
+ lines.push('FIX LOOP (post-step outer-close precheck + fix-loop iterations)');
1679
+ const seq = summary.fixLoop.attempts.map((a) => (a.source === 'outer-close' ? 'precheck' : `iter ${a.iteration ?? '?'}`) + (a.outcome === 'green' ? ' ✓' : ' ✗')).join(' · ');
1680
+ lines.push(` ${seq}`);
1681
+ lines.push(` ${summary.fixLoop.rounds} round${summary.fixLoop.rounds === 1 ? '' : 's'} · ${toolsCell(summary.fixLoop.toolCalls)} · ${duration(summary.fixLoop.wallMs)} · ${rowMoney(summary.fixLoop)}`);
1682
+ }
1683
+ if (summary.scoutPlan) {
1684
+ lines.push('');
1685
+ lines.push('SCOUT+PLAN (this run\'s own pre-step drafting)');
1686
+ 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)}`);
1687
+ }
1688
+
1070
1689
  lines.push('');
1071
1690
  lines.push('CLOSE');
1072
1691
  lines.push(` ${closeLine(summary.close)}`);