bareloop 0.39.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 (51) hide show
  1. package/CHANGELOG.md +27 -0
  2. package/bareloop.context.md +63 -11
  3. package/package.json +2 -2
  4. package/src/authorflow.js +333 -58
  5. package/src/authoring.js +65 -2
  6. package/src/authorjob.js +57 -9
  7. package/src/authorreadout.js +50 -2
  8. package/src/authorrun.js +5 -2
  9. package/src/authorscout.js +9 -6
  10. package/src/bridges.js +1 -1
  11. package/src/calibrate.js +1 -1
  12. package/src/cardauthor.js +4 -1
  13. package/src/declaredclose.js +4 -3
  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 +1 -1
  19. package/src/ledger.js +1 -1
  20. package/src/panel/authorroutes.js +15 -5
  21. package/src/panel/authorsession.js +158 -47
  22. package/src/panel/index.html +179 -70
  23. package/src/panel/jobplan.js +107 -0
  24. package/src/panel/server.js +67 -13
  25. package/src/planrun.js +4 -4
  26. package/src/promptregisters.js +1 -1
  27. package/src/providers.js +101 -19
  28. package/src/reuse.js +1 -1
  29. package/src/root.js +1 -1
  30. package/src/run.js +3 -3
  31. package/src/selection.js +1 -1
  32. package/src/source.js +64 -1
  33. package/src/text.js +13 -0
  34. package/src/transport.js +1 -1
  35. package/src/userrun.js +3 -3
  36. package/types/authorflow.d.ts +258 -39
  37. package/types/authoring.d.ts +7 -1
  38. package/types/authorjob.d.ts +35 -3
  39. package/types/authorreadout.d.ts +17 -1
  40. package/types/authorscout.d.ts +5 -3
  41. package/types/declaredclose.d.ts +4 -1
  42. package/types/draftspend.d.ts +8 -2
  43. package/types/job.d.ts +1 -1
  44. package/types/jobblock.d.ts +102 -0
  45. package/types/panel/authorsession.d.ts +50 -14
  46. package/types/panel/jobplan.d.ts +58 -0
  47. package/types/panel/server.d.ts +20 -0
  48. package/types/planrun.d.ts +1 -1
  49. package/types/providers.d.ts +47 -3
  50. package/types/run.d.ts +1 -1
  51. package/types/source.d.ts +26 -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;}
@@ -466,6 +469,41 @@
466
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 */
467
470
  /* same look as the card's one-line inputs (white when editable, ordinary 1px border) — a textarea has its own browser border otherwise */
468
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;}
469
507
  /* hamr 2026-10-05: the focused/selected Chat-card field gets a blue border (the accent), no outline ring */
470
508
  #panel-chat :is(input,select,textarea):focus{border-color:var(--accent);outline:none;}
471
509
  /* hamr 2026-10-05: the ask box is dimmed until something asks for a reply (placeholder stays readable) */
@@ -583,29 +621,23 @@
583
621
  <input id="jf-name" type="text" placeholder="kebab-case, unique">
584
622
  </div>
585
623
  <div class="field">
586
- <label for="jf-goal">Goal</label>
587
- <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>
588
629
  </div>
589
630
  <div class="field">
590
- <label for="jf-source">Source</label>
591
- <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>
592
636
  </div>
593
637
  <div class="field">
594
638
  <label for="jf-dest">Destination</label>
595
639
  <textarea class="jf-wrap" id="jf-dest" rows="1" placeholder="the write fence — where in the repo this run may change files"></textarea>
596
640
  </div>
597
- <div class="field">
598
- <label for="jf-success">Success</label>
599
- <textarea class="jf-wrap" id="jf-success" rows="2"></textarea>
600
- </div>
601
- <div class="field">
602
- <label for="jf-guardrails">Guardrails</label>
603
- <textarea class="jf-wrap" id="jf-guardrails" rows="2"></textarea>
604
- </div>
605
- <div class="field">
606
- <label for="jf-judge">Judge examples <span class="hint" style="display:inline">(rubric only)</span></label>
607
- <textarea class="jf-wrap" id="jf-judge" rows="2" disabled></textarea>
608
- </div>
609
641
 
610
642
  <div class="cap-row" data-testid="cap-row">
611
643
  <div class="cap-field">
@@ -750,17 +782,16 @@
750
782
  <h4 id="details-title">Job card — signed</h4>
751
783
  <div class="field"><label>Check type</label><div class="ro-value" id="details-verdict">—</div></div>
752
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>
753
790
  <div class="field-row">
754
791
  <div class="field"><label>$ cap</label><div class="ro-value" id="details-cap-money">—</div></div>
755
792
  <div class="field"><label>Time cap</label><div class="ro-value" id="details-cap-time">—</div></div>
756
793
  </div>
757
- <div class="field" id="details-description-field"><label>Description</label><div class="ro-value" id="details-description">—</div></div>
758
- <div class="field"><label>Goal</label><div class="ro-value scroll" id="details-goal">—</div></div>
759
- <div class="field"><label>Success</label><div class="ro-value" id="details-success">—</div></div>
760
- <div class="field"><label>Guardrails</label><div class="ro-value" id="details-guardrails">—</div></div>
761
- <div class="field"><label>Source</label><div class="ro-value" id="details-source">—</div></div>
762
- <div class="field"><label>Destination</label><div class="ro-value" id="details-dest">—</div></div>
763
- <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>
764
795
  <div class="hint" id="job-note"></div>
765
796
  </div>
766
797
  </section>
@@ -1991,8 +2022,8 @@
1991
2022
  var nr = "not recorded";
1992
2023
  renderJob({
1993
2024
  job: v.job, resolved: false, checkType: v.checkType || nr, model: v.model || nr,
1994
- goal: v.goal || nr, success: v.success || nr, guardrails: v.guardrails || nr,
1995
- 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,
1996
2027
  budgetUsd: (typeof v.budgetUsd === "number") ? v.budgetUsd : null,
1997
2028
  maxWallMs: (typeof v.maxWallMs === "number") ? v.maxWallMs : null,
1998
2029
  note: "imported · view only — the exported job's own spec",
@@ -3128,6 +3159,35 @@
3128
3159
  });
3129
3160
  });
3130
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
+
3131
3191
  // ---- Job tab ----
3132
3192
  function renderJob(job){
3133
3193
  var empty = document.getElementById("job-empty");
@@ -3143,10 +3203,14 @@
3143
3203
  var descField = document.getElementById("details-description-field");
3144
3204
  if(job.description){ descField.hidden = false; document.getElementById("details-description").textContent = job.description; }
3145
3205
  else { descField.hidden = true; }
3146
- document.getElementById("details-goal").textContent = job.goal;
3147
- document.getElementById("details-success").textContent = job.success;
3148
- document.getElementById("details-guardrails").textContent = job.guardrails;
3149
- 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";
3150
3214
  document.getElementById("details-dest").textContent = job.destination;
3151
3215
  document.getElementById("details-tools").textContent = job.tools;
3152
3216
  document.getElementById("details-cap-money").textContent = panelMoney(job.budgetUsd);
@@ -3218,7 +3282,7 @@
3218
3282
  // drives both fetches — never two independent timers that could race
3219
3283
  // each other. ----
3220
3284
  //
3221
- // 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
3222
3286
  // every call (~457ms CPU at 250 rows). hamr's ruling B, 2026-09-27:
3223
3287
  // "refresh less often, up to 30s at most, choose the lesser when
3224
3288
  // possible" — the Runs list polls every 10s (RUNS_LIST_POLL_MS) instead
@@ -3594,10 +3658,10 @@
3594
3658
  var sfNote = document.getElementById("chat-startfrom-note");
3595
3659
  var originLine = document.getElementById("chat-origin-line");
3596
3660
  // the locked boxes of a reuse card: the signed workflow, greyed and not editable (the server refuses a change anyway)
3597
- var LOCKED_IDS = ["jf-name", "jf-goal", "jf-success", "jf-guardrails", "jf-judge"];
3661
+ var LOCKED_IDS = ["jf-name", "jf-job"];
3598
3662
  // hamr 2026-10-05: while a drafting session is live NOTHING on the card is editable (the session was started from
3599
3663
  // these values). The reuse rule (only Source/Destination/caps open) applies before the start. ONE function owns it.
3600
- 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"];
3601
3665
  var reuseOn = false;
3602
3666
  // Resume mode (hamr 2026-10-07): the card shows the signed job of one run, every box locked except the two caps.
3603
3667
  // `resumeRun` = {runid, noTime} while it is on, else null. Never set together with a drafting session or a reuse pick.
@@ -3613,14 +3677,14 @@
3613
3677
  // the Check type radios stay clickable on a reuse card (Deterministic / Rubric = leave reuse); a live session or a resume freezes them
3614
3678
  document.querySelectorAll('input[name="jf-verdict"]').forEach(function(r){ r.disabled = live || resuming; });
3615
3679
  document.getElementById("job-card").classList.toggle("reuse", reuseOn);
3616
- if(!reuseOn && !live && !resuming){ var ck = document.querySelector('input[name="jf-verdict"]:checked'); document.getElementById("jf-judge").disabled = !ck || ck.value !== "rubric"; }
3617
3680
  }
3618
3681
  function setReuseLocked(on){ reuseOn = on; syncCardLock(); }
3619
3682
  function clearStartFrom(keepBoxes){
3620
3683
  // Clear after a reuse starts a genuinely new job: the reused values do not linger in the boxes — except when a Check type
3621
3684
  // radio click leaves reuse (keepBoxes): a radio never blanks what the person has on the card
3622
3685
  if(startFrom && !keepBoxes){
3623
- ["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);
3624
3688
  document.querySelectorAll('input[name="jf-verdict"]').forEach(function(r){ r.checked = (r.value === "deterministic"); });
3625
3689
  }
3626
3690
  startFrom = null; reuseSession = false; autoSigned = false; reuseCheckType = "deterministic";
@@ -3632,7 +3696,7 @@
3632
3696
  // ---- Reuse workflow picker (hamr 2026-10-06): Check type is ONE radio group of three; "Reuse workflow" shows a search
3633
3697
  // box over the jobs that have a green local run (GET /api/author/reuse-jobs), a pick fills the card through the SAME
3634
3698
  // path the Run tab's button uses (reuseWorkflow -> bareloop-reuse -> applyReuse), then a picked box with `change`.
3635
- 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"];
3636
3700
  var rwBox = document.getElementById("rw");
3637
3701
  var rwSearch = document.getElementById("rw-search");
3638
3702
  var rwQ = document.getElementById("rw-q");
@@ -3649,9 +3713,11 @@
3649
3713
  }
3650
3714
  function setVerdict(v){
3651
3715
  document.querySelectorAll('input[name="jf-verdict"]').forEach(function(r){ r.checked = (r.value === v); });
3716
+ setJobPlaceholders(v);
3652
3717
  }
3653
3718
  function clearCardBoxes(){
3654
3719
  CARD_BOX_IDS.forEach(function(id){ document.getElementById(id).value = ""; });
3720
+ NUMBERED_IDS.forEach(gutterSync);
3655
3721
  document.getElementById("jf-cap-note").textContent = "";
3656
3722
  }
3657
3723
  // ONE function shows/hides the picker: visible only while the radio is on Reuse workflow; the search box before a
@@ -3699,7 +3765,6 @@
3699
3765
  function openPicker(){
3700
3766
  rwQ.value = ""; rwHl = 0;
3701
3767
  rwJobs = null; rwFailed = false; // a fresh open shows "Loading…", never the previous list
3702
- document.getElementById("jf-judge").disabled = true;
3703
3768
  renderPicker();
3704
3769
  renderReuseList();
3705
3770
  loadReuseJobs();
@@ -3740,7 +3805,7 @@
3740
3805
  card.hidden = false;
3741
3806
  originLine.hidden = true;
3742
3807
  exitResumeMode();
3743
- thread.innerHTML = "";
3808
+ thread.innerHTML = ""; lastMsgSig = null;
3744
3809
  errEl.textContent = "";
3745
3810
  actionErrEl.textContent = "";
3746
3811
  sessionId = null;
@@ -3770,7 +3835,6 @@
3770
3835
  function resetCard(){
3771
3836
  clearCardBoxes();
3772
3837
  setVerdict("deterministic");
3773
- document.getElementById("jf-judge").disabled = true;
3774
3838
  modelSelect.selectedIndex = 0;
3775
3839
  msgInput.value = "";
3776
3840
  rwQ.value = "";
@@ -3799,12 +3863,9 @@
3799
3863
  checkType: (verdictValue() === "reuse" ? reuseCheckType : verdictValue()) === "rubric" ? "rubric" : "deterministic",
3800
3864
  model: document.getElementById("jf-model").value,
3801
3865
  jobName: document.getElementById("jf-name").value.trim(),
3802
- goal: document.getElementById("jf-goal").value.trim(),
3803
- source: document.getElementById("jf-source").value.trim(),
3866
+ jobText: document.getElementById("jf-job").value.trim(),
3867
+ inputs: document.getElementById("jf-inputs").value.trim(),
3804
3868
  destination: document.getElementById("jf-dest").value.trim(),
3805
- success: document.getElementById("jf-success").value.trim(),
3806
- guardrails: document.getElementById("jf-guardrails").value.trim(),
3807
- judgeExamples: document.getElementById("jf-judge").value.trim(),
3808
3869
  capUsd: moneyCap,
3809
3870
  maxWallMs: timeCapMin === "" ? undefined : Math.round(Number(timeCapMin) * 60000),
3810
3871
  };
@@ -3853,7 +3914,7 @@
3853
3914
  setVerdict(v);
3854
3915
  renderPicker();
3855
3916
  }
3856
- document.getElementById("jf-judge").disabled = v !== "rubric";
3917
+ setJobPlaceholders(v);
3857
3918
  refreshStartEnabled();
3858
3919
  }
3859
3920
  document.querySelectorAll('input[name="jf-verdict"]').forEach(function(r){
@@ -3964,11 +4025,23 @@
3964
4025
  if(role === "system") return "";
3965
4026
  return "bareloop";
3966
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;
3967
4030
  function renderMessages(state){
4031
+ var sig = JSON.stringify(state.messages);
4032
+ if(sig === lastMsgSig) return;
4033
+ lastMsgSig = sig;
3968
4034
  thread.innerHTML = state.messages.map(function(m){
3969
4035
  var cls = m.role === "you" ? "you" : (m.role === "system" ? "system" : "bot");
3970
4036
  var who = chatWhoLabel(m.role);
3971
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
+ }
3972
4045
  return '<div class="msg ' + cls + '">' + whoHtml + escapeXml(m.text) + '</div>';
3973
4046
  }).join("");
3974
4047
  thread.scrollTop = thread.scrollHeight;
@@ -4062,22 +4135,61 @@
4062
4135
  fitCardBoxes();
4063
4136
  }
4064
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");
4065
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.
4066
4175
  // A box that is hidden (or not laid out) measures 0 and gets no inline height.
4067
4176
  function fitBox(el){
4068
4177
  el.style.height = "auto";
4069
4178
  if(!el.scrollHeight){ el.style.height = ""; return; }
4070
4179
  var border = el.offsetHeight - el.clientHeight;
4071
- 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";
4072
4182
  }
4073
4183
  function fitCardBoxes(){
4074
- ["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);
4075
4186
  }
4076
4187
  // live growth: each card box re-fits on its own input (typing and Enter), through the same fitBox
4077
- ["jf-goal", "jf-source", "jf-dest", "jf-success", "jf-guardrails", "jf-judge"].forEach(function(id){
4188
+ FIT_IDS.forEach(function(id){
4078
4189
  var box = document.getElementById(id);
4079
- box.addEventListener("input", function(){ fitBox(box); });
4190
+ box.addEventListener("input", function(){ fitBox(box); if(NUMBERED_IDS.indexOf(id) !== -1) gutterSync(id); });
4080
4191
  });
4192
+ NUMBERED_IDS.forEach(function(id){ var box = document.getElementById(id); box.addEventListener("scroll", function(){ document.getElementById(id + "-gut").scrollTop = box.scrollTop; }); });
4081
4193
  window.addEventListener("resize", function(){ fitCardBoxes(); fitBox(msgInput); });
4082
4194
  document.getElementById("tab-chat").addEventListener("click", function(){ fitCardBoxes(); fitBox(msgInput); });
4083
4195
  // Enter sends through the one main button; Shift+Enter (or IME composition) keeps its newline
@@ -4182,11 +4294,8 @@
4182
4294
  }
4183
4295
  modelSelect.value = c.model;
4184
4296
  }
4185
- setVal("jf-name", c.jobName); setVal("jf-goal", c.goal); setVal("jf-source", c.source);
4186
- setVal("jf-dest", c.destination); setVal("jf-success", c.success); setVal("jf-guardrails", c.guardrails);
4187
- setVal("jf-judge", c.judgeExamples); setVal("jf-cap-money", c.capUsd);
4188
- setVal("jf-cap-time", typeof c.maxWallMs === "number" ? Math.round(c.maxWallMs / 60000) : "");
4189
- document.getElementById("jf-judge").disabled = c.checkType !== "rubric";
4297
+ fillCard(c);
4298
+
4190
4299
  if(reuseSession) setReuseLocked(true);
4191
4300
  renderPicker();
4192
4301
  fitCardBoxes();
@@ -4208,6 +4317,14 @@
4208
4317
 
4209
4318
  // ---- Reuse workflow — the card opens filled from the SIGNED job: four open boxes, the rest greyed ----
4210
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
+ }
4211
4328
  // ONE owner of the reuse fill: the Run tab's / Import view's button and the picker both arrive here (the
4212
4329
  // `bareloop-reuse` event), so the card ends up in the same picked state whichever door was used.
4213
4330
  function applyReuse(d){
@@ -4220,11 +4337,8 @@
4220
4337
  reusePickedName = c.jobName || (pre.origin && pre.origin.job) || "job";
4221
4338
  setVerdict("reuse");
4222
4339
  if(c.model){ modelSelect.value = c.model; }
4223
- setVal("jf-name", c.jobName); setVal("jf-goal", c.goal); setVal("jf-source", c.source);
4224
- setVal("jf-dest", c.destination); setVal("jf-success", c.success); setVal("jf-guardrails", c.guardrails);
4225
- setVal("jf-judge", c.judgeExamples); setVal("jf-cap-money", c.capUsd);
4226
- setVal("jf-cap-time", typeof c.maxWallMs === "number" ? Math.round(c.maxWallMs / 60000) : "");
4227
- document.getElementById("jf-judge").disabled = c.checkType !== "rubric";
4340
+ fillCard(c);
4341
+
4228
4342
  setReuseLocked(true);
4229
4343
  renderPicker();
4230
4344
  sfLine.hidden = false;
@@ -4249,11 +4363,8 @@
4249
4363
  openNewCard();
4250
4364
  setVerdict(c.checkType === "rubric" ? "rubric" : "deterministic");
4251
4365
  if(c.model){ modelSelect.value = c.model; }
4252
- setVal("jf-name", c.jobName); setVal("jf-goal", c.goal); setVal("jf-source", c.source);
4253
- setVal("jf-dest", c.destination); setVal("jf-success", c.success); setVal("jf-guardrails", c.guardrails);
4254
- setVal("jf-judge", c.judgeExamples); setVal("jf-cap-money", c.capUsd);
4255
- setVal("jf-cap-time", typeof c.maxWallMs === "number" ? Math.round(c.maxWallMs / 60000) : "");
4256
- document.getElementById("jf-judge").disabled = c.checkType !== "rubric";
4366
+ fillCard(c);
4367
+
4257
4368
  originLine.textContent = d.origin || "";
4258
4369
  originLine.hidden = !d.origin;
4259
4370
  renderPicker();
@@ -4276,13 +4387,10 @@
4276
4387
  document.getElementById("job-card-title").textContent = "RESUME " + (d.title || d.runid);
4277
4388
  setVerdict(c.checkType === "rubric" ? "rubric" : "deterministic");
4278
4389
  if(c.model){ modelSelect.value = c.model; }
4279
- setVal("jf-name", c.jobName); setVal("jf-goal", c.goal); setVal("jf-source", c.source);
4280
- setVal("jf-dest", c.destination); setVal("jf-success", c.success); setVal("jf-guardrails", c.guardrails);
4281
- setVal("jf-judge", c.judgeExamples);
4390
+ fillCard(c);
4282
4391
  setVal("jf-cap-money", r.budgetUsd);
4283
4392
  setVal("jf-cap-time", resumeRun.noTime ? "" : r.maxWallMin);
4284
4393
  document.getElementById("jf-cap-time").parentNode.hidden = resumeRun.noTime;
4285
- document.getElementById("jf-judge").disabled = c.checkType !== "rubric";
4286
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.";
4287
4395
  originLine.classList.add("res");
4288
4396
  originLine.hidden = false;
@@ -4400,6 +4508,7 @@
4400
4508
  if(!o.ok){ chatActionFailed(o.error); return; }
4401
4509
  openNewCard();
4402
4510
  chatActionOk();
4511
+ lastMsgSig = null;
4403
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>";
4404
4513
  }).catch(function(){ chatActionFailed("network error — could not reach the panel server, not revised"); });
4405
4514
  }
@@ -0,0 +1,107 @@
1
+ // The job plan MODEL (P7, docs/product/PANEL-BUILD.md "The job" block): ONE owner of the structure the page's single
2
+ // `renderJobPlan()` draws in two places — the drafting confirm card (the plan bubble) and the read-only Job tab. The
3
+ // server builds it; the page only draws it (so the two places can never read the same plan two ways).
4
+ //
5
+ // { lines: [{n, text, rule, checks: [{name, cls}], refused: [reason...]}],
6
+ // loose: [{name, cls}], checks that name no job line (an older caller, or a model slip)
7
+ // notChecked: [text...], "the AI's own reading" — what the drafter says nothing checks
8
+ // alwaysOn: [text...], the mandatory guards / protections, which serve no job line
9
+ // older: boolean } an older job: the numbered lines were not saved
10
+ //
11
+ // `cls` is 'machine check' | 'judge' | null (null = not known yet: the confirm card's checks are the model's plan, the
12
+ // stage kinds exist only after drafting). Pure; no file IO.
13
+ import { KIND_CATALOGUE } from '../authoring.js';
14
+
15
+ /** @typedef {{name: string, cls: 'machine check'|'judge'|null}} PlanCheck */
16
+ /** @typedef {{n: number, text: string, rule: string, checks: PlanCheck[], refused: string[]}} PlanLine */
17
+ /** @typedef {{lines: PlanLine[], loose: PlanCheck[], notChecked: string[], alwaysOn: string[], older: boolean, goal?: string}} JobPlan */
18
+
19
+ /**
20
+ * `machine check` for a kind a command decides, `judge` for one that needs judgement; `null` for a stage whose kind is
21
+ * unknown (a hand-written command close has no kind: it is a command, so a machine check).
22
+ * @param {unknown} kind
23
+ * @returns {'machine check'|'judge'|null}
24
+ */
25
+ export function checkClassOfKind(kind) {
26
+ if (typeof kind !== 'string') return null;
27
+ const spec = Object.hasOwn(KIND_CATALOGUE, kind) ? KIND_CATALOGUE[kind] : null;
28
+ if (!spec) return null;
29
+ return spec.verdictClass === 'green' ? 'machine check' : 'judge';
30
+ }
31
+
32
+ /**
33
+ * The plan of a SIGNED spec. With `jobLines`, each stage lands under EACH line its `fromLine` array names (a stage with none is
34
+ * a mandatory guard: "Always on"), `closeDecl.refused` reasons land under their line, and `closeDecl.notes` are the
35
+ * drafter's own "not checked" words. Without `jobLines` (a spec that predates the job block) the signed goal sentence is
36
+ * line 1 and every stage name is a check under it — `older: true`.
37
+ * @param {any} spec
38
+ * @returns {JobPlan}
39
+ */
40
+ export function planFromSpec(spec) {
41
+ /** @type {{name: string, cls: 'machine check'|'judge'|null, fromLine: number[]|null}[]} */
42
+ let stages = [];
43
+ if (spec && Array.isArray(spec.closeDecl?.stages)) {
44
+ stages = spec.closeDecl.stages.filter((/** @type {any} */ s) => s && typeof s.name === 'string' && s.name)
45
+ .map((/** @type {any} */ s) => ({ name: s.name, cls: checkClassOfKind(s.kind), fromLine: Array.isArray(s.fromLine) && s.fromLine.length ? s.fromLine.filter(Number.isInteger) : null }));
46
+ } else if (spec && Array.isArray(spec.close)) {
47
+ stages = spec.close.filter((/** @type {any} */ s) => s && typeof s.name === 'string' && s.name)
48
+ .map((/** @type {any} */ s) => ({ name: s.name, cls: /** @type {'machine check'} */ ('machine check'), fromLine: null }));
49
+ }
50
+ const notes = Array.isArray(spec?.closeDecl?.notes) ? spec.closeDecl.notes.filter((/** @type {any} */ n) => typeof n === 'string' && n) : [];
51
+ const jl = Array.isArray(spec?.jobLines) ? spec.jobLines.filter((/** @type {any} */ l) => l && Number.isInteger(l.n) && typeof l.text === 'string') : [];
52
+ if (jl.length === 0) {
53
+ return {
54
+ lines: [{
55
+ n: 1, text: typeof spec?.goal === 'string' && spec.goal ? spec.goal : '(no goal recorded)', rule: '',
56
+ checks: stages.map((s) => ({ name: s.name, cls: s.cls })), refused: [],
57
+ }],
58
+ loose: [], notChecked: notes, alwaysOn: [], older: true,
59
+ };
60
+ }
61
+ /** @type {PlanLine[]} */
62
+ const lines = jl.map((/** @type {any} */ l) => ({ n: l.n, text: l.text, rule: typeof l.rule === 'string' ? l.rule : '', checks: [], refused: [] }));
63
+ /** @type {PlanCheck[]} */
64
+ const loose = [];
65
+ /** @type {string[]} */
66
+ const alwaysOn = [];
67
+ for (const s of stages) {
68
+ if (s.fromLine === null) { alwaysOn.push(s.name); continue; }
69
+ // a check that serves several job lines is shown under EACH line it serves (no new element: the same check line, repeated)
70
+ const served = lines.filter((l) => /** @type {number[]} */ (s.fromLine).includes(l.n));
71
+ for (const line of served) line.checks.push({ name: s.name, cls: s.cls });
72
+ if (served.length === 0) loose.push({ name: s.name, cls: s.cls });
73
+ }
74
+ const refused = Array.isArray(spec?.closeDecl?.refused) ? spec.closeDecl.refused : [];
75
+ for (const r of refused) {
76
+ const line = lines.find((l) => l.n === r?.line);
77
+ if (line && typeof r.reason === 'string') line.refused.push(r.reason);
78
+ }
79
+ // the model's signed goal sentence rides along: the page draws it as the "AI's summary" row (an older job shows it as line 1 instead)
80
+ return { lines, loose, notChecked: notes, alwaysOn, older: false, ...(typeof spec?.goal === 'string' && spec.goal ? { goal: spec.goal } : {}) };
81
+ }
82
+
83
+ /**
84
+ * The plan of the confirm turn, before anything is drafted: the person's own job lines, the model's checks under the
85
+ * line each names, its own "not checked" words, and the real protections (code-owned) as "Always on".
86
+ * @param {{n: number, text: string, rule: string}[]|null|undefined} jobLines
87
+ * @param {{checkItems?: {text: string, fromLine: number[]|null}[], checks?: string[], notChecked?: string[], protections?: string[]}} plan
88
+ * @returns {JobPlan}
89
+ */
90
+ export function planFromConfirm(jobLines, plan) {
91
+ const items = Array.isArray(plan?.checkItems) && plan.checkItems.length
92
+ ? plan.checkItems
93
+ : (Array.isArray(plan?.checks) ? plan.checks.map((c) => ({ text: String(c), fromLine: null })) : []);
94
+ const jl = Array.isArray(jobLines) ? jobLines : [];
95
+ /** @type {PlanLine[]} */
96
+ const lines = jl.map((l) => ({ n: l.n, text: l.text, rule: l.rule, checks: [], refused: [] }));
97
+ /** @type {PlanCheck[]} */
98
+ const loose = [];
99
+ for (const c of items) {
100
+ const served = lines.filter((l) => Array.isArray(c.fromLine) && c.fromLine.includes(l.n));
101
+ for (const line of served) line.checks.push({ name: c.text, cls: null });
102
+ if (served.length === 0) loose.push({ name: c.text, cls: null });
103
+ }
104
+ return {
105
+ lines, loose, notChecked: [...(plan?.notChecked ?? [])], alwaysOn: [...(plan?.protections ?? [])], older: false,
106
+ };
107
+ }