bareloop 0.38.0 → 0.39.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.
@@ -311,9 +311,28 @@
311
311
  .imported-box .summary-row{flex-wrap:wrap;}
312
312
  .imported-box .sum-val{flex:1 1 180px;min-width:0;overflow-wrap:anywhere;}
313
313
 
314
- .map-box{border:1px solid var(--border-strong);padding:10px;overflow-x:auto;margin-top:16px;margin-bottom:14px;background:var(--panel2);}
314
+ .map-box{border:1px solid var(--border-strong);padding:10px;margin-top:16px;margin-bottom:14px;background:var(--panel2);}
315
315
  .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;}
316
+ /* ruling 1A: chips as wide as their own text, side by side, wrapping — a name is never truncated */
317
+ .map-chips{display:flex;flex-wrap:wrap;gap:8px 4px;align-items:flex-start;padding-left:1.4em;}
318
+ /* ruling A: a step + its trailing arrow are ONE unit (the arrow never wraps alone); the lead arrow of a wrapped line
319
+ hangs in the container's left padding (absolute, zero layout impact — toggling it can never move a chip) */
320
+ .map-item{position:relative;display:inline-flex;align-items:center;gap:4px;max-width:100%;}
321
+ .map-arrow{color:var(--text-dim);font-size:13px;flex:none;}
322
+ .map-lead{display:none;position:absolute;left:-1.3em;top:50%;transform:translateY(-50%);color:var(--text-dim);font-size:13px;}
323
+ .map-item.line-start .map-lead{display:block;}
324
+ .chip-retry{color:var(--text-dim);font-size:11px;}
325
+ .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;}
326
+ button.map-chip{cursor:pointer;}
327
+ .map-chip.s-done{border-color:var(--green);color:var(--text);} .map-chip.s-done .chip-sign{color:#2e7d32;}
328
+ .map-chip.s-running{border-color:var(--amber);color:var(--text);} .map-chip.s-running .chip-sign{color:#b8860b;}
329
+ .map-chip.s-stopped{border-color:var(--red);color:var(--text);} .map-chip.s-stopped .chip-sign{color:var(--red);}
330
+ .map-chip.s-died{border-color:var(--magenta);color:var(--text);} .map-chip.s-died .chip-sign{color:var(--magenta);}
331
+ .map-chip.retry{border-style:dashed;}
332
+ .map-chip.resumed-next{border-right-style:dotted;}
333
+ .map-chip .chip-blocked{color:var(--red);font-size:10px;}
334
+ .map-chip.s-running .chip-sign{animation:chip-pulse 1.2s infinite;}
335
+ @keyframes chip-pulse{0%,100%{opacity:1}50%{opacity:0.3}}
317
336
  .map-key{display:flex;gap:14px;font-size:11px;color:var(--text-dim);margin-top:6px;flex-wrap:wrap;}
318
337
 
319
338
  .step-card{position:relative;border:1px solid var(--border);padding:10px;margin-bottom:8px;background:var(--panel2);}
@@ -613,7 +632,7 @@
613
632
  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
633
  <ol class="chat-steps" id="chat-progress-row" data-testid="chat-steps" aria-live="polite" hidden></ol>
615
634
 
616
- <!-- build item 1 — a failed/rejected send, revise, sign-prepare,
635
+ <!-- build item 1 — a failed/rejected send, revise (reopen), sign-prepare,
617
636
  sign or check-deps POST shows here and STAYS shown: it is set
618
637
  only by this tab's own action handlers below and is never
619
638
  touched by renderActions()/poll() (which own #chat-card-error
@@ -623,10 +642,12 @@
623
642
 
624
643
  <div class="chat-input-row">
625
644
  <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>
645
+ <textarea id="chat-msg" rows="2" placeholder="Answer here when the draft asks you a question…" data-testid="chat-msg"></textarea>
627
646
  </div>
628
647
  <button class="btn primary wide" type="button" id="chat-main-btn" data-testid="chat-main" disabled>Start drafting</button>
629
648
  <div class="hint" id="chat-main-hint" data-testid="chat-main-hint"></div>
649
+ <!-- hamr 2026-10-09: Revise = reopen the boxes, filled as drafted, and draft again. Shown only while a plan waits for the OK. -->
650
+ <button class="btn wide" type="button" id="chat-reopen-btn" data-testid="chat-reopen" hidden>Revise</button>
630
651
  <div class="hint">Only your click signs. The chat can't.</div>
631
652
  </section>
632
653
 
@@ -869,7 +890,6 @@
869
890
  btn.setAttribute("aria-selected", "true");
870
891
  var panel = document.getElementById(btn.getAttribute("aria-controls"));
871
892
  if(panel){ panel.hidden = false; panel.classList.add("active"); }
872
- if(btn.id === "tab-run"){ reflowMap(); }
873
893
  });
874
894
  });
875
895
  }
@@ -884,33 +904,16 @@
884
904
  });
885
905
  }
886
906
 
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
- }
907
+ // ---- step map renderer (buildStepMapHTML below — ONE renderer, no second hand-authored map anywhere on this page) ----
898
908
  function escapeXml(s){
899
909
  return String(s).replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;").replace(/"/g,"&quot;");
900
910
  }
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
911
  // s.noNumber (F196): a synthetic placeholder box (e.g. "died during
909
912
  // planning") is a LABEL, never a real step — it renders without the
910
913
  // leading "<n> " step number a real step gets.
911
914
  // item 4 (2026-09-25): each step box shows its attempts INLINE, e.g.
912
915
  // "attempt 1 [✗] · attempt 2 [✓]" — appended to the box's own title text
913
- // (the existing wrapTitleLines 2-line wrap handles overflow the same way
916
+ // (the chip wraps long text inside itself the same way
914
917
  // it already does for a long step id; no third renderer, no new box shape).
915
918
  // item 1 (2026-09-27, panel P2 defect): an attempt's own outcome is null
916
919
  // in exactly two situations — its window is still open on a LIVE run (the
@@ -935,9 +938,9 @@
935
938
  if(g === "✗") return '<span class="glyph-red">' + g + '</span>';
936
939
  return g;
937
940
  }
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.
941
+ // PLAIN TEXT ONLY — feeds the step map chip label (`stepTitleText`, escaped
942
+ // before it goes into the chip); a colour span here would render as literal
943
+ // "<span>" characters inside the map.
941
944
  // {@link glyphSpan} (the P3 colour addition) is used only by the DOM
942
945
  // part-card renderer below, never by this function.
943
946
  function attemptsInlineText(attempts, boxIsLive){
@@ -1042,224 +1045,102 @@
1042
1045
  function boxStatusText(box){
1043
1046
  return box.status && box.status.word ? "[" + box.status.sign + "] " + boxStatusWord(box) : boxStatusWord(box);
1044
1047
  }
1045
- function naturalBoxWidth(steps){
1048
+ // Ruling 1A (hamr, 2026-10-09): the step map is a row of CHIPS — each box as wide as its own text, flowing
1049
+ // side by side and wrapping to the next line. Plain HTML + CSS (.map-chips / .map-chip): the browser does the
1050
+ // wrapping, so there is no width arithmetic, no resize observer, and no horizontal scroll at phone width. A name
1051
+ // is never truncated (it wraps inside its chip when it is longer than the line).
1052
+ // Marks that were line styles on the old SVG are now border styles: a retried step has a DASHED border, and a step
1053
+ // whose leg ended there (the run resumed after it) has a DOTTED right edge. The sign + word come from
1054
+ // boxStatusText (src/panel/status.js's table) — the chip shows "[sign title]" and keeps the word in its tooltip.
1055
+ function buildStepMapHTML(steps){
1046
1056
  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;
1057
+ var last = steps.length - 1;
1058
+ var body = steps.map(function(s, idx){
1059
+ var sign = s.status && s.status.sign ? s.status.sign : "";
1060
+ var tries = boxRetryTry(s);
1061
+ var cls = "map-chip s-" + String(s.state).replace(/[^a-z-]/g, "") +
1062
+ (tries ? " retry" : "") + (s.resumedNext ? " resumed-next" : "");
1063
+ var label = escapeXml(stepTitleText(numIdx[idx], s));
1064
+ var blocked = s.blocked > 0 ? ' <span class="chip-blocked">' + s.blocked + ' blocked</span>' : '';
1065
+ var retry = tries ? '<span class="chip-retry">\u21bb' + tries + '</span> ' : '';
1066
+ var inner = '[' + retry + '<span class="chip-sign">' + escapeXml(sign) + '</span> ' + label + blocked + ']';
1067
+ var tip = ' title="' + escapeXml(boxStatusText(s)) + '"';
1068
+ var attrs = ' class="' + cls + '"' + tip + (s.resumedNext ? ' data-resumed="1"' : '') + (tries ? ' data-retry="' + tries + '"' : '');
1069
+ var chip = typeof s.partIndex === "number"
1070
+ ? '<button type="button"' + attrs + ' data-part-index="' + s.partIndex + '">' + inner + '</button>'
1071
+ : '<span' + attrs + '>' + inner + '</span>';
1072
+ return '<span class="map-item" role="listitem">' +
1073
+ (idx > 0 ? '<span class="map-lead" aria-hidden="true">\u2192</span>' : '') +
1074
+ chip + (idx < last ? '<span class="map-arrow" aria-hidden="true">\u2192</span>' : '') + '</span>';
1075
+ }).join("");
1076
+ return '<div class="map-chips" role="list" aria-label="step map">' + body + '</div>';
1077
+ }
1078
+ // Ruling A: the first chip of each WRAPPED line shows a leading arrow. CSS cannot know which chip starts a line, so
1079
+ // after render/resize this compares each item's offsetTop with the previous item's (same line = same top). The lead
1080
+ // arrow is absolutely positioned (no layout impact), so toggling the class can never reflow a chip — no loop.
1081
+ // Takes anything with offsetTop + classList (real elements, or stubs in tests).
1082
+ function markLineStarts(items){
1083
+ for(var i = 0; i < items.length; i++){
1084
+ var starts = i > 0 && items[i].offsetTop > items[i - 1].offsetTop;
1085
+ if(starts) items[i].classList.add("line-start"); else items[i].classList.remove("line-start");
1072
1086
  }
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
1087
  }
1077
- // ONE shared rule for "does this box draw a retry loop, and what try number" — a multi-attempt
1088
+ // ONE shared rule for "does this box draw a retry mark, and what try number" — a multi-attempt
1078
1089
  // 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.
1090
+ // never special-cased) gets a dashed border; anything else (incl. every no-verdict kind, whose
1091
+ // attempts is already forced to [] upstream) gets 0.
1081
1092
  function boxRetryTry(box){
1082
1093
  var attempts = Array.isArray(box.attempts) ? box.attempts : [];
1083
1094
  return attempts.length > 1 ? attempts.length : 0;
1084
1095
  }
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
1096
+ // the line-style key under the MAP (hamr 2026-10-04): only the two border styles, never a sign legend
1208
1097
  function stepMapKeyHTML(steps){
1209
1098
  var resumed = (steps || []).some(function(b){ return b.resumedNext; });
1210
1099
  return '<div class="map-key" data-testid="map-key">' +
1211
- '<span>⤾ dashed = retry</span>' +
1212
- (resumed ? '<span>&middot;&middot;&middot; dotted = resumed</span>' : '') +
1100
+ '<span>\u21bbN = took N tries (dashed box)</span>' +
1101
+ (resumed ? '<span>dotted edge = resumed after</span>' : '') +
1213
1102
  '</div>';
1214
1103
  }
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
1104
  function renderStepMap(steps){
1224
1105
  var mount = document.getElementById("map-mount");
1225
1106
  var wrapper = document.getElementById("map-box");
1226
- lastSteps = steps;
1227
1107
  if(!steps || !steps.length){
1228
1108
  mount.innerHTML = "";
1229
1109
  wrapper.hidden = true;
1230
1110
  return;
1231
1111
  }
1232
1112
  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
1113
+ mount.innerHTML = buildStepMapHTML(steps) + stepMapKeyHTML(steps);
1114
+ layoutMap();
1115
+ // build item B: a map chip click jumps to the Audit tab, that same part
1237
1116
  // 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"));
1117
+ // sharing partIndex so the two can never disagree about which row a chip
1118
+ // maps to. Bound once per render (mount.innerHTML above already wiped any
1119
+ // previous listeners' targets along with the old markup).
1120
+ mount.querySelectorAll(".map-chip[data-part-index]").forEach(function(chip){
1121
+ chip.addEventListener("click", function(){
1122
+ var idx = Number(chip.getAttribute("data-part-index"));
1244
1123
  document.getElementById("tab-audit").click();
1245
1124
  openAuditPart(idx);
1246
1125
  });
1247
1126
  });
1248
1127
  }
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);
1128
+ function layoutMap(){
1129
+ var box = document.getElementById("map-box");
1130
+ if(!box || box.hidden) return;
1131
+ markLineStarts(Array.prototype.slice.call(box.querySelectorAll(".map-item")));
1132
+ }
1133
+ var layoutTimer = null;
1134
+ function layoutMapSoon(){
1135
+ if(layoutTimer) clearTimeout(layoutTimer);
1136
+ layoutTimer = setTimeout(function(){ layoutTimer = null; layoutMap(); }, 60);
1256
1137
  }
1257
1138
  if(typeof ResizeObserver !== "undefined"){
1258
- var ro = new ResizeObserver(function(){ reflowMap(); });
1259
- var mb = document.getElementById("map-box");
1260
- if(mb) ro.observe(mb);
1139
+ var mapRo = new ResizeObserver(layoutMapSoon);
1140
+ var mapBoxEl = document.getElementById("map-box");
1141
+ if(mapBoxEl) mapRo.observe(mapBoxEl);
1261
1142
  } else {
1262
- window.addEventListener("resize", reflowMap);
1143
+ window.addEventListener("resize", layoutMapSoon);
1263
1144
  }
1264
1145
 
1265
1146
  // F196: a `synthetic` step (e.g. the "died during planning" placeholder
@@ -3675,6 +3556,7 @@
3675
3556
  var msgInput = document.getElementById("chat-msg");
3676
3557
  var mainBtn = document.getElementById("chat-main-btn");
3677
3558
  var mainHint = document.getElementById("chat-main-hint");
3559
+ var reopenBtn = document.getElementById("chat-reopen-btn");
3678
3560
  var mainAction = "none";
3679
3561
  var lastState = null;
3680
3562
  var startOk = false;
@@ -4127,26 +4009,20 @@
4127
4009
  }
4128
4010
 
4129
4011
  // 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.
4012
+ // pure function of (state, typed-text-empty, reuse); state = {session, startOk, phase, pendingAsk,
4013
+ // signClicked, specHash}. Actions: start | check-deps | send | sign-prepare | sign | none.
4132
4014
  function mainButtonFor(st, textEmpty, reuse){
4133
4015
  var ask = st.pendingAsk;
4134
4016
  var atMenu = !!ask && ask.kind === "menu";
4135
- var left = st.revisesLeft || 0;
4136
- var hint = atMenu ? left + (left === 1 ? " change left" : " changes left") : "";
4017
+ var hint = "";
4137
4018
  if(!st.session){
4138
4019
  return {text: reuse ? "Sign & run" : "Start drafting", action: "start", disabled: !st.startOk, hint: ""};
4139
4020
  }
4140
4021
  if(ask && ask.kind === "install-needed"){
4141
4022
  return {text: "Check again", action: "check-deps", disabled: false, hint: ""};
4142
4023
  }
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
4024
  if(ask && !atMenu){
4149
- return {text: "Send", action: "send", disabled: true, hint: ""};
4025
+ return {text: "Send", action: "send", disabled: textEmpty, hint: ""};
4150
4026
  }
4151
4027
  if(atMenu || st.phase === "prepared"){
4152
4028
  if(st.phase === "prepared" && st.signClicked){
@@ -4156,16 +4032,21 @@
4156
4032
  }
4157
4033
  return {text: "Sign & run", action: "none", disabled: true, hint: ""};
4158
4034
  }
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
4035
+ // pure: the ask box takes typed text only when the session has a pending ask that accepts it (a question the plan
4036
+ // raised); not with no session, no ask, while the plan waits for the OK (that is Sign & run or Revise, never typed
4037
+ // text), or while waiting on an install
4161
4038
  function askBoxOpenFor(st){
4162
- return !!st.session && !!st.pendingAsk && st.pendingAsk.kind !== "install-needed";
4039
+ return !!st.session && !!st.pendingAsk && st.pendingAsk.kind !== "install-needed" && st.pendingAsk.kind !== "menu";
4040
+ }
4041
+ // pure: Revise shows only while a plan waits for the OK
4042
+ function reviseShownFor(st){
4043
+ return !!st.session && !!st.pendingAsk && st.pendingAsk.kind === "menu";
4163
4044
  }
4164
4045
  function renderMain(){
4165
4046
  var st = {session: sessionLive, startOk: startOk, signClicked: signClickedOnce};
4166
4047
  if(sessionLive && lastState){
4167
4048
  st.phase = lastState.phase; st.pendingAsk = lastState.pendingAsk;
4168
- st.revisesLeft = lastState.revisesLeft; st.specHash = lastState.specHash;
4049
+ st.specHash = lastState.specHash;
4169
4050
  }
4170
4051
  // hamr 2026-10-05: the ask box is open only while something asks for a reply; otherwise dimmed and emptied
4171
4052
  var open = askBoxOpenFor(st);
@@ -4176,6 +4057,7 @@
4176
4057
  mainBtn.disabled = b.disabled;
4177
4058
  mainAction = b.action;
4178
4059
  mainHint.textContent = b.hint;
4060
+ reopenBtn.hidden = !reviseShownFor(st);
4179
4061
  fitBox(msgInput);
4180
4062
  fitCardBoxes();
4181
4063
  }
@@ -4444,10 +4326,10 @@
4444
4326
  // build item 1 — the "lost answer" fix. ROOT CAUSE: `renderActions`
4445
4327
  // (fed by the every-2s `poll()`) unconditionally overwrites
4446
4328
  // `#chat-card-error` on every tick (`errEl.textContent = state.error ||
4447
- // ""`), including the SAME tick the send/revise/sign handlers below
4329
+ // ""`), including the SAME tick the send/sign handlers below
4448
4330
  // used to call `poll()` in their own `.then` — so a refused/failed POST's
4449
4331
  // 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
4332
+ // line of the same callback). Compounding it, `send` cleared
4451
4333
  // `msgInput.value` unconditionally, even on failure — so a rejected
4452
4334
  // answer vanished from the box with no trace at all. Fixed by: (1) a
4453
4335
  // dedicated `#chat-action-error` element only these handlers touch,
@@ -4508,20 +4390,20 @@
4508
4390
  }).catch(function(){ chatActionFailed("network error — could not reach the panel server, your text was NOT sent"); });
4509
4391
  }
4510
4392
 
4511
- function doRevise(){
4393
+ // hamr 2026-10-09 (as fwdloop's Revise): the draft is ended and the New job card opens with the SAME values in every
4394
+ // box, all editable; Start drafting then drafts fresh from whatever the boxes now say. No typed change request goes
4395
+ // to the model. The ended draft is an Abandon: money already spent stays booked.
4396
+ function doReopen(){
4512
4397
  if(!sessionId) return;
4513
- var text = msgInput.value;
4514
- authorPost("/api/author/" + sessionId + "/revise", {text: text}).then(function(r){
4398
+ authorPost("/api/author/" + sessionId + "/abandon", {}).then(function(r){
4515
4399
  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"); });
4400
+ if(!o.ok){ chatActionFailed(o.error); return; }
4401
+ openNewCard();
4402
+ chatActionOk();
4403
+ 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
+ }).catch(function(){ chatActionFailed("network error — could not reach the panel server, not revised"); });
4524
4405
  }
4406
+ reopenBtn.addEventListener("click", doReopen);
4525
4407
 
4526
4408
  function doSign(){
4527
4409
  if(!sessionId) return;
@@ -4553,7 +4435,6 @@
4553
4435
  if(mainAction === "start") doStart();
4554
4436
  else if(mainAction === "check-deps") doCheckDeps();
4555
4437
  else if(mainAction === "send") doSend();
4556
- else if(mainAction === "revise") doRevise();
4557
4438
  else if(mainAction === "sign-prepare" || mainAction === "sign") doSign();
4558
4439
  });
4559
4440
 
package/src/planrun.js CHANGED
@@ -36,6 +36,7 @@ import { createLadder, STRIKE_LIMIT } from './ladder.js';
36
36
  import { createTrend, FIX_STRIKE_LIMIT } from './trend.js';
37
37
  import { TOOL_MENU, STORE_VERBS, checkMenu } from './job.js';
38
38
  import { TOOL_BY_VERB, CTX_TOOLS, createCtxTools, toolAction, PERSONA_TOOLS, strategyFor } from './tools.js';
39
+ import { anySuppressionWarning } from './authoring.js';
39
40
  import { createReadShim, readShimArm, readShimStrategy } from './readshim.js';
40
41
  import { globToPrefix, redactSecrets, SECRET_PATTERNS } from './validate.js';
41
42
  import { validateBridge, loadGate, newestEligibleVersion, reuseEligibility, quarantinesCredit, QUARANTINED_CODE } from './bridges.js';
@@ -2035,7 +2036,7 @@ export async function runPlan(job, { workdir, provider, nativeProvider, provider
2035
2036
  }
2036
2037
  const listed = await seedListing(workdir, s.seedRef);
2037
2038
  const v = listed.stop === null
2038
- ? validateCloseDecl(job.closeDecl, { at: 'closeDecl', listing: listed.files, verdictType: job.verdictType })
2039
+ ? validateCloseDecl(job.closeDecl, { at: 'closeDecl', listing: listed.files, verdictType: job.verdictType, writeScope: job.writeScope })
2039
2040
  : { ok: false, grounded: false, reds: [{ code: 'listing-unreadable', path: 'closeDecl', detail: listed.stop }] };
2040
2041
  emit('close-decl', {
2041
2042
  genre: job.closeDecl.genre,
@@ -2546,7 +2547,7 @@ export async function runPlan(job, { workdir, provider, nativeProvider, provider
2546
2547
  const readLines = grantedNames.has(TOOL_BY_VERB.read)
2547
2548
  ? (native && !shimArm.cap ? NATIVE_READ_STRATEGY : '') + readShimStrategy(shimArm)
2548
2549
  : '';
2549
- const system = PERSONA_TOOLS + strategyFor(granted) + readLines;
2550
+ const system = PERSONA_TOOLS + strategyFor(granted) + readLines + anySuppressionWarning(closeStagesOf(job));
2550
2551
  /** @param {any} u @returns {{inputTokens: number, outputTokens: number, cacheReadTokens: number, cacheCreationTokens: number}} */
2551
2552
  const usageOf = (u) => ({ inputTokens: u?.inputTokens ?? 0, outputTokens: u?.outputTokens ?? 0, cacheReadTokens: u?.cacheReadTokens ?? 0, cacheCreationTokens: u?.cacheCreationTokens ?? 0 });
2552
2553
 
package/src/providers.js CHANGED
@@ -115,6 +115,33 @@ const OPENAI_MODEL_OPTIONS = Object.freeze({
115
115
  'deepseek-flash': Object.freeze({ legacyMaxTokens: true }),
116
116
  });
117
117
 
118
+ /**
119
+ * Per-MODEL JUDGE-ONLY request options (F192, hamr ruling A 2026-10-09). deepseek-flash
120
+ * thinks by default and bills the reasoning as output: run mv117wde's rubric judge locate
121
+ * truncated at JUDGE_MAX_TOKENS=4000 four times, and a probe measured 1835 of 2409
122
+ * completion tokens as reasoning. The judge extracts quotes and decides nothing, so it
123
+ * runs with thinking off. These are PER-CALL options (bare-agent >=0.49 forwards
124
+ * `thinking` verbatim to `body.thinking`), never constructor options: the judge may be
125
+ * the very same provider instance as the worker (`buildRunnerProviders` reuses it), and
126
+ * the worker is deliberately left as it was. A model with no entry gets nothing; the
127
+ * field is never sent to a model not listed here (OpenAI/Anthropic would reject or
128
+ * misread it). Read through `judgeCallOptions` only — one owner.
129
+ */
130
+ const OPENAI_JUDGE_CALL_OPTIONS = Object.freeze({
131
+ 'deepseek-flash': Object.freeze({ thinking: Object.freeze({ type: 'disabled' }) }),
132
+ });
133
+
134
+ /**
135
+ * The per-call options the rubric JUDGE's model calls carry for this provider
136
+ * instance, from the allow-list above. Empty for any provider/model not listed.
137
+ * @param {any} provider a constructed bare-agent provider
138
+ * @returns {Record<string, any>}
139
+ */
140
+ export function judgeCallOptions(provider) {
141
+ if (!(provider instanceof OpenAIProvider)) return {};
142
+ return OPENAI_JUDGE_CALL_OPTIONS[/** @type {keyof typeof OPENAI_JUDGE_CALL_OPTIONS} */ (provider.model)] ?? {};
143
+ }
144
+
118
145
  /**
119
146
  * @typedef {object} ProviderTableEntry
120
147
  * @property {new (options: any) => any} ctor the bare-agent provider class
package/src/trend.js CHANGED
@@ -272,6 +272,7 @@ export function createTrend({ stageOrder = [], limit = FIX_STRIKE_LIMIT, blindCa
272
272
  * @param {{gap?: string, stage?: string|null, value?: number|null}} o either a
273
273
  * raw gap (parsed here) or an already-read `{stage, value}` — never both
274
274
  * spellings of the same reading in one call.
275
+ * (A judged stage's value is its distinct red COUNT, supplied by the caller.)
275
276
  * @returns {{iteration: number, stage: string, value: number|null, improved: boolean,
276
277
  * comparable: boolean, noProgress: number, limit: number, stageIndex: number}}
277
278
  */