bareloop 0.29.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/CHANGELOG.md CHANGED
@@ -5,6 +5,56 @@ All notable changes to bareloop are documented here. Format:
5
5
  [SemVer](https://semver.org/spec/v2.0.0.html). Pre-1.0: **minor** = a ladder rung or
6
6
  feature lands, **patch** = docs, fixes, scaffolding.
7
7
 
8
+ ## [0.30.0] — 2026-09-27
9
+
10
+ ### Added
11
+
12
+ - **Panel step map draws the retry loop** — a multi-attempt step (and the post-step fix loop)
13
+ now renders its "try N" curve and label on the map, alongside a pulsing amber dot on the
14
+ live-running box (same geometry as `design/panel-mockup.html`).
15
+ - **Runs list and open run poll live** — a single poll owner refreshes the Runs list every
16
+ tick and the open run's own detail while it is still live (`▶`), stopping once it dies
17
+ (`?`) or gets a real `✓`/`✗` verdict; polling pauses on a hidden tab and catches up on
18
+ return. Re-renders are skipped when the fetched payload is unchanged, and scroll position
19
+ is preserved across poll-driven rebuilds.
20
+ - `scripts/replay-live.mjs` (**dev-only, not shipped** — excluded from the published
21
+ package) paces an archived spine into a live-looking run for panel development and
22
+ screenshot proof, without touching the source spine or `~/.config/bareloop`.
23
+
24
+ ### Changed
25
+
26
+ - **Job tab field order**: Check type, Model, `$` cap / Time cap, then the rest — moved per
27
+ hamr's ruling.
28
+ - Job tab no longer shows a separate Job name field; the run's own card title already
29
+ carries it.
30
+
31
+ ### Fixed
32
+
33
+ - The retry loop's "try N" curve and label now stay inside their own step box at every
34
+ width, including narrow/mobile widths (previously could render past the box's left edge
35
+ at widths like 350px).
36
+ - An open (still-running) attempt on a live run now reads `▶` everywhere — map, part cards,
37
+ and Audit tab — never the died `?`, which is reserved for a genuinely died attempt.
38
+ - The `[▶]` glyph now pulses for a live run everywhere it appears (runs list rows,
39
+ Workflows, the run header).
40
+ - A running step's own in-progress attempt no longer leaks into a phantom fix-loop part on
41
+ the map.
42
+ - A live fix-loop attempt whose window is still open now reports its outcome as unknown
43
+ rather than a fabricated red, so the map correctly shows it as running (with its pulsing
44
+ dot) instead of prematurely stopped.
45
+ - A live run's Audit tab and tool-call/cache summary now fall back to the during-run
46
+ tree/worktree gate-audit path, so tool calls are visible while a run is still in progress,
47
+ not only after it finishes.
48
+
49
+ ### Notes
50
+
51
+ - F195 (open): `/api/runs` re-replays every archived spine on every call (~457ms CPU at 250
52
+ rows, no per-row cache). Mitigated in this release by throttling the Runs list poll to
53
+ 10s; the root cause is still open.
54
+ - F196 (parked): a finished bundle run's tool log can be unreadable due to a sidecar naming
55
+ mismatch between `spine.jsonl` and its renamed gate-audit sidecar. Not fixed in this
56
+ release.
57
+
8
58
  ## [0.29.0] — 2026-09-26
9
59
 
10
60
  ### Added
@@ -3243,6 +3243,28 @@ interviews of their own. All three are dispatched by name only: `bareloop run-u
3243
3243
  its own. Chat/Settings/authoring (P3/P4) are not built yet; the page says so rather than
3244
3244
  hiding the gap.
3245
3245
 
3246
+ - **Panel P2 (live run view, `docs/product/PANEL-BUILD.md` Addendum 2026-09-27)** → the page
3247
+ polls, it does not push (no SSE/websocket). One `setInterval` (2s) drives two throttled
3248
+ fetches: the OPEN run's own detail (`GET /api/runs/:runid`) refetches every 2s while its
3249
+ glyph is live `▶`, and stops the instant it reaches a real `✓`/`✗` or dies `?`; the left
3250
+ Runs list refetches on a separate 10s throttle inside the same tick (`RUNS_LIST_POLL_MS`,
3251
+ lowered from the original 2s per F195 — `listRuns()` re-replays every archived spine on
3252
+ every call, ~457ms CPU at 250 rows, open/escalated), except the tick right after the open
3253
+ run stops being live, which forces one immediate list refresh so that row's glyph doesn't
3254
+ wait up to 10s. Both fetches skip re-rendering when the payload's JSON is byte-identical to
3255
+ the last one, restore each pane's scroll position across a poll-driven rebuild, and pause
3256
+ while the tab is hidden (`document.hidden`), catching up immediately on
3257
+ `visibilitychange`. The Audit tab does **not** join this loop — it refetches only when
3258
+ opened/switched to. A live run's gate-audit sidecar (before `run-u`/`bareloop run`'s
3259
+ end-of-run rename) is read from its DURING-RUN path (`<row.patient>/gate-audit.jsonl`) via
3260
+ `resolveAuditPathForRow` (`src/panel/server.js`), the one shared owner the Run tab's tools
3261
+ summary and the Audit tab both read through — tried only while the run's own spine has no
3262
+ `job-end` yet, so a finished run never risks reading a stale leftover file at that path.
3263
+ `scripts/replay-live.mjs <sourceSpine> <outDir> [--speed N]` is a dev-only, unshipped ($0,
3264
+ no provider call) instrument that paces a real archived spine's records back out under a
3265
+ new runid so the panel can be exercised against a "live" run without a paid run — it is not
3266
+ a substitute for watching a real run start-to-end, which stays a separately authorized step.
3267
+
3246
3268
  - **`bareloop run-u <flags…>`** (PANEL-BUILD.md P0 task 2/4) → the person-path run flow
3247
3269
  (the JOBS-table/`--spec` runner, resume, the review door — `docs/logs/FINDINGS.md`'s
3248
3270
  U-mode). `src/cli.js`'s `run-u` dispatch hands `rest` straight to
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bareloop",
3
- "version": "0.29.0",
3
+ "version": "0.30.0",
4
4
  "description": "Workflows that earn their own design, with receipts — agent-authored workflows for repeated, long, verifiable jobs that improve across runs under an un-gameable gate.",
5
5
  "type": "module",
6
6
  "main": "./src/index.js",
@@ -132,7 +132,22 @@
132
132
  .dot::before{content:"[·]";color:var(--grey-dot);}
133
133
  .dot.green::before{content:"[✓]";color:var(--green);}
134
134
  .dot.red::before{content:"[✗]";color:var(--red);}
135
- .dot.amber::before{content:"[▶]";color:var(--amber);}
135
+ /* Panel P2 defect 1 (hamr-watched run mujjtrvd, 2026-09-27): the [▶] glyph
136
+ itself pulses wherever a LIVE run shows it (runs list rows, the
137
+ Workflows job row when its represented run is live, the run header) —
138
+ `.dot.amber` is minted ONLY for glyph "▶" (glyphForOutcome,
139
+ src/panel/server.js: outcome null -> "▶", never for a died [?] or a
140
+ finished [✓]/[✗] run), so scoping the animation to this one selector
141
+ already excludes every other state, no extra "is it live" check needed
142
+ here. Same rhythm as the map box's own running-part dot (this file,
143
+ `buildMapSvg`'s `s.state === "running"` branch): opacity 1 -> 0.3 -> 1,
144
+ 1.2s, matching design/panel-mockup.html's pulsing amber dot exactly —
145
+ one fixed rhythm, never re-derived per call site. */
146
+ @keyframes pulse-glyph{0%,100%{opacity:1;}50%{opacity:0.3;}}
147
+ .dot.amber::before{content:"[▶]";color:var(--amber);animation:pulse-glyph 1.2s ease-in-out infinite;}
148
+ @media (prefers-reduced-motion: reduce){
149
+ .dot.amber::before{animation:none;}
150
+ }
136
151
  .dot.grey::before{content:"[·]";color:var(--grey-dot);}
137
152
  /* died (hamr's ruling B, 2026-09-25): no ending was ever recorded — a
138
153
  DIFFERENT fact from a real [✗] close/arbiter "no", so it gets its own
@@ -320,7 +335,7 @@
320
335
  <button role="tab" id="tab-runs" aria-controls="panel-runs" aria-selected="false" data-testid="left-tab-runs">Runs</button>
321
336
  </div>
322
337
 
323
- <div class="pane-body">
338
+ <div class="pane-body" id="left-pane-body">
324
339
 
325
340
  <section id="panel-chat" class="tabpanel active" role="tabpanel" aria-labelledby="tab-chat">
326
341
  <div class="chat-thread">
@@ -361,7 +376,7 @@
361
376
  <button role="tab" id="tab-details" aria-controls="panel-details" aria-selected="false" data-testid="right-tab-details">Job</button>
362
377
  </div>
363
378
 
364
- <div class="pane-body">
379
+ <div class="pane-body" id="right-pane-body">
365
380
 
366
381
  <section id="panel-run" class="tabpanel active" role="tabpanel" aria-labelledby="tab-run">
367
382
  <div id="run-empty" class="hint" style="font-size:13px;padding:24px 0;">select a run from Workflows or History</div>
@@ -412,7 +427,10 @@
412
427
  <h4 id="details-title">Job card — signed</h4>
413
428
  <div class="field"><label>Check type</label><div class="ro-value" id="details-verdict">—</div></div>
414
429
  <div class="field"><label>Model</label><div class="ro-value" id="details-model">—</div></div>
415
- <div class="field"><label>Job name</label><div class="ro-value" id="details-jobname">—</div></div>
430
+ <div class="field-row">
431
+ <div class="field"><label>$ cap</label><div class="ro-value" id="details-cap-money">—</div></div>
432
+ <div class="field"><label>Time cap</label><div class="ro-value" id="details-cap-time">—</div></div>
433
+ </div>
416
434
  <div class="field" id="details-description-field"><label>Description</label><div class="ro-value" id="details-description">—</div></div>
417
435
  <div class="field"><label>Goal</label><div class="ro-value scroll" id="details-goal">—</div></div>
418
436
  <div class="field"><label>Success</label><div class="ro-value" id="details-success">—</div></div>
@@ -420,10 +438,6 @@
420
438
  <div class="field"><label>Source</label><div class="ro-value" id="details-source">—</div></div>
421
439
  <div class="field"><label>Destination</label><div class="ro-value" id="details-dest">—</div></div>
422
440
  <div class="field"><label>Tools</label><div class="ro-value" id="details-tools">—</div></div>
423
- <div class="field-row">
424
- <div class="field"><label>$ cap</label><div class="ro-value" id="details-cap-money">—</div></div>
425
- <div class="field"><label>Time cap</label><div class="ro-value" id="details-cap-time">—</div></div>
426
- </div>
427
441
  <div class="hint" id="job-note"></div>
428
442
  </div>
429
443
  </section>
@@ -496,14 +510,27 @@
496
510
  // "attempt 1 [✗] · attempt 2 [✓]" — appended to the box's own title text
497
511
  // (the existing wrapTitleLines 2-line wrap handles overflow the same way
498
512
  // it already does for a long step id; no third renderer, no new box shape).
499
- function attemptGlyph(outcome){ return outcome === "green" ? "✓" : (outcome === null || outcome === undefined ? "?" : "✗"); }
500
- function attemptsInlineText(attempts){
513
+ // item 1 (2026-09-27, panel P2 defect): an attempt's own outcome is null
514
+ // in exactly two situations — its window is still open on a LIVE run (the
515
+ // box carrying it is the run's own live/`running` part), or the run DIED
516
+ // before that window ever closed. hamr's glyph ruling reserves "?" for
517
+ // DIED only; a live open attempt reads the same "▶" the run glyph itself
518
+ // uses for "in progress / unresolved". `boxIsLive` is the ONE place that
519
+ // context is decided (every caller passes its own box's `state ===
520
+ // "running"` — never re-derives the rule itself), so a finished or died
521
+ // run's attempts (boxIsLive always false there) render exactly as before.
522
+ function attemptGlyph(outcome, boxIsLive){
523
+ if(outcome === "green") return "✓";
524
+ if(outcome === null || outcome === undefined) return boxIsLive ? "▶" : "?";
525
+ return "✗";
526
+ }
527
+ function attemptsInlineText(attempts, boxIsLive){
501
528
  if(!attempts || !attempts.length) return "";
502
- return attempts.map(function(a){ return "attempt " + a.n + " " + attemptGlyph(a.outcome); }).join(" · ");
529
+ return attempts.map(function(a){ return "attempt " + a.n + " " + attemptGlyph(a.outcome, boxIsLive); }).join(" · ");
503
530
  }
504
531
  function stepTitleText(idx, s){
505
532
  var base = s.noNumber ? s.title : (idx + 1) + ' ' + s.title;
506
- var at = attemptsInlineText(s.attempts);
533
+ var at = attemptsInlineText(s.attempts, s.state === "running");
507
534
  return at ? base + ' — ' + at : base;
508
535
  }
509
536
  // item 5 (F197): a box's step NUMBER must count only over boxes without
@@ -550,9 +577,10 @@
550
577
  // reads the part's own outcome.
551
578
  function partResultGlyph(part, box){
552
579
  if(partHasNoVerdict(part)) return null;
553
- if(box.attempts.length > 1) return box.attempts.map(function(a){ return attemptGlyph(a.outcome); }).join("");
580
+ var boxIsLive = box.state === "running";
581
+ if(box.attempts.length > 1) return box.attempts.map(function(a){ return attemptGlyph(a.outcome, boxIsLive); }).join("");
554
582
  var outcome = (part.outcome !== null && part.outcome !== undefined) ? part.outcome : (box.attempts[0] ? box.attempts[0].outcome : null);
555
- return attemptGlyph(outcome);
583
+ return attemptGlyph(outcome, boxIsLive);
556
584
  }
557
585
  function buildOrderedBoxes(parts, isLive){
558
586
  var list = Array.isArray(parts) ? parts : [];
@@ -607,6 +635,23 @@
607
635
  var line2 = words.slice(i).join(' ');
608
636
  return line2 ? [line1, line2] : [line1];
609
637
  }
638
+ // ONE shared rule for "does this box draw a retry loop, and what try number" — a multi-attempt
639
+ // box (a re-run step, or the fix loop; `run` on an old-shape spine falls in here too, naturally,
640
+ // never special-cased) gets a dashed self-loop labelled with its final try number; anything
641
+ // else (incl. every no-verdict kind, whose attempts is already forced to [] upstream) gets 0.
642
+ function boxRetryTry(box){
643
+ var attempts = Array.isArray(box.attempts) ? box.attempts : [];
644
+ return attempts.length > 1 ? attempts.length : 0;
645
+ }
646
+ // item 2 (2026-09-27): the ONE place a retry loop's x-coordinate (curve
647
+ // start/control/end, and the "try N" label) is kept inside the box that
648
+ // owns it — MIN_BOX_W (100) is comfortably wider than 2*RETRY_INSET, so
649
+ // this never inverts the clamp range.
650
+ var RETRY_INSET = 4;
651
+ function clampRetryX(v, boxX, boxW){
652
+ var lo = boxX + RETRY_INSET, hi = boxX + boxW - RETRY_INSET;
653
+ return Math.min(hi, Math.max(lo, v));
654
+ }
610
655
  function buildStepMapSVG(steps, availWidth){
611
656
  var n = steps.length;
612
657
  var naturalW = naturalBoxWidth(steps);
@@ -626,6 +671,8 @@
626
671
  '<defs>' +
627
672
  '<marker id="' + uid + '-arrow" markerWidth="8" markerHeight="8" refX="6" refY="3" orient="auto">' +
628
673
  '<path d="M0,0 L6,3 L0,6 z" fill="var(--text-dim)"></path></marker>' +
674
+ '<marker id="' + uid + '-arrow-grey" markerWidth="8" markerHeight="8" refX="6" refY="3" orient="auto">' +
675
+ '<path d="M0,0 L6,3 L0,6 z" fill="var(--text-faint)"></path></marker>' +
629
676
  '</defs>';
630
677
  steps.forEach(function(s, idx){
631
678
  var row = Math.floor(idx / perRow);
@@ -636,6 +683,8 @@
636
683
  var y = 30 + row * rowH;
637
684
  var colors = stepMapColors(s.state);
638
685
  var lines = titleLines[idx];
686
+ var nextRow = idx < n - 1 ? Math.floor((idx + 1) / perRow) : null;
687
+ var hasDrop = nextRow !== null && nextRow !== row;
639
688
  var stateY = y + boxH - 18;
640
689
  var partIdxAttr = typeof s.partIndex === "number" ? (' data-part-index="' + s.partIndex + '" style="cursor:pointer;"') : '';
641
690
  body += '<rect x="' + x + '" y="' + y + '" width="' + boxW + '" height="' + boxH +
@@ -655,8 +704,44 @@
655
704
  }
656
705
  body += '<text x="' + (x + boxW / 2) + '" y="' + stateY +
657
706
  '" text-anchor="middle" font-size="10" fill="' + colors.textFill + '">' + escapeXml(s.state) + '</text>';
707
+ // P2 (2026-09-27 ruling): a running box gets the mockup's pulsing
708
+ // amber dot (design/panel-mockup.html ~line 1648) — same fixed color
709
+ // and animation, never re-derived from the state's own stroke color.
710
+ if(s.state === "running"){
711
+ body += '<circle cx="' + (x + 20) + '" cy="' + (y + 12) + '" r="4" fill="#b8860b">' +
712
+ '<animate attributeName="opacity" values="1;0.3;1" dur="1.2s" repeatCount="indefinite"></animate></circle>';
713
+ }
714
+ var retryTry = boxRetryTry(s);
715
+ if(retryTry){
716
+ // dashed self-loop under the box, same proportions as the design mockup's curve,
717
+ // scaled to this box's own width. When this same box also carries the row-to-row
718
+ // snake drop (which leaves from dead-center, x=cx), shift the loop's start away from
719
+ // center by a fixed pixel offset so the two never begin at the same point.
720
+ // item 2 (2026-09-27): at a narrow box width, subtracting that same
721
+ // fixed pixel shift from proportionally-small x-fractions (e.g. the
722
+ // curve's own endpoint at 2.8% of boxW) can push the coordinate past
723
+ // the box's own left edge — the fixed shift was sized for desktop
724
+ // widths and never scaled down with boxW. clampRetryX is the ONE
725
+ // place every retry x-coordinate (curve start/control/end AND the
726
+ // "try N" label) is kept inside [x + RETRY_INSET, x + boxW -
727
+ // RETRY_INSET] — never re-clamped ad hoc per coordinate — so the
728
+ // whole loop and its label always stay inside the box that owns it,
729
+ // at every width, while keeping the mockup's proportions untouched
730
+ // wherever they already fit (every desktop-width box already fits).
731
+ var retryShift = hasDrop ? 14 : 0;
732
+ var bottom = y + boxH;
733
+ var sx = clampRetryX(x + boxW * 0.50 - retryShift, x, boxW);
734
+ var sy = bottom + 5;
735
+ var c1x = clampRetryX(x + boxW * 0.278 - retryShift, x, boxW), c1y = bottom + 30;
736
+ var c2x = clampRetryX(x + boxW * 0.167 - retryShift, x, boxW), c2y = bottom + 30;
737
+ var ex = clampRetryX(x + boxW * 0.028 - retryShift, x, boxW), ey = bottom + 2;
738
+ var labelX = clampRetryX(x + boxW * 0.267 - retryShift, x, boxW);
739
+ body += '<path d="M ' + sx + ' ' + sy + ' C ' + c1x + ' ' + c1y + ', ' + c2x + ' ' + c2y + ', ' + ex + ' ' + ey +
740
+ '" fill="none" stroke="var(--text-faint)" stroke-width="1.5" stroke-dasharray="3,3" marker-end="url(#' + uid + '-arrow-grey)"></path>';
741
+ body += '<text x="' + labelX + '" y="' + (bottom + 32) +
742
+ '" text-anchor="middle" font-size="10" fill="var(--text-faint)">try ' + retryTry + '</text>';
743
+ }
658
744
  if(idx < n - 1){
659
- var nextRow = Math.floor((idx + 1) / perRow);
660
745
  if(nextRow === row){
661
746
  var midY = y + boxH / 2;
662
747
  if(!rtl){
@@ -684,6 +769,7 @@
684
769
  '<span><span class="dot red"></span>stopped</span>' +
685
770
  '<span><span class="dot grey"></span>waiting</span>' +
686
771
  '<span><span class="dot magenta"></span>died</span>' +
772
+ '<span>⤾ dashed = retry</span>' +
687
773
  '</div>';
688
774
  }
689
775
  var lastSteps = null;
@@ -1498,7 +1584,7 @@
1498
1584
 
1499
1585
  // build item C: Audit tab attempt row -> its rounds (server-built,
1500
1586
  // lazy-fetched on expand via /api/runs/:runid/rounds?part=<i>&attempt=<n>).
1501
- function buildAttemptsList(container, partIndex, attempts){
1587
+ function buildAttemptsList(container, partIndex, attempts, boxIsLive){
1502
1588
  container.innerHTML = "";
1503
1589
  attempts.forEach(function(a){
1504
1590
  var row = document.createElement("div");
@@ -1506,7 +1592,7 @@
1506
1592
  row.setAttribute("data-testid", "audit-attempt-" + partIndex + "-" + a.n);
1507
1593
  row.innerHTML =
1508
1594
  '<button class="btn small attempt-toggle" type="button" aria-expanded="false">' +
1509
- 'attempt ' + a.n + ' ' + attemptGlyph(a.outcome) +
1595
+ 'attempt ' + a.n + ' ' + attemptGlyph(a.outcome, boxIsLive) +
1510
1596
  '</button>' +
1511
1597
  '<div class="rounds-list" hidden></div>';
1512
1598
  container.appendChild(row);
@@ -1699,7 +1785,7 @@
1699
1785
  var attemptsWrap = document.createElement("div");
1700
1786
  attemptsWrap.className = "attempts-list";
1701
1787
  body.appendChild(attemptsWrap);
1702
- buildAttemptsList(attemptsWrap, idx, attempts);
1788
+ buildAttemptsList(attemptsWrap, idx, attempts, box.state === "running");
1703
1789
  } else {
1704
1790
  // parts with NO sub-loop (scout, plan, replan, judge, run, or a
1705
1791
  // single-attempt step) expand STRAIGHT to their rounds table.
@@ -1830,7 +1916,6 @@
1830
1916
  var verdictEl = document.getElementById("details-verdict");
1831
1917
  if(job.checkTypeTitle){ verdictEl.title = job.checkTypeTitle; } else { verdictEl.removeAttribute("title"); }
1832
1918
  document.getElementById("details-model").textContent = job.model;
1833
- document.getElementById("details-jobname").textContent = job.job;
1834
1919
  var descField = document.getElementById("details-description-field");
1835
1920
  if(job.description){ descField.hidden = false; document.getElementById("details-description").textContent = job.description; }
1836
1921
  else { descField.hidden = true; }
@@ -1849,23 +1934,182 @@
1849
1934
 
1850
1935
  // ---- selection + loading ----
1851
1936
  var currentRunid = null;
1937
+ // P2: guards a stale in-flight response (detail/audit/job, OR the poll
1938
+ // loop's own run-detail refetch) from overwriting whatever run is
1939
+ // actually selected by the time it resolves — bumped on every selectRun
1940
+ // call, checked before any of those responses touch the DOM.
1941
+ var selectToken = 0;
1942
+ // P2: true while the OPEN run's own detail poll should keep firing (set
1943
+ // from the run's own `glyph`/`died` fields, same rule `renderRun` already
1944
+ // uses for `isLive` — never re-derived a second way).
1945
+ var runIsLive = false;
1852
1946
  function selectRun(runid, rowEl, siblingSelector){
1853
1947
  clearSelection();
1854
1948
  document.querySelectorAll(siblingSelector).forEach(function(r){ r.classList.remove("selected"); r.setAttribute("aria-pressed","false"); });
1855
1949
  if(rowEl){ rowEl.classList.add("selected"); rowEl.setAttribute("aria-pressed","true"); }
1856
1950
  currentRunid = runid;
1857
1951
  lastJobToolsList = null;
1858
- getJSON("/api/runs/" + encodeURIComponent(runid)).then(renderRun).catch(function(e){
1952
+ lastRunDetailSig = null;
1953
+ runIsLive = false;
1954
+ var myToken = ++selectToken;
1955
+ getJSON("/api/runs/" + encodeURIComponent(runid)).then(function(detail){
1956
+ if(myToken !== selectToken) return; // a different run was selected before this resolved
1957
+ lastRunDetailSig = sig(detail);
1958
+ runIsLive = detail.glyph === "▶" && !detail.died;
1959
+ renderRun(detail);
1960
+ }).catch(function(e){
1961
+ if(myToken !== selectToken) return;
1859
1962
  document.getElementById("run-empty").hidden = false;
1860
1963
  document.getElementById("run-empty").textContent = "failed to load run: " + e.message;
1861
1964
  document.getElementById("run-content").hidden = true;
1862
1965
  });
1863
- getJSON("/api/runs/" + encodeURIComponent(runid) + "/audit").then(renderAudit).catch(function(){ renderAudit(null); });
1864
- getJSON("/api/runs/" + encodeURIComponent(runid) + "/job").then(renderJob).catch(function(){ renderJob(null); });
1966
+ getJSON("/api/runs/" + encodeURIComponent(runid) + "/audit").then(function(result){
1967
+ if(myToken !== selectToken) return;
1968
+ renderAudit(result);
1969
+ }).catch(function(){ if(myToken === selectToken) renderAudit(null); });
1970
+ getJSON("/api/runs/" + encodeURIComponent(runid) + "/job").then(function(job){
1971
+ if(myToken !== selectToken) return;
1972
+ renderJob(job);
1973
+ }).catch(function(){ if(myToken === selectToken) renderJob(null); });
1974
+ }
1975
+
1976
+ // Q2 (2026-09-27 ruling): the Audit tab does NOT live-refresh on the 2s
1977
+ // poll — it re-fetches only when the person opens/switches to it.
1978
+ document.getElementById("tab-audit").addEventListener("click", function(){
1979
+ if(!currentRunid) return;
1980
+ var myToken = selectToken;
1981
+ getJSON("/api/runs/" + encodeURIComponent(currentRunid) + "/audit").then(function(result){
1982
+ if(myToken !== selectToken) return;
1983
+ renderAudit(result);
1984
+ }).catch(function(){});
1985
+ });
1986
+
1987
+ // ---- P2: live polling — ONE owner for both the left Runs list and the
1988
+ // open live run's map/cards/counters/summary (hamr's ruling, 2026-09-27:
1989
+ // 2s interval for the open run's own detail; the open run's own detail
1990
+ // refetch stops once it is no longer live; the Audit tab is wired
1991
+ // separately above and never joins this loop). A single `setInterval`
1992
+ // drives both fetches — never two independent timers that could race
1993
+ // each other. ----
1994
+ //
1995
+ // F195 (docs/logs/FINDINGS.md): /api/runs re-replays every spine on
1996
+ // every call (~457ms CPU at 250 rows). hamr's ruling B, 2026-09-27:
1997
+ // "refresh less often, up to 30s at most, choose the lesser when
1998
+ // possible" — the Runs list polls every 10s (RUNS_LIST_POLL_MS) instead
1999
+ // of every 2s tick; the open run's own detail is unaffected. The 2s
2000
+ // `pollTick` still fires the list check every tick, but `refreshRunsList`
2001
+ // itself skips the fetch unless RUNS_LIST_POLL_MS has elapsed since the
2002
+ // last one — one timer, one owner, just a throttle inside it. The one
2003
+ // exception: the instant the open run's own poll sees it stop being live
2004
+ // (a real verdict or died), `pollTick` forces ONE immediate list refresh
2005
+ // so that run's left-list glyph flips right away instead of waiting up
2006
+ // to 10s.
2007
+ var POLL_MS = 2000;
2008
+ var RUNS_LIST_POLL_MS = 10000;
2009
+ var pollTimer = null;
2010
+ var lastRunsSig = null;
2011
+ var lastRunDetailSig = null;
2012
+ var lastRunsListRefreshAt = 0;
2013
+ var pollFailStreak = 0;
2014
+
2015
+ function sig(obj){
2016
+ try { return JSON.stringify(obj); } catch(e){ return null; }
2017
+ }
2018
+
2019
+ // Saves/restores scrollTop of the given element ids around a re-render —
2020
+ // both `.pane-body` scroll containers keep their offset across a poll-
2021
+ // driven rebuild instead of snapping back to the top.
2022
+ function withScrollPreserved(ids, fn){
2023
+ var saved = ids.map(function(id){
2024
+ var el = document.getElementById(id);
2025
+ return el ? el.scrollTop : null;
2026
+ });
2027
+ fn();
2028
+ ids.forEach(function(id, i){
2029
+ if(saved[i] === null) return;
2030
+ var el = document.getElementById(id);
2031
+ if(el) el.scrollTop = saved[i];
2032
+ });
2033
+ }
2034
+
2035
+ function refreshRunsList(force){
2036
+ var now = Date.now();
2037
+ if(!force && (now - lastRunsListRefreshAt) < RUNS_LIST_POLL_MS) return Promise.resolve();
2038
+ lastRunsListRefreshAt = now;
2039
+ return getJSON("/api/runs").then(function(result){
2040
+ pollFailStreak = 0;
2041
+ var runs = result.runs || [];
2042
+ var next = sig(runs);
2043
+ if(next === lastRunsSig) return; // unchanged payload — never rebuild the DOM for nothing
2044
+ lastRunsSig = next;
2045
+ withScrollPreserved(["left-pane-body"], function(){
2046
+ // setItems re-runs the SAME filter/search state and view mode
2047
+ // (runsFilterFn/runsApplyFn close over runsViewMode and the filter
2048
+ // bar's own state) — a poll tick never resets either.
2049
+ runsFilterBar.setItems(runs);
2050
+ });
2051
+ }).catch(function(){
2052
+ pollFailStreak++;
2053
+ // Keep the last good render; a transient failure is invisible. Only a
2054
+ // PERSISTENT failure (3+ ticks, ~6s) gets a quiet, non-alarming note —
2055
+ // the page itself is never wiped.
2056
+ if(pollFailStreak === 3){
2057
+ var countEl = document.getElementById("runs-filter-count");
2058
+ if(countEl) countEl.textContent = "runs list: fetch failing, showing last known data";
2059
+ }
2060
+ });
1865
2061
  }
1866
2062
 
2063
+ function refreshOpenRun(){
2064
+ if(!currentRunid || !runIsLive) return Promise.resolve();
2065
+ var runid = currentRunid;
2066
+ var myToken = selectToken;
2067
+ return getJSON("/api/runs/" + encodeURIComponent(runid)).then(function(detail){
2068
+ // stale guard: a different run was selected (or re-selected) while
2069
+ // this request was in flight — never let it overwrite what's open now.
2070
+ if(myToken !== selectToken || runid !== currentRunid) return;
2071
+ var next = sig(detail);
2072
+ var changed = next !== lastRunDetailSig;
2073
+ lastRunDetailSig = next;
2074
+ runIsLive = detail.glyph === "▶" && !detail.died;
2075
+ if(changed){
2076
+ withScrollPreserved(["right-pane-body"], function(){ renderRun(detail); });
2077
+ }
2078
+ }).catch(function(){ /* keep last good render; retry next tick */ });
2079
+ }
2080
+
2081
+ function pollTick(forceList){
2082
+ if(document.hidden) return;
2083
+ refreshRunsList(forceList);
2084
+ var wasLive = runIsLive;
2085
+ return refreshOpenRun().then(function(){
2086
+ // the open run just stopped being live (real verdict or died) —
2087
+ // don't make the left-list glyph wait up to RUNS_LIST_POLL_MS to
2088
+ // catch up; force one immediate refresh.
2089
+ if(wasLive && !runIsLive) refreshRunsList(true);
2090
+ });
2091
+ }
2092
+
2093
+ function startPolling(){
2094
+ if(pollTimer) return;
2095
+ pollTimer = setInterval(pollTick, POLL_MS);
2096
+ }
2097
+ function stopPolling(){
2098
+ if(!pollTimer) return;
2099
+ clearInterval(pollTimer);
2100
+ pollTimer = null;
2101
+ }
2102
+ // Pause while the tab is hidden (cheap, standard); resume — with an
2103
+ // immediate catch-up tick, forcing BOTH fetches regardless of the list's
2104
+ // 10s throttle — on return, rather than waiting a full 2s (or up to 10s
2105
+ // for the list).
2106
+ document.addEventListener("visibilitychange", function(){
2107
+ if(!document.hidden) pollTick(true);
2108
+ });
2109
+
1867
2110
  getJSON("/api/runs").then(function(result){
1868
2111
  var runs = result.runs || [];
2112
+ lastRunsSig = sig(runs);
1869
2113
  runsFilterBar.setItems(runs);
1870
2114
  if(runs.length && !runs[0].fileMissing){
1871
2115
  document.getElementById("tab-runs").click();
@@ -1877,9 +2121,11 @@
1877
2121
  document.getElementById("tab-run").click();
1878
2122
  runsFilterBar.refresh();
1879
2123
  }
2124
+ startPolling();
1880
2125
  }).catch(function(e){
1881
2126
  document.getElementById("wf-list").innerHTML = '<div class="hint">failed to load: ' + escapeXml(e.message) + '</div>';
1882
2127
  document.getElementById("history-list").innerHTML = '<div class="hint">failed to load: ' + escapeXml(e.message) + '</div>';
2128
+ startPolling(); // still start — a transient initial failure shouldn't block all future polling
1883
2129
  });
1884
2130
 
1885
2131
  // theme toggle
@@ -556,7 +556,7 @@ export function getRunDetail(runid, opts = {}) {
556
556
  // `replayRun` (a single spine record, never audit-sidecar-sourced, so it
557
557
  // carries no contamination risk); `null` when the spine carries no
558
558
  // `memory-cache` record at all (never armed on this run).
559
- behaviour: scopedBehaviour(row.spine, rawSpineRecords),
559
+ behaviour: scopedBehaviour(row, rawSpineRecords),
560
560
  memoryCache: summary.memoryCache,
561
561
  };
562
562
  }
@@ -633,6 +633,55 @@ function makeRoundLookup(spineRecords) {
633
633
  };
634
634
  }
635
635
 
636
+ /**
637
+ * One owner for "where does THIS run's gate-audit sidecar live on disk right
638
+ * now" — used by both {@link scopedBehaviour} (Run tab) and {@link
639
+ * getRunAudit} (Audit tab), so the two can never disagree. Two cases:
640
+ * (a) the FINISHED convention ({@link resolveSiblings}: `<spine-stem>-
641
+ * gate-audit.jsonl` beside the spine) — `run-u`'s end-of-run rename
642
+ * (`src/userrun.js:1727-1729`) and every existing archived-run test
643
+ * already reads this shape;
644
+ * (b) LIVE fallback (panel P2 defect 3, hamr-watched run mujjtrvd,
645
+ * 2026-09-27): while a run has no `job-end` yet, its sidecar is still
646
+ * sitting at its DURING-RUN path, `<row.patient>/gate-audit.jsonl` —
647
+ * `run-u` writes it at `wd` (`src/userrun.js:1727`'s `auditSrc`) and
648
+ * `bareloop run`/`via:'bundle'` writes it at the worktree
649
+ * (`src/cli.js:448`'s `auditSrc`); `row.patient` already carries
650
+ * exactly that path for both (`src/userrun.js:1355`, `src/cli.js:418`),
651
+ * so this one fallback covers both without knowing which via wrote it.
652
+ * Only tried when (a) found nothing AND this run's own spine carries no
653
+ * `job-end` yet, so a finished run never pays the extra stat and never
654
+ * risks reading a stale leftover file ((a)'s rename already moved it
655
+ * away by the time job-end lands). `row.patient` is `null` on a
656
+ * `backfill` row (never a live run) and on some pre-cutoff `run-u` rows
657
+ * — both correctly fall through to `null` here.
658
+ * Callers still scope the rows they read from this file by this run's own
659
+ * job-start..job-end ts window ({@link runAuditWindow}) — a shared tree/
660
+ * worktree gate-audit file can carry another run's rows too (this file's own
661
+ * header comment on `runAuditWindow`), live or finished.
662
+ *
663
+ * NOT handled here (a separate, pre-existing gap, out of this fix's scope):
664
+ * a FINISHED `via:'bundle'` run's sidecar is renamed to a bare
665
+ * `gate-audit.jsonl` inside its own `runs/<runid>/` dir (`src/cli.js:449`),
666
+ * but its spine is named `spine.jsonl`, so {@link resolveSiblings} looks for
667
+ * `spine-gate-audit.jsonl` and never finds it — a finished bundle run reads
668
+ * `no-sidecar`/`toolLogSaved:false` even though its tool log really was
669
+ * saved. Reported, not fixed here (a naming mismatch in `src/cli.js`'s own
670
+ * rename target, not a panel-server read-path bug).
671
+ * @param {{spine: string, patient: string|null}} row
672
+ * @param {any[]} spineRecords
673
+ * @returns {string|null}
674
+ */
675
+ function resolveAuditPathForRow(row, spineRecords) {
676
+ const { auditPath } = resolveSiblings(row.spine);
677
+ if (auditPath) return auditPath;
678
+ const hasJobEnd = spineRecords.some((r) => r && r.type === 'job-end');
679
+ if (hasJobEnd) return null;
680
+ if (typeof row.patient !== 'string' || row.patient.length === 0) return null;
681
+ const live = join(row.patient, 'gate-audit.jsonl');
682
+ return existsSync(live) ? live : null;
683
+ }
684
+
636
685
  /**
637
686
  * `runBehaviour`, fed only the gate-audit rows inside this run's own ts
638
687
  * window (see {@link runAuditWindow}) — the panel-side fix for the same
@@ -641,12 +690,14 @@ function makeRoundLookup(spineRecords) {
641
690
  * can never disagree with each other about which rows belong to this run.
642
691
  * `null` when no gate-audit sidecar exists at all (never a fake all-zero
643
692
  * object — same rule `replayRun`'s own `auditAvailable` already follows).
644
- * @param {string} spinePath
693
+ * `auditPath` resolution (finished sibling, or the live during-run fallback)
694
+ * is {@link resolveAuditPathForRow} — the one shared owner.
695
+ * @param {{spine: string, patient: string|null}} row
645
696
  * @param {any[]} spineRecords
646
697
  * @returns {ReturnType<typeof runBehaviour>|null}
647
698
  */
648
- function scopedBehaviour(spinePath, spineRecords) {
649
- const { auditPath } = resolveSiblings(spinePath);
699
+ function scopedBehaviour(row, spineRecords) {
700
+ const auditPath = resolveAuditPathForRow(row, spineRecords);
650
701
  if (!auditPath || !existsSync(auditPath)) return null;
651
702
  const { records } = parseJsonl(auditPath);
652
703
  const { startTs, endTs } = runAuditWindow(spineRecords);
@@ -803,13 +854,18 @@ export function getRunAudit(runid, opts = {}) {
803
854
  runid, rows: [], raw: '', empty: true, reason: 'no-sidecar',
804
855
  };
805
856
  }
806
- const { auditPath } = resolveSiblings(row.spine);
857
+ const { records: spineRecords, skipped: spineSkipped } = parseJsonl(row.spine);
858
+ // {@link resolveAuditPathForRow} needs `spineRecords` (to know whether this
859
+ // run has reached `job-end` yet) before it can decide whether the LIVE
860
+ // during-run fallback is even worth trying — so the spine is parsed once,
861
+ // here, before the sidecar path is resolved (moved up from right after the
862
+ // old `resolveSiblings`-only check this replaces).
863
+ const auditPath = resolveAuditPathForRow(row, spineRecords);
807
864
  if (!auditPath || !existsSync(auditPath)) {
808
865
  return {
809
866
  runid, rows: [], raw: '', empty: true, reason: 'no-sidecar',
810
867
  };
811
868
  }
812
- const { records: spineRecords, skipped: spineSkipped } = parseJsonl(row.spine);
813
869
  const { startTs, endTs } = runAuditWindow(spineRecords);
814
870
  const roundOf = makeRoundLookup(spineRecords);
815
871
  const roundRecords = sortedRoundRecords(spineRecords);
package/src/replay.js CHANGED
@@ -799,8 +799,40 @@ export function replayRun(spineEvents, auditEvents = [], { runId = null, auditAv
799
799
  // plan run whose close was satisfied on the first outer-close precheck
800
800
  // never enters a fix loop, and this field must read exactly as it did
801
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.
802
834
  let fixLoop = null;
803
- if (timelineKind === 'steps' && stepStarts.length > 0) {
835
+ if (timelineKind === 'steps' && stepStarts.length > 0 && steps[steps.length - 1].stepEndSeq !== Infinity) {
804
836
  const lastStepEndSeq = stepEnds.reduce((max, se) => (typeof se.seq === 'number' && se.seq > max ? se.seq : max), -Infinity);
805
837
  const outerClosesAfter = spine
806
838
  .filter((e) => e.type === 'outer-close' && typeof e.seq === 'number' && e.seq > lastStepEndSeq)
@@ -869,7 +901,21 @@ export function replayRun(spineEvents, auditEvents = [], { runId = null, auditAv
869
901
  iteration: typeof is.iteration === 'number' ? is.iteration : null,
870
902
  verdict,
871
903
  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'),
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),
873
919
  ...buildOccurrenceMetrics(startSeq, startTs, endSeq, endTs, roundsInWindow),
874
920
  windowStartSeq: startSeq,
875
921
  windowEndSeq: endSeq,