bareloop 0.38.0 → 0.40.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 (56) hide show
  1. package/CHANGELOG.md +59 -0
  2. package/bareloop.context.md +87 -24
  3. package/package.json +3 -3
  4. package/src/authorflow.js +335 -59
  5. package/src/authoring.js +125 -3
  6. package/src/authorjob.js +93 -32
  7. package/src/authorreadout.js +498 -3
  8. package/src/authorrun.js +26 -6
  9. package/src/authorscout.js +9 -6
  10. package/src/bridges.js +1 -1
  11. package/src/calibrate.js +85 -11
  12. package/src/cardauthor.js +8 -2
  13. package/src/declaredclose.js +12 -5
  14. package/src/draftspend.js +6 -1
  15. package/src/interviewrun.js +56 -27
  16. package/src/job.js +38 -5
  17. package/src/jobblock.js +140 -0
  18. package/src/judged.js +323 -29
  19. package/src/kinds.js +13 -1
  20. package/src/ledger.js +1 -1
  21. package/src/panel/authorroutes.js +17 -11
  22. package/src/panel/authorsession.js +202 -75
  23. package/src/panel/index.html +302 -312
  24. package/src/panel/jobplan.js +107 -0
  25. package/src/panel/server.js +67 -13
  26. package/src/planrun.js +6 -5
  27. package/src/promptregisters.js +1 -1
  28. package/src/providers.js +111 -2
  29. package/src/reuse.js +1 -1
  30. package/src/root.js +1 -1
  31. package/src/run.js +3 -3
  32. package/src/selection.js +1 -1
  33. package/src/source.js +64 -1
  34. package/src/text.js +13 -0
  35. package/src/transport.js +1 -1
  36. package/src/trend.js +1 -0
  37. package/src/userrun.js +3 -3
  38. package/types/authorflow.d.ts +258 -39
  39. package/types/authoring.d.ts +30 -1
  40. package/types/authorjob.d.ts +52 -13
  41. package/types/authorreadout.d.ts +123 -1
  42. package/types/authorscout.d.ts +5 -3
  43. package/types/calibrate.d.ts +4 -1
  44. package/types/declaredclose.d.ts +5 -1
  45. package/types/draftspend.d.ts +8 -2
  46. package/types/job.d.ts +1 -1
  47. package/types/jobblock.d.ts +102 -0
  48. package/types/judged.d.ts +21 -2
  49. package/types/panel/authorsession.d.ts +57 -7
  50. package/types/panel/jobplan.d.ts +58 -0
  51. package/types/panel/server.d.ts +20 -0
  52. package/types/planrun.d.ts +1 -1
  53. package/types/providers.d.ts +52 -1
  54. package/types/run.d.ts +1 -1
  55. package/types/source.d.ts +26 -0
  56. package/types/trend.d.ts +1 -0
@@ -21,7 +21,7 @@
21
21
  --grey-dot:#878eac; --grey-bg:#20242f;
22
22
  --cyan:#7dcfff; --cyan-bg:#182634;
23
23
  --magenta:#bb9af7;
24
- --accent:#7aa2f7;
24
+ --accent:#7aa2f7; --plan:#7aa2f7;
25
25
  --btn-bg:#1f2335; --btn-text:#c0caf5;
26
26
  --field-bg:var(--bg); --field-soft-bg:var(--bg); --field-border:var(--text-dim);
27
27
  --primary-bg:#7aa2f7; --primary-text:#1a1b26; --primary-border:#7aa2f7;
@@ -67,7 +67,7 @@
67
67
  --grey-dot:#626887; --grey-bg:#e2e2e8;
68
68
  --cyan:#007197; --cyan-bg:#d6ecf1;
69
69
  --magenta:#9854f1;
70
- --accent:#2668c2;
70
+ --accent:#2668c2; --plan:#2668c2;
71
71
  --btn-bg:#e9e9ed; --btn-text:#3760bf;
72
72
  --field-bg:#ffffff; --field-soft-bg:#f7f7f9;
73
73
  --primary-bg:#2668c2; --primary-text:#e1e2e7; --primary-border:#2668c2;
@@ -82,7 +82,7 @@
82
82
  --grey-dot:#626887; --grey-bg:#e2e2e8;
83
83
  --cyan:#007197; --cyan-bg:#d6ecf1;
84
84
  --magenta:#9854f1;
85
- --accent:#2668c2;
85
+ --accent:#2668c2; --plan:#2668c2;
86
86
  --btn-bg:#e9e9ed; --btn-text:#3760bf;
87
87
  --field-bg:#ffffff; --field-soft-bg:#f7f7f9;
88
88
  --primary-bg:#2668c2; --primary-text:#e1e2e7; --primary-border:#2668c2;
@@ -121,6 +121,9 @@
121
121
  [hidden]{display:none !important;}
122
122
  .main{display:flex;flex:1;min-height:0;}
123
123
  .left-pane{width:420px;flex:none;border-right:1px solid var(--border-strong);display:flex;flex-direction:column;background:var(--panel);}
124
+ /* hamr 2026-10-10: the left pane is 630px on wide screens only. 1200px is the floor: the right pane is then >=570px, never
125
+ narrower than the 480px it already had at 900px with the 420px pane (630 at 900 left it 270 and wrapped its tabs) */
126
+ @media (min-width: 1200px){ .left-pane{width:630px;} }
124
127
  .right-pane{flex:1;min-width:0;display:flex;flex-direction:column;}
125
128
 
126
129
  .tabrow{display:flex;border-bottom:1px solid var(--border-strong);background:var(--panel2);counter-reset:tabnum;}
@@ -311,9 +314,28 @@
311
314
  .imported-box .summary-row{flex-wrap:wrap;}
312
315
  .imported-box .sum-val{flex:1 1 180px;min-width:0;overflow-wrap:anywhere;}
313
316
 
314
- .map-box{border:1px solid var(--border-strong);padding:10px;overflow-x:auto;margin-top:16px;margin-bottom:14px;background:var(--panel2);}
317
+ .map-box{border:1px solid var(--border-strong);padding:10px;margin-top:16px;margin-bottom:14px;background:var(--panel2);}
315
318
  .map-box::before{content:"┤ MAP ├";display:block;font-size:11px;color:var(--text-faint);letter-spacing:0.08em;margin:-2px 0 8px 0;}
316
- .map-box svg{display:block;max-width:100%;height:auto;}
319
+ /* ruling 1A: chips as wide as their own text, side by side, wrapping — a name is never truncated */
320
+ .map-chips{display:flex;flex-wrap:wrap;gap:8px 4px;align-items:flex-start;padding-left:1.4em;}
321
+ /* ruling A: a step + its trailing arrow are ONE unit (the arrow never wraps alone); the lead arrow of a wrapped line
322
+ hangs in the container's left padding (absolute, zero layout impact — toggling it can never move a chip) */
323
+ .map-item{position:relative;display:inline-flex;align-items:center;gap:4px;max-width:100%;}
324
+ .map-arrow{color:var(--text-dim);font-size:13px;flex:none;}
325
+ .map-lead{display:none;position:absolute;left:-1.3em;top:50%;transform:translateY(-50%);color:var(--text-dim);font-size:13px;}
326
+ .map-item.line-start .map-lead{display:block;}
327
+ .chip-retry{color:var(--text-dim);font-size:11px;}
328
+ .map-chip{display:inline-block;width:auto;max-width:100%;min-width:0;box-sizing:border-box;font:inherit;font-size:12px;line-height:1.4;text-align:left;padding:4px 8px;border:2px solid var(--border-strong);border-radius:0;background:var(--panel2);color:var(--text-faint);white-space:normal;overflow-wrap:anywhere;}
329
+ button.map-chip{cursor:pointer;}
330
+ .map-chip.s-done{border-color:var(--green);color:var(--text);} .map-chip.s-done .chip-sign{color:#2e7d32;}
331
+ .map-chip.s-running{border-color:var(--amber);color:var(--text);} .map-chip.s-running .chip-sign{color:#b8860b;}
332
+ .map-chip.s-stopped{border-color:var(--red);color:var(--text);} .map-chip.s-stopped .chip-sign{color:var(--red);}
333
+ .map-chip.s-died{border-color:var(--magenta);color:var(--text);} .map-chip.s-died .chip-sign{color:var(--magenta);}
334
+ .map-chip.retry{border-style:dashed;}
335
+ .map-chip.resumed-next{border-right-style:dotted;}
336
+ .map-chip .chip-blocked{color:var(--red);font-size:10px;}
337
+ .map-chip.s-running .chip-sign{animation:chip-pulse 1.2s infinite;}
338
+ @keyframes chip-pulse{0%,100%{opacity:1}50%{opacity:0.3}}
317
339
  .map-key{display:flex;gap:14px;font-size:11px;color:var(--text-dim);margin-top:6px;flex-wrap:wrap;}
318
340
 
319
341
  .step-card{position:relative;border:1px solid var(--border);padding:10px;margin-bottom:8px;background:var(--panel2);}
@@ -447,6 +469,41 @@
447
469
  /* hamr 2026-10-05: the card's free-text boxes wrap like the ask box (one shared fitBox); a few rows, then they scroll inside */
448
470
  /* same look as the card's one-line inputs (white when editable, ordinary 1px border) — a textarea has its own browser border otherwise */
449
471
  .job-card textarea.jf-wrap{width:100%;box-sizing:border-box;font:inherit;padding:6px 8px;border:1px solid var(--border-strong);border-radius:0;background:var(--bg);color:var(--text);resize:vertical;overflow-y:auto;max-height:200px;}
472
+ /* P7 — numbered boxes (The job, Inputs): the number gutter sits OUTSIDE the box, left; a hidden mirror measures each
473
+ logical line's wrapped height so a number stays level with its line (docs/product/PANEL-BUILD.md, signed mock) */
474
+ .nbox{display:grid;grid-template-columns:22px minmax(0,1fr);column-gap:6px;align-items:stretch;}
475
+ /* contain:size keeps the gutter's own content out of the row height (the box alone decides it); its padding mirrors the
476
+ textarea's 6px + 1px border top AND bottom so both scroll the same distance and a number never drifts or falls out */
477
+ .nbox .gut{color:var(--text-faint);text-align:right;padding:7px 0;box-sizing:border-box;overflow:hidden;contain:size;font-size:inherit;line-height:1.5;}
478
+ .nbox .gut div{min-height:1.5em;}
479
+ .nbox-in{position:relative;min-width:0;}
480
+ /* the numbered boxes grow with their text up to 8 lines (8 x 1.5em + 6px padding x2 + 1px border x2), then scroll; fitBox reads this cap */
481
+ .job-card .nbox textarea.jf-wrap{display:block;line-height:1.5;max-height:calc(12em + 14px);}
482
+ .nbox-mir{position:absolute;top:0;left:0;visibility:hidden;pointer-events:none;box-sizing:border-box;padding:6px 8px;font:inherit;line-height:1.5;white-space:pre-wrap;overflow-wrap:anywhere;}
483
+ .nbox-mir div{min-height:1.5em;}
484
+ /* P7 — the ONE job plan drawing (chat plan bubble + Job tab): job lines numbered, rule under, checks folded; only the marker is blue */
485
+ .jp{white-space:normal;font-size:13px;}
486
+ .jp-legend{font-size:12px;color:var(--text-faint);margin:0 0 8px;}
487
+ .jp-legend b,.jp .mk{color:var(--plan);font-weight:700;}
488
+ /* a wrapped job line hangs: its text lines up after the number, not under it (the signed mock's gutter shape) */
489
+ .jp .jl{margin-bottom:8px;overflow-wrap:anywhere;padding-left:30px;text-indent:-30px;}
490
+ .jp .jl .sub,.jp .jl .fold{padding-left:0;text-indent:0;}
491
+ .jp .num{display:inline-block;text-indent:0;width:22px;text-align:right;color:var(--text-faint);margin-right:8px;}
492
+ .jp .sub{color:var(--text-dim);padding-left:30px;}
493
+ .jp .fold{padding-left:30px;color:var(--text);}
494
+ .jp details.fold{padding-left:0;margin:6px 0;}
495
+ .jp details>summary{cursor:pointer;list-style:none;}
496
+ .jp details>summary::-webkit-details-marker{display:none;}
497
+ .jp details>summary::before{content:"\203A";color:var(--plan);font-weight:700;margin-right:6px;}
498
+ .jp details[open]>summary::before{content:"\2304";}
499
+ .jp details ul{margin:4px 0 0;padding-left:26px;color:var(--text-dim);}
500
+ .jp .older{font-size:12px;color:var(--text-faint);margin:0 0 8px;}
501
+ .jp .jp-sum{margin:0 0 8px;overflow-wrap:anywhere;}
502
+ .jp .jp-sum .lbl{display:inline-block;color:var(--text-faint);margin-right:8px;}
503
+ .jp-qs{margin:8px 0;overflow-wrap:anywhere;}
504
+ .jp-qs .lbl{display:inline-block;color:var(--text-faint);margin-right:8px;}
505
+ .jp-qs ul{margin:4px 0;padding-left:26px;}
506
+ .jp-goal{font-size:12px;color:var(--text-faint);margin-top:8px;}
450
507
  /* hamr 2026-10-05: the focused/selected Chat-card field gets a blue border (the accent), no outline ring */
451
508
  #panel-chat :is(input,select,textarea):focus{border-color:var(--accent);outline:none;}
452
509
  /* hamr 2026-10-05: the ask box is dimmed until something asks for a reply (placeholder stays readable) */
@@ -564,29 +621,23 @@
564
621
  <input id="jf-name" type="text" placeholder="kebab-case, unique">
565
622
  </div>
566
623
  <div class="field">
567
- <label for="jf-goal">Goal</label>
568
- <textarea class="jf-wrap" id="jf-goal" rows="2"></textarea>
624
+ <label for="jf-job">The job</label>
625
+ <div class="nbox" data-testid="nbox-job">
626
+ <div class="gut" id="jf-job-gut" aria-hidden="true"></div>
627
+ <div class="nbox-in"><textarea class="jf-wrap" id="jf-job" rows="3" data-testid="jf-job" spellcheck="false"></textarea><div class="nbox-mir" id="jf-job-mir" aria-hidden="true"></div></div>
628
+ </div>
569
629
  </div>
570
630
  <div class="field">
571
- <label for="jf-source">Source</label>
572
- <textarea class="jf-wrap" id="jf-source" rows="1" placeholder="absolute path to a local repo"></textarea>
631
+ <label for="jf-inputs">Inputs</label>
632
+ <div class="nbox" data-testid="nbox-inputs">
633
+ <div class="gut" id="jf-inputs-gut" aria-hidden="true"></div>
634
+ <div class="nbox-in"><textarea class="jf-wrap" id="jf-inputs" rows="2" data-testid="jf-inputs" spellcheck="false"></textarea><div class="nbox-mir" id="jf-inputs-mir" aria-hidden="true"></div></div>
635
+ </div>
573
636
  </div>
574
637
  <div class="field">
575
638
  <label for="jf-dest">Destination</label>
576
639
  <textarea class="jf-wrap" id="jf-dest" rows="1" placeholder="the write fence — where in the repo this run may change files"></textarea>
577
640
  </div>
578
- <div class="field">
579
- <label for="jf-success">Success</label>
580
- <textarea class="jf-wrap" id="jf-success" rows="2"></textarea>
581
- </div>
582
- <div class="field">
583
- <label for="jf-guardrails">Guardrails</label>
584
- <textarea class="jf-wrap" id="jf-guardrails" rows="2"></textarea>
585
- </div>
586
- <div class="field">
587
- <label for="jf-judge">Judge examples <span class="hint" style="display:inline">(rubric only)</span></label>
588
- <textarea class="jf-wrap" id="jf-judge" rows="2" disabled></textarea>
589
- </div>
590
641
 
591
642
  <div class="cap-row" data-testid="cap-row">
592
643
  <div class="cap-field">
@@ -613,7 +664,7 @@
613
664
  animated dots, then a check or a cross with its code-owned reason; chat turns stay in the thread above it, right over the ask box. -->
614
665
  <ol class="chat-steps" id="chat-progress-row" data-testid="chat-steps" aria-live="polite" hidden></ol>
615
666
 
616
- <!-- build item 1 — a failed/rejected send, revise, sign-prepare,
667
+ <!-- build item 1 — a failed/rejected send, revise (reopen), sign-prepare,
617
668
  sign or check-deps POST shows here and STAYS shown: it is set
618
669
  only by this tab's own action handlers below and is never
619
670
  touched by renderActions()/poll() (which own #chat-card-error
@@ -623,10 +674,12 @@
623
674
 
624
675
  <div class="chat-input-row">
625
676
  <label for="chat-msg" style="display:none">Message</label>
626
- <textarea id="chat-msg" rows="2" placeholder="Ask for a change, or reply to the plan…" data-testid="chat-msg"></textarea>
677
+ <textarea id="chat-msg" rows="2" placeholder="Answer here when the draft asks you a question…" data-testid="chat-msg"></textarea>
627
678
  </div>
628
679
  <button class="btn primary wide" type="button" id="chat-main-btn" data-testid="chat-main" disabled>Start drafting</button>
629
680
  <div class="hint" id="chat-main-hint" data-testid="chat-main-hint"></div>
681
+ <!-- hamr 2026-10-09: Revise = reopen the boxes, filled as drafted, and draft again. Shown only while a plan waits for the OK. -->
682
+ <button class="btn wide" type="button" id="chat-reopen-btn" data-testid="chat-reopen" hidden>Revise</button>
630
683
  <div class="hint">Only your click signs. The chat can't.</div>
631
684
  </section>
632
685
 
@@ -729,17 +782,16 @@
729
782
  <h4 id="details-title">Job card — signed</h4>
730
783
  <div class="field"><label>Check type</label><div class="ro-value" id="details-verdict">—</div></div>
731
784
  <div class="field"><label>Model</label><div class="ro-value" id="details-model">—</div></div>
785
+ <div class="field" id="details-description-field"><label>Description</label><div class="ro-value" id="details-description">—</div></div>
786
+ <div class="field"><label>The job</label><div class="ro-value" id="details-plan" data-testid="details-plan">—</div></div>
787
+ <div class="field"><label>Inputs</label><div class="ro-value" id="details-inputs" data-testid="details-inputs">—</div></div>
788
+ <div class="field"><label>Destination</label><div class="ro-value" id="details-dest">—</div></div>
789
+ <div class="field"><label>Tools</label><div class="ro-value" id="details-tools">—</div></div>
732
790
  <div class="field-row">
733
791
  <div class="field"><label>$ cap</label><div class="ro-value" id="details-cap-money">—</div></div>
734
792
  <div class="field"><label>Time cap</label><div class="ro-value" id="details-cap-time">—</div></div>
735
793
  </div>
736
- <div class="field" id="details-description-field"><label>Description</label><div class="ro-value" id="details-description">—</div></div>
737
- <div class="field"><label>Goal</label><div class="ro-value scroll" id="details-goal">—</div></div>
738
- <div class="field"><label>Success</label><div class="ro-value" id="details-success">—</div></div>
739
- <div class="field"><label>Guardrails</label><div class="ro-value" id="details-guardrails">—</div></div>
740
- <div class="field"><label>Source</label><div class="ro-value" id="details-source">—</div></div>
741
- <div class="field"><label>Destination</label><div class="ro-value" id="details-dest">—</div></div>
742
- <div class="field"><label>Tools</label><div class="ro-value" id="details-tools">—</div></div>
794
+ <div class="field"><label>Signed</label><div class="ro-value" id="details-signed" data-testid="details-signed">—</div></div>
743
795
  <div class="hint" id="job-note"></div>
744
796
  </div>
745
797
  </section>
@@ -869,7 +921,6 @@
869
921
  btn.setAttribute("aria-selected", "true");
870
922
  var panel = document.getElementById(btn.getAttribute("aria-controls"));
871
923
  if(panel){ panel.hidden = false; panel.classList.add("active"); }
872
- if(btn.id === "tab-run"){ reflowMap(); }
873
924
  });
874
925
  });
875
926
  }
@@ -884,33 +935,16 @@
884
935
  });
885
936
  }
886
937
 
887
- // ---- step map renderer (verbatim algorithm from design/panel-mockup.html's
888
- // buildStepMapSVG — ONE renderer, no second hand-authored map anywhere on this page) ----
889
- function stepMapColors(state){
890
- if(state === "done") return {stroke:"var(--green)", textFill:"#2e7d32"};
891
- if(state === "running") return {stroke:"var(--amber)", textFill:"#b8860b"};
892
- if(state === "stopped") return {stroke:"var(--red)", textFill:"var(--red)"};
893
- // died (hamr's ruling B, 2026-09-25): distinct from stopped/red — no
894
- // verdict was ever reached, so this box never shares the red look.
895
- if(state === "died") return {stroke:"var(--magenta)", textFill:"var(--magenta)"};
896
- return {stroke:"var(--border-strong)", textFill:"var(--text-faint)"};
897
- }
938
+ // ---- step map renderer (buildStepMapHTML below — ONE renderer, no second hand-authored map anywhere on this page) ----
898
939
  function escapeXml(s){
899
940
  return String(s).replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;").replace(/"/g,"&quot;");
900
941
  }
901
- var stepMapUidCounter = 0;
902
- var MAP_MARGIN = 10, MAP_GAP = 28;
903
- var CHAR_W = 7.2;
904
- var TITLE_HPAD = 16;
905
- var MIN_BOX_W = 100;
906
- var BOX_H = 60, BOX_H_2LINE = 78;
907
- var ROW_GAP = 70;
908
942
  // s.noNumber (F196): a synthetic placeholder box (e.g. "died during
909
943
  // planning") is a LABEL, never a real step — it renders without the
910
944
  // leading "<n> " step number a real step gets.
911
945
  // item 4 (2026-09-25): each step box shows its attempts INLINE, e.g.
912
946
  // "attempt 1 [✗] · attempt 2 [✓]" — appended to the box's own title text
913
- // (the existing wrapTitleLines 2-line wrap handles overflow the same way
947
+ // (the chip wraps long text inside itself the same way
914
948
  // it already does for a long step id; no third renderer, no new box shape).
915
949
  // item 1 (2026-09-27, panel P2 defect): an attempt's own outcome is null
916
950
  // in exactly two situations — its window is still open on a LIVE run (the
@@ -935,9 +969,9 @@
935
969
  if(g === "✗") return '<span class="glyph-red">' + g + '</span>';
936
970
  return g;
937
971
  }
938
- // PLAIN TEXT ONLY — feeds the SVG step map's title/width calculation
939
- // (`stepTitleText`, an SVG <text> node), never innerHTML; a colour span
940
- // here would render as literal "<span>" characters inside the map.
972
+ // PLAIN TEXT ONLY — feeds the step map chip label (`stepTitleText`, escaped
973
+ // before it goes into the chip); a colour span here would render as literal
974
+ // "<span>" characters inside the map.
941
975
  // {@link glyphSpan} (the P3 colour addition) is used only by the DOM
942
976
  // part-card renderer below, never by this function.
943
977
  function attemptsInlineText(attempts, boxIsLive){
@@ -1042,224 +1076,102 @@
1042
1076
  function boxStatusText(box){
1043
1077
  return box.status && box.status.word ? "[" + box.status.sign + "] " + boxStatusWord(box) : boxStatusWord(box);
1044
1078
  }
1045
- function naturalBoxWidth(steps){
1079
+ // Ruling 1A (hamr, 2026-10-09): the step map is a row of CHIPS — each box as wide as its own text, flowing
1080
+ // side by side and wrapping to the next line. Plain HTML + CSS (.map-chips / .map-chip): the browser does the
1081
+ // wrapping, so there is no width arithmetic, no resize observer, and no horizontal scroll at phone width. A name
1082
+ // is never truncated (it wraps inside its chip when it is longer than the line).
1083
+ // Marks that were line styles on the old SVG are now border styles: a retried step has a DASHED border, and a step
1084
+ // whose leg ended there (the run resumed after it) has a DOTTED right edge. The sign + word come from
1085
+ // boxStatusText (src/panel/status.js's table) — the chip shows "[sign title]" and keeps the word in its tooltip.
1086
+ function buildStepMapHTML(steps){
1046
1087
  var numIdx = stepNumberIndices(steps);
1047
- var maxChars = 0;
1048
- steps.forEach(function(s, idx){ maxChars = Math.max(maxChars, stepTitleText(numIdx[idx], s).length); });
1049
- return Math.max(MIN_BOX_W, Math.ceil(maxChars * CHAR_W) + TITLE_HPAD);
1050
- }
1051
- function computeMapLayout(n, availW, naturalW){
1052
- if(!availW) return {perRow:n, boxW:naturalW};
1053
- var usableW = availW - MAP_MARGIN * 2;
1054
- for(var perRow = n; perRow >= 2; perRow--){
1055
- var totalW = perRow * naturalW + (perRow - 1) * MAP_GAP;
1056
- if(totalW <= usableW){
1057
- var stretched = (usableW - (perRow - 1) * MAP_GAP) / perRow;
1058
- return {perRow:perRow, boxW:Math.max(naturalW, stretched)};
1059
- }
1060
- }
1061
- return {perRow:1, boxW:Math.max(MIN_BOX_W, usableW)};
1062
- }
1063
- function wrapTitleLines(text, maxWidth){
1064
- var maxChars = Math.max(1, Math.floor((maxWidth - TITLE_HPAD) / CHAR_W));
1065
- if(text.length <= maxChars) return [text];
1066
- var words = text.split(' ');
1067
- var line1 = '', i = 0;
1068
- for(; i < words.length; i++){
1069
- var cand = line1 ? line1 + ' ' + words[i] : words[i];
1070
- if(line1 && cand.length > maxChars) break;
1071
- line1 = cand;
1088
+ var last = steps.length - 1;
1089
+ var body = steps.map(function(s, idx){
1090
+ var sign = s.status && s.status.sign ? s.status.sign : "";
1091
+ var tries = boxRetryTry(s);
1092
+ var cls = "map-chip s-" + String(s.state).replace(/[^a-z-]/g, "") +
1093
+ (tries ? " retry" : "") + (s.resumedNext ? " resumed-next" : "");
1094
+ var label = escapeXml(stepTitleText(numIdx[idx], s));
1095
+ var blocked = s.blocked > 0 ? ' <span class="chip-blocked">' + s.blocked + ' blocked</span>' : '';
1096
+ var retry = tries ? '<span class="chip-retry">\u21bb' + tries + '</span> ' : '';
1097
+ var inner = '[' + retry + '<span class="chip-sign">' + escapeXml(sign) + '</span> ' + label + blocked + ']';
1098
+ var tip = ' title="' + escapeXml(boxStatusText(s)) + '"';
1099
+ var attrs = ' class="' + cls + '"' + tip + (s.resumedNext ? ' data-resumed="1"' : '') + (tries ? ' data-retry="' + tries + '"' : '');
1100
+ var chip = typeof s.partIndex === "number"
1101
+ ? '<button type="button"' + attrs + ' data-part-index="' + s.partIndex + '">' + inner + '</button>'
1102
+ : '<span' + attrs + '>' + inner + '</span>';
1103
+ return '<span class="map-item" role="listitem">' +
1104
+ (idx > 0 ? '<span class="map-lead" aria-hidden="true">\u2192</span>' : '') +
1105
+ chip + (idx < last ? '<span class="map-arrow" aria-hidden="true">\u2192</span>' : '') + '</span>';
1106
+ }).join("");
1107
+ return '<div class="map-chips" role="list" aria-label="step map">' + body + '</div>';
1108
+ }
1109
+ // Ruling A: the first chip of each WRAPPED line shows a leading arrow. CSS cannot know which chip starts a line, so
1110
+ // after render/resize this compares each item's offsetTop with the previous item's (same line = same top). The lead
1111
+ // arrow is absolutely positioned (no layout impact), so toggling the class can never reflow a chip — no loop.
1112
+ // Takes anything with offsetTop + classList (real elements, or stubs in tests).
1113
+ function markLineStarts(items){
1114
+ for(var i = 0; i < items.length; i++){
1115
+ var starts = i > 0 && items[i].offsetTop > items[i - 1].offsetTop;
1116
+ if(starts) items[i].classList.add("line-start"); else items[i].classList.remove("line-start");
1072
1117
  }
1073
- if(!line1) return [text.slice(0, maxChars), text.slice(maxChars)];
1074
- var line2 = words.slice(i).join(' ');
1075
- return line2 ? [line1, line2] : [line1];
1076
1118
  }
1077
- // ONE shared rule for "does this box draw a retry loop, and what try number" — a multi-attempt
1119
+ // ONE shared rule for "does this box draw a retry mark, and what try number" — a multi-attempt
1078
1120
  // box (a re-run step, or the fix loop; `run` on an old-shape spine falls in here too, naturally,
1079
- // never special-cased) gets a dashed self-loop labelled with its final try number; anything
1080
- // else (incl. every no-verdict kind, whose attempts is already forced to [] upstream) gets 0.
1121
+ // never special-cased) gets a dashed border; anything else (incl. every no-verdict kind, whose
1122
+ // attempts is already forced to [] upstream) gets 0.
1081
1123
  function boxRetryTry(box){
1082
1124
  var attempts = Array.isArray(box.attempts) ? box.attempts : [];
1083
1125
  return attempts.length > 1 ? attempts.length : 0;
1084
1126
  }
1085
- // item 2 (2026-09-27): the ONE place a retry loop's x-coordinate (curve
1086
- // start/control/end, and the "try N" label) is kept inside the box that
1087
- // owns it — MIN_BOX_W (100) is comfortably wider than 2*RETRY_INSET, so
1088
- // this never inverts the clamp range.
1089
- var RETRY_INSET = 4;
1090
- function clampRetryX(v, boxX, boxW){
1091
- var lo = boxX + RETRY_INSET, hi = boxX + boxW - RETRY_INSET;
1092
- return Math.min(hi, Math.max(lo, v));
1093
- }
1094
- function buildStepMapSVG(steps, availWidth){
1095
- var n = steps.length;
1096
- var naturalW = naturalBoxWidth(steps);
1097
- var layout = computeMapLayout(n, availWidth, naturalW);
1098
- var perRow = layout.perRow, boxW = layout.boxW, gap = MAP_GAP;
1099
- var rows = Math.ceil(n / perRow);
1100
- var margin = MAP_MARGIN;
1101
- var numIdx = stepNumberIndices(steps);
1102
- var titleLines = steps.map(function(s, idx){ return wrapTitleLines(stepTitleText(numIdx[idx], s), boxW); });
1103
- var maxLines = titleLines.reduce(function(m, l){ return Math.max(m, l.length); }, 1);
1104
- var boxH = maxLines > 1 ? BOX_H_2LINE : BOX_H;
1105
- var rowH = boxH + ROW_GAP;
1106
- var totalW = margin * 2 + perRow * boxW + (perRow - 1) * gap;
1107
- var totalH = rows * rowH;
1108
- var uid = "sm" + (stepMapUidCounter++);
1109
- var body =
1110
- '<defs>' +
1111
- '<marker id="' + uid + '-arrow" markerWidth="8" markerHeight="8" refX="6" refY="3" orient="auto">' +
1112
- '<path d="M0,0 L6,3 L0,6 z" fill="var(--text-dim)"></path></marker>' +
1113
- '<marker id="' + uid + '-arrow-grey" markerWidth="8" markerHeight="8" refX="6" refY="3" orient="auto">' +
1114
- '<path d="M0,0 L6,3 L0,6 z" fill="var(--text-faint)"></path></marker>' +
1115
- '</defs>';
1116
- steps.forEach(function(s, idx){
1117
- var row = Math.floor(idx / perRow);
1118
- var col = idx % perRow;
1119
- var rtl = (row % 2) === 1;
1120
- var slotCol = rtl ? (perRow - 1 - col) : col;
1121
- var x = margin + slotCol * (boxW + gap);
1122
- var y = 30 + row * rowH;
1123
- var colors = stepMapColors(s.state);
1124
- var lines = titleLines[idx];
1125
- var nextRow = idx < n - 1 ? Math.floor((idx + 1) / perRow) : null;
1126
- var hasDrop = nextRow !== null && nextRow !== row;
1127
- var stateY = y + boxH - 18;
1128
- var partIdxAttr = typeof s.partIndex === "number" ? (' data-part-index="' + s.partIndex + '" style="cursor:pointer;"') : '';
1129
- body += '<rect x="' + x + '" y="' + y + '" width="' + boxW + '" height="' + boxH +
1130
- '" rx="0" fill="var(--panel2)" stroke="' + colors.stroke + '" stroke-width="2"' + partIdxAttr + '></rect>';
1131
- if(s.blocked > 0){
1132
- body += '<text x="' + (x + boxW - 6) + '" y="' + (y + 12) +
1133
- '" text-anchor="end" font-size="10" fill="var(--red)">' + s.blocked + ' blocked</text>';
1134
- }
1135
- if(lines.length === 1){
1136
- body += '<text x="' + (x + boxW / 2) + '" y="' + (y + 25) +
1137
- '" text-anchor="middle" font-size="12" fill="var(--text)">' + escapeXml(lines[0]) + '</text>';
1138
- } else {
1139
- body += '<text x="' + (x + boxW / 2) + '" y="' + (y + 17) +
1140
- '" text-anchor="middle" font-size="12" fill="var(--text)">' + escapeXml(lines[0]) + '</text>';
1141
- body += '<text x="' + (x + boxW / 2) + '" y="' + (y + 29) +
1142
- '" text-anchor="middle" font-size="12" fill="var(--text)">' + escapeXml(lines[1]) + '</text>';
1143
- }
1144
- body += '<text x="' + (x + boxW / 2) + '" y="' + stateY +
1145
- '" text-anchor="middle" font-size="10" fill="' + colors.textFill + '">' + escapeXml(boxStatusText(s)) + '</text>';
1146
- // P2 (2026-09-27 ruling): a running box gets the mockup's pulsing
1147
- // amber dot (design/panel-mockup.html ~line 1648) — same fixed color
1148
- // and animation, never re-derived from the state's own stroke color.
1149
- if(s.state === "running"){
1150
- body += '<circle cx="' + (x + 20) + '" cy="' + (y + 12) + '" r="4" fill="#b8860b">' +
1151
- '<animate attributeName="opacity" values="1;0.3;1" dur="1.2s" repeatCount="indefinite"></animate></circle>';
1152
- }
1153
- var retryTry = boxRetryTry(s);
1154
- if(retryTry){
1155
- // dashed self-loop under the box, same proportions as the design mockup's curve,
1156
- // scaled to this box's own width. When this same box also carries the row-to-row
1157
- // snake drop (which leaves from dead-center, x=cx), shift the loop's start away from
1158
- // center by a fixed pixel offset so the two never begin at the same point.
1159
- // item 2 (2026-09-27): at a narrow box width, subtracting that same
1160
- // fixed pixel shift from proportionally-small x-fractions (e.g. the
1161
- // curve's own endpoint at 2.8% of boxW) can push the coordinate past
1162
- // the box's own left edge — the fixed shift was sized for desktop
1163
- // widths and never scaled down with boxW. clampRetryX is the ONE
1164
- // place every retry x-coordinate (curve start/control/end AND the
1165
- // "try N" label) is kept inside [x + RETRY_INSET, x + boxW -
1166
- // RETRY_INSET] — never re-clamped ad hoc per coordinate — so the
1167
- // whole loop and its label always stay inside the box that owns it,
1168
- // at every width, while keeping the mockup's proportions untouched
1169
- // wherever they already fit (every desktop-width box already fits).
1170
- var retryShift = hasDrop ? 14 : 0;
1171
- var bottom = y + boxH;
1172
- var sx = clampRetryX(x + boxW * 0.50 - retryShift, x, boxW);
1173
- var sy = bottom + 5;
1174
- var c1x = clampRetryX(x + boxW * 0.278 - retryShift, x, boxW), c1y = bottom + 30;
1175
- var c2x = clampRetryX(x + boxW * 0.167 - retryShift, x, boxW), c2y = bottom + 30;
1176
- var ex = clampRetryX(x + boxW * 0.028 - retryShift, x, boxW), ey = bottom + 2;
1177
- var labelX = clampRetryX(x + boxW * 0.267 - retryShift, x, boxW);
1178
- body += '<path d="M ' + sx + ' ' + sy + ' C ' + c1x + ' ' + c1y + ', ' + c2x + ' ' + c2y + ', ' + ex + ' ' + ey +
1179
- '" fill="none" stroke="var(--text-faint)" stroke-width="1.5" stroke-dasharray="3,3" marker-end="url(#' + uid + '-arrow-grey)"></path>';
1180
- body += '<text x="' + labelX + '" y="' + (bottom + 32) +
1181
- '" text-anchor="middle" font-size="10" fill="var(--text-faint)">try ' + retryTry + '</text>';
1182
- }
1183
- if(idx < n - 1){
1184
- // P5-R: where a leg ended and the next leg picked up, the connector is DOTTED (and says so) —
1185
- // one run, one map; the dotted line is the only mark of the resume.
1186
- var dash = s.resumedNext ? ' stroke-dasharray="2,4" data-resumed="1"' : '';
1187
- if(nextRow === row){
1188
- var midY = y + boxH / 2;
1189
- if(!rtl){
1190
- var nx = x + boxW + gap;
1191
- body += '<line x1="' + (x + boxW) + '" y1="' + midY + '" x2="' + (nx - 2) + '" y2="' + midY +
1192
- '" stroke="var(--text-dim)" stroke-width="2"' + dash + ' marker-end="url(#' + uid + '-arrow)"></line>';
1193
- } else {
1194
- var px = x - gap - boxW;
1195
- body += '<line x1="' + x + '" y1="' + midY + '" x2="' + (px + boxW + 2) + '" y2="' + midY +
1196
- '" stroke="var(--text-dim)" stroke-width="2"' + dash + ' marker-end="url(#' + uid + '-arrow)"></line>';
1197
- }
1198
- } else {
1199
- var cx = x + boxW / 2;
1200
- body += '<line x1="' + cx + '" y1="' + (y + boxH) + '" x2="' + cx + '" y2="' + (y + rowH - 2) +
1201
- '" stroke="var(--text-dim)" stroke-width="2"' + dash + ' marker-end="url(#' + uid + '-arrow)"></line>';
1202
- }
1203
- }
1204
- });
1205
- return '<svg width="' + totalW + '" height="' + totalH + '" viewBox="0 0 ' + totalW + ' ' + totalH + '" role="img" aria-label="step map">' + body + '</svg>';
1206
- }
1207
- // the line-style key under the MAP (hamr 2026-10-04): only the two line styles, never a sign legend
1127
+ // the line-style key under the MAP (hamr 2026-10-04): only the two border styles, never a sign legend
1208
1128
  function stepMapKeyHTML(steps){
1209
1129
  var resumed = (steps || []).some(function(b){ return b.resumedNext; });
1210
1130
  return '<div class="map-key" data-testid="map-key">' +
1211
- '<span>⤾ dashed = retry</span>' +
1212
- (resumed ? '<span>&middot;&middot;&middot; dotted = resumed</span>' : '') +
1131
+ '<span>\u21bbN = took N tries (dashed box)</span>' +
1132
+ (resumed ? '<span>dotted edge = resumed after</span>' : '') +
1213
1133
  '</div>';
1214
1134
  }
1215
- var lastSteps = null;
1216
- var lastWidth = null;
1217
- function mapAvailWidth(wrapper){
1218
- if(!wrapper) return null;
1219
- var w = wrapper.getBoundingClientRect().width;
1220
- if(!w) return null;
1221
- return Math.max(0, w - 20);
1222
- }
1223
1135
  function renderStepMap(steps){
1224
1136
  var mount = document.getElementById("map-mount");
1225
1137
  var wrapper = document.getElementById("map-box");
1226
- lastSteps = steps;
1227
1138
  if(!steps || !steps.length){
1228
1139
  mount.innerHTML = "";
1229
1140
  wrapper.hidden = true;
1230
1141
  return;
1231
1142
  }
1232
1143
  wrapper.hidden = false;
1233
- var w = mapAvailWidth(wrapper);
1234
- lastWidth = w;
1235
- mount.innerHTML = buildStepMapSVG(steps, w) + stepMapKeyHTML(steps);
1236
- // build item B: a map box click jumps to the Audit tab, that same part
1144
+ mount.innerHTML = buildStepMapHTML(steps) + stepMapKeyHTML(steps);
1145
+ layoutMap();
1146
+ // build item B: a map chip click jumps to the Audit tab, that same part
1237
1147
  // expanded and scrolled into view — same click-through as the card list,
1238
- // sharing partIndex so the two can never disagree about which row a box
1239
- // maps to. Delegated once per render (mount.innerHTML above already
1240
- // wiped any previous listeners' targets along with the old markup).
1241
- mount.querySelectorAll("rect[data-part-index]").forEach(function(rect){
1242
- rect.addEventListener("click", function(){
1243
- var idx = Number(rect.getAttribute("data-part-index"));
1148
+ // sharing partIndex so the two can never disagree about which row a chip
1149
+ // maps to. Bound once per render (mount.innerHTML above already wiped any
1150
+ // previous listeners' targets along with the old markup).
1151
+ mount.querySelectorAll(".map-chip[data-part-index]").forEach(function(chip){
1152
+ chip.addEventListener("click", function(){
1153
+ var idx = Number(chip.getAttribute("data-part-index"));
1244
1154
  document.getElementById("tab-audit").click();
1245
1155
  openAuditPart(idx);
1246
1156
  });
1247
1157
  });
1248
1158
  }
1249
- function reflowMap(){
1250
- var wrapper = document.getElementById("map-box");
1251
- if(!wrapper || wrapper.hidden || !lastSteps) return;
1252
- var w = mapAvailWidth(wrapper);
1253
- if(w === null || w === lastWidth) return;
1254
- lastWidth = w;
1255
- document.getElementById("map-mount").innerHTML = buildStepMapSVG(lastSteps, w) + stepMapKeyHTML(lastSteps);
1159
+ function layoutMap(){
1160
+ var box = document.getElementById("map-box");
1161
+ if(!box || box.hidden) return;
1162
+ markLineStarts(Array.prototype.slice.call(box.querySelectorAll(".map-item")));
1163
+ }
1164
+ var layoutTimer = null;
1165
+ function layoutMapSoon(){
1166
+ if(layoutTimer) clearTimeout(layoutTimer);
1167
+ layoutTimer = setTimeout(function(){ layoutTimer = null; layoutMap(); }, 60);
1256
1168
  }
1257
1169
  if(typeof ResizeObserver !== "undefined"){
1258
- var ro = new ResizeObserver(function(){ reflowMap(); });
1259
- var mb = document.getElementById("map-box");
1260
- if(mb) ro.observe(mb);
1170
+ var mapRo = new ResizeObserver(layoutMapSoon);
1171
+ var mapBoxEl = document.getElementById("map-box");
1172
+ if(mapBoxEl) mapRo.observe(mapBoxEl);
1261
1173
  } else {
1262
- window.addEventListener("resize", reflowMap);
1174
+ window.addEventListener("resize", layoutMapSoon);
1263
1175
  }
1264
1176
 
1265
1177
  // F196: a `synthetic` step (e.g. the "died during planning" placeholder
@@ -2110,8 +2022,8 @@
2110
2022
  var nr = "not recorded";
2111
2023
  renderJob({
2112
2024
  job: v.job, resolved: false, checkType: v.checkType || nr, model: v.model || nr,
2113
- goal: v.goal || nr, success: v.success || nr, guardrails: v.guardrails || nr,
2114
- source: nr, destination: nr, tools: nr,
2025
+ goal: v.goal || nr, plan: v.plan || null, inputs: v.inputs || null, signed: null,
2026
+ destination: nr, tools: nr,
2115
2027
  budgetUsd: (typeof v.budgetUsd === "number") ? v.budgetUsd : null,
2116
2028
  maxWallMs: (typeof v.maxWallMs === "number") ? v.maxWallMs : null,
2117
2029
  note: "imported · view only — the exported job's own spec",
@@ -3247,6 +3159,35 @@
3247
3159
  });
3248
3160
  });
3249
3161
 
3162
+ // ---- the ONE job plan drawing (P7): used by the chat's plan bubble AND the Job tab. `plan` is the server's model
3163
+ // (src/panel/jobplan.js): numbered lines with their rule, the checks under the line each serves (folded), the AI's own
3164
+ // "Not checked", the always-on guards. Only the marker is blue (--plan). Returns HTML (every text is escaped).
3165
+ function renderJobPlan(plan){
3166
+ var h = '<div class="jp" data-testid="job-plan">' + (plan.older ? '<div class="older" data-testid="plan-older">older job \u2014 numbered lines were not saved</div>' : "") + '<div class="jp-legend"><b>\u203A</b> = the approved plan</div>' +
3167
+ (plan.goal && !plan.older ? '<div class="jp-sum" data-testid="plan-goal"><span class="lbl">AI\'s summary</span>' + escapeXml(plan.goal) + "</div>" : "");
3168
+ var checkLine = function(c){
3169
+ return '<div class="fold" data-testid="plan-check"><span class="mk">\u203A</span> check \u00B7 ' + escapeXml(c.name) + (c.cls ? " \u00B7 " + escapeXml(c.cls) : "") + "</div>";
3170
+ };
3171
+ (plan.lines || []).forEach(function(l){
3172
+ h += '<div class="jl" data-testid="plan-line"><span class="num">' + escapeXml(String(l.n)) + "</span>" + escapeXml(l.text);
3173
+ if(l.rule) h += '<div class="sub">~ ' + escapeXml(l.rule) + "</div>";
3174
+ (l.checks || []).forEach(function(c){ h += checkLine(c); });
3175
+ (l.refused || []).forEach(function(r){ h += '<div class="fold" data-testid="plan-refused"><span class="mk">\u203A</span> not checked \u00B7 ' + escapeXml(r) + "</div>"; });
3176
+ h += "</div>";
3177
+ });
3178
+ if((plan.loose || []).length){
3179
+ h += '<div class="jl"><span class="num"></span>other checks' + plan.loose.map(checkLine).join("") + "</div>";
3180
+ }
3181
+ var fold = function(title, items, testid){
3182
+ if(!items || !items.length) return "";
3183
+ return '<details class="fold" data-testid="' + testid + '"><summary>' + escapeXml(title) + " \u00B7 " + items.length + "</summary><ul>" +
3184
+ items.map(function(x){ return "<li>" + escapeXml(x) + "</li>"; }).join("") + "</ul></details>";
3185
+ };
3186
+ h += fold("Not checked (the AI's own reading)", plan.notChecked, "plan-notchecked");
3187
+ h += fold("Always on", plan.alwaysOn, "plan-alwayson");
3188
+ return h + "</div>";
3189
+ }
3190
+
3250
3191
  // ---- Job tab ----
3251
3192
  function renderJob(job){
3252
3193
  var empty = document.getElementById("job-empty");
@@ -3262,10 +3203,14 @@
3262
3203
  var descField = document.getElementById("details-description-field");
3263
3204
  if(job.description){ descField.hidden = false; document.getElementById("details-description").textContent = job.description; }
3264
3205
  else { descField.hidden = true; }
3265
- document.getElementById("details-goal").textContent = job.goal;
3266
- document.getElementById("details-success").textContent = job.success;
3267
- document.getElementById("details-guardrails").textContent = job.guardrails;
3268
- document.getElementById("details-source").textContent = job.source;
3206
+ // P7: the numbered job lines, their rules and checks — the SAME drawing the chat's plan bubble uses (renderJobPlan)
3207
+ var plan = job.plan || {lines: [{n: 1, text: job.goal || "not recorded", rule: "", checks: [], refused: []}], loose: [], notChecked: [], alwaysOn: [], older: true};
3208
+ document.getElementById("details-plan").innerHTML = renderJobPlan(plan);
3209
+ var ins = Array.isArray(job.inputs) ? job.inputs : [];
3210
+ document.getElementById("details-inputs").innerHTML = ins.length
3211
+ ? ins.map(function(i){ return '<div><span class="num-in">' + escapeXml(String(i.n)) + "</span> " + escapeXml(i.label + ": " + i.value) + "</div>"; }).join("")
3212
+ : "not recorded";
3213
+ document.getElementById("details-signed").textContent = job.signed || "not recorded";
3269
3214
  document.getElementById("details-dest").textContent = job.destination;
3270
3215
  document.getElementById("details-tools").textContent = job.tools;
3271
3216
  document.getElementById("details-cap-money").textContent = panelMoney(job.budgetUsd);
@@ -3337,7 +3282,7 @@
3337
3282
  // drives both fetches — never two independent timers that could race
3338
3283
  // each other. ----
3339
3284
  //
3340
- // F195 (docs/logs/FINDINGS.md): /api/runs re-replays every spine on
3285
+ // F195 (docs/wiki/FINDINGS.md): /api/runs re-replays every spine on
3341
3286
  // every call (~457ms CPU at 250 rows). hamr's ruling B, 2026-09-27:
3342
3287
  // "refresh less often, up to 30s at most, choose the lesser when
3343
3288
  // possible" — the Runs list polls every 10s (RUNS_LIST_POLL_MS) instead
@@ -3675,6 +3620,7 @@
3675
3620
  var msgInput = document.getElementById("chat-msg");
3676
3621
  var mainBtn = document.getElementById("chat-main-btn");
3677
3622
  var mainHint = document.getElementById("chat-main-hint");
3623
+ var reopenBtn = document.getElementById("chat-reopen-btn");
3678
3624
  var mainAction = "none";
3679
3625
  var lastState = null;
3680
3626
  var startOk = false;
@@ -3712,10 +3658,10 @@
3712
3658
  var sfNote = document.getElementById("chat-startfrom-note");
3713
3659
  var originLine = document.getElementById("chat-origin-line");
3714
3660
  // the locked boxes of a reuse card: the signed workflow, greyed and not editable (the server refuses a change anyway)
3715
- var LOCKED_IDS = ["jf-name", "jf-goal", "jf-success", "jf-guardrails", "jf-judge"];
3661
+ var LOCKED_IDS = ["jf-name", "jf-job"];
3716
3662
  // hamr 2026-10-05: while a drafting session is live NOTHING on the card is editable (the session was started from
3717
3663
  // these values). The reuse rule (only Source/Destination/caps open) applies before the start. ONE function owns it.
3718
- var OPEN_IDS = ["jf-source", "jf-dest", "jf-cap-money", "jf-cap-time", "jf-model"];
3664
+ var OPEN_IDS = ["jf-inputs", "jf-dest", "jf-cap-money", "jf-cap-time", "jf-model"];
3719
3665
  var reuseOn = false;
3720
3666
  // Resume mode (hamr 2026-10-07): the card shows the signed job of one run, every box locked except the two caps.
3721
3667
  // `resumeRun` = {runid, noTime} while it is on, else null. Never set together with a drafting session or a reuse pick.
@@ -3731,14 +3677,14 @@
3731
3677
  // the Check type radios stay clickable on a reuse card (Deterministic / Rubric = leave reuse); a live session or a resume freezes them
3732
3678
  document.querySelectorAll('input[name="jf-verdict"]').forEach(function(r){ r.disabled = live || resuming; });
3733
3679
  document.getElementById("job-card").classList.toggle("reuse", reuseOn);
3734
- if(!reuseOn && !live && !resuming){ var ck = document.querySelector('input[name="jf-verdict"]:checked'); document.getElementById("jf-judge").disabled = !ck || ck.value !== "rubric"; }
3735
3680
  }
3736
3681
  function setReuseLocked(on){ reuseOn = on; syncCardLock(); }
3737
3682
  function clearStartFrom(keepBoxes){
3738
3683
  // Clear after a reuse starts a genuinely new job: the reused values do not linger in the boxes — except when a Check type
3739
3684
  // radio click leaves reuse (keepBoxes): a radio never blanks what the person has on the card
3740
3685
  if(startFrom && !keepBoxes){
3741
- ["jf-name", "jf-goal", "jf-source", "jf-dest", "jf-success", "jf-guardrails", "jf-judge", "jf-cap-money", "jf-cap-time"].forEach(function(id){ document.getElementById(id).value = ""; });
3686
+ CARD_BOX_IDS.forEach(function(id){ document.getElementById(id).value = ""; });
3687
+ NUMBERED_IDS.forEach(gutterSync);
3742
3688
  document.querySelectorAll('input[name="jf-verdict"]').forEach(function(r){ r.checked = (r.value === "deterministic"); });
3743
3689
  }
3744
3690
  startFrom = null; reuseSession = false; autoSigned = false; reuseCheckType = "deterministic";
@@ -3750,7 +3696,7 @@
3750
3696
  // ---- Reuse workflow picker (hamr 2026-10-06): Check type is ONE radio group of three; "Reuse workflow" shows a search
3751
3697
  // box over the jobs that have a green local run (GET /api/author/reuse-jobs), a pick fills the card through the SAME
3752
3698
  // path the Run tab's button uses (reuseWorkflow -> bareloop-reuse -> applyReuse), then a picked box with `change`.
3753
- var CARD_BOX_IDS = ["jf-name", "jf-goal", "jf-source", "jf-dest", "jf-success", "jf-guardrails", "jf-judge", "jf-cap-money", "jf-cap-time"];
3699
+ var CARD_BOX_IDS = ["jf-name", "jf-job", "jf-inputs", "jf-dest", "jf-cap-money", "jf-cap-time"];
3754
3700
  var rwBox = document.getElementById("rw");
3755
3701
  var rwSearch = document.getElementById("rw-search");
3756
3702
  var rwQ = document.getElementById("rw-q");
@@ -3767,9 +3713,11 @@
3767
3713
  }
3768
3714
  function setVerdict(v){
3769
3715
  document.querySelectorAll('input[name="jf-verdict"]').forEach(function(r){ r.checked = (r.value === v); });
3716
+ setJobPlaceholders(v);
3770
3717
  }
3771
3718
  function clearCardBoxes(){
3772
3719
  CARD_BOX_IDS.forEach(function(id){ document.getElementById(id).value = ""; });
3720
+ NUMBERED_IDS.forEach(gutterSync);
3773
3721
  document.getElementById("jf-cap-note").textContent = "";
3774
3722
  }
3775
3723
  // ONE function shows/hides the picker: visible only while the radio is on Reuse workflow; the search box before a
@@ -3817,7 +3765,6 @@
3817
3765
  function openPicker(){
3818
3766
  rwQ.value = ""; rwHl = 0;
3819
3767
  rwJobs = null; rwFailed = false; // a fresh open shows "Loading…", never the previous list
3820
- document.getElementById("jf-judge").disabled = true;
3821
3768
  renderPicker();
3822
3769
  renderReuseList();
3823
3770
  loadReuseJobs();
@@ -3858,7 +3805,7 @@
3858
3805
  card.hidden = false;
3859
3806
  originLine.hidden = true;
3860
3807
  exitResumeMode();
3861
- thread.innerHTML = "";
3808
+ thread.innerHTML = ""; lastMsgSig = null;
3862
3809
  errEl.textContent = "";
3863
3810
  actionErrEl.textContent = "";
3864
3811
  sessionId = null;
@@ -3888,7 +3835,6 @@
3888
3835
  function resetCard(){
3889
3836
  clearCardBoxes();
3890
3837
  setVerdict("deterministic");
3891
- document.getElementById("jf-judge").disabled = true;
3892
3838
  modelSelect.selectedIndex = 0;
3893
3839
  msgInput.value = "";
3894
3840
  rwQ.value = "";
@@ -3917,12 +3863,9 @@
3917
3863
  checkType: (verdictValue() === "reuse" ? reuseCheckType : verdictValue()) === "rubric" ? "rubric" : "deterministic",
3918
3864
  model: document.getElementById("jf-model").value,
3919
3865
  jobName: document.getElementById("jf-name").value.trim(),
3920
- goal: document.getElementById("jf-goal").value.trim(),
3921
- source: document.getElementById("jf-source").value.trim(),
3866
+ jobText: document.getElementById("jf-job").value.trim(),
3867
+ inputs: document.getElementById("jf-inputs").value.trim(),
3922
3868
  destination: document.getElementById("jf-dest").value.trim(),
3923
- success: document.getElementById("jf-success").value.trim(),
3924
- guardrails: document.getElementById("jf-guardrails").value.trim(),
3925
- judgeExamples: document.getElementById("jf-judge").value.trim(),
3926
3869
  capUsd: moneyCap,
3927
3870
  maxWallMs: timeCapMin === "" ? undefined : Math.round(Number(timeCapMin) * 60000),
3928
3871
  };
@@ -3971,7 +3914,7 @@
3971
3914
  setVerdict(v);
3972
3915
  renderPicker();
3973
3916
  }
3974
- document.getElementById("jf-judge").disabled = v !== "rubric";
3917
+ setJobPlaceholders(v);
3975
3918
  refreshStartEnabled();
3976
3919
  }
3977
3920
  document.querySelectorAll('input[name="jf-verdict"]').forEach(function(r){
@@ -4082,11 +4025,23 @@
4082
4025
  if(role === "system") return "";
4083
4026
  return "bareloop";
4084
4027
  }
4028
+ // the thread is redrawn only when its messages changed: a 2 s poll tick must not close a fold the person just opened
4029
+ var lastMsgSig = null;
4085
4030
  function renderMessages(state){
4031
+ var sig = JSON.stringify(state.messages);
4032
+ if(sig === lastMsgSig) return;
4033
+ lastMsgSig = sig;
4086
4034
  thread.innerHTML = state.messages.map(function(m){
4087
4035
  var cls = m.role === "you" ? "you" : (m.role === "system" ? "system" : "bot");
4088
4036
  var who = chatWhoLabel(m.role);
4089
4037
  var whoHtml = who ? ('<div class="who">' + escapeXml(who) + '</div>') : "";
4038
+ if(m.kind === "plan" && m.plan){
4039
+ // P7: the confirm turn's plan is drawn by the one job-plan renderer (the Job tab uses the same)
4040
+ var qs = (m.plan.questions || []).length
4041
+ ? '<div class="jp-qs" data-testid="plan-questions"><span class="lbl">AI\'s open questions</span><ul>' +
4042
+ m.plan.questions.map(function(q){ return "<li>" + escapeXml(q) + "</li>"; }).join("") + "</ul><b>Revise if you need to.</b></div>" : "";
4043
+ return '<div class="msg ' + cls + '" data-testid="plan-bubble">' + whoHtml + renderJobPlan(m.plan) + qs + '<div class="jp-goal">' + escapeXml(m.text) + "</div></div>";
4044
+ }
4090
4045
  return '<div class="msg ' + cls + '">' + whoHtml + escapeXml(m.text) + '</div>';
4091
4046
  }).join("");
4092
4047
  thread.scrollTop = thread.scrollHeight;
@@ -4127,26 +4082,20 @@
4127
4082
  }
4128
4083
 
4129
4084
  // hamr's ruling 2026-10-05: ONE wide button below the message box. Its wording, action and enabled state are ONE
4130
- // pure function of (state, typed-text-empty, reuse); state = {session, startOk, phase, pendingAsk, revisesLeft,
4131
- // signClicked, specHash}. Actions: start | check-deps | send | revise | sign-prepare | sign | none.
4085
+ // pure function of (state, typed-text-empty, reuse); state = {session, startOk, phase, pendingAsk,
4086
+ // signClicked, specHash}. Actions: start | check-deps | send | sign-prepare | sign | none.
4132
4087
  function mainButtonFor(st, textEmpty, reuse){
4133
4088
  var ask = st.pendingAsk;
4134
4089
  var atMenu = !!ask && ask.kind === "menu";
4135
- var left = st.revisesLeft || 0;
4136
- var hint = atMenu ? left + (left === 1 ? " change left" : " changes left") : "";
4090
+ var hint = "";
4137
4091
  if(!st.session){
4138
4092
  return {text: reuse ? "Sign & run" : "Start drafting", action: "start", disabled: !st.startOk, hint: ""};
4139
4093
  }
4140
4094
  if(ask && ask.kind === "install-needed"){
4141
4095
  return {text: "Check again", action: "check-deps", disabled: false, hint: ""};
4142
4096
  }
4143
- if(ask && !textEmpty){
4144
- return atMenu
4145
- ? {text: "Send", action: "revise", disabled: left <= 0, hint: hint}
4146
- : {text: "Send", action: "send", disabled: false, hint: ""};
4147
- }
4148
4097
  if(ask && !atMenu){
4149
- return {text: "Send", action: "send", disabled: true, hint: ""};
4098
+ return {text: "Send", action: "send", disabled: textEmpty, hint: ""};
4150
4099
  }
4151
4100
  if(atMenu || st.phase === "prepared"){
4152
4101
  if(st.phase === "prepared" && st.signClicked){
@@ -4156,16 +4105,21 @@
4156
4105
  }
4157
4106
  return {text: "Sign & run", action: "none", disabled: true, hint: ""};
4158
4107
  }
4159
- // pure: the ask box takes typed text only when the session has a pending ask that accepts it (the plan menu -> a
4160
- // change request, or any question); not with no session, no ask, or while waiting on an install
4108
+ // pure: the ask box takes typed text only when the session has a pending ask that accepts it (a question the plan
4109
+ // raised); not with no session, no ask, while the plan waits for the OK (that is Sign & run or Revise, never typed
4110
+ // text), or while waiting on an install
4161
4111
  function askBoxOpenFor(st){
4162
- return !!st.session && !!st.pendingAsk && st.pendingAsk.kind !== "install-needed";
4112
+ return !!st.session && !!st.pendingAsk && st.pendingAsk.kind !== "install-needed" && st.pendingAsk.kind !== "menu";
4113
+ }
4114
+ // pure: Revise shows only while a plan waits for the OK
4115
+ function reviseShownFor(st){
4116
+ return !!st.session && !!st.pendingAsk && st.pendingAsk.kind === "menu";
4163
4117
  }
4164
4118
  function renderMain(){
4165
4119
  var st = {session: sessionLive, startOk: startOk, signClicked: signClickedOnce};
4166
4120
  if(sessionLive && lastState){
4167
4121
  st.phase = lastState.phase; st.pendingAsk = lastState.pendingAsk;
4168
- st.revisesLeft = lastState.revisesLeft; st.specHash = lastState.specHash;
4122
+ st.specHash = lastState.specHash;
4169
4123
  }
4170
4124
  // hamr 2026-10-05: the ask box is open only while something asks for a reply; otherwise dimmed and emptied
4171
4125
  var open = askBoxOpenFor(st);
@@ -4176,26 +4130,66 @@
4176
4130
  mainBtn.disabled = b.disabled;
4177
4131
  mainAction = b.action;
4178
4132
  mainHint.textContent = b.hint;
4133
+ reopenBtn.hidden = !reviseShownFor(st);
4179
4134
  fitBox(msgInput);
4180
4135
  fitCardBoxes();
4181
4136
  }
4182
4137
  msgInput.addEventListener("input", renderMain);
4138
+ // P7 — the numbered boxes. A plain job line is numbered 1, 2, 3; a line starting `~` is a rule for the line above and has no
4139
+ // number; Inputs numbers every line; empty lines have none. A hidden mirror (same font/width/padding) measures each logical
4140
+ // line's wrapped height so every number stays level with its own line — the same rules src/jobblock.js parses by.
4141
+ var NUMBERED_IDS = ["jf-job", "jf-inputs"];
4142
+ var FIT_IDS = ["jf-job", "jf-inputs", "jf-dest"];
4143
+ function gutterSync(id){
4144
+ var ta = document.getElementById(id), gut = document.getElementById(id + "-gut"), mir = document.getElementById(id + "-mir");
4145
+ if(!ta || !gut || !mir) return;
4146
+ var lines = ta.value.split("\n");
4147
+ var isJob = id === "jf-job";
4148
+ var n = 0;
4149
+ var nums = lines.map(function(l){
4150
+ var t = l.trim();
4151
+ if(t === "" || (isJob && t.charAt(0) === "~")) return "";
4152
+ n += 1; return String(n);
4153
+ });
4154
+ mir.style.width = ta.clientWidth ? ta.clientWidth + "px" : "";
4155
+ mir.innerHTML = lines.map(function(l){ return "<div>" + (l === "" ? "\u200b" : escapeXml(l)) + "</div>"; }).join("");
4156
+ var rows = mir.children;
4157
+ gut.innerHTML = nums.map(function(t, i){
4158
+ var hgt = rows[i] ? rows[i].offsetHeight : 0;
4159
+ return '<div' + (hgt ? ' style="height:' + hgt + 'px"' : "") + ">" + t + "</div>";
4160
+ }).join("");
4161
+ gut.scrollTop = ta.scrollTop;
4162
+ }
4163
+ // the empty-box examples (grey placeholders that vanish on typing) change with the Check type radio
4164
+ var JOB_PLACEHOLDERS = {
4165
+ deterministic: "Fix the failing date tests in src/date.js\n~ don't edit any test file\nMake npm test pass",
4166
+ rubric: "Write a one-page summary of docs/plan.md\n~ PASS: every number is quoted from the file\n~ FAIL: a number that is not in the file\nKeep it under 300 words",
4167
+ };
4168
+ var INPUTS_PLACEHOLDER = "repo: /home/me/myrepo\nspec: docs/spec.md";
4169
+ function setJobPlaceholders(v){
4170
+ document.getElementById("jf-job").setAttribute("placeholder", JOB_PLACEHOLDERS[v === "rubric" ? "rubric" : "deterministic"]);
4171
+ document.getElementById("jf-inputs").setAttribute("placeholder", INPUTS_PLACEHOLDER);
4172
+ }
4173
+ setJobPlaceholders("deterministic");
4183
4174
  // grow a text box with its text (a few rows, then it scrolls inside); ONE function for the ask box and the card boxes.
4184
4175
  // A box that is hidden (or not laid out) measures 0 and gets no inline height.
4185
4176
  function fitBox(el){
4186
4177
  el.style.height = "auto";
4187
4178
  if(!el.scrollHeight){ el.style.height = ""; return; }
4188
4179
  var border = el.offsetHeight - el.clientHeight;
4189
- el.style.height = Math.min(el.scrollHeight + border, 200) + "px";
4180
+ var cap = parseFloat(window.getComputedStyle(el).maxHeight);
4181
+ el.style.height = Math.min(el.scrollHeight + border, cap > 0 ? cap : 200) + "px";
4190
4182
  }
4191
4183
  function fitCardBoxes(){
4192
- ["jf-goal", "jf-source", "jf-dest", "jf-success", "jf-guardrails", "jf-judge"].forEach(function(id){ fitBox(document.getElementById(id)); });
4184
+ FIT_IDS.forEach(function(id){ fitBox(document.getElementById(id)); });
4185
+ NUMBERED_IDS.forEach(gutterSync);
4193
4186
  }
4194
4187
  // live growth: each card box re-fits on its own input (typing and Enter), through the same fitBox
4195
- ["jf-goal", "jf-source", "jf-dest", "jf-success", "jf-guardrails", "jf-judge"].forEach(function(id){
4188
+ FIT_IDS.forEach(function(id){
4196
4189
  var box = document.getElementById(id);
4197
- box.addEventListener("input", function(){ fitBox(box); });
4190
+ box.addEventListener("input", function(){ fitBox(box); if(NUMBERED_IDS.indexOf(id) !== -1) gutterSync(id); });
4198
4191
  });
4192
+ NUMBERED_IDS.forEach(function(id){ var box = document.getElementById(id); box.addEventListener("scroll", function(){ document.getElementById(id + "-gut").scrollTop = box.scrollTop; }); });
4199
4193
  window.addEventListener("resize", function(){ fitCardBoxes(); fitBox(msgInput); });
4200
4194
  document.getElementById("tab-chat").addEventListener("click", function(){ fitCardBoxes(); fitBox(msgInput); });
4201
4195
  // Enter sends through the one main button; Shift+Enter (or IME composition) keeps its newline
@@ -4300,11 +4294,8 @@
4300
4294
  }
4301
4295
  modelSelect.value = c.model;
4302
4296
  }
4303
- setVal("jf-name", c.jobName); setVal("jf-goal", c.goal); setVal("jf-source", c.source);
4304
- setVal("jf-dest", c.destination); setVal("jf-success", c.success); setVal("jf-guardrails", c.guardrails);
4305
- setVal("jf-judge", c.judgeExamples); setVal("jf-cap-money", c.capUsd);
4306
- setVal("jf-cap-time", typeof c.maxWallMs === "number" ? Math.round(c.maxWallMs / 60000) : "");
4307
- document.getElementById("jf-judge").disabled = c.checkType !== "rubric";
4297
+ fillCard(c);
4298
+
4308
4299
  if(reuseSession) setReuseLocked(true);
4309
4300
  renderPicker();
4310
4301
  fitCardBoxes();
@@ -4326,6 +4317,14 @@
4326
4317
 
4327
4318
  // ---- Reuse workflow — the card opens filled from the SIGNED job: four open boxes, the rest greyed ----
4328
4319
  function setVal(id, v){ document.getElementById(id).value = (v === undefined || v === null) ? "" : String(v); }
4320
+ // ONE owner of filling the card from a prefill (a re-attached session, Reuse, Edit in chat, Resume): the six boxes P7 left
4321
+ // (the old Goal/Source/Success/Guardrails/Judge boxes are gone), then every box re-fits and its number gutter re-measures.
4322
+ function fillCard(c){
4323
+ setVal("jf-name", c.jobName); setVal("jf-job", c.jobText); setVal("jf-inputs", c.inputs);
4324
+ setVal("jf-dest", c.destination); setVal("jf-cap-money", c.capUsd);
4325
+ setVal("jf-cap-time", typeof c.maxWallMs === "number" ? Math.round(c.maxWallMs / 60000) : "");
4326
+ fitCardBoxes();
4327
+ }
4329
4328
  // ONE owner of the reuse fill: the Run tab's / Import view's button and the picker both arrive here (the
4330
4329
  // `bareloop-reuse` event), so the card ends up in the same picked state whichever door was used.
4331
4330
  function applyReuse(d){
@@ -4338,11 +4337,8 @@
4338
4337
  reusePickedName = c.jobName || (pre.origin && pre.origin.job) || "job";
4339
4338
  setVerdict("reuse");
4340
4339
  if(c.model){ modelSelect.value = c.model; }
4341
- setVal("jf-name", c.jobName); setVal("jf-goal", c.goal); setVal("jf-source", c.source);
4342
- setVal("jf-dest", c.destination); setVal("jf-success", c.success); setVal("jf-guardrails", c.guardrails);
4343
- setVal("jf-judge", c.judgeExamples); setVal("jf-cap-money", c.capUsd);
4344
- setVal("jf-cap-time", typeof c.maxWallMs === "number" ? Math.round(c.maxWallMs / 60000) : "");
4345
- document.getElementById("jf-judge").disabled = c.checkType !== "rubric";
4340
+ fillCard(c);
4341
+
4346
4342
  setReuseLocked(true);
4347
4343
  renderPicker();
4348
4344
  sfLine.hidden = false;
@@ -4367,11 +4363,8 @@
4367
4363
  openNewCard();
4368
4364
  setVerdict(c.checkType === "rubric" ? "rubric" : "deterministic");
4369
4365
  if(c.model){ modelSelect.value = c.model; }
4370
- setVal("jf-name", c.jobName); setVal("jf-goal", c.goal); setVal("jf-source", c.source);
4371
- setVal("jf-dest", c.destination); setVal("jf-success", c.success); setVal("jf-guardrails", c.guardrails);
4372
- setVal("jf-judge", c.judgeExamples); setVal("jf-cap-money", c.capUsd);
4373
- setVal("jf-cap-time", typeof c.maxWallMs === "number" ? Math.round(c.maxWallMs / 60000) : "");
4374
- document.getElementById("jf-judge").disabled = c.checkType !== "rubric";
4366
+ fillCard(c);
4367
+
4375
4368
  originLine.textContent = d.origin || "";
4376
4369
  originLine.hidden = !d.origin;
4377
4370
  renderPicker();
@@ -4394,13 +4387,10 @@
4394
4387
  document.getElementById("job-card-title").textContent = "RESUME " + (d.title || d.runid);
4395
4388
  setVerdict(c.checkType === "rubric" ? "rubric" : "deterministic");
4396
4389
  if(c.model){ modelSelect.value = c.model; }
4397
- setVal("jf-name", c.jobName); setVal("jf-goal", c.goal); setVal("jf-source", c.source);
4398
- setVal("jf-dest", c.destination); setVal("jf-success", c.success); setVal("jf-guardrails", c.guardrails);
4399
- setVal("jf-judge", c.judgeExamples);
4390
+ fillCard(c);
4400
4391
  setVal("jf-cap-money", r.budgetUsd);
4401
4392
  setVal("jf-cap-time", resumeRun.noTime ? "" : r.maxWallMin);
4402
4393
  document.getElementById("jf-cap-time").parentNode.hidden = resumeRun.noTime;
4403
- document.getElementById("jf-judge").disabled = c.checkType !== "rubric";
4404
4394
  originLine.textContent = "The same run goes on, not a new one. Only the money cap and the time cap can change; everything else is the signed job.";
4405
4395
  originLine.classList.add("res");
4406
4396
  originLine.hidden = false;
@@ -4444,10 +4434,10 @@
4444
4434
  // build item 1 — the "lost answer" fix. ROOT CAUSE: `renderActions`
4445
4435
  // (fed by the every-2s `poll()`) unconditionally overwrites
4446
4436
  // `#chat-card-error` on every tick (`errEl.textContent = state.error ||
4447
- // ""`), including the SAME tick the send/revise/sign handlers below
4437
+ // ""`), including the SAME tick the send/sign handlers below
4448
4438
  // used to call `poll()` in their own `.then` — so a refused/failed POST's
4449
4439
  // error was wiped before a person could read it (often on the very next
4450
- // line of the same callback). Compounding it, `send`/`revise` cleared
4440
+ // line of the same callback). Compounding it, `send` cleared
4451
4441
  // `msgInput.value` unconditionally, even on failure — so a rejected
4452
4442
  // answer vanished from the box with no trace at all. Fixed by: (1) a
4453
4443
  // dedicated `#chat-action-error` element only these handlers touch,
@@ -4508,20 +4498,21 @@
4508
4498
  }).catch(function(){ chatActionFailed("network error — could not reach the panel server, your text was NOT sent"); });
4509
4499
  }
4510
4500
 
4511
- function doRevise(){
4501
+ // hamr 2026-10-09 (as fwdloop's Revise): the draft is ended and the New job card opens with the SAME values in every
4502
+ // box, all editable; Start drafting then drafts fresh from whatever the boxes now say. No typed change request goes
4503
+ // to the model. The ended draft is an Abandon: money already spent stays booked.
4504
+ function doReopen(){
4512
4505
  if(!sessionId) return;
4513
- var text = msgInput.value;
4514
- authorPost("/api/author/" + sessionId + "/revise", {text: text}).then(function(r){
4506
+ authorPost("/api/author/" + sessionId + "/abandon", {}).then(function(r){
4515
4507
  var o = chatPostOutcome(r);
4516
- if(o.ok){
4517
- chatActionOk();
4518
- msgInput.value = "";
4519
- } else {
4520
- chatActionFailed(o.error);
4521
- }
4522
- poll();
4523
- }).catch(function(){ chatActionFailed("network error — could not reach the panel server, your text was NOT sent"); });
4508
+ if(!o.ok){ chatActionFailed(o.error); return; }
4509
+ openNewCard();
4510
+ chatActionOk();
4511
+ lastMsgSig = null;
4512
+ thread.innerHTML = '<div class="msg system">' + escapeXml("The boxes are open as you drafted them. Edit what you want, then Start drafting. The first draft's cost stays counted.") + "</div>";
4513
+ }).catch(function(){ chatActionFailed("network error — could not reach the panel server, not revised"); });
4524
4514
  }
4515
+ reopenBtn.addEventListener("click", doReopen);
4525
4516
 
4526
4517
  function doSign(){
4527
4518
  if(!sessionId) return;
@@ -4553,7 +4544,6 @@
4553
4544
  if(mainAction === "start") doStart();
4554
4545
  else if(mainAction === "check-deps") doCheckDeps();
4555
4546
  else if(mainAction === "send") doSend();
4556
- else if(mainAction === "revise") doRevise();
4557
4547
  else if(mainAction === "sign-prepare" || mainAction === "sign") doSign();
4558
4548
  });
4559
4549