bareloop 0.42.0 → 0.43.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.
Files changed (43) hide show
  1. package/CHANGELOG.md +46 -0
  2. package/bareloop.context.md +53 -30
  3. package/package.json +1 -1
  4. package/src/authorjob.js +6 -0
  5. package/src/declaredclose.js +2 -2
  6. package/src/interviewrun.js +0 -1
  7. package/src/job.js +38 -11
  8. package/src/panel/authorroutes.js +107 -33
  9. package/src/panel/authorsession.js +59 -27
  10. package/src/panel/index.html +813 -484
  11. package/src/panel/runroutes.js +163 -5
  12. package/src/panel/scheduler.js +156 -0
  13. package/src/panel/schedules.js +667 -0
  14. package/src/panel/schedulestart.js +243 -0
  15. package/src/panel/server.js +451 -48
  16. package/src/planrun.js +39 -7
  17. package/src/providerrows.js +3 -3
  18. package/src/reviewdoor.js +37 -0
  19. package/src/runlist.js +35 -0
  20. package/src/schedulewords.js +296 -0
  21. package/src/trigger.js +48 -0
  22. package/src/triggerclock.js +127 -0
  23. package/src/triggerfiles.js +215 -0
  24. package/src/triggerledger.js +79 -0
  25. package/src/userrun.js +6 -7
  26. package/types/declaredclose.d.ts +1 -1
  27. package/types/job.d.ts +8 -1
  28. package/types/panel/authorroutes.d.ts +34 -1
  29. package/types/panel/authorsession.d.ts +37 -12
  30. package/types/panel/runroutes.d.ts +32 -1
  31. package/types/panel/scheduler.d.ts +17 -0
  32. package/types/panel/schedules.d.ts +412 -0
  33. package/types/panel/schedulestart.d.ts +106 -0
  34. package/types/panel/server.d.ts +143 -14
  35. package/types/planrun.d.ts +2 -0
  36. package/types/providerrows.d.ts +2 -2
  37. package/types/reviewdoor.d.ts +27 -0
  38. package/types/runlist.d.ts +20 -0
  39. package/types/schedulewords.d.ts +135 -0
  40. package/types/trigger.d.ts +37 -0
  41. package/types/triggerclock.d.ts +52 -0
  42. package/types/triggerfiles.d.ts +93 -0
  43. package/types/triggerledger.d.ts +35 -0
@@ -144,8 +144,8 @@
144
144
  .dot::before{content:"[·]";color:var(--grey-dot);}
145
145
  .dot.green::before{content:"[✓]";color:var(--green);}
146
146
  .dot.red::before{content:"[✗]";color:var(--red);}
147
- /* stopped (hamr 2026-10-07): the person pressed Stop — its own sign, same red as [✗] */
148
- .dot.stopped::before{content:"[■]";color:var(--red);}
147
+ /* stopped (hamr 2026-10-07): the person pressed Stop — its own sign, grey (RULED 2026-10-15) */
148
+ .dot.stopped::before{content:"[■]";color:var(--grey-dot);}
149
149
  /* Panel build P3 addition (2026-09-27, "yes, go"): the part cards below the
150
150
  step map (`partResultGlyph`/`attemptGlyph`) and the chat job card render
151
151
  their result glyph as PLAIN inline text inside a larger string (never
@@ -170,9 +170,11 @@
170
170
  1.2s, matching design/panel-mockup.html's pulsing amber dot exactly —
171
171
  one fixed rhythm, never re-derived per call site. */
172
172
  @keyframes pulse-glyph{0%,100%{opacity:1;}50%{opacity:0.3;}}
173
- .dot.amber::before{content:"[▶]";color:var(--amber);animation:pulse-glyph 1.2s ease-in-out infinite;}
173
+ .dot.amber::before{content:"[▶]";color:var(--amber);}
174
+ /* RULED 2026-10-15 (matches fwdloop glyphPulses): a sign blinks only while running or waiting for your Accept, keyed to `.pulse` (dotPulses decides) */
175
+ .dot.pulse::before{animation:pulse-glyph 1.2s ease-in-out infinite;}
174
176
  @media (prefers-reduced-motion: reduce){
175
- .dot.amber::before{animation:none;}
177
+ .dot.pulse::before{animation:none;}
176
178
  }
177
179
  .dot.grey::before{content:"[·]";color:var(--grey-dot);}
178
180
  /* died (hamr's ruling B, 2026-09-25): no ending was ever recorded — a
@@ -208,12 +210,12 @@
208
210
  .btn:disabled::before,.btn:disabled::after{color:var(--text-faint);opacity:0.4;}
209
211
  .btn.small{padding:0 9px;height:32px;min-height:0;font-size:12px;}
210
212
 
211
- .wf-row,.hist-row{
213
+ .wf-row{
212
214
  display:flex;flex-direction:column;row-gap:4px;min-width:0;
213
215
  padding:8px 8px 8px 18px;position:relative;border:1px solid var(--border);margin-bottom:6px;background:var(--panel2);cursor:pointer;
214
216
  }
215
217
  .wf-row::before{content:" ";position:absolute;left:6px;top:8px;color:var(--accent);font-weight:700;}
216
- .wf-row:hover,.hist-row:hover{border-color:var(--border-strong);}
218
+ .wf-row:hover{border-color:var(--border-strong);}
217
219
  .wf-row.selected::before{content:">";}
218
220
  /* line 1: glyph + name, kept on ONE line — truncated with an ellipsis
219
221
  (never wrapped) when the job/runid text is longer than the row; the
@@ -226,15 +228,42 @@
226
228
  .wf-meta{font-size:12px;color:var(--text-dim);}
227
229
  .wf-ended{display:block;font-size:12px;color:var(--text-dim);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;min-width:0;}
228
230
  .wf-meta + .wf-meta::before{content:" · ";color:var(--text-faint);}
229
- .wf-row.selected,.hist-row.selected{border:2px solid var(--accent);border-radius:4px;}
230
- /* item 2 (2026-09-26 merge): a Workflows-view job row expands inline to
231
- list ALL its runs (build spec's own design) — the caret glyph on the
232
- job row, and the indent wrapper the expanded runs render into. */
233
- .wf-job{margin-bottom:6px;}
234
- .wf-job .wf-row{margin-bottom:0;}
231
+ .wf-row.selected{border:2px solid var(--accent);border-radius:4px;}
235
232
  .wf-caret{display:inline-block;width:11px;flex:none;color:var(--text-faint);font-size:10px;}
236
- .wf-runs{margin:4px 0 8px 16px;padding-left:8px;border-left:2px solid var(--border);}
237
- .wf-runs .hist-row{margin-bottom:4px;}
233
+
234
+ /* P9 v2: the one runs list. Copied from fwdloop M11 (index.html), values unchanged; only the class names carry the rl- prefix. A card's lines clip with an ellipsis (the full text is on the run page). */
235
+ :root{--rl-accent-bg:rgba(122,162,247,.12);--rl-accent-bg-strong:rgba(122,162,247,.2);}
236
+ .rl-card{display:block;width:100%;text-align:left;background:var(--panel2);border:1px solid var(--border);padding:6px 8px;margin:0 0 6px;cursor:pointer;min-height:44px;color:inherit;font:inherit;}
237
+ div.rl-card{cursor:default;}
238
+ .rl-card:hover{border-color:var(--accent);background:var(--rl-accent-bg);}
239
+ div.rl-card:hover{border-color:var(--border);background:var(--panel2);}
240
+ .rl-card.rl-sel,div.rl-card.rl-sel:hover{border:2px solid var(--accent);border-radius:4px;background:var(--rl-accent-bg-strong);}
241
+ .rl-card:focus-visible,.rl-fold:focus-visible{outline:2px solid var(--accent);outline-offset:2px;}
242
+ .rl-card.rl-live{border-color:var(--amber);}
243
+ .rl-card.rl-need{border-color:var(--accent);}
244
+ .rl-l1{display:flex;gap:6px;align-items:center;min-width:0;overflow:hidden;}
245
+ .rl-nm{font-weight:700;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;min-width:0;}
246
+ .rl-rn{color:var(--accent);flex:none;}
247
+ .rl-l2{display:block;font-size:12px;color:var(--text-dim);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}
248
+ .rl-l2 .st-word{color:var(--text);}
249
+ .rl-faint{color:var(--text-faint);font-size:12px;}
250
+ .rl-rest{color:var(--text-faint);min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
251
+ .rl-cnt{color:var(--text-faint);font-size:inherit;flex:none;}
252
+ .rl-runs{margin:6px 0 0 6px;padding-left:8px;border-left:2px solid var(--border);}
253
+ .rl-runs .rl-card{background:var(--bg);}
254
+ .rl-runs .rl-card.rl-sel{background:var(--rl-accent-bg-strong);}
255
+ .rl-fold{background:none;border:0;color:var(--text-dim);font:inherit;font-size:12px;padding:4px 0;cursor:pointer;min-height:32px;}
256
+ .rl-fold:hover{color:var(--accent);}
257
+ .rl-hint{font-size:12px;color:var(--text-faint);padding:2px 0 6px;}
258
+ .rl-pill{font-size:11px;border:1px solid var(--amber);color:var(--amber);padding:0 5px;margin-left:auto;flex:none;}
259
+ .rl-pill-need{border-color:var(--accent);color:var(--accent);}
260
+ .rl-dash{border-top:1px dashed var(--border-strong);margin:10px 0 6px;}
261
+ .rl-sect{font-size:12px;color:var(--text-dim);font-weight:700;margin:12px 0 6px;}
262
+ .rl-sect:first-child{margin-top:0;}
263
+ #panel-runs .rl-faint{margin-bottom:4px;}
264
+ @media (max-width:899px){ .rl-fold{min-height:44px;} }
265
+ /* the one thing P9 v2 adds to fwd: a run that matches the search or filter is marked, not selected */
266
+ .rl-card.rl-hit{box-shadow:inset 3px 0 0 var(--accent);}
238
267
 
239
268
  .rp-header{display:flex;align-items:center;justify-content:space-between;padding:10px 16px;border-bottom:2px solid var(--border-strong);background:var(--panel);flex-wrap:wrap;gap:8px;}
240
269
  .rp-header h2{margin:0;font-size:15px;}
@@ -257,6 +286,7 @@
257
286
  .ended-block.magenta{border-color:var(--magenta);}
258
287
  .ended-block.red{border-color:var(--red);}
259
288
  .ended-block.green{border-color:var(--green);}
289
+ .ended-block.grey{border-color:var(--grey-dot);}
260
290
  .ended-row{display:flex;gap:10px;align-items:baseline;flex-wrap:wrap;margin-bottom:6px;}
261
291
  .ended-label{color:var(--text-faint);font-size:11px;letter-spacing:0.08em;min-width:52px;}
262
292
  .ended-text{flex:1 1 220px;min-width:0;overflow-wrap:anywhere;}
@@ -293,15 +323,19 @@
293
323
  .rw-picked{display:flex;justify-content:space-between;align-items:center;gap:8px;margin-top:6px;padding:6px 8px;border:1px solid var(--accent);}
294
324
  .rw-picked span{overflow-wrap:anywhere;}
295
325
  .rw-picked a{font-size:12px;cursor:pointer;flex:none;}
326
+ /* T Part 7: the SCHEDULED / NEXT twin of the ENDED block, and the note under both */
327
+ /* RULED 2026-10-15 (aligned with fwdloop M9 am7): a schedule that started nothing turns its whole SCHEDULE row red; the frame turns red with `.ended-block.red` */
328
+ .ended-row.is-red .ended-label,.ended-row.is-red .ended-text,.ended-text.is-red{color:var(--red);}
329
+ .ended-text .sched-line2{display:block;}
296
330
  .run-actions{display:flex;gap:10px;align-items:center;flex-wrap:wrap;margin-bottom:12px;}
297
331
  .run-actions[hidden]{display:none;}
298
332
  .run-actions .stopping{color:var(--amber);font-size:13px;overflow-wrap:anywhere;}
299
333
  .resume-err{color:var(--red);white-space:pre-wrap;overflow-wrap:anywhere;}
300
334
  /* P5-R: a resumed run is ONE card; its legs are told apart by a divider line, a dotted map connector, a small tag */
301
- .wf-resumed{color:var(--amber);}
302
335
  .audit-leg-divider{text-align:center;color:var(--text-dim);font-size:12px;margin:10px 0;overflow-wrap:anywhere;}
303
336
  .audit-leg-divider-row td{text-align:center;color:var(--text-dim);font-size:12px;background:var(--panel2);}
304
337
  .summary-box.red{border-color:var(--red);}
338
+ .summary-box.grey{border-color:var(--grey-dot);}
305
339
  .summary-box.amber{border-color:var(--amber);}
306
340
  .summary-box.magenta{border-color:var(--magenta);}
307
341
  .summary-box::before{content:"┤ SUMMARY ├";position:absolute;top:-10px;left:10px;background:var(--panel2);padding:0 6px;font-size:11px;color:var(--text-faint);letter-spacing:0.08em;}
@@ -331,6 +365,7 @@
331
365
  .map-chip.s-done{border-color:var(--green);color:var(--text);} .map-chip.s-done .chip-sign{color:#2e7d32;}
332
366
  .map-chip.s-running{border-color:var(--amber);color:var(--text);} .map-chip.s-running .chip-sign{color:#b8860b;}
333
367
  .map-chip.s-stopped{border-color:var(--red);color:var(--text);} .map-chip.s-stopped .chip-sign{color:var(--red);}
368
+ .map-chip.s-stopsign{border-color:var(--grey-dot);} .map-chip.s-stopsign .chip-sign{color:var(--grey-dot);} /* RULED 2026-10-15: a step you stopped is grey, not red */
334
369
  .map-chip.s-died{border-color:var(--magenta);color:var(--text);} .map-chip.s-died .chip-sign{color:var(--magenta);}
335
370
  .map-chip.retry{border-style:dashed;}
336
371
  .map-chip.resumed-next{border-right-style:dotted;}
@@ -401,6 +436,13 @@
401
436
  .job-card h4::before{content:"┤ ";color:var(--text-faint);}
402
437
  .job-card h4::after{content:" ├";color:var(--text-faint);}
403
438
  .field{margin-bottom:10px;}
439
+ /* T Part 7: the When field (sub-boxes show and hide; 44px controls) */
440
+ .when-sub{display:flex;gap:6px;margin-top:6px;flex-wrap:wrap;}
441
+ .when-sub[hidden]{display:none;}
442
+ .when-sub select,.when-sub input{flex:1 1 120px;min-width:0;width:auto;min-height:44px;}
443
+ #jf-when{min-height:44px;}
444
+ .jf-err{color:var(--red);font-size:12px;overflow-wrap:anywhere;}
445
+ .jf-err:empty{display:none;}
404
446
  .field input,.field select{width:100%;}
405
447
  label{display:block;font-size:11px;font-weight:700;color:var(--text-dim);margin-bottom:2px;text-transform:uppercase;letter-spacing:0.04em;}
406
448
  .ro-value{padding:6px 8px;min-height:32px;box-sizing:border-box;border:1px solid var(--border);background:var(--bg);color:var(--text-dim);}
@@ -670,6 +712,7 @@
670
712
  <button class="btn card-clear" type="button" id="chat-clear-btn" data-testid="chat-clear">Clear</button>
671
713
  <div class="hint" id="chat-startfrom-note" data-testid="startfrom-note" hidden></div>
672
714
  <div class="origin-line" id="chat-origin-line" data-testid="origin-line" hidden></div>
715
+ <div class="startfrom-line" id="chat-startfrom-line" data-testid="startfrom-line" hidden></div>
673
716
 
674
717
  <div class="field">
675
718
  <label id="jf-verdict-label">Check type</label>
@@ -684,7 +727,6 @@
684
727
  <ul class="rw-list" id="rw-list" data-testid="rw-list"></ul>
685
728
  </div>
686
729
  <div class="rw-picked" id="rw-picked" data-testid="rw-picked" hidden><span id="rw-picked-txt" data-testid="rw-picked-txt"></span><a id="rw-change" data-testid="rw-change" role="button" tabindex="0">change</a></div>
687
- <div class="startfrom-line" id="chat-startfrom-line" data-testid="startfrom-line" hidden></div>
688
730
  </div>
689
731
  </div>
690
732
  <div class="field">
@@ -720,6 +762,24 @@
720
762
  <textarea class="jf-wrap" id="jf-dest" rows="1" placeholder="the write fence — where in the repo this run may change files"></textarea>
721
763
  </div>
722
764
 
765
+ <!-- T Part 7: the When field, only on the card opened by Schedule run / Cancel-modify schedule. Every word is filled from the server's table. -->
766
+ <div class="field" id="jf-when-field" data-testid="jf-when-field" hidden>
767
+ <label for="jf-when" id="jf-when-label">When</label>
768
+ <select id="jf-when" data-testid="jf-when" aria-label="When this job runs"></select>
769
+ <div class="when-sub" id="jf-when-clock" hidden>
770
+ <select id="jf-when-day" data-testid="jf-when-day" aria-label="Day of the week">
771
+ <option value="mon">Monday</option><option value="tue">Tuesday</option><option value="wed">Wednesday</option><option value="thu">Thursday</option>
772
+ <option value="fri">Friday</option><option value="sat">Saturday</option><option value="sun">Sunday</option>
773
+ </select>
774
+ <input id="jf-when-time" type="time" value="09:00" data-testid="jf-when-time" aria-label="Time, HH:MM">
775
+ </div>
776
+ <div class="when-sub" id="jf-when-filebox" hidden>
777
+ <input id="jf-when-path" type="text" autocomplete="off" spellcheck="false" data-testid="jf-when-path" aria-label="File or folder">
778
+ </div>
779
+ <div class="hint" id="jf-when-now" data-testid="jf-when-now"></div>
780
+ <div class="jf-err" id="jf-err-when" data-testid="jf-err-when"></div>
781
+ </div>
782
+
723
783
  <div class="cap-row" data-testid="cap-row">
724
784
  <div class="cap-field">
725
785
  <label for="jf-cap-money">Cap $</label>
@@ -765,9 +825,7 @@
765
825
  </section>
766
826
 
767
827
  <section id="panel-runs" class="tabpanel" role="tabpanel" aria-labelledby="tab-runs" hidden>
768
- <div class="chips" role="group" aria-label="Runs view" style="margin-bottom:8px;">
769
- <button class="chip" type="button" aria-pressed="true" data-runs-view="workflows" data-testid="runs-view-workflows">Workflows</button>
770
- <button class="chip" type="button" aria-pressed="false" data-runs-view="history" data-testid="runs-view-history">History</button>
828
+ <div style="margin-bottom:8px;">
771
829
  <button class="btn small" type="button" id="wf-import" data-testid="wf-import" title="look at an exported job folder (read only)">Import</button>
772
830
  </div>
773
831
  <div class="import-box" id="import-box" data-testid="import-box" hidden>
@@ -784,10 +842,12 @@
784
842
  <button class="btn" type="button" id="import-cancel" data-testid="import-cancel">Cancel</button>
785
843
  </div>
786
844
  </div>
845
+ <div id="rl-top" data-testid="rl-top"></div>
846
+ <div class="rl-dash"></div>
847
+ <div class="rl-faint" data-testid="rl-filter-note">filters below act on Jobs and quiet jobs only</div>
787
848
  <div id="runs-filterbar-anchor"></div>
788
849
  <div id="wf-list" data-testid="wf-list"></div>
789
850
  <div id="import-list" data-testid="import-list"></div>
790
- <div id="history-list" data-testid="history-list" hidden></div>
791
851
  </section>
792
852
 
793
853
  </div>
@@ -813,8 +873,12 @@
813
873
 
814
874
  <section id="panel-run" class="tabpanel active" role="tabpanel" aria-labelledby="tab-run">
815
875
  <div class="import-view" id="import-view" data-testid="import-view" hidden></div>
816
- <div id="run-empty" class="hint" style="font-size:13px;padding:24px 0;">select a run from Workflows or History</div>
876
+ <div id="run-empty" class="hint" style="font-size:13px;padding:24px 0;">select a run from the Runs list</div>
817
877
  <div id="run-content" hidden>
878
+ <!-- RULED 2026-10-15: ONE summary block: ENDED, NEXT, SCHEDULE (fwd #summary-blocks, aligned with M9 am7) -->
879
+ <div id="summary-blocks" data-testid="summary-blocks">
880
+ <div id="ended-block" class="ended-block" data-testid="ended-block" hidden></div>
881
+ </div>
818
882
  <div class="run-actions" id="run-actions" data-testid="run-actions" hidden></div>
819
883
  <div class="counters" id="run-counters" data-testid="live-counters"></div>
820
884
  <div class="summary-box" id="run-summary" data-testid="run-summary"></div>
@@ -826,7 +890,6 @@
826
890
  <section id="panel-audit" class="tabpanel" role="tabpanel" aria-labelledby="tab-audit" hidden>
827
891
  <div id="audit-select-empty" class="hint" style="font-size:13px;padding:24px 0;">select a run first</div>
828
892
  <div id="audit-body" hidden>
829
- <div id="ended-block" class="ended-block" data-testid="ended-block" hidden></div>
830
893
  <div class="audit-toolbar" style="display:flex;align-items:center;justify-content:space-between;gap:10px;flex-wrap:wrap;margin-bottom:10px;">
831
894
  <div class="chips" role="group" aria-label="Filter audit rows" style="margin-bottom:0;">
832
895
  <button class="chip" aria-pressed="true" data-filter="all" data-testid="chip-all">All</button>
@@ -863,6 +926,8 @@
863
926
  <h4 id="details-title">Job card — signed</h4>
864
927
  <div class="field"><label>Check type</label><div class="ro-value" id="details-verdict">—</div></div>
865
928
  <div class="field"><label>Model</label><div class="ro-value" id="details-model">—</div></div>
929
+ <div class="field" id="details-started-field" hidden><label>Started</label><div class="ro-value" id="details-started" data-testid="details-started" style="overflow-wrap:anywhere;">—</div></div>
930
+ <div class="field" id="details-schedule-field" hidden><label>Schedule</label><div class="ro-value" id="details-schedule" data-testid="details-schedule" style="overflow-wrap:anywhere;">—</div></div>
866
931
  <div class="field" id="details-description-field"><label>Description</label><div class="ro-value" id="details-description">—</div></div>
867
932
  <div class="field"><label>The job</label><div class="ro-value" id="details-plan" data-testid="details-plan">—</div></div>
868
933
  <div class="field"><label>Inputs</label><div class="ro-value" id="details-inputs" data-testid="details-inputs">—</div></div>
@@ -1167,7 +1232,7 @@
1167
1232
  var sign = s.status && s.status.sign ? s.status.sign : "";
1168
1233
  var tries = boxRetryTry(s);
1169
1234
  var cls = "map-chip s-" + String(s.state).replace(/[^a-z-]/g, "") +
1170
- (tries ? " retry" : "") + (s.resumedNext ? " resumed-next" : "");
1235
+ (tries ? " retry" : "") + (sign === "\u25A0" ? " s-stopsign" : "") + (s.resumedNext ? " resumed-next" : "");
1171
1236
  var label = escapeXml(stepTitleText(numIdx[idx], s));
1172
1237
  var blocked = s.blocked > 0 ? ' <span class="chip-blocked">' + s.blocked + ' blocked</span>' : '';
1173
1238
  var retry = tries ? '<span class="chip-retry">\u21bb' + tries + '</span> ' : '';
@@ -1259,6 +1324,10 @@
1259
1324
  function realSteps(steps){ return steps.filter(function(s){ return !s.synthetic; }); }
1260
1325
 
1261
1326
  // ---- glyph/badge helpers (only [✓]/[✗]/[▶]/[·]/[?] — never the words green/red/soft-green/died) ----
1327
+ // RULED 2026-10-15 (matches fwdloop glyphPulses): ONE rule for which sign blinks. Running blinks; a run waiting for the person's Accept blinks;
1328
+ // everything else rests (a schedule that is on and waiting included). `needs` = the run's Needs-you tag.
1329
+ function dotPulses(g, needs){ return g === "▶" || needs === "Accept"; }
1330
+ function dotClass(g, needs){ return "dot " + glyphClass(g) + (dotPulses(g, needs) ? " pulse" : ""); }
1262
1331
  function glyphClass(g){
1263
1332
  if(g === "✓") return "green";
1264
1333
  if(g === "✗") return "red";
@@ -1285,9 +1354,9 @@
1285
1354
  // A run with no number (an imported bundle's run is not in the run list) falls back to its id.
1286
1355
  function runLabel(runNo, runid){ return typeof runNo === "number" ? "run-" + runNo : String(runid || ""); }
1287
1356
  function runName(job, runNo, runid){ return job + " (" + runLabel(runNo, runid) + ")"; }
1288
- function setRunHeader(sign, nameText, bodyHtml){
1357
+ function setRunHeader(sign, nameText, bodyHtml, needs){
1289
1358
  document.getElementById("active-wf-name").textContent = nameText;
1290
- document.getElementById("active-wf-dot").className = "dot " + glyphClass(sign);
1359
+ document.getElementById("active-wf-dot").className = dotClass(sign, needs);
1291
1360
  var st = document.getElementById("active-wf-verdict");
1292
1361
  st.innerHTML = bodyHtml ? '<span class="rp-sep">\u2502</span>' + bodyHtml : "";
1293
1362
  st.removeAttribute("title");
@@ -1306,131 +1375,89 @@
1306
1375
  return statusWordHtml(detail.status, reason) + " \u00B7 " + escapeXml(when);
1307
1376
  }
1308
1377
 
1309
- // P5-R: a resumed run is the SAME run — one card, never two. This is only a small tag on it.
1310
- function resumedTagHtml(count, testid){
1311
- return (typeof count === "number" && count > 0)
1312
- ? '<span class="wf-meta wf-resumed" data-testid="' + escapeXml(testid) + '">resumed &times;' + count + '</span>' : '';
1313
- }
1314
-
1315
- // ---- Runs tab: ONE run-row builder shared by History (flat) and the
1316
- // Workflows view's per-job expansion (item 2, 2026-09-26 merge) — the two
1317
- // never drift on how a run row reads.
1318
- function buildRunRowEl(r, compact){
1319
- var row = document.createElement("div");
1320
- // build item (2026-09-26 fix): compute selected state here, off the
1321
- // module's own `currentRunid`, so a row freshly built by a RE-RENDER
1322
- // (switching Runs view mode, or a Workflows parent auto-expanding onto
1323
- // an older run picked from History) shows selected without needing a
1324
- // click on this exact element — `selectRun`'s imperative class toggle
1325
- // (used when a click passes its own `rowEl`) stays layered on top of
1326
- // this, never replaced by it.
1327
- var selected = r.runid === currentRunid;
1328
- row.className = "hist-row" + (selected ? " selected" : "");
1329
- row.setAttribute("data-testid", "hist-row-" + r.runid);
1330
- row.setAttribute("tabindex", "0");
1331
- row.setAttribute("role", "button");
1332
- row.setAttribute("aria-pressed", selected ? "true" : "false");
1333
- var histFullName = runName(r.job, r.runNo, r.runid);
1334
- if(r.starting){
1335
- row.innerHTML =
1336
- '<span class="wf-line1"><span class="dot amber"></span>' +
1337
- '<span class="wf-name" title="' + escapeXml(histFullName) + '">' + escapeXml(histFullName) + '</span></span>' +
1338
- '<span class="wf-meta-line">' +
1339
- '<span class="wf-meta" data-testid="hist-starting-' + escapeXml(r.runid) + '">starting…</span>' +
1340
- '<span class="wf-meta">' + escapeXml((r.at || "").slice(0,10) || "unknown") + '</span>' +
1341
- '</span>';
1342
- } else if(r.fileMissing){
1343
- row.innerHTML =
1344
- '<span class="wf-line1"><span class="dot grey"></span>' +
1345
- '<span class="wf-name" title="' + escapeXml(histFullName) + '">' + escapeXml(histFullName) + '</span></span>' +
1346
- '<span class="wf-meta-line">' +
1347
- '<span class="wf-meta">file missing</span>' +
1348
- '<span class="wf-meta">' + escapeXml((r.at || "").slice(0,10) || "unknown") + '</span>' +
1349
- '</span>';
1350
- } else if(compact){
1351
- // an EXPANDED per-run row under a workflow (hamr 2026-10-06), 2 lines, left-aligned:
1352
- // `[sign] (run-N) **word** — reason` / `$ ($drafting) · wall · date`
1353
- row.innerHTML =
1354
- '<span class="wf-line1 wf-compact"><span class="dot ' + glyphClass(r.glyph) + '"></span>' +
1355
- '<span class="wf-name wf-runno" title="' + escapeXml(histFullName) + '" data-testid="hist-runno-' + escapeXml(r.runid) + '">(' + escapeXml(runLabel(r.runNo, r.runid)) + ')</span>' +
1356
- '<span class="wf-ended wf-ended-inline" data-testid="hist-ended-' + escapeXml(r.runid) + '">' + statusWordHtml(r.status, r.endedLine) + '</span></span>' +
1357
- '<span class="wf-meta-line">' +
1358
- '<span class="wf-meta">' + escapeXml(rowSpendText(r, true)) + '</span>' +
1359
- '<span class="wf-meta">' + escapeXml(rowWallText(r)) + '</span>' +
1360
- '<span class="wf-meta">' + escapeXml(r.date || "unknown") + '</span>' +
1361
- resumedTagHtml(r.resumedCount, "hist-resumed-" + r.runid) +
1362
- '</span>';
1363
- } else {
1364
- // History card: line 1 sign + job (run-N); line 2 **word** — reason; line 3 the facts
1365
- row.innerHTML =
1366
- '<span class="wf-line1"><span class="dot ' + glyphClass(r.glyph) + '"></span>' +
1367
- '<span class="wf-name" title="' + escapeXml(histFullName) + '">' + escapeXml(histFullName) + '</span></span>' +
1368
- (r.status ? '<span class="wf-ended" data-testid="hist-ended-' + escapeXml(r.runid) + '">' + statusWordHtml(r.status, r.endedLine) + '</span>' : '') +
1369
- '<span class="wf-meta-line">' +
1370
- '<span class="wf-meta">' + escapeXml(r.checkType) + '</span>' +
1371
- '<span class="wf-meta">' + escapeXml(rowSpendText(r)) + '</span>' +
1372
- '<span class="wf-meta">' + escapeXml(rowWallText(r)) + '</span>' +
1373
- '<span class="wf-meta">' + escapeXml(r.date || "unknown") + '</span>' +
1374
- resumedTagHtml(r.resumedCount, "hist-resumed-" + r.runid) +
1375
- '</span>';
1376
- }
1377
- row.addEventListener("click", function(){ selectRun(r.runid, row, ".hist-row"); document.getElementById("tab-run").click(); scrollRunIntoViewMobile(); });
1378
- row.addEventListener("keydown", function(e){ if(e.key === "Enter" || e.key === " "){ e.preventDefault(); row.click(); } });
1379
- return row;
1380
- }
1381
-
1382
- // ---- History view ----
1383
- function renderHistory(runs){
1384
- var el = document.getElementById("history-list");
1385
- el.innerHTML = "";
1386
- var listed = (runs || []).filter(function(r){ return !r.fileMissing; });
1387
- if(!listed.length){
1388
- el.innerHTML = '<div class="hint">no runs listed yet</div>';
1389
- return;
1390
- }
1391
- (runs || []).forEach(function(r){ el.appendChild(buildRunRowEl(r)); });
1378
+ // ---- P9 v2: ONE runs list (PANEL-BUILD.md "P9 v2", copied from fwdloop M11). Top to bottom: Running now, Needs you (the Scheduled slot follows
1379
+ // them once the triggers piece lands), a dashed line and the filters (they act on Jobs and quiet jobs only), Jobs, the quiet fold. The page works
1380
+ // out NOTHING: `live`, `needs`, each job's head run and quiet flag, the order and the limits all come in GET /api/runs. `rlModel` is pure (payload
1381
+ // in, cards out); the rl*Html functions only draw it; `paintRunsList` is the ONE painter.
1382
+ var rlData = { runs: [], jobs: [], scheduled: [], limits: { quietDays: 0, olderRuns: 0, needsYouDays: 0 } };
1383
+ // manual fold memory, keyed by job ("__quiet" is the quiet fold): a per-viewer convenience, reset on reload. A manual click wins over an automatic open.
1384
+ var rlOpen = {};
1385
+ // the last markup written into each box: an identical repaint (every poll) writes nothing, so focus, scroll and a half-read card survive it
1386
+ var rlLast = { top: null, jobs: null };
1387
+ // The ONE writer of `rlData`: the whole /api/runs payload, as the server sent it.
1388
+ function setRunsData(r){
1389
+ rlData = { runs: r.runs || [], jobs: r.jobs || [], scheduled: r.scheduled || [], limits: r.limits || rlData.limits };
1390
+ }
1391
+
1392
+ // The ONE date of every card in the list: local `MM/DD HH:MM`. An unparseable stamp is shown raw, never blank.
1393
+ function rlShortDate(iso){
1394
+ var d = new Date(iso);
1395
+ if(isNaN(d.getTime())) return String(iso);
1396
+ var t = function(n){ return ("0" + n).slice(-2); };
1397
+ return t(d.getMonth() + 1) + "/" + t(d.getDate()) + " " + t(d.getHours()) + ":" + t(d.getMinutes());
1398
+ }
1399
+ function runAtText(r){
1400
+ return r.at ? rlShortDate(r.at) : "unknown";
1401
+ }
1402
+
1403
+ // Everything a run card says, in ONE place: the card is drawn from it and the search reads it, so what you can see is what you can find.
1404
+ function rlRunParts(r){
1405
+ var rn = runLabel(r.runNo, r.runid);
1406
+ var p = { rn: rn, name: r.job + " (" + rn + ")", date: runAtText(r), model: r.model || "", word: "", why: "", spend: "", wall: "" };
1407
+ if(r.starting){ p.word = "running"; p.why = "starting…"; return p; }
1408
+ if(r.fileMissing){ p.word = ""; p.why = "file missing"; return p; }
1409
+ p.word = r.status && r.status.word ? r.status.word : "";
1410
+ p.why = r.endedLine || (p.word === "passed" ? "goal met" : "");
1411
+ p.spend = rowSpendText(r, true);
1412
+ p.wall = rowWallText(r) || "";
1413
+ return p;
1414
+ }
1415
+ // ONE function for "does this run match the search": the filter and the highlight both call it, so they cannot disagree. M11 am5 (PANEL-BUILD.md P9 v2 O):
1416
+ // the search splits at spaces and EVERY word must match, case-insensitive. A word shaped run-N is that run exactly; a word shaped MM/DD or YYYY-MM-DD (a real
1417
+ // day) is the run's LOCAL start day, from the same `at` the card shows (MM/DD = any year; no date = no match); a part date (10/1, 10/) matches nothing, not even
1418
+ // the card's date text; any other word is a substring of the whole visible row (job, run, run id, model, status word, why, spend, wall, date, check type).
1419
+ function runMatchesSearch(r, query){
1420
+ var words = String(query || "").toLowerCase().replace(/[()]/g, "").split(/\s+/).filter(function(w){ return w; });
1421
+ if(!words.length) return true;
1422
+ var p = rlRunParts(r);
1423
+ var d = r.at ? new Date(r.at) : null;
1424
+ var dated = !!d && !isNaN(d.getTime());
1425
+ var rn = String(p.rn).toLowerCase();
1426
+ var rest = [r.job, p.rn, p.name, r.runid, p.model, p.word, p.why, p.spend, p.wall, r.checkType || ""].join(" ").toLowerCase();
1427
+ var whole = rest + " " + String(p.date).toLowerCase();
1428
+ return words.every(function(w){
1429
+ if(/^run-\d+$/.test(w)) return rn === w;
1430
+ var day = /^(?:(\d{4})-)?(\d\d)[-\/](\d\d)$/.exec(w);
1431
+ if(day && (day[1] ? w.charAt(4) === "-" && w.charAt(7) === "-" : w.charAt(2) === "/" || w.charAt(2) === "-")){
1432
+ var mo = Number(day[2]), da = Number(day[3]);
1433
+ if(mo >= 1 && mo <= 12 && da >= 1 && da <= 31){
1434
+ return dated && d.getMonth() + 1 === mo && d.getDate() === da && (!day[1] || d.getFullYear() === Number(day[1]));
1435
+ }
1436
+ }
1437
+ if(/^[\d\/-]+$/.test(w) && /[\/-]/.test(w)) return rest.indexOf(w) !== -1;
1438
+ return whole.indexOf(w) !== -1;
1439
+ });
1392
1440
  }
1393
1441
 
1394
- // ---- Workflows view (item 2, 2026-09-26 merge): groups the ONE /api/runs
1395
- // payload by job name client-side — the sole owner of that grouping now
1396
- // (the old /api/workflows endpoint is gone; see src/panel/server.js). Runs
1397
- // arrive newest-first (server contract, see listRuns), so the FIRST run
1398
- // seen for a job is its latest — no re-sort needed, and job order (first-
1399
- // job-seen order) is already newest-first by construction.
1400
- function groupRunsByJob(runs){
1442
+ // A job's runs, the server's head first, the rest in the server's order (newest first). The head is the server's call (`headRunid`), never "the first row".
1443
+ function groupRunsByJob(runs, jobs){
1401
1444
  var byJob = {};
1402
1445
  var order = [];
1446
+ var headOf = {};
1447
+ (jobs || []).forEach(function(j){ headOf[j.job] = j.headRunid; if(!byJob[j.job]){ byJob[j.job] = []; order.push(j.job); } });
1403
1448
  (runs || []).forEach(function(r){
1404
1449
  if(!byJob[r.job]){ byJob[r.job] = []; order.push(r.job); }
1405
1450
  byJob[r.job].push(r);
1406
1451
  });
1407
1452
  return order.map(function(job){
1408
- var jobRuns = byJob[job];
1409
- var last = jobRuns[0];
1410
- return {
1411
- job: job,
1412
- runs: jobRuns,
1413
- runCount: jobRuns.length,
1414
- lastRunid: last.runid,
1415
- lastRunNo: last.runNo,
1416
- lastAt: last.at,
1417
- lastGlyph: last.glyph || "▶",
1418
- lastStatus: last.status || null,
1419
- lastEndedLine: last.endedLine || null,
1420
- lastResumedCount: last.resumedCount || 0,
1421
- lastCheckType: last.checkType || "unknown",
1422
- lastModel: last.model || null,
1423
- lastSpend: rowSpendText(last, true),
1424
- lastWall: rowWallText(last) || "unknown",
1425
- lastDate: last.date,
1426
- };
1453
+ var all = byJob[job];
1454
+ var head = all.filter(function(r){ return r.runid === headOf[job]; })[0] || all[0] || null;
1455
+ return { job: job, runs: head ? [head].concat(all.filter(function(r){ return r !== head; })) : [], runCount: all.length, headRunid: head ? head.runid : null };
1427
1456
  });
1428
1457
  }
1429
1458
 
1430
- // Matches a job if ANY of its runs match the filters (not only the
1431
- // latest) — a one-line adapter over filterRuns (never a reimplementation
1432
- // of the AND/OR/time/search semantics), same posture as the old
1433
- // filterWorkflows but now correct against the job's FULL run list.
1459
+ // Matches a job if ANY of its runs match the filters (not only the head): a one-line adapter over filterRuns, never a reimplementation of the
1460
+ // AND/OR/time/search semantics.
1434
1461
  function filterWorkflows(jobs, filters, nowMs){
1435
1462
  return (jobs || []).filter(function(g){
1436
1463
  return filterRuns(g.runs, filters, nowMs).length > 0;
@@ -1446,166 +1473,230 @@
1446
1473
  ));
1447
1474
  }
1448
1475
 
1449
- // Pure rule (item 2 build spec): when filters/search are active, a job
1450
- // whose LATEST run doesn't match but an older one does auto-expands so the
1451
- // matching run is visible without an extra click; inactive filters never
1452
- // force an expand.
1453
- function autoExpandJob(group, matchingRuns, active){
1454
- if(!active || !matchingRuns.length) return false;
1455
- var latestId = group.runs.length ? group.runs[0].runid : null;
1456
- var latestIncluded = matchingRuns.some(function(r){ return r.runid === latestId; });
1457
- return !latestIncluded;
1458
- }
1459
-
1460
- // build item (2026-09-26 fix, F: parent-row/older-run mismatch): ONE owner
1461
- // for "which run does this parent row stand for" — both its own selected
1462
- // state and what its click opens read this, never two separate rules that
1463
- // could drift. Unfiltered (or when the latest still matches), a job's
1464
- // parent always stands for its latest run, same as before this fix. With
1465
- // a filter/search that matches an OLDER run but excludes the latest, the
1466
- // parent stands for the newest MATCHING run instead — so its click opens
1467
- // the run the search actually found, and (once picked) the parent's own
1468
- // selected state follows that same run rather than silently reverting to
1469
- // the latest.
1470
- function representedRun(group, candidateRuns, active){
1471
- if(active && candidateRuns.length){
1472
- var latestIncluded = candidateRuns.some(function(r){ return r.runid === group.lastRunid; });
1473
- if(!latestIncluded) return candidateRuns[0].runid;
1474
- }
1475
- return group.lastRunid;
1476
- }
1477
-
1478
- // Is `currentRunid` one of this job's OLDER runs (i.e. a run that
1479
- // belongs to this job but isn't its latest)? Selecting a run from History
1480
- // and switching to Workflows lands here: the job must auto-expand and its
1481
- // matching child row must carry the selected marker even though no
1482
- // filter/search is active and no click happened inside Workflows itself.
1483
- function activeOlderRun(group){
1484
- if(!currentRunid || currentRunid === group.lastRunid) return null;
1485
- var found = group.runs.some(function(r){ return r.runid === currentRunid; });
1486
- return found ? currentRunid : null;
1487
- }
1488
-
1489
- // manual expand/collapse memory, keyed by job name — survives a re-render
1490
- // triggered by a filter/search change within the same page load, reset on
1491
- // full page reload (per-viewer convenience only, never persisted). A
1492
- // manual collapse click still wins over auto-expand-for-selection on the
1493
- // next render (simplest rule: one flag, no "but selection forces it back
1494
- // open" special case) — collapsing a job you're not looking at any more
1495
- // is a normal, expected action.
1496
- var wfExpanded = {};
1497
-
1498
- // hamr 2026-10-08 (display only, nothing deleted): with NO search/filter active an expanded job shows at most its
1499
- // latest WF_SUB_CAP other runs (g.runs is latest-first). A selected older run beyond the cap (picked from History) is
1500
- // appended after them rather than hidden. With search/filters active every matching sub-card shows.
1501
- var WF_SUB_CAP = 7;
1502
- function capSubRuns(childRuns, active, selectedRunid){
1503
- if(active || childRuns.length <= WF_SUB_CAP) return {shown: childRuns, more: false};
1504
- var shown = childRuns.slice(0, WF_SUB_CAP);
1505
- var sel = selectedRunid ? childRuns.slice(WF_SUB_CAP).filter(function(r){ return r.runid === selectedRunid; }) : [];
1506
- return {shown: shown.concat(sel), more: true};
1507
- }
1508
-
1509
- function renderWorkflows(jobs){
1510
- var el = document.getElementById("wf-list");
1511
- el.innerHTML = "";
1512
- if(!jobs.length){
1513
- el.innerHTML = '<div class="hint">no runs listed yet — run `bareloop run-u` or `bareloop run`, or `bareloop runs backfill <dir>`</div>';
1514
- return;
1476
+ function rlModel(data, groups, filters, limits, sel, open){
1477
+ var active = filtersActive(filters);
1478
+ var place = {};
1479
+ (data.jobs || []).forEach(function(j, i){ place[j.job] = { quiet: j.quiet === true, at: i }; });
1480
+ var cards = groups.slice().sort(function(a, b){
1481
+ return (place[a.job] ? place[a.job].at : Infinity) - (place[b.job] ? place[b.job].at : Infinity);
1482
+ }).map(function(g){
1483
+ var quiet = !!(place[g.job] && place[g.job].quiet);
1484
+ if(g.headRunid === null) return { job: g.job, group: g, head: null, empty: true, quiet: quiet, selected: false, count: 0, sel: sel, hits: [], older: { open: false, shown: [], hidden: 0, left: [], active: active, total: 0 } };
1485
+ var head = g.runs[0];
1486
+ var matching = active ? filterRuns(g.runs, filters, Date.now()) : [];
1487
+ var older = g.runs.slice(1);
1488
+ var isSel = function(r){ return r.runid === sel.runid; };
1489
+ // M11 am5 item 4: the fold is capped at limits.olderRuns, newest first. With a search or chip on, only the older runs that match count; the selected run stays shown either way.
1490
+ var pool = active ? older.filter(function(r){ return matching.indexOf(r) !== -1; }) : older;
1491
+ var top = pool.slice(0, limits.olderRuns);
1492
+ var shown = top.concat(older.filter(function(r){ return isSel(r) && top.indexOf(r) === -1; }));
1493
+ var left = pool.filter(function(r){ return shown.indexOf(r) === -1; });
1494
+ // the fold opens by itself when any older run matches (even if the head matches too), or when the selected run is an older one
1495
+ var auto = (active && older.some(function(r){ return matching.indexOf(r) !== -1; })) || older.some(isSel);
1496
+ return {
1497
+ job: g.job, group: g, head: head, empty: false, quiet: quiet, count: g.runCount, sel: sel,
1498
+ selected: g.runs.some(isSel),
1499
+ hits: matching.map(function(r){ return r.runid; }),
1500
+ older: { open: Object.prototype.hasOwnProperty.call(open, g.job) ? open[g.job] : auto, shown: shown, hidden: left.length, left: left, active: active, total: older.length },
1501
+ };
1502
+ });
1503
+ var quietCards = cards.filter(function(c){ return c.quiet; });
1504
+ return {
1505
+ running: data.runs.filter(function(r){ return r.live === true; }),
1506
+ needs: data.runs.filter(function(r){ return typeof r.needs === "string" && r.needs !== ""; }),
1507
+ scheduled: data.scheduled || [],
1508
+ gaveUp: data.gaveUp || [],
1509
+ jobs: cards.filter(function(c){ return !c.quiet; }),
1510
+ quiet: quietCards,
1511
+ quietOpen: Object.prototype.hasOwnProperty.call(open, "__quiet") ? open.__quiet : quietCards.some(function(c){ return c.selected; }),
1512
+ limits: limits,
1513
+ noRows: data.runs.length === 0,
1514
+ };
1515
+ }
1516
+ function rlSectHtml(label, n, note){
1517
+ return '<div class="rl-sect">' + label + ' (' + n + ')' + (note ? ' ' + note : '') + '</div>';
1518
+ }
1519
+ // line 1 and 2 of a Running now / Needs you card; both jump to the run (rlJump). The tag is a label (a span), never a button.
1520
+ function rlJumpCardHtml(r, need){
1521
+ var rn = runLabel(r.runNo, r.runid);
1522
+ var faint;
1523
+ if(r.liveSince){
1524
+ faint = '<span data-rl-since="' + escapeXml(r.liveSince) + '" data-rl-prior="' + (typeof r.priorWallMs === "number" ? r.priorWallMs : "leg") + '"></span>';
1525
+ } else faint = 'time unknown';
1526
+ var l2;
1527
+ if(need){
1528
+ var p = rlRunParts(r);
1529
+ var why = r.needs === "Accept" ? "passed · waiting for your Accept" : [p.word, r.endedLine].filter(function(x){ return x; }).join(" · ");
1530
+ l2 = escapeXml(why) + ' &middot; ' + escapeXml(r.stoppedAt ? rlShortDate(r.stoppedAt) : runAtText(r));
1531
+ } else l2 = escapeXml(r.liveWhy || "running");
1532
+ return '<button class="rl-card ' + (need ? 'rl-need' : 'rl-live') + '" type="button" data-rl-jump="' + escapeXml(r.runid) + '" data-testid="rl-' + (need ? 'need' : 'live') + '-' + escapeXml(r.job) + '-' + escapeXml(r.runid) + '">' +
1533
+ '<span class="rl-l1"><span class="' + dotClass(r.glyph, r.needs) + '"></span><span class="rl-nm" title="' + escapeXml(r.job) + '">' + escapeXml(r.job) + '</span> <span class="rl-rn">&middot; ' + escapeXml(rn) + '</span>' +
1534
+ (need ? '<span class="rl-pill rl-pill-need">' + escapeXml(r.needs) + '</span>' : ' <span class="rl-rest">&middot; ' + faint + ' &middot; ' + escapeXml(rowSpendText(r, true)) + '</span>') + '</span>' +
1535
+ '<span class="rl-l2">' + l2 + '</span></button>';
1536
+ }
1537
+ // RULED 2026-10-15: `job · run-N` in the small lists; the server names the run a Scheduled / gave-up card opens (`runNo`), nothing is made up when it has none
1538
+ function rlRunTagHtml(c){ return typeof c.runNo === "number" ? ' <span class="rl-rn">&middot; ' + escapeXml(runLabel(c.runNo, c.runId)) + '</span>' : ''; }
1539
+ // A schedule that gave up (RULED 2026-10-15): all words from the server (`pill`, `words`, `at`); a click opens the job's newest run, like the other cards.
1540
+ function rlGaveUpCardHtml(g){
1541
+ var body = '<span class="rl-l1"><span class="dot grey"></span><span class="rl-nm" title="' + escapeXml(g.job) + '">' + escapeXml(g.job) + '</span>' + rlRunTagHtml(g) + ' <span class="rl-pill rl-pill-need">' + escapeXml(g.pill) + '</span></span>' +
1542
+ '<span class="rl-l2">' + escapeXml(g.words) + ' &middot; ' + escapeXml(g.at) + '</span>';
1543
+ if(typeof g.runId !== "string" || g.runId === "") return '<div class="rl-card rl-need" data-testid="rl-gaveup-' + escapeXml(g.job) + '">' + body + '</div>';
1544
+ return '<button class="rl-card rl-need" type="button" data-rl-jump="' + escapeXml(g.runId) + '" data-testid="rl-gaveup-' + escapeXml(g.job) + '">' + body + '</button>';
1545
+ }
1546
+ // Scheduled card (T Part 7): `[·] job today 15:00` / `next in 3h · Every day at 15:00`, all from the server. A file trigger has no clock time:
1547
+ // `at` / `in` are null and nothing is made up. A click does what Running now does (rlJump to the job's HEAD run, `runId` from the server and
1548
+ // scrolls to its job). A job with no run to open stays a plain box. Never filtered or searched.
1549
+ function rlScheduledCardHtml(s){
1550
+ var body = '<span class="rl-l1"><span class="dot grey"></span><span class="rl-nm" title="' + escapeXml(s.job) + '">' + escapeXml(s.job) + '</span>' + rlRunTagHtml(s) +
1551
+ (s.at ? ' <span class="rl-rest">&middot; ' + escapeXml(s.at) + '</span>' : '') + '</span>' +
1552
+ '<span class="rl-l2">' + escapeXml([s.in, s.words].filter(function(x){ return x; }).join(" · ")) + '</span>';
1553
+ if(typeof s.runId !== "string" || s.runId === "") return '<div class="rl-card" data-testid="rl-sched-' + escapeXml(s.job) + '">' + body + '</div>';
1554
+ return '<button class="rl-card" type="button" data-rl-jump="' + escapeXml(s.runId) + '" data-testid="rl-sched-' + escapeXml(s.job) + '">' + body + '</button>';
1555
+ }
1556
+ // The sections above the filters, from ONE ordered list: the Scheduled section (PRD v1.99) is one more entry here after Needs you, plus one draw function.
1557
+ function rlTopHtml(m){
1558
+ var sections = [
1559
+ { label: "Running now", items: m.running, draw: function(r){ return rlJumpCardHtml(r, false); } },
1560
+ { label: "Needs you", items: m.needs.concat(m.gaveUp || []), draw: function(r){ return r.pill ? rlGaveUpCardHtml(r) : rlJumpCardHtml(r, true); } },
1561
+ { label: "Scheduled", items: m.scheduled, draw: rlScheduledCardHtml },
1562
+ ];
1563
+ return sections.map(function(s){
1564
+ return s.items.length ? rlSectHtml(s.label, s.items.length) + s.items.map(s.draw).join("") : "";
1565
+ }).join("");
1566
+ }
1567
+ // a run card inside a job: `[sign] run-N MM/DD HH:MM · model` / `word — why · $cost · wall`
1568
+ function rlRunCardHtml(r, selected, hit){
1569
+ var p = rlRunParts(r);
1570
+ var bits = [p.spend, p.wall].filter(function(b){ return b; });
1571
+ var l2;
1572
+ if(r.fileMissing) l2 = escapeXml(p.why);
1573
+ else if(r.starting) l2 = '<b class="st-word">' + escapeXml(p.word) + '</b> — ' + escapeXml(p.why);
1574
+ else l2 = (p.word ? '<b class="st-word">' + escapeXml(p.word) + '</b>' : '') + (p.why ? (p.word ? ' — ' : '') + escapeXml(p.why) : '') + (bits.length ? ' &middot; ' + escapeXml(bits.join(" · ")) : '');
1575
+ return '<button class="rl-card' + (selected ? ' rl-sel' : '') + (hit ? ' rl-hit' : '') + (r.live ? ' rl-live' : '') + '" type="button" data-rl-run="' + escapeXml(r.runid) + '" data-testid="rl-run-' + escapeXml(r.job) + '-' + escapeXml(r.runid) + '" aria-pressed="' + (selected ? 'true' : 'false') + '" title="' + escapeXml(p.name) + '">' +
1576
+ '<span class="rl-l1"><span class="' + dotClass(r.glyph, r.needs) + '"></span><span class="rl-rn">' + escapeXml(p.rn) + '</span> <span class="rl-rest">&middot; ' + escapeXml([p.date, p.model].filter(function(b){ return b; }).join(" · ")) + '</span></span>' +
1577
+ '<span class="rl-l2">' + l2 + '</span></button>';
1578
+ }
1579
+ // a job card: `[sign] job · k runs · type` / `word · why` of its newest run, highlighted when the selected run is one of its runs; not clickable. Inside: the newest run's card, then the older-runs fold.
1580
+ function rlJobCardHtml(c){
1581
+ var h = c.head;
1582
+ var top = '<div class="rl-card' + (c.selected ? ' rl-sel' : '') + '" id="rl-job-' + escapeXml(c.job) + '" data-testid="rl-job-' + escapeXml(c.job) + '">';
1583
+ if(c.empty){
1584
+ return top + '<span class="rl-l1"><span class="dot grey"></span><span class="rl-nm" title="' + escapeXml(c.job) + '">' + escapeXml(c.job) + '</span></span><span class="rl-l2">no runs yet</span></div>';
1515
1585
  }
1516
- var active = filtersActive(currentRunsFilters());
1517
- jobs.forEach(function(g){
1518
- // build item (2026-09-26 fix): the parent row IS the job's latest run
1519
- // (clicking it opens that run), so the inline child list must be the
1520
- // job's OTHER runs only — never repeat the latest as its own first
1521
- // child. Expand affordance (caret, aria-expanded, toggle) is gated on
1522
- // there being a child to actually show: with filters/search active,
1523
- // that's the FILTERED other-runs list, so a job whose only match is
1524
- // its own latest run gets no caret and never expands — clicking it
1525
- // still opens the run, same as any job row. With no filters active
1526
- // this is unchanged (childRuns = all other runs, same as runCount>1).
1527
- var candidateRuns = active ? filterRuns(g.runs, currentRunsFilters(), Date.now()) : g.runs;
1528
- var childRuns = candidateRuns.filter(function(r){ return r.runid !== g.lastRunid; });
1529
- var hasOtherRuns = childRuns.length > 0;
1530
- // build item (2026-09-26 fix): auto-expand for a filter/search match
1531
- // on an older run (existing rule) OR because the run picked elsewhere
1532
- // (e.g. History) IS one of this job's older runs — either way the
1533
- // matching child must be visible without an extra click.
1534
- var olderSelected = activeOlderRun(g);
1535
- var autoExpand = hasOtherRuns && (autoExpandJob(g, candidateRuns, active) || !!olderSelected);
1536
- var expanded = hasOtherRuns && (Object.prototype.hasOwnProperty.call(wfExpanded, g.job) ? wfExpanded[g.job] : autoExpand);
1537
- var targetRunid = representedRun(g, candidateRuns, active);
1538
-
1539
- var wrap = document.createElement("div");
1540
- wrap.className = "wf-job";
1541
-
1542
- var row = document.createElement("div");
1543
- // build item (2026-09-26): a job (parent) row also opens its latest
1544
- // run in the Run tab, same as a child row — so it carries the same
1545
- // "selected" visual state a child row gets from selectRun (the class
1546
- // is computed HERE, at render time, rather than left on the clicked
1547
- // element, since this function tears down and rebuilds every wf-row
1548
- // on every click — see below). `targetRunid` (see `representedRun`
1549
- // above) is normally the latest run, so this is unchanged in the
1550
- // common case; a filter/search that matched an older run instead
1551
- // makes the parent follow THAT run once it's the selected one.
1552
- var isSelected = currentRunid === targetRunid;
1553
- row.className = "wf-row" + (isSelected ? " selected" : "");
1554
- row.setAttribute("data-wf", g.job);
1555
- row.setAttribute("data-testid", "wf-row-" + g.job);
1556
- row.setAttribute("tabindex", "0");
1557
- row.setAttribute("role", "button");
1558
- row.setAttribute("aria-expanded", expanded ? "true" : "false");
1559
- row.setAttribute("aria-pressed", isSelected ? "true" : "false");
1560
- var wfFullName = runName(g.job, g.lastRunNo, g.lastRunid);
1561
- row.innerHTML =
1562
- '<span class="wf-line1 wf-compact"><span class="wf-caret">' + (hasOtherRuns ? (expanded ? "▼" : "▶") : "") + '</span><span class="dot ' + glyphClass(g.lastGlyph) + '"></span>' +
1563
- '<span class="wf-name" title="' + escapeXml(wfFullName) + '">' + escapeXml(wfFullName) + '</span>' +
1564
- (g.lastStatus ? '<span class="wf-ended wf-ended-inline" data-testid="wf-ended-' + escapeXml(g.job) + '">' + statusWordHtml(g.lastStatus, g.lastEndedLine) + '</span>' : '') + '</span>' +
1565
- '<span class="wf-meta-line">' +
1566
- '<span class="wf-meta">' + escapeXml(g.lastCheckType) + '</span>' +
1567
- '<span class="wf-meta">' + escapeXml(g.lastSpend) + '</span>' +
1568
- '<span class="wf-meta">' + escapeXml(g.lastWall) + '</span>' +
1569
- '<span class="wf-meta">' + escapeXml(g.lastDate || "unknown") + '</span>' +
1570
- '</span>';
1571
- row.addEventListener("click", function(){
1572
- // a single-run job has nothing to expand — the click still opens
1573
- // the run, it just never toggles `wfExpanded` for it.
1574
- if(hasOtherRuns) wfExpanded[g.job] = !expanded;
1575
- // build item (2026-09-26): clicking the parent also opens its
1576
- // latest run (as it did before the Workflows/History merge) — pass
1577
- // rowEl null since renderWorkflows(jobs) below rebuilds every row
1578
- // right after, so a class set directly on this element would be
1579
- // discarded; the fresh render marks selection off `currentRunid`
1580
- // instead (see `isSelected` above). build item (2026-09-26 fix):
1581
- // opens `targetRunid`, not always `g.lastRunid` — with a filter/
1582
- // search active that excludes the latest but matches an older run,
1583
- // that's the run the search actually found (see `representedRun`).
1584
- selectRun(targetRunid, null, ".hist-row");
1585
- document.getElementById("tab-run").click();
1586
- scrollRunIntoViewMobile();
1587
- renderWorkflows(jobs);
1588
- });
1589
- row.addEventListener("keydown", function(e){ if(e.key === "Enter" || e.key === " "){ e.preventDefault(); row.click(); } });
1590
- wrap.appendChild(row);
1591
-
1592
- if(expanded){
1593
- var runsWrap = document.createElement("div");
1594
- runsWrap.className = "wf-runs";
1595
- var capped = capSubRuns(childRuns, active, olderSelected);
1596
- capped.shown.forEach(function(r){ runsWrap.appendChild(buildRunRowEl(r, true)); });
1597
- if(capped.more){
1598
- var olderHint = document.createElement("div");
1599
- olderHint.className = "hint";
1600
- olderHint.setAttribute("data-testid", "wf-older-" + g.job);
1601
- olderHint.textContent = "older: use search.";
1602
- runsWrap.appendChild(olderHint);
1586
+ var p = rlRunParts(h);
1587
+ var isSel = function(r){ return r.runid === c.sel.runid; };
1588
+ var isHit = function(r){ return c.hits.indexOf(r.runid) !== -1; };
1589
+ var type = h.checkType && h.checkType !== "unknown" ? ' &middot; ' + escapeXml(h.checkType) : '';
1590
+ var l2 = h.fileMissing ? escapeXml(p.why) : '<b class="st-word">' + escapeXml(p.word) + '</b>' + (p.why ? ' &middot; ' + escapeXml(p.why) : '');
1591
+ var out = top + '<span class="rl-l1"><span class="' + dotClass(h.glyph, h.needs) + '"></span><span class="rl-nm" title="' + escapeXml(c.job) + '">' + escapeXml(c.job) + '</span> <span class="rl-cnt">&middot; ' + c.count + ' run' + (c.count === 1 ? '' : 's') + type + '</span></span>' +
1592
+ '<span class="rl-l2">' + l2 + '</span>' +
1593
+ '<div class="rl-runs">' + rlRunCardHtml(h, isSel(h), isHit(h));
1594
+ if(c.older.total > 0){
1595
+ out += '<button class="rl-fold" type="button" data-rl-fold="' + escapeXml(c.job) + '" data-testid="rl-fold-' + escapeXml(c.job) + '" aria-expanded="' + (c.older.open ? 'true' : 'false') + '">' + (c.older.open ? '▾' : '▸') + ' older runs (' + c.older.total + ')</button>';
1596
+ if(c.older.open){
1597
+ out += c.older.shown.map(function(r){ return rlRunCardHtml(r, isSel(r), isHit(r)); }).join("");
1598
+ if(c.older.hidden > 0){
1599
+ // M11 am7 item 4 + am9, the line after the 7: the hidden runs' (searching: matches') real start days (the time the card
1600
+ // shows, `at`), the MIN to the MAX over ALL of `left` whatever its order: `k more older runs, MM/DD to MM/DD. Search to find one.`, or one day
1601
+ // `k more runs on MM/DD. Search to find one.`. A hidden run with no date drops the date part; nothing is made up. "run" is singular for k = 1.
1602
+ var days = c.older.left.map(function(x){
1603
+ var dd = x.at ? new Date(x.at) : null;
1604
+ return dd && !isNaN(dd.getTime()) ? new Date(dd.getFullYear(), dd.getMonth(), dd.getDate()) : null;
1605
+ });
1606
+ var md = function(dd){ return ("0" + (dd.getMonth() + 1)).slice(-2) + "/" + ("0" + dd.getDate()).slice(-2); };
1607
+ var dated = days.length > 0 && days.indexOf(null) === -1;
1608
+ var lo = dated ? new Date(Math.min.apply(null, days)) : null, hi = dated ? new Date(Math.max.apply(null, days)) : null;
1609
+ var k = c.older.hidden, runWord = k === 1 ? "run" : "runs";
1610
+ var one = dated && md(lo) === md(hi);
1611
+ // am9 (fwd verbatim, `matches` for every k): searching, the same days as the no-search line
1612
+ out += '<div class="rl-hint">' + (c.older.active ?
1613
+ (!dated ? k + ' more matches. Add a date or run number to narrow it.' :
1614
+ one ? k + ' more matches on ' + md(lo) + '. Add a run number to narrow it.' :
1615
+ k + ' more matches, ' + md(lo) + ' to ' + md(hi) + '. Add a date or run number to narrow it.') :
1616
+ !dated ? k + ' more older ' + runWord + '. Search to find one.' :
1617
+ one ? k + ' more ' + runWord + ' on ' + md(lo) + '. Search to find one.' :
1618
+ k + ' more older runs, ' + md(lo) + ' to ' + md(hi) + '. Search to find one.') + '</div>';
1603
1619
  }
1604
- wrap.appendChild(runsWrap);
1605
1620
  }
1606
- el.appendChild(wrap);
1621
+ }
1622
+ return out + '</div></div>';
1623
+ }
1624
+ function rlJobsHtml(m){
1625
+ var h = "";
1626
+ if(m.jobs.length) h += rlSectHtml("Jobs", m.jobs.length, "· run in the last " + m.limits.quietDays + " days") + m.jobs.map(rlJobCardHtml).join("");
1627
+ if(m.quiet.length){
1628
+ h += '<button class="rl-fold" type="button" data-rl-fold="__quiet" data-testid="rl-fold-quiet" aria-expanded="' + (m.quietOpen ? 'true' : 'false') + '">' + (m.quietOpen ? '▾' : '▸') + ' quiet jobs (' + m.quiet.length + ') · no run in ' + m.limits.quietDays + ' days</button>';
1629
+ if(m.quietOpen) h += m.quiet.map(rlJobCardHtml).join("");
1630
+ }
1631
+ if(h === "" && m.noRows) h = '<div class="hint">no runs listed yet</div>';
1632
+ return h;
1633
+ }
1634
+ // Running now: the wall counts up IN PLACE from the server's start time (no rebuild): the page's 2 s tick calls this. Earlier legs add the server's
1635
+ // `priorWallMs`; when that is not exact the card says it counts this leg only.
1636
+ function tickSince(){
1637
+ document.querySelectorAll("[data-rl-since]").forEach(function(el){
1638
+ var t = Date.parse(el.getAttribute("data-rl-since"));
1639
+ if(isNaN(t)){ el.textContent = "time unknown"; return; }
1640
+ var prior = el.getAttribute("data-rl-prior");
1641
+ var leg = prior === "leg";
1642
+ el.textContent = duration(Math.max(0, Date.now() - t) + (leg ? 0 : Number(prior) || 0)) + (leg ? " this leg" : "");
1607
1643
  });
1608
1644
  }
1645
+ // keep focus on the same card (by testid) across a real repaint of a box
1646
+ function withFocusPreserved(box, fn){
1647
+ var a = document.activeElement;
1648
+ var tid = a && box.contains && box.contains(a) && a.getAttribute ? a.getAttribute("data-testid") : null;
1649
+ fn();
1650
+ if(!tid) return;
1651
+ var nodes = box.querySelectorAll("[data-testid]");
1652
+ for(var i = 0; i < nodes.length; i += 1){
1653
+ if(nodes[i].getAttribute("data-testid") === tid){ if(nodes[i].focus) nodes[i].focus(); return; }
1654
+ }
1655
+ }
1656
+ // The ONE painter of the list. Called by the filter bar (a poll, a chip, the search box) and by every select/click here; it writes a box only when its markup changed.
1657
+ function paintRunsList(){
1658
+ var filters = currentRunsFilters();
1659
+ var groups = filterWorkflows(groupRunsByJob(rlData.runs, rlData.jobs), filters, Date.now());
1660
+ var m = rlModel(rlData, groups, filters, rlData.limits, { runid: currentRunid }, rlOpen);
1661
+ var topEl = document.getElementById("rl-top");
1662
+ var listEl = document.getElementById("wf-list");
1663
+ var top = rlTopHtml(m);
1664
+ var jobs = rlJobsHtml(m);
1665
+ if(top !== rlLast.top){ rlLast.top = top; withFocusPreserved(topEl, function(){ topEl.innerHTML = top; }); }
1666
+ if(jobs !== rlLast.jobs){ rlLast.jobs = jobs; withFocusPreserved(listEl, function(){ listEl.innerHTML = jobs; }); }
1667
+ tickSince();
1668
+ }
1669
+ function rlRowOf(runid){
1670
+ return rlData.runs.filter(function(r){ return r.runid === runid; })[0] || null;
1671
+ }
1672
+ // Running now / Needs you click: select that run, show it on the Run tab and scroll its job card into view.
1673
+ function rlJump(runid){
1674
+ selectRun(runid);
1675
+ document.getElementById("tab-run").click();
1676
+ var row = rlRowOf(runid);
1677
+ var card = row ? document.getElementById("rl-job-" + row.job) : null;
1678
+ if(card && card.scrollIntoView) card.scrollIntoView({ block: "nearest" });
1679
+ }
1680
+ // one delegated click handler for the whole list: a fold, a jump card or a run card
1681
+ function rlClick(ev){
1682
+ var t = ev.target && ev.target.closest ? ev.target : null;
1683
+ if(!t) return;
1684
+ var fold = t.closest("[data-rl-fold]");
1685
+ if(fold){
1686
+ rlOpen[fold.getAttribute("data-rl-fold")] = fold.getAttribute("aria-expanded") !== "true";
1687
+ paintRunsList();
1688
+ return;
1689
+ }
1690
+ var jump = t.closest("[data-rl-jump]");
1691
+ if(jump){ rlJump(jump.getAttribute("data-rl-jump")); return; }
1692
+ var card = t.closest("[data-rl-run]");
1693
+ if(card){
1694
+ selectRun(card.getAttribute("data-rl-run"));
1695
+ document.getElementById("tab-run").click();
1696
+ scrollRunIntoViewMobile();
1697
+ }
1698
+ }
1699
+ document.getElementById("panel-runs").addEventListener("click", rlClick);
1609
1700
 
1610
1701
  // ---- Filter bar (item 5 + hamr's 2026-09-25 live-panel feedback) ----
1611
1702
  // ONE component, ONE state shape ({checkTypes, results, time}), used by
@@ -1616,51 +1707,38 @@
1616
1707
  // "[ ]"; ".fchip" turns that off), time is a short label, then "clear".
1617
1708
  // No "unknown" check-type chip — runs/workflows with unknown type still
1618
1709
  // show whenever no type chip is selected (checkTypes stays empty).
1710
+ // The chips, in the order they are drawn: ONE declared array, never object key order.
1711
+ var RL_CHIPS = [
1712
+ { group: "checkType", value: "deterministic", title: "deterministic", id: "ct-deterministic", label: "D" },
1713
+ { group: "checkType", value: "rubric", title: "rubric", id: "ct-rubric", label: "R" },
1714
+ { group: "result", value: "\u2713", title: "pass", id: "res-pass", label: "&#10003;" },
1715
+ { group: "result", value: "\u2717", title: "fail", id: "res-fail", label: "&#10007;" },
1716
+ { group: "result", value: "?", title: "died", id: "res-died", label: "?" },
1717
+ { group: "result", value: "\u25B6", title: "running", id: "res-running", label: "&#9654;" },
1718
+ { group: "time", value: "7d", title: "last 7 days", id: "time-7d", label: "7d" },
1719
+ { group: "time", value: "30d", title: "last 30 days", id: "time-30d", label: "30d" },
1720
+ { group: "time", value: "all", title: "all time", id: "time-all", label: "all", pressed: true },
1721
+ { clear: true },
1722
+ ];
1619
1723
  function filterBarHTML(scope){
1620
1724
  return (
1621
1725
  '<div class="filterbar" data-scope="' + scope + '" data-testid="' + scope + '-filters">' +
1622
- '<input type="text" class="search-input" placeholder="search job, run-N, run id or model…" ' +
1726
+ '<input type="text" class="search-input" placeholder="job, run-23, or date 10/13" ' +
1623
1727
  'id="' + scope + '-filter-search" data-testid="' + scope + '-filter-search" autocomplete="off">' +
1624
1728
  '<div class="chips" role="group" aria-label="Filter">' +
1625
- '<button class="chip fchip" type="button" aria-pressed="false" data-filter-group="checkType" data-filter-value="deterministic" title="deterministic" data-testid="chip-' + scope + '-ct-deterministic">D</button>' +
1626
- '<button class="chip fchip" type="button" aria-pressed="false" data-filter-group="checkType" data-filter-value="rubric" title="rubric" data-testid="chip-' + scope + '-ct-rubric">R</button>' +
1627
- '<button class="chip fchip" type="button" aria-pressed="false" data-filter-group="result" data-filter-value="&#10003;" title="pass" data-testid="chip-' + scope + '-res-pass">&#10003;</button>' +
1628
- '<button class="chip fchip" type="button" aria-pressed="false" data-filter-group="result" data-filter-value="&#10007;" title="fail" data-testid="chip-' + scope + '-res-fail">&#10007;</button>' +
1629
- '<button class="chip fchip" type="button" aria-pressed="false" data-filter-group="result" data-filter-value="?" title="died" data-testid="chip-' + scope + '-res-died">?</button>' +
1630
- '<button class="chip fchip" type="button" aria-pressed="false" data-filter-group="result" data-filter-value="&#9654;" title="running" data-testid="chip-' + scope + '-res-running">&#9654;</button>' +
1631
- '<button class="chip fchip" type="button" aria-pressed="false" data-filter-group="time" data-filter-value="7d" title="last 7 days" data-testid="chip-' + scope + '-time-7d">7d</button>' +
1632
- '<button class="chip fchip" type="button" aria-pressed="false" data-filter-group="time" data-filter-value="30d" title="last 30 days" data-testid="chip-' + scope + '-time-30d">30d</button>' +
1633
- '<button class="chip fchip" type="button" aria-pressed="true" data-filter-group="time" data-filter-value="all" title="all time" data-testid="chip-' + scope + '-time-all">all</button>' +
1634
- '<button class="chip fchip" type="button" id="' + scope + '-filter-clear" data-testid="' + scope + '-filter-clear">clear</button>' +
1729
+ RL_CHIPS.map(function(c){
1730
+ if(c.clear) return '<button class="chip fchip" type="button" id="' + scope + '-filter-clear" data-testid="' + scope + '-filter-clear">clear</button>';
1731
+ return '<button class="chip fchip" type="button" aria-pressed="' + (c.pressed ? 'true' : 'false') + '" data-filter-group="' + c.group + '" data-filter-value="' + escapeXml(c.value) + '" title="' + c.title + '" data-testid="chip-' + scope + '-' + c.id + '">' + c.label + '</button>';
1732
+ }).join("") +
1635
1733
  '</div>' +
1636
1734
  '<div class="hint" id="' + scope + '-filter-count" data-testid="' + scope + '-filter-count"></div>' +
1637
1735
  '</div>'
1638
1736
  );
1639
1737
  }
1640
1738
 
1641
- // Pure function: chips toggle on/off independently and combine AND across
1642
- // the three groups (checkType/result/time), OR within a group (an empty
1643
- // group array means "no filter" for that group — everything passes it).
1644
- // `time` is single-select, default "all". `nowMs` is injectable for tests.
1645
- // Field names (checkType/glyph/at) match a History run row's own shape.
1646
- // item 1 (2026-09-25): case-insensitive substring match on job name AND
1647
- // runid, combined AND with the chip groups (an empty/whitespace-only query
1648
- // means "no search filter" — everything passes it, same posture as an
1649
- // empty chip group). item C (2026-09-26): also matches the run's model
1650
- // name (e.g. "deepseek", "sonnet") — `model` is `null`/absent on an older
1651
- // archived run, same posture as a missing job/runid. Also the run's shown
1652
- // name: `run-N` and `job (run-N)` (N is the server's `runNo`, never counted here).
1653
- function matchesSearch(job, runid, model, query, runNo){
1654
- if(!query) return true;
1655
- var q = String(query).trim().toLowerCase();
1656
- if(!q) return true;
1657
- var j = String(job || "").toLowerCase();
1658
- var r = String(runid || "").toLowerCase();
1659
- var m = String(model || "").toLowerCase();
1660
- var label = typeof runNo === "number" ? "run-" + runNo : "";
1661
- var shown = label ? j + " (" + label + ")" : "";
1662
- return j.indexOf(q) !== -1 || r.indexOf(q) !== -1 || m.indexOf(q) !== -1 || (label !== "" && (label.indexOf(q) !== -1 || shown.indexOf(q) !== -1));
1663
- }
1739
+ // Pure function: chips toggle on/off independently and combine AND across the three groups (checkType/result/time), OR within a group (an empty
1740
+ // group array means "no filter" for that group — everything passes it). `time` is single-select, default "all". `nowMs` is injectable for tests.
1741
+ // The search is `runMatchesSearch`: the whole visible row, case-insensitive; an empty query passes everything.
1664
1742
  function filterRuns(runs, filters, nowMs){
1665
1743
  nowMs = (typeof nowMs === "number") ? nowMs : Date.now();
1666
1744
  var ct = (filters && filters.checkTypes) || [];
@@ -1671,7 +1749,7 @@
1671
1749
  if(ct.length && ct.indexOf(r.checkType) === -1) return false;
1672
1750
  // a stopped run ([■]) was a [✗] before hamr's 2026-10-07 sign ruling — the fail chip still finds it
1673
1751
  if(rs.length && rs.indexOf(r.glyph) === -1 && !(r.glyph === "\u25A0" && rs.indexOf("\u2717") !== -1)) return false;
1674
- if(!matchesSearch(r.job, r.runid, r.model, q, r.runNo)) return false;
1752
+ if(!runMatchesSearch(r, q)) return false;
1675
1753
  if(time !== "all"){
1676
1754
  var days = time === "7d" ? 7 : 30;
1677
1755
  var t = typeof r.at === "string" ? Date.parse(r.at) : NaN;
@@ -1686,13 +1764,12 @@
1686
1764
  * One filter-bar instance: its own localStorage key, its own DOM scope
1687
1765
  * (`.filterbar[data-scope="..."]`), its own item list — but the same
1688
1766
  * wiring as every other instance.
1689
- * @param {{scope:string, storageKey:string, filterFn:Function, applyFn:Function}} opts
1767
+ * @param {{scope:string, storageKey:string, onChange:Function}} opts
1690
1768
  */
1691
1769
  function createFilterBar(opts){
1692
1770
  var scope = opts.scope;
1693
1771
  var storageKey = opts.storageKey;
1694
1772
  var state = { checkTypes: [], results: [], time: "all", search: "" };
1695
- var allItems = [];
1696
1773
 
1697
1774
  function load(){
1698
1775
  try{
@@ -1727,10 +1804,7 @@
1727
1804
  if(searchEl && searchEl.value !== state.search) searchEl.value = state.search;
1728
1805
  }
1729
1806
  function apply(){
1730
- var filtered = opts.filterFn(allItems, state, Date.now());
1731
- opts.applyFn(filtered);
1732
- var countEl = document.getElementById(scope + "-filter-count");
1733
- if(countEl) countEl.textContent = "showing " + filtered.length + " of " + allItems.length + " runs";
1807
+ opts.onChange(state);
1734
1808
  }
1735
1809
  document.querySelectorAll('.filterbar[data-scope="' + scope + '"] .chip[data-filter-group]').forEach(function(chip){
1736
1810
  chip.addEventListener("click", function(){
@@ -1771,72 +1845,22 @@
1771
1845
  paint();
1772
1846
 
1773
1847
  return {
1774
- setItems: function(items){ allItems = items || []; apply(); },
1775
1848
  getState: function(){ return state; },
1776
1849
  refresh: function(){ apply(); },
1777
1850
  };
1778
1851
  }
1779
1852
 
1780
- // item 2 (2026-09-26 merge): Workflows/History view-mode memory — same
1781
- // per-viewer-convenience posture as every other localStorage read/write on
1782
- // this page (try/catch, never assumed available).
1783
- var RUNS_VIEW_KEY = "bareloop-panel-runs-view";
1784
- function loadRunsViewMode(){
1785
- try{
1786
- var v = localStorage.getItem(RUNS_VIEW_KEY);
1787
- if(v === "workflows" || v === "history") return v;
1788
- }catch(e){ /* per-viewer convenience only */ }
1789
- return "workflows";
1790
- }
1791
- function saveRunsViewMode(v){
1792
- try{ localStorage.setItem(RUNS_VIEW_KEY, v); }catch(e){ /* best-effort only */ }
1793
- }
1794
-
1795
- // item 2 (2026-09-26 merge): ONE shared filter bar drives whichever view
1796
- // (Workflows/History) is currently on — one scope, one localStorage key,
1797
- // so switching views never loses the search/chip state.
1853
+ // ONE filter bar drives the runs list (the Workflows / History switch is gone, P9 v2): one scope, one localStorage key.
1798
1854
  document.getElementById("runs-filterbar-anchor").innerHTML = filterBarHTML("runs");
1799
1855
 
1800
- var runsViewMode = loadRunsViewMode();
1801
- function runsFilterFn(items, filters, nowMs){
1802
- if(runsViewMode === "history") return filterRuns(items, filters, nowMs);
1803
- return filterWorkflows(groupRunsByJob(items), filters, nowMs);
1804
- }
1805
- function runsApplyFn(filtered){
1806
- if(runsViewMode === "history") renderHistory(filtered);
1807
- else renderWorkflows(filtered);
1808
- }
1809
1856
  var runsFilterBar = createFilterBar({
1810
1857
  scope: "runs",
1811
1858
  storageKey: "bareloop-panel-runs-filters",
1812
- filterFn: runsFilterFn,
1813
- applyFn: runsApplyFn,
1859
+ onChange: function(){ paintRunsList(); },
1814
1860
  });
1815
1861
  function currentRunsFilters(){
1816
1862
  return runsFilterBar ? runsFilterBar.getState() : { checkTypes: [], results: [], time: "all", search: "" };
1817
1863
  }
1818
- function paintRunsViewToggle(){
1819
- document.querySelectorAll('.chip[data-runs-view]').forEach(function(c){
1820
- c.setAttribute("aria-pressed", c.getAttribute("data-runs-view") === runsViewMode ? "true" : "false");
1821
- });
1822
- document.getElementById("wf-list").hidden = runsViewMode !== "workflows";
1823
- document.getElementById("history-list").hidden = runsViewMode !== "history";
1824
- // P5 item 4: Import lives on the Workflows view only
1825
- document.getElementById("wf-import").hidden = runsViewMode !== "workflows";
1826
- document.getElementById("import-list").hidden = runsViewMode !== "workflows";
1827
- if(runsViewMode !== "workflows") document.getElementById("import-box").hidden = true;
1828
- }
1829
- document.querySelectorAll('.chip[data-runs-view]').forEach(function(chip){
1830
- chip.addEventListener("click", function(){
1831
- var next = chip.getAttribute("data-runs-view");
1832
- if(next === runsViewMode) return;
1833
- runsViewMode = next;
1834
- saveRunsViewMode(runsViewMode);
1835
- paintRunsViewToggle();
1836
- runsFilterBar.refresh();
1837
- });
1838
- });
1839
- paintRunsViewToggle();
1840
1864
 
1841
1865
  // item 4: phone-only "scroll the run view into view" on row select, and
1842
1866
  // the matching "↑ list" jump back. Desktop never runs either — both panes
@@ -1858,8 +1882,8 @@
1858
1882
  });
1859
1883
  }
1860
1884
 
1861
- function clearSelection(selector){
1862
- document.querySelectorAll(".wf-row, .hist-row").forEach(function(r){ r.classList.remove("selected"); r.setAttribute("aria-pressed","false"); });
1885
+ function clearSelection(){
1886
+ document.querySelectorAll(".wf-row").forEach(function(r){ r.classList.remove("selected"); r.setAttribute("aria-pressed","false"); });
1863
1887
  }
1864
1888
 
1865
1889
  // ---- P5 item 4: IMPORT (read only) — the folder browser, the imported rows, the view ----
@@ -1974,13 +1998,14 @@
1974
1998
  currentImportId = null;
1975
1999
  document.getElementById("import-view").hidden = true;
1976
2000
  document.getElementById("run-counters").hidden = false;
1977
- document.getElementById("run-empty").textContent = "select a run from Workflows or History";
2001
+ document.getElementById("run-empty").textContent = "select a run from the Runs list";
1978
2002
  document.getElementById("audit-select-empty").textContent = "select a run first";
1979
2003
  document.querySelectorAll("#import-list .wf-row").forEach(function(r){ r.classList.remove("selected"); r.setAttribute("aria-pressed", "false"); });
1980
2004
  }
1981
2005
  function selectImport(id){
1982
2006
  clearSelection();
1983
2007
  currentRunid = null;
2008
+ paintRunsList();
1984
2009
  runIsLive = false;
1985
2010
  currentImportId = id;
1986
2011
  document.querySelectorAll("#import-list .wf-row").forEach(function(r){
@@ -2164,8 +2189,9 @@
2164
2189
  * complete run whose drafting share is itself incomplete — hamr's ruling:
2165
2190
  * a reader summing the two must never read the leading number as exact
2166
2191
  * when the bracket next to it isn't). `draftSpendComplete === false` marks
2167
- * the bracket itself a floor. `budgetUsd` (when given) appends " of $X.XX". `shortForm` drops the word
2168
- * "drafting" (the Runs list cards only — "$5.00 ($0.08)").
2192
+ * the bracket itself a floor. `budgetUsd` (when given) appends " of $X.XX". `shortForm` moves the word
2193
+ * "drafting" to lead inside the bracket (the Runs list cards only — "$5.00 (drafting $0.08)",
2194
+ * floor "(drafting at least $0.08)").
2169
2195
  */
2170
2196
  function panelMoneyWithDraft(spendCoreVal, spendCoreFloor, draftSpentUsd, draftSpendComplete, budgetUsd, shortForm){
2171
2197
  var hasDraft = typeof draftSpentUsd === "number" && draftSpentUsd !== null;
@@ -2173,7 +2199,7 @@
2173
2199
  var core = (spendCoreVal === null || spendCoreVal === undefined)
2174
2200
  ? "unknown"
2175
2201
  : ((spendCoreFloor || draftFloor) ? "at least " + panelMoney(spendCoreVal) : panelMoney(spendCoreVal));
2176
- var draftPart = hasDraft ? " (" + (draftFloor ? "at least " : "") + panelMoney(draftSpentUsd) + (shortForm ? ")" : " drafting)") : "";
2202
+ var draftPart = hasDraft ? (shortForm ? " (drafting " : " (") + (draftFloor ? "at least " : "") + panelMoney(draftSpentUsd) + (shortForm ? ")" : " drafting)") : "";
2177
2203
  var capPart = (budgetUsd === undefined) ? "" : (" of " + panelMoney(budgetUsd));
2178
2204
  return core + draftPart + capPart;
2179
2205
  }
@@ -2231,7 +2257,7 @@
2231
2257
  if(spendFloorUsd === null || spendFloorUsd === undefined) return "unknown";
2232
2258
  var hasDraft = typeof draftSpentUsd === "number" && draftSpentUsd !== null;
2233
2259
  var draftFloor = hasDraft && draftSpendComplete === false;
2234
- var draftPart = hasDraft ? " (" + (draftFloor ? "at least " : "") + panelMoney(draftSpentUsd) + (shortForm ? ")" : " drafting)") : "";
2260
+ var draftPart = hasDraft ? (shortForm ? " (drafting " : " (") + (draftFloor ? "at least " : "") + panelMoney(draftSpentUsd) + (shortForm ? ")" : " drafting)") : "";
2235
2261
  return panelMoney(spendFloorUsd) + " so far" + draftPart;
2236
2262
  }
2237
2263
  /** build item 6 — the matching wall-clock phrase: "running <duration>",
@@ -2374,55 +2400,70 @@
2374
2400
  else { row.hidden = true; val.textContent = ""; }
2375
2401
  }
2376
2402
 
2377
- // ---- P5: the ENDED block (Audit tab, first block, every finished run) and Resume ----
2378
- // The sentences are CODE-OWNED on the server (`endedFor`, src/panel/server.js) — this only
2379
- // paints them. `detail.ended` is null while a run is live (no block while running).
2403
+ // ---- P5: the ENDED block (Run tab, first block above the action row, every finished run) and Resume ----
2380
2404
  var lastEndedSig = null;
2405
+ function summaryRow(label, text, testid, red, extraHtml){
2406
+ return '<div class="ended-row' + (red ? " is-red" : "") + '"><span class="ended-label">' + escapeXml(label) + '</span><span class="ended-text" data-testid="' + testid + '">' + escapeXml(text) + (extraHtml || "") + '</span></div>';
2407
+ }
2381
2408
  // Resume (P5-R, hamr 2026-10-02: opened the run's own Job tab) MOVED to the left Chat card (hamr 2026-10-07): the card
2382
2409
  // in RESUME mode, only the money cap and the time cap open (applyResume / signResume). The Job tab is read-only on
2383
2410
  // every run — values only, no inputs, no buttons. A resume is the SAME run: no new card, no new job.
2411
+ // RULED 2026-10-15 (aligned with fwdloop M9 am7): ONE block, the same shape every time: ENDED (a finished run), NEXT (a finished run), SCHEDULE
2412
+ // (always: the schedule's words and a second line, or "By hand"). A schedule that started nothing turns the SCHEDULE row AND the frame red.
2413
+ // The sentences are CODE-OWNED on the server (`endedFor`/`endedText` in src/panel/server.js, `detail.schedule` from src/schedulewords.js) — this only paints them.
2384
2414
  function renderEnded(detail){
2385
2415
  var box = document.getElementById("ended-block");
2386
2416
  var e = detail.ended;
2387
- if(!e){ box.hidden = true; box.innerHTML = ""; lastEndedSig = null; return; }
2417
+ var sc = detail.schedule;
2418
+ if(!e && !sc){ box.hidden = true; box.innerHTML = ""; lastEndedSig = null; return; }
2388
2419
  // rebuilt only when what it shows changed
2389
- var sig2 = detail.runid + "|" + JSON.stringify(e) + "|" + JSON.stringify(detail.resume);
2420
+ var sig2 = detail.runid + "|" + JSON.stringify(e) + "|" + JSON.stringify(detail.resume) + "|" + (detail.endedText || "") + "|" + JSON.stringify(sc && [sc.scheduled, sc.text, sc.line2, sc.problem]);
2390
2421
  if(sig2 === lastEndedSig) return;
2391
2422
  lastEndedSig = sig2;
2392
2423
  box.hidden = false;
2393
- box.className = "ended-block " + (detail.died ? "magenta" : (detail.glyph === "✓" ? "green" : "red"));
2394
- // text only: the ONE place for [Stop] / [Resume] / [Reuse workflow] is the Run tab's top action row (renderRunActions)
2395
- box.innerHTML =
2396
- '<div class="ended-row"><span class="ended-label">ENDED</span><span class="ended-text" data-testid="ended-reason">' + escapeXml(e.reason) + '</span></div>' +
2397
- '<div class="ended-row"><span class="ended-label">NEXT</span><span class="ended-text" data-testid="ended-next">' + escapeXml(e.next) + '</span></div>';
2424
+ var problem = !!(sc && sc.scheduled && sc.problem === true);
2425
+ box.className = "ended-block " + (problem ? "red" : (!e ? "" : (detail.died ? "magenta" : (detail.glyph === "✓" ? "green" : (detail.glyph === "■" ? "grey" : "red")))));
2426
+ var h = "";
2427
+ if(e){
2428
+ // text only: the ONE place for [Stop] / [Resume] / [Reuse workflow] is the Run tab's top action row (renderRunActions)
2429
+ h += summaryRow("ENDED", detail.endedText || e.reason, "ended-reason", false) + summaryRow("NEXT", e.next, "ended-next", false);
2430
+ }
2431
+ if(sc){
2432
+ var w = sc.words || {};
2433
+ var label = w.scheduledLabel || "SCHEDULE";
2434
+ if(sc.scheduled){
2435
+ h += summaryRow(label, sc.text, "sched-text", problem, sc.line2 ? '<span class="sched-line2" data-testid="sched-line2">' + escapeXml(sc.line2) + '</span>' : "");
2436
+ } else {
2437
+ h += summaryRow(label, w.byHand || "By hand", "sched-text", false);
2438
+ }
2439
+ }
2440
+ box.innerHTML = h;
2398
2441
  }
2399
2442
  // ---- P5 item 5: the run's ACTION AREA (Run tab, above the counters) — Stop while the run is live, Resume once
2400
- // it ended in a way the engine would accept (opens the Chat card's resume mode), Edit in chat on every non-green ending.
2401
- // The Ended block (Audit tab) is text only and carries no buttons.
2443
+ // it ended in a way the engine would accept (opens the Chat card's resume mode), Reuse workflow on every ending.
2444
+ // The Ended block (Run tab, above this row) is text only and carries no buttons.
2402
2445
  var stopAsked = {};
2403
2446
  // Reuse workflow (replaces P5 item 3's Start from this) — ask the server for the prefill (the signed job), then
2404
2447
  // hand it to the Chat tab's New job card. The page decides nothing: the server owns what is locked and what is open.
2405
2448
  // `model` (optional) = the Chat card's Model box, when the picker asks: the estimate line then counts only that worker's runs.
2406
- function reuseWorkflow(runid, model){
2449
+ function reuseWorkflow(runid, model, label){
2407
2450
  authorGet("/api/author/start-from?runid=" + encodeURIComponent(runid) + (model ? "&" + model : "")).then(function(r){
2408
2451
  if(!r || !r.ok){ window.alert((r && r.error) || "nothing to start from"); return; }
2409
2452
  document.getElementById("tab-chat").click();
2410
- document.dispatchEvent(new CustomEvent("bareloop-reuse", {detail: {runid: runid, prefill: r}}));
2453
+ document.dispatchEvent(new CustomEvent("bareloop-reuse", {detail: {runid: runid, prefill: r, label: label || ""}}));
2411
2454
  }).catch(function(x){ window.alert("could not reach the panel: " + x.message); });
2412
2455
  }
2413
- // Edit in chat (hamr 2026-10-07, option A) — the same prefill door as Reuse workflow (`start-from`, the ONE reader of a
2414
- // run's signed job), but the Chat card opens with EVERY box open and the normal [Start drafting] path: it drafts a new job.
2415
- // The origin line is code-owned fixed text; run name and status word come from the same helpers the run header uses.
2416
- function editInChat(detail){
2417
- var origin = "Copied from " + detail.job + " (" + runLabel(detail.runNo, detail.runid) + ") \u2014 " +
2418
- ((detail.status && detail.status.word) || "ended") +
2419
- ". Every box is open. Change anything and it drafts as a new job; change nothing and it is another run of the same job.";
2420
- authorGet("/api/author/start-from?runid=" + encodeURIComponent(detail.runid)).then(function(r){
2456
+ // Schedule run / Cancel / modify schedule (T Part 7): starts nothing and signs nothing. It opens the SAME Chat card as the neighbouring Reuse
2457
+ // workflow button, in schedule mode (the When field showing the current schedule). The run it copies from is the server's answer
2458
+ // (`detail.schedule.fromRunid`: the green the schedule was made from), so a red run of a scheduled job still opens the card.
2459
+ function scheduleInChat(detail){
2460
+ var from = (detail.schedule && detail.schedule.fromRunid) || detail.runid;
2461
+ authorGet("/api/author/start-from?mode=schedule&runid=" + encodeURIComponent(from)).then(function(r){
2421
2462
  document.getElementById("tab-chat").click();
2422
- document.dispatchEvent(new CustomEvent("bareloop-edit", {detail: (r && r.ok) ? {runid: detail.runid, prefill: r, origin: origin} : {error: (r && r.error) || "nothing to copy"}}));
2463
+ document.dispatchEvent(new CustomEvent("bareloop-schedule", {detail: (r && r.ok) ? {runid: from, prefill: r, job: detail.job} : {error: (r && r.error) || "nothing to schedule"}}));
2423
2464
  }).catch(function(x){
2424
2465
  document.getElementById("tab-chat").click();
2425
- document.dispatchEvent(new CustomEvent("bareloop-edit", {detail: {error: "could not reach the panel: " + x.message}}));
2466
+ document.dispatchEvent(new CustomEvent("bareloop-schedule", {detail: {error: "could not reach the panel: " + x.message}}));
2426
2467
  });
2427
2468
  }
2428
2469
  // Resume (hamr 2026-10-07, option B — moved from the run's Job tab): the Chat card opens in RESUME mode, filled from the
@@ -2452,20 +2493,46 @@
2452
2493
  var canResume = !!detail.resume && !!detail.ended && (detail.ended.actions || []).some(function(a){ return a.id === "resume"; });
2453
2494
  if(canResume) html = '<button class="btn primary" type="button" data-testid="btn-resume-run">Resume</button>';
2454
2495
  }
2455
- // Edit in chat: every non-green ending (the server's Ended block says so) — a died run included
2456
- if(!live && !!detail.ended && (detail.ended.actions || []).some(function(a){ return a.id === "edit"; })){
2457
- html += '<button class="btn" type="button" data-testid="btn-edit-chat">Edit in chat</button>';
2496
+ // Accept (T Part 8): right after Resume, before Reuse workflow. Shown only when the SERVER says this run waits at its review door
2497
+ // (`detail.accept`: a door record, not expired, no honoured decision after it). It records the person's accept through the engine's own door.
2498
+ if(!live && detail.accept === true){
2499
+ html += '<button class="btn primary" type="button" data-testid="btn-accept-run">Accept</button>';
2458
2500
  }
2459
- // Reuse workflow: on a GREEN run only (the server's Ended block says so) — a new run of the same signed job
2460
- if(!live && !detail.died && !!detail.ended && (detail.ended.actions || []).some(function(a){ return a.id === "reuse"; })){
2501
+ // Reuse workflow: ONE door on every ending (v1.101), a died run included - the Chat card opens with every box open
2502
+ if(!live && !!detail.ended && (detail.ended.actions || []).some(function(a){ return a.id === "reuse"; })){
2461
2503
  html += '<button class="btn" type="button" data-testid="btn-reuse">Reuse workflow</button>';
2462
2504
  }
2505
+ // T Part 7: the schedule button is LAST, right after Reuse workflow. The SERVER decides (detail.schedule): `scheduled` shows
2506
+ // Cancel / modify schedule (every run of the job, also while one is live: cancel is never refused); else `canSchedule` shows
2507
+ // Schedule run; else no button. The label never changes in place — a different state is a different button.
2508
+ var sc = detail.schedule || null;
2509
+ var scw = (sc && sc.words) || {};
2510
+ if(sc && sc.scheduled){
2511
+ html += '<button class="btn" type="button" data-testid="run-cancel-schedule">' + escapeXml(scw.cancelModify || "") + '</button>';
2512
+ } else if(sc && sc.canSchedule){
2513
+ html += '<button class="btn" type="button" data-testid="run-schedule">' + escapeXml(scw.scheduleRun || "") + '</button>';
2514
+ }
2515
+ // RULED 2026-10-15: Remove from Needs you is LAST. The SERVER says when it shows (a stopped run in Needs you, never a live Accept, never a running run).
2516
+ if(!live && detail.canRemoveFromNeeds === true){
2517
+ html += '<button class="btn" type="button" data-testid="btn-needs-remove">Remove from Needs you</button>';
2518
+ }
2463
2519
  bar.innerHTML = html;
2464
2520
  bar.hidden = html === "";
2521
+ var needsBtn = bar.querySelector('[data-testid="btn-needs-remove"]');
2522
+ if(needsBtn){
2523
+ needsBtn.addEventListener("click", function(){
2524
+ needsBtn.disabled = true;
2525
+ authorPost("/api/runs/" + encodeURIComponent(detail.runid) + "/needs-remove", {}).then(function(res){
2526
+ if(res.status === 200 && res.body && res.body.ok){ refreshRunsList(true); selectRun(detail.runid); return; }
2527
+ needsBtn.disabled = false;
2528
+ bar.insertAdjacentHTML("beforeend", '<span class="resume-err" data-testid="needs-remove-err">' + escapeXml((res.body && res.body.error) || ("refused (" + res.status + ")")) + '</span>');
2529
+ }).catch(function(x){ needsBtn.disabled = false; bar.insertAdjacentHTML("beforeend", '<span class="resume-err" data-testid="needs-remove-err">could not reach the panel: ' + escapeXml(x.message) + '</span>'); });
2530
+ });
2531
+ }
2532
+ var schedBtn = bar.querySelector('[data-testid="run-schedule"], [data-testid="run-cancel-schedule"]');
2533
+ if(schedBtn) schedBtn.addEventListener("click", function(){ scheduleInChat(detail); });
2465
2534
  var reuseBtn = bar.querySelector('[data-testid="btn-reuse"]');
2466
- if(reuseBtn) reuseBtn.addEventListener("click", function(){ reuseWorkflow(detail.runid); });
2467
- var editBtn = bar.querySelector('[data-testid="btn-edit-chat"]');
2468
- if(editBtn) editBtn.addEventListener("click", function(){ editInChat(detail); });
2535
+ if(reuseBtn) reuseBtn.addEventListener("click", function(){ reuseWorkflow(detail.runid, "", runName(detail.job, detail.runNo, detail.runid)); });
2469
2536
  var stopBtn = bar.querySelector('[data-testid="btn-stop"]');
2470
2537
  if(stopBtn){
2471
2538
  stopBtn.addEventListener("click", function(){
@@ -2476,6 +2543,21 @@
2476
2543
  }).catch(function(x){ stopBtn.disabled = false; bar.insertAdjacentHTML("beforeend", '<span class="resume-err" data-testid="stop-err">could not reach the panel: ' + escapeXml(x.message) + '</span>'); });
2477
2544
  });
2478
2545
  }
2546
+ var accBtn = bar.querySelector('[data-testid="btn-accept-run"]');
2547
+ if(accBtn){
2548
+ accBtn.addEventListener("click", function(){
2549
+ accBtn.disabled = true;
2550
+ authorPost("/api/runs/" + encodeURIComponent(detail.runid) + "/accept", {}).then(function(res){
2551
+ if(res.status === 200 && res.body && res.body.ok){ refreshRunsList(true); selectRun(detail.runid); return; }
2552
+ if(res.status === 202 && res.body && res.body.pending){
2553
+ bar.insertAdjacentHTML("beforeend", '<span class="stopping" data-testid="accept-pending">still re-checking the tree; look again in a moment</span>');
2554
+ return;
2555
+ }
2556
+ accBtn.disabled = false;
2557
+ bar.insertAdjacentHTML("beforeend", '<span class="resume-err" data-testid="accept-err">' + escapeXml((res.body && res.body.error) || ("refused (" + res.status + ")")) + '</span>');
2558
+ }).catch(function(x){ accBtn.disabled = false; bar.insertAdjacentHTML("beforeend", '<span class="resume-err" data-testid="accept-err">could not reach the panel: ' + escapeXml(x.message) + '</span>'); });
2559
+ });
2560
+ }
2479
2561
  var resBtn = bar.querySelector('[data-testid="btn-resume-run"]');
2480
2562
  if(resBtn) resBtn.addEventListener("click", function(){ resumeInChat(detail); });
2481
2563
  }
@@ -2487,7 +2569,7 @@
2487
2569
  if(currentRunid !== runid) return;
2488
2570
  getJSON("/api/runs/" + encodeURIComponent(runid)).then(function(d){
2489
2571
  if(currentRunid !== runid) return;
2490
- if(d.glyph === "▶" && !d.died){ selectRun(runid, null, ".hist-row"); return; }
2572
+ if(d.glyph === "▶" && !d.died){ selectRun(runid); return; }
2491
2573
  if(tries > 0) afterResumeRefresh(runid, tries - 1);
2492
2574
  }).catch(function(){ if(tries > 0) afterResumeRefresh(runid, tries - 1); });
2493
2575
  }, 1500);
@@ -2532,7 +2614,7 @@
2532
2614
  document.getElementById("audit-body").hidden = false;
2533
2615
  renderEnded(detail);
2534
2616
 
2535
- setRunHeader(detail.glyph, runName(detail.job, detail.runNo, detail.runid), runHeaderBody(detail));
2617
+ setRunHeader(detail.glyph, runName(detail.job, detail.runNo, detail.runid), runHeaderBody(detail), detail.accept ? "Accept" : null);
2536
2618
 
2537
2619
  document.getElementById("run-empty").hidden = true;
2538
2620
  document.getElementById("run-content").hidden = false;
@@ -2584,7 +2666,7 @@
2584
2666
  " &middot; " + wallPhrase;
2585
2667
 
2586
2668
  var box = document.getElementById("run-summary");
2587
- box.className = "summary-box " + (detail.died ? "magenta" : ((detail.glyph === "✗" || detail.glyph === "■") ? "red" : (detail.glyph === "▶" ? "amber" : "")));
2669
+ box.className = "summary-box " + (detail.died ? "magenta" : (detail.glyph === "✗" ? "red" : (detail.glyph === "■" ? "grey" : (detail.glyph === "▶" ? "amber" : ""))));
2588
2670
  // F196: a synthetic placeholder box (e.g. "died during planning") is a
2589
2671
  // LABEL, not a real step — excluded from this count so a run that died
2590
2672
  // before any step started reads "0 of 0 done" / "none started", never
@@ -2664,7 +2746,7 @@
2664
2746
  // ONE spelling of a step's state: its dot + the bold state word — read by the Run tab's step cards AND the Audit tab's grouped rows
2665
2747
  function stepStateHTML(box, testid){
2666
2748
  var cls = box.state === "done" ? "green" : (box.state === "stopped" ? (box.status && box.status.sign === "\u25A0" ? "stopped" : "red") : (box.state === "running" ? "amber" : (box.state === "died" ? "magenta" : "grey")));
2667
- return '<span class="step-state" data-testid="' + testid + '"><span class="dot ' + cls + '"></span><b class="st-word">' + escapeXml(boxStatusWord(box)) + '</b></span>';
2749
+ return '<span class="step-state" data-testid="' + testid + '"><span class="dot ' + cls + (cls === "amber" ? " pulse" : "") + '"></span><b class="st-word">' + escapeXml(boxStatusWord(box)) + '</b></span>';
2668
2750
  }
2669
2751
  // build item C: Audit tab attempt row -> its rounds (server-built,
2670
2752
  // lazy-fetched on expand via /api/runs/:runid/rounds?part=<i>&attempt=<n>).
@@ -3059,6 +3141,57 @@
3059
3141
  var isLiveRun = detail.glyph === "▶" && !detail.died;
3060
3142
  var boxes = buildOrderedBoxes(parts, isLiveRun);
3061
3143
  var numberMap = stepNumberIndices(boxes);
3144
+ // RULED 2026-10-15 (v1.104): ONE `schedule` section at the top of the Audit of every run of a job that has a schedule now, always folded when the page
3145
+ // opens, worded on the server (`detail.scheduleSection`: title, sign, head, rows [{label,text}]). A run a CANCELLED schedule started keeps its one old line.
3146
+ if(typeof detail.startedAudit === "string" && detail.startedAudit !== ""){
3147
+ var startedEl = document.createElement("div");
3148
+ startedEl.className = "hint";
3149
+ startedEl.setAttribute("data-testid", "audit-started");
3150
+ startedEl.style.overflowWrap = "anywhere";
3151
+ startedEl.textContent = detail.startedAudit;
3152
+ container.appendChild(startedEl);
3153
+ }
3154
+ var sec = detail.scheduleSection;
3155
+ if(sec && Array.isArray(sec.rows) && sec.rows.length){
3156
+ var gWrap = document.createElement("div");
3157
+ gWrap.className = "audit-part";
3158
+ gWrap.setAttribute("data-testid", "audit-schedule-group");
3159
+ var gBtn = document.createElement("button");
3160
+ gBtn.type = "button";
3161
+ gBtn.className = "btn audit-part-toggle";
3162
+ gBtn.setAttribute("aria-expanded", "false");
3163
+ gBtn.setAttribute("data-testid", "audit-schedule-toggle");
3164
+ var secHead = function(open){ return (open ? "\u25BE " : "\u25B8 ") + escapeXml(sec.title) + " [" + glyphSpan(sec.sign) + "] " + escapeXml(sec.head); };
3165
+ gBtn.innerHTML = secHead(false);
3166
+ var gBody = document.createElement("div");
3167
+ gBody.className = "audit-part-body";
3168
+ gBody.hidden = true;
3169
+ gBody.setAttribute("data-testid", "audit-schedule-body");
3170
+ // same row look as the drafting/step tables: the `rounds-table` in the `audit-table-scroll` wrapper (label column, dotted row lines)
3171
+ var schedRows = "";
3172
+ sec.rows.forEach(function(l){
3173
+ schedRows += '<tr><td class="hint" style="white-space:nowrap">' + escapeXml(l.label) + '</td><td style="overflow-wrap:anywhere;white-space:pre-wrap">' + escapeXml(l.text) + '</td></tr>';
3174
+ });
3175
+ gBody.innerHTML = '<div class="audit-table-scroll"><table class="rounds-table" data-testid="audit-schedule-table"><tbody>' + schedRows + '</tbody></table></div>';
3176
+ gBtn.addEventListener("click", function(){
3177
+ var open = gBtn.getAttribute("aria-expanded") === "true";
3178
+ gBtn.setAttribute("aria-expanded", open ? "false" : "true");
3179
+ gBody.hidden = open;
3180
+ gBtn.innerHTML = secHead(!open);
3181
+ });
3182
+ gWrap.appendChild(gBtn);
3183
+ gWrap.appendChild(gBody);
3184
+ container.appendChild(gWrap);
3185
+ }
3186
+ // RULED 2026-10-15: how the run left Needs you (by you, or after 7 days), worded on the server
3187
+ if(typeof detail.needsAudit === "string" && detail.needsAudit !== ""){
3188
+ var needsEl = document.createElement("div");
3189
+ needsEl.className = "hint";
3190
+ needsEl.setAttribute("data-testid", "audit-needs-removed");
3191
+ needsEl.style.overflowWrap = "anywhere";
3192
+ needsEl.textContent = detail.needsAudit;
3193
+ container.appendChild(needsEl);
3194
+ }
3062
3195
  // P5-R: a resumed run is ONE run — its legs are told apart by a divider line (code-owned text from the server)
3063
3196
  var dividers = Array.isArray(detail.legDividers) ? detail.legDividers : [];
3064
3197
  boxes.forEach(function(box, idx){
@@ -3291,6 +3424,12 @@
3291
3424
  var verdictEl = document.getElementById("details-verdict");
3292
3425
  if(job.checkTypeTitle){ verdictEl.title = job.checkTypeTitle; } else { verdictEl.removeAttribute("title"); }
3293
3426
  document.getElementById("details-model").textContent = job.model;
3427
+ // RULED 2026-10-15: how the run began and the job's schedule NOW, both worded on the server (absent on an imported job)
3428
+ [["started", job.started], ["schedule", job.schedule]].forEach(function(p){
3429
+ var f = document.getElementById("details-" + p[0] + "-field");
3430
+ if(typeof p[1] === "string" && p[1] !== ""){ f.hidden = false; document.getElementById("details-" + p[0]).textContent = p[1]; }
3431
+ else { f.hidden = true; }
3432
+ });
3294
3433
  var descField = document.getElementById("details-description-field");
3295
3434
  if(job.description){ descField.hidden = false; document.getElementById("details-description").textContent = job.description; }
3296
3435
  else { descField.hidden = true; }
@@ -3322,21 +3461,27 @@
3322
3461
  // from the run's own `glyph`/`died` fields, same rule `renderRun` already
3323
3462
  // uses for `isLive` — never re-derived a second way).
3324
3463
  var runIsLive = false;
3325
- function selectRun(runid, rowEl, siblingSelector){
3464
+ // a FINISHED run of a scheduled job is still refetched on the list cadence: its SCHEDULED / NEXT block changes without the run
3465
+ // changing (a refused fire turns NEXT red, a slot passes), and nothing else would repaint it
3466
+ var openIsScheduled = false;
3467
+ var lastOpenFetchAt = 0;
3468
+ function selectRun(runid){
3326
3469
  hideImportView();
3327
3470
  clearSelection();
3328
- document.querySelectorAll(siblingSelector).forEach(function(r){ r.classList.remove("selected"); r.setAttribute("aria-pressed","false"); });
3329
- if(rowEl){ rowEl.classList.add("selected"); rowEl.setAttribute("aria-pressed","true"); }
3330
3471
  currentRunid = runid;
3472
+ paintRunsList(); // the list marks the selected run itself (rl-sel); it writes only when the markup changed
3331
3473
  lastLegDividers = [];
3332
3474
  lastJobToolsList = null;
3333
3475
  lastRunDetailSig = null;
3334
3476
  runIsLive = false;
3477
+ openIsScheduled = false;
3478
+ lastOpenFetchAt = Date.now();
3335
3479
  var myToken = ++selectToken;
3336
3480
  getJSON("/api/runs/" + encodeURIComponent(runid)).then(function(detail){
3337
3481
  if(myToken !== selectToken) return; // a different run was selected before this resolved
3338
3482
  lastRunDetailSig = sig(detail);
3339
3483
  runIsLive = detail.glyph === "▶" && !detail.died;
3484
+ openIsScheduled = !!(detail.schedule && detail.schedule.scheduled);
3340
3485
  renderRun(detail);
3341
3486
  }).catch(function(e){
3342
3487
  if(myToken !== selectToken) return;
@@ -3389,6 +3534,7 @@
3389
3534
  var RUNS_LIST_POLL_MS = 10000;
3390
3535
  var pollTimer = null;
3391
3536
  var lastRunsSig = null;
3537
+ var lastRunsPayloadSig = null;
3392
3538
  var lastRunDetailSig = null;
3393
3539
  var lastRunsListRefreshAt = 0;
3394
3540
  var pollFailStreak = 0;
@@ -3420,14 +3566,14 @@
3420
3566
  return getJSON("/api/runs").then(function(result){
3421
3567
  pollFailStreak = 0;
3422
3568
  var runs = result.runs || [];
3423
- var next = sig(runs);
3424
- if(next === lastRunsSig) return runs; // unchanged payload — never rebuild the DOM for nothing
3425
- lastRunsSig = next;
3569
+ var next = sig(result);
3570
+ if(next === lastRunsPayloadSig) return runs; // unchanged payload — never rebuild the DOM for nothing
3571
+ lastRunsPayloadSig = next;
3572
+ lastRunsSig = sig(runs);
3426
3573
  withScrollPreserved(["left-pane-body"], function(){
3427
- // setItems re-runs the SAME filter/search state and view mode
3428
- // (runsFilterFn/runsApplyFn close over runsViewMode and the filter
3429
- // bar's own state) — a poll tick never resets either.
3430
- runsFilterBar.setItems(runs);
3574
+ // the painter re-runs the SAME filter/search state — a poll tick never resets it, and it writes a box only when its markup changed.
3575
+ setRunsData(result);
3576
+ runsFilterBar.refresh();
3431
3577
  });
3432
3578
  return runs;
3433
3579
  }).catch(function(){
@@ -3443,7 +3589,10 @@
3443
3589
  }
3444
3590
 
3445
3591
  function refreshOpenRun(){
3446
- if(!currentRunid || !runIsLive) return Promise.resolve();
3592
+ if(!currentRunid) return Promise.resolve();
3593
+ var scheduleDue = openIsScheduled && (Date.now() - lastOpenFetchAt) >= RUNS_LIST_POLL_MS;
3594
+ if(!runIsLive && !scheduleDue) return Promise.resolve();
3595
+ lastOpenFetchAt = Date.now();
3447
3596
  var runid = currentRunid;
3448
3597
  var myToken = selectToken;
3449
3598
  return getJSON("/api/runs/" + encodeURIComponent(runid)).then(function(detail){
@@ -3454,6 +3603,7 @@
3454
3603
  var changed = next !== lastRunDetailSig;
3455
3604
  lastRunDetailSig = next;
3456
3605
  runIsLive = detail.glyph === "▶" && !detail.died;
3606
+ openIsScheduled = !!(detail.schedule && detail.schedule.scheduled);
3457
3607
  if(changed){
3458
3608
  withScrollPreserved(["right-pane-body"], function(){ renderRun(detail); });
3459
3609
  }
@@ -3462,6 +3612,7 @@
3462
3612
 
3463
3613
  function pollTick(forceList){
3464
3614
  if(document.hidden) return;
3615
+ tickSince(); // Running now: the wall counts up in place
3465
3616
  refreshRunsList(forceList);
3466
3617
  var wasLive = runIsLive;
3467
3618
  return refreshOpenRun().then(function(){
@@ -3487,25 +3638,17 @@
3487
3638
  getJSON("/api/runs").then(function(result){
3488
3639
  var runs = result.runs || [];
3489
3640
  lastRunsSig = sig(runs);
3490
- runsFilterBar.setItems(runs);
3641
+ lastRunsPayloadSig = sig(result);
3642
+ setRunsData(result);
3643
+ runsFilterBar.refresh();
3491
3644
  if(runs.length && !runs[0].fileMissing){
3492
- // build item 5 — the LEFT pane opens on Chat by default; this no
3493
- // longer force-clicks "tab-runs" (it used to switch the left pane
3494
- // away from Chat on every load that had a run). The RIGHT pane still
3495
- // loads the newest run's details so they're ready the moment a
3496
- // person switches to the Runs tab themselves — the Workflows view
3497
- // starts collapsed, but its job row still renders "selected" (build
3498
- // item, 2026-09-26: a job row shows selected state like a child row)
3499
- // since `runsFilterBar.refresh()` re-renders AFTER `currentRunid` is
3500
- // set.
3501
- selectRun(runs[0].runid, null, ".hist-row");
3645
+ // the LEFT pane opens on Chat by default; the RIGHT pane loads the newest run so it is ready when a person opens the Runs tab.
3646
+ selectRun(runs[0].runid);
3502
3647
  document.getElementById("tab-run").click();
3503
- runsFilterBar.refresh();
3504
3648
  }
3505
3649
  startPolling();
3506
3650
  }).catch(function(e){
3507
3651
  document.getElementById("wf-list").innerHTML = '<div class="hint">failed to load: ' + escapeXml(e.message) + '</div>';
3508
- document.getElementById("history-list").innerHTML = '<div class="hint">failed to load: ' + escapeXml(e.message) + '</div>';
3509
3652
  startPolling(); // still start — a transient initial failure shouldn't block all future polling
3510
3653
  });
3511
3654
 
@@ -3957,6 +4100,18 @@
3957
4100
  // Reuse workflow — set when the card was opened by [Reuse workflow]: the run (or imported job) it came from.
3958
4101
  // Only Source, Destination and the two caps are open on that card; the SERVER refuses any other change.
3959
4102
  var startFrom = null;
4103
+ // ONE door (v1.101): what the card was prefilled from, so the page can tell whether a JOB box changed. {card, green, name, nextName}
4104
+ // `name` = the origin's job name (shown while no job box changed), `nextName` = the server's next free <base>-N (shown once one does).
4105
+ var reuseOrigin = null;
4106
+ var nameTouched = false; // the person typed their own name for a changed job
4107
+ // T Part 7: the card in SCHEDULE mode (opened by Schedule run / Cancel-modify schedule): the Reuse card plus the When field.
4108
+ // `sched` = {job, runid, words, cur, scheduled, refusal, now} while it is on, else null. `cur` = the signed trigger (the When field's
4109
+ // starting value); every word comes from `words` (the server's one table, src/schedulewords.js SCHEDULE_WORDS).
4110
+ var sched = null;
4111
+ var whenHash = null; // the hash click 1 returned; null = click 1 not done (or a When / card box was edited since)
4112
+ var whenConfirm = null; // the lower-cased words the label shows after click 1: "Confirm: every day at 13:15"
4113
+ var whenBusy = false; // a request is out: the main button dims and cannot be pressed
4114
+ var whenLocked = false; // ~1.5 s after click 2 the main button is dead (hamr clicked Run twice thinking it was the trigger)
3960
4115
  // the picked job's OWN check type: with the Check type radio on "Reuse workflow" the card's checkType comes from here, never from the radio
3961
4116
  var reuseCheckType = "deterministic";
3962
4117
  var reusePickedName = "";
@@ -3983,7 +4138,8 @@
3983
4138
  // the one exception on a resume card: an older spec that names no model leaves the Model box open (picked at resume)
3984
4139
  var modelOpen = resuming && resumeRun.pickModel === true;
3985
4140
  OPEN_IDS.forEach(function(id){ setLock(id, live || (resuming && CAP_IDS.indexOf(id) === -1 && !(modelOpen && id === "jf-model"))); });
3986
- document.getElementById("jf-model").disabled = live || (resuming && !modelOpen);
4141
+ // a schedule card keeps the Model the green run had (T9): only Inputs, Destination, When and the two caps are open
4142
+ document.getElementById("jf-model").disabled = live || (resuming && !modelOpen) || !!sched;
3987
4143
  // the Check type radios stay clickable on a reuse card (Deterministic / Rubric = leave reuse); a live session or a resume freezes them
3988
4144
  document.querySelectorAll('input[name="jf-verdict"]').forEach(function(r){ r.disabled = live || resuming; });
3989
4145
  document.getElementById("job-card").classList.toggle("reuse", reuseOn);
@@ -3998,7 +4154,8 @@
3998
4154
  NUMBERED_IDS.forEach(gutterSync);
3999
4155
  document.querySelectorAll('input[name="jf-verdict"]').forEach(function(r){ r.checked = (r.value === "deterministic"); });
4000
4156
  }
4001
- startFrom = null; reuseSession = false; autoSigned = false; reuseCheckType = "deterministic";
4157
+ startFrom = null; reuseOrigin = null; nameTouched = false; reuseSession = false; autoSigned = false; reuseCheckType = "deterministic";
4158
+ scheduleOff();
4002
4159
  sfLine.hidden = true; sfNote.hidden = true; originLine.hidden = true;
4003
4160
  setReuseLocked(false);
4004
4161
  renderPicker();
@@ -4040,7 +4197,6 @@
4040
4197
  rwSearch.hidden = picked;
4041
4198
  rwPicked.hidden = !picked;
4042
4199
  rwPickedTxt.innerHTML = picked ? "<b>" + escapeXml(reusePickedName) + "</b>" : "";
4043
- if(!picked) sfLine.hidden = true;
4044
4200
  }
4045
4201
  function rwMark(name, q){
4046
4202
  var i = q ? name.toLowerCase().indexOf(q.toLowerCase()) : -1;
@@ -4226,13 +4382,18 @@
4226
4382
  // Check type: Deterministic / Rubric / Reuse workflow. Reuse opens the picker on an empty card; Deterministic or
4227
4383
  // Rubric while the picker is showing (picked or not) LEAVES reuse — boxes untouched (clearStartFrom(true)).
4228
4384
  function onVerdictChange(v){
4229
- if(v === "reuse"){ if(rwBox.hidden) openPicker(); return; }
4385
+ if(v === "reuse"){
4386
+ if(startFrom !== null){ clearStartFrom(true); setVerdict("reuse"); openPicker(); return; }
4387
+ if(rwBox.hidden) openPicker();
4388
+ return;
4389
+ }
4230
4390
  if(!rwBox.hidden){
4231
4391
  clearStartFrom(true);
4232
4392
  setVerdict(v);
4233
4393
  renderPicker();
4234
4394
  }
4235
4395
  setJobPlaceholders(v);
4396
+ syncReuseName();
4236
4397
  refreshStartEnabled();
4237
4398
  }
4238
4399
  document.querySelectorAll('input[name="jf-verdict"]').forEach(function(r){
@@ -4479,6 +4640,22 @@
4479
4640
  var ask = st.pendingAsk;
4480
4641
  var atMenu = !!ask && ask.kind === "menu";
4481
4642
  var hint = "";
4643
+ // T Part 7 (hamr found these by clicking fwdloop): after click 2 of a schedule the card is the empty New job card and the button is DEAD for ~1.5 s,
4644
+ // reading "Start drafting" — never a Run / Sign & run invitation
4645
+ if(!st.session && st.whenLocked){
4646
+ return {text: "Start drafting", action: "none", disabled: true, hint: ""};
4647
+ }
4648
+ // When changed from the signed value = the ONE button turns into Sign schedule (Cancel schedule when the choice is By hand): two clicks, $0, starts
4649
+ // nothing. After click 1 it reads "Confirm: <words>". It never reaches the run routes. Unchanged = disabled, with the plain words that nothing changed.
4650
+ if(!st.session && st.when){
4651
+ var ww = st.when.words || {};
4652
+ var wtext = st.when.confirm ? (ww.confirmPrefix || "") + st.when.confirm : (st.when.cancel ? ww.cancelSchedule : ww.signSchedule);
4653
+ if(st.when.changed){
4654
+ return {text: wtext, action: st.when.confirm ? "when-sign" : "when-sign-prepare", disabled: !!st.when.busy, hint: ""};
4655
+ }
4656
+ // nothing changed: a schedule card has nothing to sign and NEVER falls through to the run button; the plain words say why (a refused card says its own reason)
4657
+ return {text: ww.signSchedule, action: "none", disabled: true, hint: st.when.refused ? "" : (ww.nothingChanged || "")};
4658
+ }
4482
4659
  if(!st.session){
4483
4660
  return {text: reuse ? "Sign & run" : "Start drafting", action: "start", disabled: !st.startOk, hint: ""};
4484
4661
  }
@@ -4507,7 +4684,8 @@
4507
4684
  return !!st.session && !!st.pendingAsk && st.pendingAsk.kind === "menu";
4508
4685
  }
4509
4686
  function renderMain(){
4510
- var st = {session: sessionLive, startOk: startOk, signClicked: signClickedOnce};
4687
+ var st = {session: sessionLive, startOk: startOk, signClicked: signClickedOnce, whenLocked: whenLocked};
4688
+ if(sched){ st.when = {changed: whenChanged(), refused: !!sched.refusal, cancel: byId("jf-when").value === "hand", confirm: whenConfirm, busy: whenBusy, words: sched.words}; }
4511
4689
  if(sessionLive && lastState){
4512
4690
  st.phase = lastState.phase; st.pendingAsk = lastState.pendingAsk;
4513
4691
  st.specHash = lastState.specHash;
@@ -4516,7 +4694,7 @@
4516
4694
  var open = askBoxOpenFor(st);
4517
4695
  msgInput.disabled = !open;
4518
4696
  if(!open) msgInput.value = "";
4519
- var b = mainButtonFor(st, !msgInput.value.trim(), !!startFrom || verdictValue() === "reuse");
4697
+ var b = mainButtonFor(st, !msgInput.value.trim(), reusePathNow() || verdictValue() === "reuse");
4520
4698
  mainBtn.textContent = b.text;
4521
4699
  mainBtn.disabled = b.disabled;
4522
4700
  mainAction = b.action;
@@ -4709,7 +4887,7 @@
4709
4887
 
4710
4888
  // ---- Reuse workflow — the card opens filled from the SIGNED job: four open boxes, the rest greyed ----
4711
4889
  function setVal(id, v){ document.getElementById(id).value = (v === undefined || v === null) ? "" : String(v); }
4712
- // ONE owner of filling the card from a prefill (a re-attached session, Reuse, Edit in chat, Resume): the six boxes P7 left
4890
+ // ONE owner of filling the card from a prefill (a re-attached session, Reuse, Resume): the six boxes P7 left
4713
4891
  // (the old Goal/Source/Success/Guardrails/Judge boxes are gone), then every box re-fits and its number gutter re-measures.
4714
4892
  function fillCard(c){
4715
4893
  setVal("jf-name", c.jobName); setVal("jf-job", c.jobText); setVal("jf-inputs", c.inputs);
@@ -4719,20 +4897,27 @@
4719
4897
  fitCardBoxes();
4720
4898
  }
4721
4899
  // ONE owner of the reuse fill: the Run tab's / Import view's button and the picker both arrive here (the
4722
- // `bareloop-reuse` event), so the card ends up in the same picked state whichever door was used.
4900
+ // `bareloop-reuse` event). ONE DOOR (v1.101): every box is open and prefilled; nothing is locked. Only the schedule card
4901
+ // (`d.lock`) keeps the job boxes locked - it signs the green run's plan as it is.
4723
4902
  function applyReuse(d){
4724
4903
  var pre = d.prefill || {};
4725
4904
  var c = pre.card || {};
4905
+ var lock = d.lock === true;
4726
4906
  clearStartFrom();
4727
4907
  openNewCard();
4728
4908
  startFrom = d.importId ? {importId: d.importId} : {runid: d.runid};
4729
4909
  reuseCheckType = c.checkType === "rubric" ? "rubric" : "deterministic";
4730
4910
  reusePickedName = c.jobName || (pre.origin && pre.origin.job) || "job";
4731
- setVerdict("reuse");
4911
+ setVerdict(lock ? "reuse" : reuseCheckType);
4732
4912
  if(c.model){ setPick(c); }
4733
4913
  fillCard(c);
4734
-
4735
- setReuseLocked(true);
4914
+ reuseOrigin = {card: c, green: pre.green === true, name: reusePickedName, nextName: pre.nextName || ""};
4915
+ nameTouched = false;
4916
+ setReuseLocked(lock);
4917
+ if(!lock){
4918
+ originLine.textContent = "Reuse: " + (d.label || reusePickedName);
4919
+ originLine.hidden = false;
4920
+ }
4736
4921
  renderPicker();
4737
4922
  sfLine.hidden = false;
4738
4923
  sfLine.textContent = pre.line ? rwMeta({checkType: reuseCheckType, line: pre.line}) : "";
@@ -4745,27 +4930,33 @@
4745
4930
  refreshStartEnabled();
4746
4931
  }
4747
4932
  document.addEventListener("bareloop-reuse", function(e){ applyReuse(e.detail || {}); });
4748
-
4749
- // Edit in chat — the card opens filled from the SIGNED job with EVERY box open; the button stays the normal
4750
- // [Start drafting] (startFrom / reuse state are never set: no borrowed track record). A live drafting session is
4751
- // handled exactly as applyReuse does (clearStartFrom + openNewCard). A refused prefill opens nothing, error only.
4752
- function applyEdit(d){
4753
- if(d.error){ errEl.textContent = d.error; return; }
4754
- var c = (d.prefill || {}).card || {};
4755
- clearStartFrom();
4756
- openNewCard();
4757
- setVerdict(c.checkType === "rubric" ? "rubric" : "deterministic");
4758
- if(c.model){ setPick(c); }
4759
- fillCard(c);
4760
-
4761
- originLine.textContent = d.origin || "";
4762
- originLine.hidden = !d.origin;
4763
- renderPicker();
4764
- refreshModelStatus();
4765
- refreshCapNote();
4766
- refreshStartEnabled();
4933
+ // Did a JOB box (the job text, the check type, the thinking tick) change from the run the card came from? The same comparison the
4934
+ // server makes on the start (whitespace ignored): the Run boxes (Inputs, Destination, Model, caps) never count.
4935
+ function jobBoxesChangedNow(){
4936
+ if(reuseOrigin === null) return false;
4937
+ var cur = currentCard(), o = reuseOrigin.card;
4938
+ var t = function(v){ return typeof v === "string" ? v.replace(/\s+/g, "") : ""; };
4939
+ return t(cur.jobText) !== t(o.jobText) || cur.checkType !== (o.checkType === "rubric" ? "rubric" : "deterministic") || (cur.thinking === true) !== (o.thinking === true);
4940
+ }
4941
+ // the reuse path (signed plan verbatim, no drafting, $0): a card made by Reuse workflow from a GREEN run with no job box changed
4942
+ function reusePathNow(){
4943
+ return startFrom !== null && (reuseOn || (reuseOrigin !== null && reuseOrigin.green === true && !jobBoxesChangedNow()));
4767
4944
  }
4768
- document.addEventListener("bareloop-edit", function(e){ applyEdit(e.detail || {}); });
4945
+ // the Job name box: the origin's name while no job box changed; the next free <base>-N the moment one does (renamable); the origin's
4946
+ // again when the boxes go back. A name typed while nothing changed is put back (the origin's name is forced).
4947
+ function syncReuseName(){
4948
+ if(reuseOrigin === null || reuseOn) return;
4949
+ var nameEl = document.getElementById("jf-name");
4950
+ if(!jobBoxesChangedNow()){ nameEl.value = reuseOrigin.name; nameTouched = false; }
4951
+ else if(!nameTouched){ nameEl.value = reuseOrigin.nextName; }
4952
+ }
4953
+ function reuseBoxesEdited(){ syncReuseName(); renderMain(); }
4954
+ document.getElementById("jf-job").addEventListener("input", reuseBoxesEdited);
4955
+ thinkingBox.addEventListener("change", reuseBoxesEdited);
4956
+ document.getElementById("jf-name").addEventListener("input", function(){
4957
+ if(reuseOrigin === null || reuseOn) return;
4958
+ if(jobBoxesChangedNow()) nameTouched = true; else syncReuseName();
4959
+ });
4769
4960
 
4770
4961
  // Resume — the card in RESUME mode: the signed job filled and locked, only Cap $ and Time cap open (prefilled from the
4771
4962
  // engine's own resume plan, the values the Job tab used to show). A live drafting session is handled exactly as
@@ -4940,12 +5131,150 @@
4940
5131
  });
4941
5132
  }
4942
5133
 
5134
+ // ---- T Part 7: the schedule card (Schedule run / Cancel-modify schedule, PANEL-BUILD.md T addendum C.7, design/schedule-mock.html) ----
5135
+ // The Reuse card plus the When field, copied from fwdloop's Run card (index.html:998-1019, 4690-4772). The page works out nothing: the
5136
+ // server's words, refusals and hashes. Click 1 (schedule-prepare) is $0 and signs nothing; click 2 (schedule) signs; NOTHING runs.
5137
+ function byId(id){ return document.getElementById(id); }
5138
+ var WHEN_KINDS = ["hand", "daily", "weekdays", "weekly", "file-updated", "new-file"];
5139
+ function whenTriggerFromBoxes(){
5140
+ var kind = byId("jf-when").value;
5141
+ if(kind === "hand") return null;
5142
+ if(kind === "daily" || kind === "weekdays" || kind === "weekly"){
5143
+ var m = /^(\d{2}):(\d{2})$/.exec(byId("jf-when-time").value);
5144
+ var o = m ? {kind: kind, hour: Number(m[1]), minute: Number(m[2])} : {kind: kind}; // an empty time goes to the server, which says so
5145
+ if(kind === "weekly") o.day = byId("jf-when-day").value;
5146
+ return o;
5147
+ }
5148
+ var q = {kind: kind};
5149
+ q[kind === "new-file" ? "folder" : "path"] = byId("jf-when-path").value.trim();
5150
+ return q;
5151
+ }
5152
+ // one comparable spelling of a trigger (or By hand), so "changed" is a plain string compare
5153
+ function whenCanon(t){
5154
+ if(!t) return "hand";
5155
+ return [t.kind, t.hour === undefined ? "" : t.hour, t.minute === undefined ? "" : t.minute, t.day || "", t.path || t.folder || ""].join("|");
5156
+ }
5157
+ // Is the card changed from what was signed? The ONE reader. A schedule card (Schedule run, Cancel / modify schedule) never starts a run: the main
5158
+ // button is Sign schedule while this is true and disabled while it is not. On a scheduled job any When, Inputs, Destination or cap edit counts
5159
+ // (a re-sign); on an unscheduled job only When does (nothing to re-sign until a schedule is picked).
5160
+ function whenChanged(){
5161
+ if(!sched || sched.refusal) return false;
5162
+ if(whenCanon(whenTriggerFromBoxes()) !== whenCanon(sched.cur)) return true;
5163
+ if(sched.scheduled !== true || !sched.base) return false;
5164
+ var b = sched.base;
5165
+ return byId("jf-inputs").value.trim() !== b.inputs || byId("jf-dest").value.trim() !== b.dest ||
5166
+ byId("jf-cap-money").value.trim() !== b.capUsd || byId("jf-cap-time").value.trim() !== b.capTime;
5167
+ }
5168
+ function whenShowSubs(){
5169
+ var kind = byId("jf-when").value;
5170
+ byId("jf-when-clock").hidden = !(kind === "daily" || kind === "weekdays" || kind === "weekly");
5171
+ byId("jf-when-day").hidden = kind !== "weekly";
5172
+ byId("jf-when-filebox").hidden = !(kind === "file-updated" || kind === "new-file");
5173
+ byId("jf-when-path").placeholder = kind === "new-file" ? "/full/path/to/folder" : "/full/path/to/file";
5174
+ }
5175
+ function paintWhen(){
5176
+ var field = byId("jf-when-field");
5177
+ field.hidden = !sched;
5178
+ if(!sched) return;
5179
+ var w = sched.words || {};
5180
+ var opts = w.options || {};
5181
+ byId("jf-when-label").textContent = w.whenLabel || "";
5182
+ var sel = byId("jf-when");
5183
+ sel.innerHTML = "";
5184
+ WHEN_KINDS.forEach(function(k){
5185
+ var op = document.createElement("option");
5186
+ op.value = k;
5187
+ // on a scheduled job the first option reads Cancel schedule instead of By hand
5188
+ op.textContent = k === "hand" ? (sched.scheduled ? w.cancelSchedule : w.byHand) : (opts[k] || k);
5189
+ sel.appendChild(op);
5190
+ });
5191
+ var t = sched.cur;
5192
+ sel.value = t ? t.kind : "hand";
5193
+ if(t && t.hour !== undefined){ byId("jf-when-time").value = ("0" + t.hour).slice(-2) + ":" + ("0" + t.minute).slice(-2); }
5194
+ if(t && t.day) byId("jf-when-day").value = t.day;
5195
+ byId("jf-when-path").value = t ? (t.path || t.folder || "") : "";
5196
+ // refused = the select and every sub-box are DISABLED and the reason stands where "Now:" would be
5197
+ var refused = !!sched.refusal;
5198
+ sel.disabled = byId("jf-when-day").disabled = byId("jf-when-time").disabled = byId("jf-when-path").disabled = refused;
5199
+ byId("jf-when-now").textContent = refused ? sched.refusal : (sched.now ? (w.nowPrefix || "") + sched.now : "");
5200
+ byId("jf-err-when").textContent = "";
5201
+ whenShowSubs();
5202
+ }
5203
+ function scheduleOff(){
5204
+ sched = null; whenHash = null; whenConfirm = null; whenBusy = false;
5205
+ paintWhen();
5206
+ }
5207
+ // editing any When box between the two clicks DROPS the hash: the label returns to Sign schedule
5208
+ function whenEdited(){
5209
+ whenHash = null; whenConfirm = null;
5210
+ byId("jf-err-when").textContent = "";
5211
+ whenShowSubs();
5212
+ renderMain();
5213
+ }
5214
+ ["jf-when", "jf-when-day", "jf-when-time"].forEach(function(id){ byId(id).addEventListener("change", whenEdited); });
5215
+ byId("jf-when-path").addEventListener("input", whenEdited);
5216
+ // the hash binds the WHOLE card (inputs, destination, caps), so a change to any of them drops it too (and repaints the button: on a scheduled job a box edit is Sign schedule)
5217
+ ["jf-inputs", "jf-dest", "jf-cap-money", "jf-cap-time"].forEach(function(id){ byId(id).addEventListener("input", function(){ if(!sched) return; if(whenHash !== null) whenEdited(); else renderMain(); }); });
5218
+ function doWhenSign(){
5219
+ if(!sched || whenBusy || whenLocked) return;
5220
+ var timeCapMin = byId("jf-cap-time").value.trim();
5221
+ var body = {
5222
+ runid: sched.runid, when: whenTriggerFromBoxes(), inputs: byId("jf-inputs").value.trim(), destination: byId("jf-dest").value.trim(),
5223
+ capUsd: Number(byId("jf-cap-money").value), maxWallMs: timeCapMin === "" ? undefined : Math.round(Number(timeCapMin) * 60000),
5224
+ };
5225
+ var second = whenHash !== null;
5226
+ if(second) body.hash = whenHash;
5227
+ whenBusy = true; renderMain();
5228
+ authorPost(second ? "/api/author/schedule" : "/api/author/schedule-prepare", body).then(function(r){
5229
+ whenBusy = false;
5230
+ if(!(r.status >= 200 && r.status < 300 && r.body && r.body.ok === true)){
5231
+ whenHash = null; whenConfirm = null;
5232
+ byId("jf-err-when").textContent = (r.body && (r.body.say || r.body.error)) || ("refused (" + r.status + ")");
5233
+ renderMain(); return;
5234
+ }
5235
+ var words = typeof r.body.words === "string" ? r.body.words : "";
5236
+ if(!second){
5237
+ whenHash = r.body.hash;
5238
+ whenConfirm = words ? words.charAt(0).toLowerCase() + words.slice(1) : "";
5239
+ byId("jf-err-when").textContent = "";
5240
+ renderMain(); return;
5241
+ }
5242
+ // after click 2 the card clears to the empty New job card and NOTHING runs; the button is dead for 1.5 s
5243
+ var say = typeof r.body.say === "string" ? r.body.say : "";
5244
+ whenHash = null; whenConfirm = null;
5245
+ whenLocked = true;
5246
+ setTimeout(function(){ whenLocked = false; renderMain(); }, 1500);
5247
+ resetCard();
5248
+ if(say) chatActionFailed(say);
5249
+ refreshRunsList(true);
5250
+ if(currentRunid) selectRun(currentRunid);
5251
+ }).catch(function(){
5252
+ whenBusy = false; whenHash = null; whenConfirm = null;
5253
+ byId("jf-err-when").textContent = "network error — could not reach the panel server, nothing was signed";
5254
+ renderMain();
5255
+ });
5256
+ }
5257
+ // the card opened by Schedule run / Cancel-modify schedule: the SAME Reuse fill (applyReuse), then the When field
5258
+ function applyScheduleCard(d){
5259
+ if(d.error){ errEl.textContent = d.error; return; }
5260
+ if(sessionLive || resumeRun !== null){ errEl.textContent = "Finish or clear what is on the card first, then try again."; return; }
5261
+ applyReuse({runid: d.runid, prefill: d.prefill, lock: true});
5262
+ var sc = (d.prefill || {}).scheduleCard || {};
5263
+ sched = {job: d.job, runid: d.runid, words: sc.words || {}, cur: sc.trigger || null, scheduled: sc.scheduled === true, refusal: sc.refusal || null, now: sc.now || null,
5264
+ base: {inputs: byId("jf-inputs").value.trim(), dest: byId("jf-dest").value.trim(), capUsd: byId("jf-cap-money").value.trim(), capTime: byId("jf-cap-time").value.trim()}};
5265
+ whenHash = null; whenConfirm = null; whenBusy = false;
5266
+ paintWhen();
5267
+ refreshStartEnabled();
5268
+ }
5269
+ document.addEventListener("bareloop-schedule", function(e){ applyScheduleCard(e.detail || {}); });
5270
+
4943
5271
  mainBtn.addEventListener("click", function(){
4944
5272
  if(mainBtn.disabled) return;
4945
5273
  if(mainAction === "start") doStart();
4946
5274
  else if(mainAction === "check-deps") doCheckDeps();
4947
5275
  else if(mainAction === "send") doSend();
4948
5276
  else if(mainAction === "sign-prepare" || mainAction === "sign") doSign();
5277
+ else if(mainAction === "when-sign-prepare" || mainAction === "when-sign") doWhenSign();
4949
5278
  });
4950
5279
 
4951
5280
  refreshStartEnabled();