bareloop 0.37.0 → 0.38.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -4,6 +4,27 @@ All notable changes to bareloop are documented here. Format:
4
4
  [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); versioning:
5
5
  [SemVer](https://semver.org/spec/v2.0.0.html). Pre-1.0: **minor** = a ladder rung or
6
6
  feature lands, **patch** = docs, fixes, scaffolding.
7
+ ## [0.38.0] - 2026-10-08
8
+
9
+ ### Added
10
+
11
+ - **Edit in chat on every run that did not finish green.** The Chat card opens filled from the run's signed job with every box open; Start drafting works as for any new job. The Ended block's NEXT line points at the button, and a refused Resume says so too.
12
+ - **Resume moves to the left Chat card.** Resume mode locks the signed job and opens only the two caps (money and time); Sign & resume posts the same body as before.
13
+ - **Expanded jobs show their latest 7 other runs;** older runs are reached by search.
14
+
15
+ ### Changed
16
+
17
+ - The run page's Job tab is read-only on every run; the Job-tab resume mode is gone.
18
+ - A stopped run wears `[■]` (red), not `[✗]`, on every surface.
19
+ - Reuse workflow is offered only from a run that earned its green. The Reuse list shows only jobs with a real green run; an already-green run offers Edit in chat instead; `POST /api/author/start` refuses a reuse of any local run that did not finish green (the server owns the rule, not just the pickers); and a delivery-refused ending offers Reuse only on a run that earned its green.
20
+ - The Reuse picker reads "Loading…" until its list arrives, and switching the Check type radios no longer blanks the New job boxes.
21
+
22
+ ### Fixed
23
+
24
+ - A run cut by the money or wall cap reads `capped` on every surface: a leg that ended escalated on a money-halt or wall-halt, and a cap-cut open step on a single-leg run, no longer read as failed. One helper (`capKindOf`) owns the rule.
25
+ - A Stop clicked in the fix loop reads "during the fix phase ... a resume continues the run" instead of the step text "a resume re-enters this step", which was wrong where no step exists (found on run muxwwrgs).
26
+ - Small cleanups with unchanged behaviour: the `npm ci` comment matches the code, a dead pid above `pid_max` is handled, and tests follow the new panel behaviour.
27
+
7
28
  ## [0.37.0] - 2026-10-07
8
29
 
9
30
  ### Added
@@ -3503,16 +3503,16 @@ key you want first (the panel picks the row by the Model menu).
3503
3503
  result glyphs are unchanged. The reuse button is described under Reuse workflow, below.
3504
3504
  **Status words (click-through 2026-10-04)** → every run row, run detail and imported row carries `status`
3505
3505
  `{key, sign, word}` from ONE table (`src/panel/status.js`): `[▶] running · [·] waiting · [✓] passed · [✗] failed ·
3506
- [✗] capped · [✗] stopped · [?] died`; `glyph` is that sign. Mapping: green/already-green/satisfied → passed; no
3506
+ [✗] capped · [■] stopped · [?] died`; `glyph` is that sign (the Runs `✗` filter chip also finds a `[■]` run). Mapping: green/already-green/satisfied → passed; no
3507
3507
  job-end + runner alive → running, gone → died; cap-halt, wall-halt, and an escalation that is a money-halt or wall-halt
3508
- → capped; `stopped` → stopped; every other outcome (plan-red, check-red, step-red, close-red, provider-red,
3508
+ → capped (ONE rule, `capKindOf` in `src/legs.js`, shared by the run status, a leg's stop reason and the part cards); `stopped` → stopped; every other outcome (plan-red, check-red, step-red, close-red, provider-red,
3509
3509
  step-stalled, other escalations, refusals) → failed. The detail also carries `startedAt` and `endedAt` (the last
3510
3510
  `job-end` timestamp, null while live or died). `GET /api/imports` rows and `GET /api/imports/:id` carry `runStatus`
3511
3511
  (passed, or null when the bundle shows no green) and `runReason` (`goal met`). The Ended block's lines for died and
3512
3512
  stopped runs are `no ending recorded` and `you pressed Stop` (each `— resume` when Resume is offered).
3513
3513
  Every PART of a run detail (`parts[]`) also carries its own `status` `{key, sign, word}` from the same table
3514
3514
  (`partStatusFor`, `src/panel/status.js`): drafting, a green outcome and a part with no verdict and no cut-off → passed; a red
3515
- outcome → its run-outcome word; a part its leg's halt cut off → capped (money cap / time cap), stopped (you stopped it),
3515
+ outcome → its run-outcome word; a part its leg's halt cut off → capped (money cap / time cap — also a leg that ended `escalated` on a money-halt or wall-halt, and the open step of a single-leg run that ended on a cap), stopped (you stopped it),
3516
3516
  died, else failed. A run that DIED (no `job-end`, runner gone) leaves its last part in flight: when that part has no outcome
3517
3517
  and no cut-off, the server marks it `died` (`[?] died`), never the "happened, so passed" default — replay alone cannot tell
3518
3518
  died from still running, so `getRunDetail` decides it from the same `death` verdict as the run's status. A live run's open
@@ -3534,7 +3534,9 @@ key you want first (the panel picks the row by the Model menu).
3534
3534
  the log tail is returned as `error` verbatim; otherwise `{ok:true, runid, specHash, capsChanged,
3535
3535
  log}`. The run keeps its runid and its one list row: the resumed leg appends to the same spine (P5-R,
3536
3536
  `leg-start` in the run list), it never appears as a new run. The page's Resume button opens the
3537
- caps form (prefilled, "spent so far") and `Sign & resume` is the human click.
3537
+ Chat card in RESUME mode (title `RESUME <job> (run-N)`, filled from `GET /api/author/start-from`, every box locked
3538
+ except the two caps, "spent so far" shown, Clear/Start drafting hidden) and `Sign & resume` on that card is the human
3539
+ click, posting this same route; the run's Job tab is read-only on every run.
3538
3540
  **Stop (item 5)** → `POST /api/runs/:runid/stop` (`src/panel/runroutes.js`, `checkHumanGuard`, no body).
3539
3541
  It writes the run's STOP REQUEST, a file `<spine>.stop` (`stopFilePath(spine)`, `src/legs.js` — the one
3540
3542
  spelling), and signals nothing. `404` unknown run; `409` when the run's LATEST leg is not live
@@ -3556,11 +3558,18 @@ key you want first (the panel picks the row by the Model menu).
3556
3558
  in the Run tab's action area while live, "stopping after this turn…" after the click, and the same
3557
3559
  `[Resume]` there once the engine would accept one. A Stop clicked while the run is in the
3558
3560
  scout, plan or close-fix phase is honoured at that phase's next round boundary, the same as in a step (it used to be read in
3559
- step workers only).
3560
- **Reuse workflow (replaces P5 item 3's Start from this, 2026-10-03)** — a button on GREEN runs only (the Run tab's
3561
- action row and the Ended block; `ended.actions` carries `{id:'reuse', label:'Reuse workflow'}` on green and
3562
- green-with-destination-refused rows, and on no other: a red row's next line is "Change the job: Clear the card and draft a new one.", and
3563
- stopped/capped/died rows offer `resume` only) and on every imported job; the Chat card's Check type is one radio group of three
3561
+ step workers only). A stop in the close-fix loop reads "You stopped the run during the fix phase … a resume continues the
3562
+ run" (`ralph`'s `phase` option; the step loop keeps "a resume re-enters this step").
3563
+ **Edit in chat (2026-10-08)** — `ended.actions` carries `{id:'edit', label:'Edit in chat'}` on EVERY ending that is not a
3564
+ real green (failed, capped, stopped, died, every other red, and an already-green or delivery-refused already-green),
3565
+ after `resume` when both show. The button reads `GET /api/author/start-from` and opens the Chat card filled from the
3566
+ signed job with EVERY box open and the normal Start drafting path (no reuse state, no borrowed track record), under a
3567
+ code-owned origin line ("Copied from <job> (run-N) — <status word>. …").
3568
+ **Reuse workflow (replaces P5 item 3's Start from this, 2026-10-03)** — a button on runs that EARNED their green only
3569
+ (the Run tab's action row and the Ended block; `ended.actions` carries `{id:'reuse', label:'Reuse workflow'}` on `green`
3570
+ and green-with-destination-refused `green` rows, and on no other: an `already-green` run (the check passed before any
3571
+ work) offers Edit in chat instead, and a red row's next line is "Change the job: press Edit in chat."; stopped/capped/died
3572
+ rows offer `resume` and `edit`) and on every imported job; the Chat card's Check type is one radio group of three
3564
3573
  (Deterministic · Rubric · Reuse workflow), and the third opens a search picker over `GET /api/author/reuse-jobs` (below) that
3565
3574
  fills the card through this same path (a pick, then `change` returns to the empty search; Deterministic or Rubric leaves reuse).
3566
3575
  It opens the Chat tab's New job card
@@ -3574,10 +3583,12 @@ key you want first (the panel picks the row by the Model menu).
3574
3583
  green, notGreen, live, finished, avgSpendUsd, avgWallMs}, line}` (`404` unknown, `409` with an `error` when
3575
3584
  there is nothing to copy: no log, the run's signed job is not on disk, or an imported job that changed, lost its
3576
3585
  dependency or no longer matches its signed bytes); `POST /api/author/start` accepts `startFrom: <runid>` or
3577
- `startFrom: {importId}` beside the card and answers `{ok, sessionId, reuse, state}`. The old
3586
+ `startFrom: {importId}` beside the card and answers `{ok, sessionId, reuse, state}`; a local `startFrom: <runid>` whose
3587
+ latest outcome is not `green` (`start-from` carries that `outcome`) is refused `409 "only a run that finished green can
3588
+ be reused — this one did not"` — the server owns the rule, not just the pickers. The old
3578
3589
  `POST /api/author/start-from-check` and the "Changed — new job" mode are gone. `GET /api/author/reuse-jobs[?model=<Name>]` →
3579
3590
  `{ok, jobs:[{job, runid, checkType:'deterministic'|'rubric', line}]}` is the Chat card's Reuse workflow picker list: one entry per
3580
- job (same `workflowKey`) with at least one GREEN local run, `runid` = its newest green run (hand it to `start-from`), `line` =
3591
+ job (same `workflowKey`) with at least one real `green` local run (`already-green` is not a reuse source), `runid` = its newest green run (hand it to `start-from`), `line` =
3581
3592
  start-from's own estimate line, newest green first; imported jobs are not listed. **Identity, two keys:** the
3582
3593
  signature hash `jobSpecHash` is unchanged (it covers the caps and the write fence — what the person signs);
3583
3594
  `workflowKey(spec)` (`src/job.js`, not exported from the package root) is a sha256 over the signed spec WITHOUT
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bareloop",
3
- "version": "0.37.0",
3
+ "version": "0.38.0",
4
4
  "description": "Workflows that earn their own design, with receipts — agent-authored workflows for repeated, long, verifiable jobs that improve across runs under an un-gameable gate.",
5
5
  "type": "module",
6
6
  "main": "./src/index.js",
package/src/legs.js CHANGED
@@ -169,3 +169,18 @@ export function watchdogNotePath(spineFile, leg) {
169
169
  export function stopFilePath(spineFile) {
170
170
  return `${spineFile}.stop`;
171
171
  }
172
+
173
+ /**
174
+ * Did a run (or one leg) END on a cap? The ONE spelling of the rule every surface follows (hamr 2026-10-08): the
175
+ * outcome is `cap-halt` / `wall-halt`, or it is `escalated` and the last escalation's category names the cap —
176
+ * `wall-halt`, or `cap-halt` backed by a `money-halt` record (a strike-ladder `cap-halt` is not the money cap).
177
+ * @param {{outcome: string|null|undefined, category?: string|null, moneyHalt?: boolean}} o
178
+ * @returns {'money'|'time'|null}
179
+ */
180
+ export function capKindOf(o) {
181
+ if (o.outcome === 'cap-halt') return 'money';
182
+ if (o.outcome === 'wall-halt') return 'time';
183
+ if (o.outcome !== 'escalated') return null;
184
+ if (o.category === 'wall-halt') return 'time';
185
+ return o.category === 'cap-halt' && o.moneyHalt ? 'money' : null;
186
+ }
@@ -254,6 +254,13 @@ export function createAuthorRoutes(opts) {
254
254
  const pre = opts.startFrom ? (fromImport !== null ? opts.startFrom.getImport(fromImport) : opts.startFrom.get(fromRunid)) : null;
255
255
  if (pre === null || pre === undefined) { send(404, { ok: false, error: 'no such run to reuse' }); return true; }
256
256
  if (!pre.ok) { send(409, pre); return true; }
257
+ // only a run that earned a real green is a reuse source (hamr 2026-10-08) — the pickers already filter, but a
258
+ // hand-made POST must not sign a reuse of an already-green, red or still-running run. Imports are green by ruling;
259
+ // Edit in chat and Resume only READ start-from (GET) and never post a startFrom, so this gate never touches them.
260
+ if (fromImport === null && pre.outcome !== 'green') {
261
+ send(409, { ok: false, error: 'only a run that finished green can be reused — this one did not' });
262
+ return true;
263
+ }
257
264
  const locked = lockedFieldChanged(card, pre.card);
258
265
  if (locked !== null) { send(400, { ok: false, error: `${locked} is locked on a reused workflow — Clear the card to change it` }); return true; }
259
266
  const rv = validateReuseCard(card, { rows });
@@ -641,10 +641,11 @@ export function createSession(card, deps = {}) {
641
641
  // a detached checkout of the person's current commit, inside their
642
642
  // repo), so a JS/TS repo's tree never carries `node_modules` until
643
643
  // someone installs there. Refusing outright here was ITSELF the F182
644
- // class of bug one layer up. bareloop still never runs an install
645
- // itself — this only re-checks the SAME worktree on demand, via the
646
- // "Check again" button (`checkDeps()`, below), instead of ending the
647
- // session. (P6 item 3: for an npm lock file bareloop runs `npm ci` itself first, above.)
644
+ // class of bug one layer up. For an npm lock file bareloop
645
+ // runs `npm ci --ignore-scripts` itself first (P6 item 3, below); any other
646
+ // gap (or a failed install) waits on the person: this re-checks the SAME
647
+ // worktree on demand, via the "Check again" button (`checkDeps()`, below),
648
+ // instead of ending the session.
648
649
  let depsGap = missingDependencies(prep.tree, prep.manifest.sourceSubdir ?? '');
649
650
  // P6 item 3: a committed npm lock file -> bareloop runs `npm ci --ignore-scripts` itself, $0, before
650
651
  // any model call. Any failure (or another lock file) falls through to the wait below, unchanged.
@@ -140,6 +140,8 @@
140
140
  .dot::before{content:"[·]";color:var(--grey-dot);}
141
141
  .dot.green::before{content:"[✓]";color:var(--green);}
142
142
  .dot.red::before{content:"[✗]";color:var(--red);}
143
+ /* stopped (hamr 2026-10-07): the person pressed Stop — its own sign, same red as [✗] */
144
+ .dot.stopped::before{content:"[■]";color:var(--red);}
143
145
  /* Panel build P3 addition (2026-09-27, "yes, go"): the part cards below the
144
146
  step map (`partResultGlyph`/`attemptGlyph`) and the chat job card render
145
147
  their result glyph as PLAIN inline text inside a larger string (never
@@ -295,10 +297,6 @@
295
297
  .wf-resumed{color:var(--amber);}
296
298
  .audit-leg-divider{text-align:center;color:var(--text-dim);font-size:12px;margin:10px 0;overflow-wrap:anywhere;}
297
299
  .audit-leg-divider-row td{text-align:center;color:var(--text-dim);font-size:12px;background:var(--panel2);}
298
- /* Resume opens the run's own Job tab: only the two caps turn into inputs, everything else stays read-only */
299
- .resume-job{border:1px dashed var(--border-strong);margin-top:12px;padding:10px;display:grid;gap:8px;}
300
- .resume-cap{display:flex;gap:6px;align-items:center;flex-wrap:wrap;}
301
- .resume-cap input{width:7em;max-width:100%;min-height:36px;background:var(--panel);color:var(--text);border:1px solid var(--border-strong);padding:4px 8px;font:inherit;}
302
300
  .summary-box.red{border-color:var(--red);}
303
301
  .summary-box.amber{border-color:var(--amber);}
304
302
  .summary-box.magenta{border-color:var(--magenta);}
@@ -374,6 +372,9 @@
374
372
  .job-card{position:relative;border:1px solid var(--border-strong);padding:14px 10px 10px;background:var(--panel2);margin-top:10px;margin-bottom:10px;}
375
373
  .job-card h4{margin:0 0 8px;font-size:13px;position:absolute;top:-10px;left:10px;background:var(--panel2);padding:0 6px;}
376
374
  .job-card .card-clear{position:absolute;top:-13px;right:10px;padding:1px 8px;font-size:11px;line-height:1.4;background:var(--panel2);}
375
+ .job-card .origin-line{font-size:12px;padding:6px 8px;border-left:3px solid var(--amber);background:var(--panel);color:var(--text-dim);margin-bottom:6px;overflow-wrap:anywhere;}
376
+ .job-card .origin-line.res{border-color:var(--accent, var(--text));}
377
+ .job-card .chat-resume-box{display:grid;gap:8px;margin-top:10px;}
377
378
  .job-card h4::before{content:"┤ ";color:var(--text-faint);}
378
379
  .job-card h4::after{content:" ├";color:var(--text-faint);}
379
380
  .field{margin-bottom:10px;}
@@ -533,6 +534,7 @@
533
534
  <h4 id="job-card-title">Job card — draft</h4>
534
535
  <button class="btn card-clear" type="button" id="chat-clear-btn" data-testid="chat-clear">Clear</button>
535
536
  <div class="hint" id="chat-startfrom-note" data-testid="startfrom-note" hidden></div>
537
+ <div class="origin-line" id="chat-origin-line" data-testid="origin-line" hidden></div>
536
538
 
537
539
  <div class="field">
538
540
  <label id="jf-verdict-label">Check type</label>
@@ -598,6 +600,11 @@
598
600
  </div>
599
601
  </div>
600
602
  <div class="hint" id="chat-card-error" data-testid="chat-card-error"></div>
603
+ <div id="chat-resume-box" class="chat-resume-box" data-testid="resume-box" hidden>
604
+ <div class="hint" id="chat-resume-spent" data-testid="resume-spent"></div>
605
+ <div class="ended-actions"><button class="btn primary" type="button" id="chat-resume-sign" data-testid="btn-sign-resume">Sign &amp; resume</button> <button class="btn" type="button" id="chat-resume-cancel" data-testid="btn-resume-cancel">Cancel</button></div>
606
+ <div class="resume-err" id="chat-resume-err" data-testid="resume-err" hidden></div>
607
+ </div>
601
608
  </div>
602
609
 
603
610
  <div class="chat-thread" id="chat-thread" data-testid="chat-thread"></div>
@@ -723,8 +730,8 @@
723
730
  <div class="field"><label>Check type</label><div class="ro-value" id="details-verdict">—</div></div>
724
731
  <div class="field"><label>Model</label><div class="ro-value" id="details-model">—</div></div>
725
732
  <div class="field-row">
726
- <div class="field"><label id="details-cap-money-label">$ cap</label><div class="ro-value" id="details-cap-money">—</div></div>
727
- <div class="field"><label id="details-cap-time-label">Time cap</label><div class="ro-value" id="details-cap-time">—</div></div>
733
+ <div class="field"><label>$ cap</label><div class="ro-value" id="details-cap-money">—</div></div>
734
+ <div class="field"><label>Time cap</label><div class="ro-value" id="details-cap-time">—</div></div>
728
735
  </div>
729
736
  <div class="field" id="details-description-field"><label>Description</label><div class="ro-value" id="details-description">—</div></div>
730
737
  <div class="field"><label>Goal</label><div class="ro-value scroll" id="details-goal">—</div></div>
@@ -734,7 +741,6 @@
734
741
  <div class="field"><label>Destination</label><div class="ro-value" id="details-dest">—</div></div>
735
742
  <div class="field"><label>Tools</label><div class="ro-value" id="details-tools">—</div></div>
736
743
  <div class="hint" id="job-note"></div>
737
- <div id="resume-job" class="resume-job" data-testid="resume-job" hidden></div>
738
744
  </div>
739
745
  </section>
740
746
 
@@ -1267,6 +1273,7 @@
1267
1273
  function glyphClass(g){
1268
1274
  if(g === "✓") return "green";
1269
1275
  if(g === "✗") return "red";
1276
+ if(g === "■") return "stopped";
1270
1277
  if(g === "▶") return "amber";
1271
1278
  if(g === "?") return "magenta";
1272
1279
  return "grey";
@@ -1499,6 +1506,17 @@
1499
1506
  // is a normal, expected action.
1500
1507
  var wfExpanded = {};
1501
1508
 
1509
+ // hamr 2026-10-08 (display only, nothing deleted): with NO search/filter active an expanded job shows at most its
1510
+ // latest WF_SUB_CAP other runs (g.runs is latest-first). A selected older run beyond the cap (picked from History) is
1511
+ // appended after them rather than hidden. With search/filters active every matching sub-card shows.
1512
+ var WF_SUB_CAP = 7;
1513
+ function capSubRuns(childRuns, active, selectedRunid){
1514
+ if(active || childRuns.length <= WF_SUB_CAP) return {shown: childRuns, more: false};
1515
+ var shown = childRuns.slice(0, WF_SUB_CAP);
1516
+ var sel = selectedRunid ? childRuns.slice(WF_SUB_CAP).filter(function(r){ return r.runid === selectedRunid; }) : [];
1517
+ return {shown: shown.concat(sel), more: true};
1518
+ }
1519
+
1502
1520
  function renderWorkflows(jobs){
1503
1521
  var el = document.getElementById("wf-list");
1504
1522
  el.innerHTML = "";
@@ -1585,7 +1603,15 @@
1585
1603
  if(expanded){
1586
1604
  var runsWrap = document.createElement("div");
1587
1605
  runsWrap.className = "wf-runs";
1588
- childRuns.forEach(function(r){ runsWrap.appendChild(buildRunRowEl(r, true)); });
1606
+ var capped = capSubRuns(childRuns, active, olderSelected);
1607
+ capped.shown.forEach(function(r){ runsWrap.appendChild(buildRunRowEl(r, true)); });
1608
+ if(capped.more){
1609
+ var olderHint = document.createElement("div");
1610
+ olderHint.className = "hint";
1611
+ olderHint.setAttribute("data-testid", "wf-older-" + g.job);
1612
+ olderHint.textContent = "older: use search.";
1613
+ runsWrap.appendChild(olderHint);
1614
+ }
1589
1615
  wrap.appendChild(runsWrap);
1590
1616
  }
1591
1617
  el.appendChild(wrap);
@@ -1654,7 +1680,8 @@
1654
1680
  var q = (filters && filters.search) || "";
1655
1681
  return (runs || []).filter(function(r){
1656
1682
  if(ct.length && ct.indexOf(r.checkType) === -1) return false;
1657
- if(rs.length && rs.indexOf(r.glyph) === -1) return false;
1683
+ // a stopped run ([■]) was a [✗] before hamr's 2026-10-07 sign ruling — the fail chip still finds it
1684
+ if(rs.length && rs.indexOf(r.glyph) === -1 && !(r.glyph === "\u25A0" && rs.indexOf("\u2717") !== -1)) return false;
1658
1685
  if(!matchesSearch(r.job, r.runid, r.model, q, r.runNo)) return false;
1659
1686
  if(time !== "all"){
1660
1687
  var days = time === "7d" ? 7 : 30;
@@ -1966,7 +1993,6 @@
1966
1993
  clearSelection();
1967
1994
  currentRunid = null;
1968
1995
  runIsLive = false;
1969
- resumeMode = null;
1970
1996
  currentImportId = id;
1971
1997
  document.querySelectorAll("#import-list .wf-row").forEach(function(r){
1972
1998
  var on = r.getAttribute("data-testid") === "import-row-" + id;
@@ -2363,11 +2389,9 @@
2363
2389
  // The sentences are CODE-OWNED on the server (`endedFor`, src/panel/server.js) — this only
2364
2390
  // paints them. `detail.ended` is null while a run is live (no block while running).
2365
2391
  var lastEndedSig = null;
2366
- // P5-R (hamr 2026-10-02): Resume opens the run's OWN Job tab. There ONLY the money cap and the time cap are
2367
- // editable; every other field is the signed job, shown read-only exactly as the tab already shows it. The click
2368
- // on [Sign & resume] is the signature when a cap changed. A resume is the SAME run: no new card, no new job.
2369
- var resumeMode = null;
2370
- var lastJobShown = null;
2392
+ // Resume (P5-R, hamr 2026-10-02: opened the run's own Job tab) MOVED to the left Chat card (hamr 2026-10-07): the card
2393
+ // in RESUME mode, only the money cap and the time cap open (applyResume / signResume). The Job tab is read-only on
2394
+ // every run — values only, no inputs, no buttons. A resume is the SAME run: no new card, no new job.
2371
2395
  function renderEnded(detail){
2372
2396
  var box = document.getElementById("ended-block");
2373
2397
  var e = detail.ended;
@@ -2384,7 +2408,7 @@
2384
2408
  '<div class="ended-row"><span class="ended-label">NEXT</span><span class="ended-text" data-testid="ended-next">' + escapeXml(e.next) + '</span></div>';
2385
2409
  }
2386
2410
  // ---- P5 item 5: the run's ACTION AREA (Run tab, above the counters) — Stop while the run is live, Resume once
2387
- // it ended in a way the engine would accept (the SAME button as the Ended block's, same Job-tab resume mode).
2411
+ // it ended in a way the engine would accept (opens the Chat card's resume mode), Edit in chat on every non-green ending.
2388
2412
  // The Ended block (Audit tab) is text only and carries no buttons.
2389
2413
  var stopAsked = {};
2390
2414
  // Reuse workflow (replaces P5 item 3's Start from this) — ask the server for the prefill (the signed job), then
@@ -2397,6 +2421,33 @@
2397
2421
  document.dispatchEvent(new CustomEvent("bareloop-reuse", {detail: {runid: runid, prefill: r}}));
2398
2422
  }).catch(function(x){ window.alert("could not reach the panel: " + x.message); });
2399
2423
  }
2424
+ // Edit in chat (hamr 2026-10-07, option A) — the same prefill door as Reuse workflow (`start-from`, the ONE reader of a
2425
+ // run's signed job), but the Chat card opens with EVERY box open and the normal [Start drafting] path: it drafts a new job.
2426
+ // The origin line is code-owned fixed text; run name and status word come from the same helpers the run header uses.
2427
+ function editInChat(detail){
2428
+ var origin = "Copied from " + detail.job + " (" + runLabel(detail.runNo, detail.runid) + ") \u2014 " +
2429
+ ((detail.status && detail.status.word) || "ended") +
2430
+ ". Every box is open. Change anything and it drafts as a new job; change nothing and it is another run of the same job.";
2431
+ authorGet("/api/author/start-from?runid=" + encodeURIComponent(detail.runid)).then(function(r){
2432
+ document.getElementById("tab-chat").click();
2433
+ document.dispatchEvent(new CustomEvent("bareloop-edit", {detail: (r && r.ok) ? {runid: detail.runid, prefill: r, origin: origin} : {error: (r && r.error) || "nothing to copy"}}));
2434
+ }).catch(function(x){
2435
+ document.getElementById("tab-chat").click();
2436
+ document.dispatchEvent(new CustomEvent("bareloop-edit", {detail: {error: "could not reach the panel: " + x.message}}));
2437
+ });
2438
+ }
2439
+ // Resume (hamr 2026-10-07, option B — moved from the run's Job tab): the Chat card opens in RESUME mode, filled from the
2440
+ // SAME prefill (`start-from`), everything locked but the two caps. The card posts /api/runs/<id>/resume; a resume never
2441
+ // drafts and never makes a new card, job or run id (one run = one id).
2442
+ function resumeInChat(detail){
2443
+ authorGet("/api/author/start-from?runid=" + encodeURIComponent(detail.runid)).then(function(r){
2444
+ document.getElementById("tab-chat").click();
2445
+ document.dispatchEvent(new CustomEvent("bareloop-resume", {detail: (r && r.ok) ? {runid: detail.runid, prefill: r, resume: detail.resume || {}, title: detail.job + " (" + runLabel(detail.runNo, detail.runid) + ")"} : {error: (r && r.error) || "nothing to resume"}}));
2446
+ }).catch(function(x){
2447
+ document.getElementById("tab-chat").click();
2448
+ document.dispatchEvent(new CustomEvent("bareloop-resume", {detail: {error: "could not reach the panel: " + x.message}}));
2449
+ });
2450
+ }
2400
2451
  function renderRunActions(detail){
2401
2452
  var bar = document.getElementById("run-actions");
2402
2453
  var html = "";
@@ -2412,6 +2463,10 @@
2412
2463
  var canResume = !!detail.resume && !!detail.ended && (detail.ended.actions || []).some(function(a){ return a.id === "resume"; });
2413
2464
  if(canResume) html = '<button class="btn primary" type="button" data-testid="btn-resume-run">Resume</button>';
2414
2465
  }
2466
+ // Edit in chat: every non-green ending (the server's Ended block says so) — a died run included
2467
+ if(!live && !!detail.ended && (detail.ended.actions || []).some(function(a){ return a.id === "edit"; })){
2468
+ html += '<button class="btn" type="button" data-testid="btn-edit-chat">Edit in chat</button>';
2469
+ }
2415
2470
  // Reuse workflow: on a GREEN run only (the server's Ended block says so) — a new run of the same signed job
2416
2471
  if(!live && !detail.died && !!detail.ended && (detail.ended.actions || []).some(function(a){ return a.id === "reuse"; })){
2417
2472
  html += '<button class="btn" type="button" data-testid="btn-reuse">Reuse workflow</button>';
@@ -2420,6 +2475,8 @@
2420
2475
  bar.hidden = html === "";
2421
2476
  var reuseBtn = bar.querySelector('[data-testid="btn-reuse"]');
2422
2477
  if(reuseBtn) reuseBtn.addEventListener("click", function(){ reuseWorkflow(detail.runid); });
2478
+ var editBtn = bar.querySelector('[data-testid="btn-edit-chat"]');
2479
+ if(editBtn) editBtn.addEventListener("click", function(){ editInChat(detail); });
2423
2480
  var stopBtn = bar.querySelector('[data-testid="btn-stop"]');
2424
2481
  if(stopBtn){
2425
2482
  stopBtn.addEventListener("click", function(){
@@ -2431,82 +2488,7 @@
2431
2488
  });
2432
2489
  }
2433
2490
  var resBtn = bar.querySelector('[data-testid="btn-resume-run"]');
2434
- if(resBtn) resBtn.addEventListener("click", function(){ openResumeOnJobTab(detail); });
2435
- }
2436
- function openResumeOnJobTab(detail){
2437
- var r = detail.resume || {};
2438
- resumeMode = {
2439
- runid: detail.runid, spentUsd: r.spentUsd, spendComplete: r.spendComplete,
2440
- wallUsedMs: r.wallUsedMs,
2441
- budget: (r.budgetUsd === null || r.budgetUsd === undefined) ? "" : String(r.budgetUsd),
2442
- wall: typeof r.maxWallMin === "number" ? String(r.maxWallMin) : null,
2443
- painted: false,
2444
- };
2445
- document.getElementById("tab-details").click();
2446
- paintResumeMode();
2447
- }
2448
- // Turns the Job tab's two cap values into inputs (and shows the Sign box) while a resume is open on this run;
2449
- // called again after every Job re-render so a re-render never drops the typed numbers.
2450
- function paintResumeMode(){
2451
- var box = document.getElementById("resume-job");
2452
- var moneyLabel = document.getElementById("details-cap-money-label");
2453
- var timeLabel = document.getElementById("details-cap-time-label");
2454
- if(!resumeMode || resumeMode.runid !== currentRunid){
2455
- box.hidden = true; box.innerHTML = "";
2456
- moneyLabel.textContent = "$ cap"; timeLabel.textContent = "Time cap";
2457
- return;
2458
- }
2459
- var m = resumeMode;
2460
- // the unit lives in the caption, never as loose text around the input
2461
- moneyLabel.textContent = "Money cap ($)";
2462
- timeLabel.textContent = "Time cap (min)";
2463
- document.getElementById("details-cap-money").innerHTML =
2464
- '<span class="resume-cap"><input type="number" min="0" step="0.01" id="resume-budget" data-testid="resume-budget" value="' + escapeXml(m.budget) + '"></span>';
2465
- if(m.wall !== null){
2466
- document.getElementById("details-cap-time").innerHTML =
2467
- '<span class="resume-cap"><input type="number" min="1" step="1" id="resume-wall" data-testid="resume-wall" value="' + escapeXml(m.wall) + '"></span>';
2468
- }
2469
- var budgetEl = document.getElementById("resume-budget");
2470
- budgetEl.addEventListener("input", function(){ m.budget = budgetEl.value; });
2471
- var wallEl = document.getElementById("resume-wall");
2472
- if(wallEl) wallEl.addEventListener("input", function(){ m.wall = wallEl.value; });
2473
- box.hidden = false;
2474
- if(m.painted) return;
2475
- m.painted = true;
2476
- var spent = (m.spentUsd === null || m.spentUsd === undefined) ? "unknown" : ((m.spendComplete ? "" : "at least ") + panelMoney(m.spentUsd));
2477
- box.innerHTML =
2478
- '<div data-testid="resume-intro">Resume run ' + escapeXml(m.runid) + ' &mdash; the same run, not a new one. Only the money cap and the time cap can change; everything else is the signed job.</div>' +
2479
- '<div class="hint" data-testid="resume-spent">spent so far ' + escapeXml(spent) +
2480
- (typeof m.wallUsedMs === "number" && isFinite(m.wallUsedMs) ? ' &middot; time used so far ' + Math.ceil(m.wallUsedMs / 60000) + ' min' : '') + '</div>' +
2481
- '<div class="ended-actions"><button class="btn primary" type="button" data-testid="btn-sign-resume">Sign &amp; resume</button> ' +
2482
- '<button class="btn" type="button" data-testid="btn-resume-cancel">Cancel</button></div>' +
2483
- '<div class="resume-err" id="resume-err" data-testid="resume-err" hidden></div>';
2484
- var resumeSignBtn = box.querySelector('[data-testid="btn-sign-resume"]');
2485
- box.querySelector('[data-testid="btn-resume-cancel"]').addEventListener("click", function(){
2486
- resumeMode = null;
2487
- renderJob(lastJobShown);
2488
- });
2489
- resumeSignBtn.addEventListener("click", function(){
2490
- var err = document.getElementById("resume-err");
2491
- err.hidden = true;
2492
- resumeSignBtn.disabled = true;
2493
- var body = {budgetUsd: document.getElementById("resume-budget").value};
2494
- var wall = document.getElementById("resume-wall");
2495
- if(wall){ body.maxWallMin = wall.value; }
2496
- return authorPost("/api/runs/" + encodeURIComponent(m.runid) + "/resume", body).then(function(res){
2497
- if(res.body && res.body.ok){
2498
- // the SAME run goes on: back to its Run tab, which follows it until it is live again
2499
- resumeMode = null;
2500
- renderJob(lastJobShown);
2501
- document.getElementById("tab-run").click();
2502
- afterResumeRefresh(m.runid, 8);
2503
- } else {
2504
- resumeSignBtn.disabled = false;
2505
- err.textContent = (res.body && res.body.error) || ("refused (" + res.status + ")");
2506
- err.hidden = false;
2507
- }
2508
- }).catch(function(x){ resumeSignBtn.disabled = false; err.textContent = "could not reach the panel: " + x.message; err.hidden = false; });
2509
- });
2491
+ if(resBtn) resBtn.addEventListener("click", function(){ resumeInChat(detail); });
2510
2492
  }
2511
2493
  // After a resume starts, the open run's own page follows it: re-select once the run reads live again (the spine's
2512
2494
  // new leg appears a moment after the engine starts), a few tries, never forever.
@@ -2600,7 +2582,7 @@
2600
2582
  " &middot; " + wallPhrase;
2601
2583
 
2602
2584
  var box = document.getElementById("run-summary");
2603
- box.className = "summary-box " + (detail.died ? "magenta" : (detail.glyph === "✗" ? "red" : (detail.glyph === "▶" ? "amber" : "")));
2585
+ box.className = "summary-box " + (detail.died ? "magenta" : ((detail.glyph === "✗" || detail.glyph === "■") ? "red" : (detail.glyph === "▶" ? "amber" : "")));
2604
2586
  // F196: a synthetic placeholder box (e.g. "died during planning") is a
2605
2587
  // LABEL, not a real step — excluded from this count so a run that died
2606
2588
  // before any step started reads "0 of 0 done" / "none started", never
@@ -2679,7 +2661,7 @@
2679
2661
 
2680
2662
  // ONE spelling of a step's state: its dot + the bold state word — read by the Run tab's step cards AND the Audit tab's grouped rows
2681
2663
  function stepStateHTML(box, testid){
2682
- var cls = box.state === "done" ? "green" : (box.state === "stopped" ? "red" : (box.state === "running" ? "amber" : (box.state === "died" ? "magenta" : "grey")));
2664
+ var cls = box.state === "done" ? "green" : (box.state === "stopped" ? (box.status && box.status.sign === "\u25A0" ? "stopped" : "red") : (box.state === "running" ? "amber" : (box.state === "died" ? "magenta" : "grey")));
2683
2665
  return '<span class="step-state" data-testid="' + testid + '"><span class="dot ' + cls + '"></span><b class="st-word">' + escapeXml(boxStatusWord(box)) + '</b></span>';
2684
2666
  }
2685
2667
  // build item C: Audit tab attempt row -> its rounds (server-built,
@@ -3269,7 +3251,6 @@
3269
3251
  function renderJob(job){
3270
3252
  var empty = document.getElementById("job-empty");
3271
3253
  var card = document.getElementById("job-card-readonly");
3272
- lastJobShown = job;
3273
3254
  if(!job){ empty.hidden = false; card.hidden = true; return; }
3274
3255
  empty.hidden = true;
3275
3256
  card.hidden = false;
@@ -3292,7 +3273,6 @@
3292
3273
  document.getElementById("job-note").textContent = job.note || "";
3293
3274
  lastJobToolsList = Array.isArray(job.toolsList) ? job.toolsList : null;
3294
3275
  paintOfferedRow();
3295
- paintResumeMode();
3296
3276
  }
3297
3277
 
3298
3278
  // ---- selection + loading ----
@@ -3312,7 +3292,6 @@
3312
3292
  document.querySelectorAll(siblingSelector).forEach(function(r){ r.classList.remove("selected"); r.setAttribute("aria-pressed","false"); });
3313
3293
  if(rowEl){ rowEl.classList.add("selected"); rowEl.setAttribute("aria-pressed","true"); }
3314
3294
  currentRunid = runid;
3315
- resumeMode = null;
3316
3295
  lastLegDividers = [];
3317
3296
  lastJobToolsList = null;
3318
3297
  lastRunDetailSig = null;
@@ -3731,32 +3710,39 @@
3731
3710
  var autoSigned = false;
3732
3711
  var sfLine = document.getElementById("chat-startfrom-line");
3733
3712
  var sfNote = document.getElementById("chat-startfrom-note");
3713
+ var originLine = document.getElementById("chat-origin-line");
3734
3714
  // the locked boxes of a reuse card: the signed workflow, greyed and not editable (the server refuses a change anyway)
3735
3715
  var LOCKED_IDS = ["jf-name", "jf-goal", "jf-success", "jf-guardrails", "jf-judge"];
3736
3716
  // hamr 2026-10-05: while a drafting session is live NOTHING on the card is editable (the session was started from
3737
3717
  // these values). The reuse rule (only Source/Destination/caps open) applies before the start. ONE function owns it.
3738
3718
  var OPEN_IDS = ["jf-source", "jf-dest", "jf-cap-money", "jf-cap-time", "jf-model"];
3739
3719
  var reuseOn = false;
3720
+ // Resume mode (hamr 2026-10-07): the card shows the signed job of one run, every box locked except the two caps.
3721
+ // `resumeRun` = {runid, noTime} while it is on, else null. Never set together with a drafting session or a reuse pick.
3722
+ var resumeRun = null;
3723
+ var CAP_IDS = ["jf-cap-money", "jf-cap-time"];
3740
3724
  function syncCardLock(){
3741
3725
  var live = sessionLive;
3726
+ var resuming = resumeRun !== null;
3742
3727
  var setLock = function(id, on){ var el = document.getElementById(id); el.readOnly = on; el.classList.toggle("locked", on); };
3743
- LOCKED_IDS.forEach(function(id){ setLock(id, reuseOn || live); });
3744
- OPEN_IDS.forEach(function(id){ setLock(id, live); });
3745
- document.getElementById("jf-model").disabled = live;
3746
- // the Check type radios stay clickable on a reuse card (Deterministic / Rubric = leave reuse); only a live session freezes them
3747
- document.querySelectorAll('input[name="jf-verdict"]').forEach(function(r){ r.disabled = live; });
3728
+ LOCKED_IDS.forEach(function(id){ setLock(id, reuseOn || live || resuming); });
3729
+ OPEN_IDS.forEach(function(id){ setLock(id, live || (resuming && CAP_IDS.indexOf(id) === -1)); });
3730
+ document.getElementById("jf-model").disabled = live || resuming;
3731
+ // the Check type radios stay clickable on a reuse card (Deterministic / Rubric = leave reuse); a live session or a resume freezes them
3732
+ document.querySelectorAll('input[name="jf-verdict"]').forEach(function(r){ r.disabled = live || resuming; });
3748
3733
  document.getElementById("job-card").classList.toggle("reuse", reuseOn);
3749
- if(!reuseOn && !live){ var ck = document.querySelector('input[name="jf-verdict"]:checked'); document.getElementById("jf-judge").disabled = !ck || ck.value !== "rubric"; }
3734
+ if(!reuseOn && !live && !resuming){ var ck = document.querySelector('input[name="jf-verdict"]:checked'); document.getElementById("jf-judge").disabled = !ck || ck.value !== "rubric"; }
3750
3735
  }
3751
3736
  function setReuseLocked(on){ reuseOn = on; syncCardLock(); }
3752
- function clearStartFrom(){
3753
- // Clear after a reuse starts a genuinely new job: the reused values do not linger in the boxes
3754
- if(startFrom){
3737
+ function clearStartFrom(keepBoxes){
3738
+ // Clear after a reuse starts a genuinely new job: the reused values do not linger in the boxes — except when a Check type
3739
+ // radio click leaves reuse (keepBoxes): a radio never blanks what the person has on the card
3740
+ if(startFrom && !keepBoxes){
3755
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 = ""; });
3756
3742
  document.querySelectorAll('input[name="jf-verdict"]').forEach(function(r){ r.checked = (r.value === "deterministic"); });
3757
3743
  }
3758
3744
  startFrom = null; reuseSession = false; autoSigned = false; reuseCheckType = "deterministic";
3759
- sfLine.hidden = true; sfNote.hidden = true;
3745
+ sfLine.hidden = true; sfNote.hidden = true; originLine.hidden = true;
3760
3746
  setReuseLocked(false);
3761
3747
  renderPicker();
3762
3748
  renderMain();
@@ -3807,6 +3793,7 @@
3807
3793
  function renderReuseList(){
3808
3794
  var q = rwQ.value.trim();
3809
3795
  var all = rwJobs || [];
3796
+ var loading = rwJobs === null && !rwFailed;
3810
3797
  rwShown = all.filter(function(j){ return j.job.toLowerCase().indexOf(q.toLowerCase()) !== -1; });
3811
3798
  if(rwHl >= rwShown.length) rwHl = 0;
3812
3799
  if(rwShown.length){
@@ -3815,7 +3802,7 @@
3815
3802
  }).join("");
3816
3803
  } else {
3817
3804
  rwList.innerHTML = '<div class="rw-empty" data-testid="rw-empty">' + (rwFailed ? "Could not load the job list."
3818
- : (all.length === 0 ? "No green jobs yet." : 'No green job matches "' + escapeXml(q) + '".')) + "</div>";
3805
+ : loading ? "Loading\u2026" : (all.length === 0 ? "No green jobs yet." : 'No green job matches "' + escapeXml(q) + '".')) + "</div>";
3819
3806
  }
3820
3807
  }
3821
3808
  function loadReuseJobs(){
@@ -3826,9 +3813,10 @@
3826
3813
  }).catch(function(){ rwFailed = true; rwJobs = null; renderReuseList(); });
3827
3814
  }
3828
3815
  // the empty search state: card emptied, nothing picked, Sign & run disabled until a job is picked
3816
+ // (the boxes are NOT cleared here: clicking the Reuse radio never blanks typing; only a pick fills the card)
3829
3817
  function openPicker(){
3830
- clearCardBoxes();
3831
3818
  rwQ.value = ""; rwHl = 0;
3819
+ rwJobs = null; rwFailed = false; // a fresh open shows "Loading…", never the previous list
3832
3820
  document.getElementById("jf-judge").disabled = true;
3833
3821
  renderPicker();
3834
3822
  renderReuseList();
@@ -3854,8 +3842,22 @@
3854
3842
  openPicker();
3855
3843
  }
3856
3844
  document.getElementById("rw-change").addEventListener("click", unpickReuse);
3845
+ // leaving resume mode: the title, the hidden drafting chrome, the time box and the resume box all come back
3846
+ function exitResumeMode(){
3847
+ resumeRun = null;
3848
+ originLine.classList.remove("res");
3849
+ document.getElementById("job-card-title").textContent = "Job card \u2014 draft";
3850
+ document.getElementById("chat-resume-box").hidden = true;
3851
+ document.getElementById("chat-resume-err").hidden = true;
3852
+ document.getElementById("jf-cap-time").parentNode.hidden = false;
3853
+ document.getElementById("chat-clear-btn").hidden = false;
3854
+ ["chat-main-btn", "chat-main-hint"].forEach(function(id){ document.getElementById(id).hidden = false; });
3855
+ document.querySelector(".chat-input-row").hidden = false;
3856
+ }
3857
3857
  function openNewCard(){
3858
3858
  card.hidden = false;
3859
+ originLine.hidden = true;
3860
+ exitResumeMode();
3859
3861
  thread.innerHTML = "";
3860
3862
  errEl.textContent = "";
3861
3863
  actionErrEl.textContent = "";
@@ -3961,12 +3963,11 @@
3961
3963
  // the limit changed in Settings: re-read the note for whatever cap is typed
3962
3964
  document.addEventListener("bareloop-limit-changed", refreshCapNote);
3963
3965
  // Check type: Deterministic / Rubric / Reuse workflow. Reuse opens the picker on an empty card; Deterministic or
3964
- // Rubric while the picker is showing (picked or not) LEAVES reuse — the empty card on that type (clearStartFrom).
3966
+ // Rubric while the picker is showing (picked or not) LEAVES reuse — boxes untouched (clearStartFrom(true)).
3965
3967
  function onVerdictChange(v){
3966
3968
  if(v === "reuse"){ if(rwBox.hidden) openPicker(); return; }
3967
3969
  if(!rwBox.hidden){
3968
- clearStartFrom();
3969
- clearCardBoxes();
3970
+ clearStartFrom(true);
3970
3971
  setVerdict(v);
3971
3972
  renderPicker();
3972
3973
  }
@@ -4314,7 +4315,7 @@
4314
4315
  poll();
4315
4316
  }
4316
4317
  function reattachLive(){
4317
- if(sessionId || sessionLive) return;
4318
+ if(sessionId || sessionLive || resumeRun !== null) return;
4318
4319
  authorGet("/api/author/live").then(function(j){
4319
4320
  if(sessionId || sessionLive || !j || !j.ok || !j.sessionId || !j.state) return;
4320
4321
  attachSession(j);
@@ -4356,6 +4357,90 @@
4356
4357
  }
4357
4358
  document.addEventListener("bareloop-reuse", function(e){ applyReuse(e.detail || {}); });
4358
4359
 
4360
+ // Edit in chat — the card opens filled from the SIGNED job with EVERY box open; the button stays the normal
4361
+ // [Start drafting] (startFrom / reuse state are never set: no borrowed track record). A live drafting session is
4362
+ // handled exactly as applyReuse does (clearStartFrom + openNewCard). A refused prefill opens nothing, error only.
4363
+ function applyEdit(d){
4364
+ if(d.error){ errEl.textContent = d.error; return; }
4365
+ var c = (d.prefill || {}).card || {};
4366
+ clearStartFrom();
4367
+ openNewCard();
4368
+ setVerdict(c.checkType === "rubric" ? "rubric" : "deterministic");
4369
+ 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";
4375
+ originLine.textContent = d.origin || "";
4376
+ originLine.hidden = !d.origin;
4377
+ renderPicker();
4378
+ refreshModelStatus();
4379
+ refreshCapNote();
4380
+ refreshStartEnabled();
4381
+ }
4382
+ document.addEventListener("bareloop-edit", function(e){ applyEdit(e.detail || {}); });
4383
+
4384
+ // Resume — the card in RESUME mode: the signed job filled and locked, only Cap $ and Time cap open (prefilled from the
4385
+ // engine's own resume plan, the values the Job tab used to show). A live drafting session is handled exactly as
4386
+ // applyReuse does. A refused prefill opens nothing, error only.
4387
+ function applyResume(d){
4388
+ if(d.error){ errEl.textContent = d.error; return; }
4389
+ var c = (d.prefill || {}).card || {};
4390
+ var r = d.resume || {};
4391
+ clearStartFrom();
4392
+ openNewCard();
4393
+ resumeRun = {runid: d.runid, noTime: typeof r.maxWallMin !== "number"};
4394
+ document.getElementById("job-card-title").textContent = "RESUME " + (d.title || d.runid);
4395
+ setVerdict(c.checkType === "rubric" ? "rubric" : "deterministic");
4396
+ 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);
4400
+ setVal("jf-cap-money", r.budgetUsd);
4401
+ setVal("jf-cap-time", resumeRun.noTime ? "" : r.maxWallMin);
4402
+ document.getElementById("jf-cap-time").parentNode.hidden = resumeRun.noTime;
4403
+ document.getElementById("jf-judge").disabled = c.checkType !== "rubric";
4404
+ 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
+ originLine.classList.add("res");
4406
+ originLine.hidden = false;
4407
+ var spent = (r.spentUsd === null || r.spentUsd === undefined) ? "unknown" : ((r.spendComplete ? "" : "at least ") + panelMoney(r.spentUsd));
4408
+ document.getElementById("chat-resume-spent").textContent = "spent so far " + spent +
4409
+ (typeof r.wallUsedMs === "number" && isFinite(r.wallUsedMs) ? " \u00B7 time used so far " + Math.ceil(r.wallUsedMs / 60000) + " min" : "");
4410
+ document.getElementById("chat-resume-box").hidden = false;
4411
+ document.getElementById("chat-clear-btn").hidden = true;
4412
+ ["chat-main-btn", "chat-main-hint"].forEach(function(id){ document.getElementById(id).hidden = true; });
4413
+ document.querySelector(".chat-input-row").hidden = true;
4414
+ renderPicker();
4415
+ refreshModelStatus();
4416
+ refreshStartEnabled();
4417
+ }
4418
+ document.addEventListener("bareloop-resume", function(e){ applyResume(e.detail || {}); });
4419
+ document.getElementById("chat-resume-cancel").addEventListener("click", function(){ resetCard(); });
4420
+ var resumeSignBtn = document.getElementById("chat-resume-sign");
4421
+ function signResume(){
4422
+ var run = resumeRun;
4423
+ if(run === null) return;
4424
+ var err = document.getElementById("chat-resume-err");
4425
+ err.hidden = true;
4426
+ resumeSignBtn.disabled = true;
4427
+ var body = {budgetUsd: document.getElementById("jf-cap-money").value};
4428
+ if(!run.noTime){ body.maxWallMin = document.getElementById("jf-cap-time").value; }
4429
+ return authorPost("/api/runs/" + encodeURIComponent(run.runid) + "/resume", body).then(function(res){
4430
+ resumeSignBtn.disabled = false;
4431
+ if(res.body && res.body.ok){
4432
+ // the SAME run goes on: the card goes back to empty, the right side to its Run tab, which follows it until it is live again
4433
+ resetCard();
4434
+ document.getElementById("tab-run").click();
4435
+ afterResumeRefresh(run.runid, 8);
4436
+ } else {
4437
+ err.textContent = (res.body && res.body.error) || ("refused (" + res.status + ")");
4438
+ err.hidden = false;
4439
+ }
4440
+ }).catch(function(x){ resumeSignBtn.disabled = false; err.textContent = "could not reach the panel: " + x.message; err.hidden = false; });
4441
+ }
4442
+ resumeSignBtn.addEventListener("click", signResume);
4443
+
4359
4444
  // build item 1 — the "lost answer" fix. ROOT CAUSE: `renderActions`
4360
4445
  // (fed by the every-2s `poll()`) unconditionally overwrites
4361
4446
  // `#chat-card-error` on every tick (`errEl.textContent = state.error ||
@@ -74,7 +74,7 @@ export const RUNID_RE = /^[A-Za-z0-9._~-]+$/;
74
74
  export const DEFAULT_PORT = 4700;
75
75
 
76
76
  /**
77
- * `[✓]`/`[✗]`/`[▶]` — the ONLY vocabulary a result is ever rendered in
77
+ * `[✓]`/`[✗]`/`[■]`/`[▶]` — the ONLY vocabulary a result is ever rendered in
78
78
  * (auto-memory `ui-verdict-words.md`: never the words green/red/soft-green
79
79
  * anywhere in the page). `null` (no `job-end` reached — a killed-mid-run or
80
80
  * still-running spine) reads `▶` — the same "in progress / unresolved" glyph
@@ -82,10 +82,10 @@ export const DEFAULT_PORT = 4700;
82
82
  * running attempt and an archived spine that never reached its own end are
83
83
  * the same fact from this read-only side: no verdict has been recorded yet.
84
84
  * @param {string|null} outcome
85
- * @returns {'✓'|'✗'|'▶'}
85
+ * @returns {'✓'|'✗'|'■'|'▶'}
86
86
  */
87
87
  export function glyphForOutcome(outcome) {
88
- return /** @type {'✓'|'✗'|'▶'} */ (statusFor({ outcome }).sign);
88
+ return /** @type {'✓'|'✗'|'■'|'▶'} */ (statusFor({ outcome }).sign);
89
89
  }
90
90
 
91
91
  /**
@@ -417,7 +417,7 @@ const GOAL_NOT_MET = new Set(['plan-red', 'check-red', 'step-red', 'escalated'])
417
417
  * @returns {{reason: string, next: string, line: string, actions: {id: string, label: string}[]}|null}
418
418
  */
419
419
  export function endedFor(summary, death, o = {}) {
420
- const base = endedBase(summary, death, o);
420
+ const base = withEditAction(endedBase(summary, death, o));
421
421
  const wt = o.worktree;
422
422
  if (!base || !wt) return base;
423
423
  // P6 item 1 — the run worked on a worktree in the person's own repo. GREEN: the branch carries the work (the folder is
@@ -432,21 +432,31 @@ export function endedFor(summary, death, o = {}) {
432
432
  return wt.exists ? { ...base, next: `${base.next} Your work is in ${wt.folder}.` } : base;
433
433
  }
434
434
 
435
+ /**
436
+ * "Edit in chat" (hamr 2026-10-07): offered on EVERY non-green ending (failed, capped, stopped, died, every other red),
437
+ * after Resume when both show; a green ending keeps Reuse workflow only. ONE place, so no ending branch can forget it.
438
+ * @param {{reason: string, next: string, line: string, actions: {id: string, label: string}[]}|null} ended
439
+ */
440
+ function withEditAction(ended) {
441
+ if (!ended || ended.actions.some((a) => a.id === 'reuse')) return ended;
442
+ return { ...ended, actions: [...ended.actions, { id: 'edit', label: 'Edit in chat' }] };
443
+ }
444
+
435
445
  /** @param {any} summary @param {any} death @param {any} o */
436
446
  function endedBase(summary, death, o) {
437
447
  const resumeOk = !!(o.resume && o.resume.ok);
438
448
  /** @type {{id: string, label: string}[]} */
439
449
  const RESUME = [{ id: 'resume', label: 'Resume' }];
440
450
  /** Reuse workflow (replaces P5 item 3's Start from this, hamr 2026-10-03): the same signed job on a new source — a NEW
441
- * run, never a rerun. Offered on GREEN rows only; every red row says "Change the job: Clear the card and draft a new one" instead. */
451
+ * run, never a rerun. Offered on GREEN rows only; every red row says "Change the job: press Edit in chat" instead. */
442
452
  const REUSE = [{ id: 'reuse', label: 'Reuse workflow' }];
443
- const CHANGE = 'Change the job: Clear the card and draft a new one.';
453
+ const CHANGE = 'Change the job: press Edit in chat.';
444
454
  const detailOf = (/** @type {string|null} */ s) => {
445
455
  if (typeof s !== 'string' || s.length === 0) return '';
446
456
  return s.length > 120 ? `${s.slice(0, 120)}…` : s;
447
457
  };
448
458
  /** a resumable ending whose Resume the engine would refuse: say so, never offer the button */
449
- const resumeOr = (/** @type {string} */ okNext) => (resumeOk ? okNext : `Resume is not available for this run${o.resume && o.resume.why ? ` (${o.resume.why})` : ''}.`);
459
+ const resumeOr = (/** @type {string} */ okNext) => (resumeOk ? okNext : `Resume is not available for this run${o.resume && o.resume.why ? ` (${o.resume.why})` : ''}. ${CHANGE}`);
450
460
 
451
461
  if (death.died) {
452
462
  return {
@@ -497,13 +507,20 @@ function endedBase(summary, death, o) {
497
507
 
498
508
  if (outcome === 'green' || outcome === 'already-green' || outcome === 'satisfied') {
499
509
  if (o.destinationRefused) {
510
+ // Reuse workflow only on a run that earned its green (hamr 2026-10-08, as below); the rest take Edit in chat
511
+ // (withEditAction adds it) and are told to change the job, not to reuse it
512
+ const earned = outcome === 'green';
500
513
  return {
501
514
  reason: 'Goal met, but the output could not be delivered.',
502
- next: 'Fix the destination, then Reuse workflow.',
515
+ next: earned ? 'Fix the destination, then Reuse workflow.' : CHANGE,
503
516
  line: `${GOAL_MET_LINE} — not delivered`,
504
- actions: REUSE,
517
+ actions: earned ? REUSE : [],
505
518
  };
506
519
  }
520
+ // hamr 2026-10-08: already-green (the check passed before any work) is not a reusable job — only a run that
521
+ // earned its green offers Reuse workflow; the rest take Edit in chat (withEditAction adds it). `satisfied` is a
522
+ // close VERDICT, never a run outcome (ralph and the job-end emitters write `green`), so it rides with already-green.
523
+ if (outcome !== 'green') return { reason: 'Goal met.', next: CHANGE, line: GOAL_MET_LINE, actions: [] };
507
524
  return { reason: 'Goal met.', next: 'Nothing to do.', line: GOAL_MET_LINE, actions: REUSE };
508
525
  }
509
526
  if (outcome === 'cap-halt') {
@@ -545,7 +562,7 @@ function endedBase(summary, death, o) {
545
562
  if (outcome === 'step-stalled') {
546
563
  return {
547
564
  reason: 'A step stopped making progress.',
548
- next: resumeOr('Resume, or change the job: Clear the card and draft a new one.'),
565
+ next: resumeOr('Resume, or change the job: press Edit in chat.'),
549
566
  line: resumeOk ? 'step stalled — resume' : 'step stalled',
550
567
  actions: resumeOk ? RESUME : [],
551
568
  };
@@ -2408,7 +2425,7 @@ export function reuseLine(record) {
2408
2425
  * to copy: change the job: Clear the card). `null` when the runid is not listed.
2409
2426
  * @param {string} runid
2410
2427
  * @param {{ home?: string, model?: string }} [opts] `model` = the Model box's current Name (the estimate counts only runs on that worker); absent = the card's own Model
2411
- * @returns {{ok: true, origin: {runid: string, job: string}, card: Record<string, any>, from: 'signed job',
2428
+ * @returns {{ok: true, origin: {runid: string, job: string}, outcome: string|null, card: Record<string, any>, from: 'signed job',
2412
2429
  * locked: readonly string[], open: readonly string[], note: string|null,
2413
2430
  * specHash: string, workflowKey: string, spec: any, specPath: string,
2414
2431
  * trackRecord: ReturnType<typeof trackRecordFor>, line: string}|{ok: false, error: string}|null}
@@ -2447,6 +2464,8 @@ export function getStartFrom(runid, opts = {}) {
2447
2464
  return {
2448
2465
  ok: true,
2449
2466
  origin: { runid, job: row.job },
2467
+ // the run's terminal outcome — the Reuse start (POST /api/author/start) refuses anything but a real green
2468
+ outcome: legsOf(records).at(-1)?.outcome ?? null,
2450
2469
  card,
2451
2470
  from: 'signed job',
2452
2471
  locked: REUSE_LOCKED_FIELDS,
@@ -2480,8 +2499,9 @@ export function listReuseJobs(opts = {}) {
2480
2499
  let records;
2481
2500
  try { records = parseJsonl(row.spine).records; } catch { continue; }
2482
2501
  const outcome = legsOf(records).at(-1)?.outcome ?? null;
2483
- // the same endings the Run tab's own Reuse button is offered on (`endedBase`)
2484
- if (outcome !== 'green' && outcome !== 'already-green' && outcome !== 'satisfied') continue;
2502
+ // only a REAL green (hamr 2026-10-08): `already-green` means the check passed before any work, so there is nothing to reuse
2503
+ // (src/reuse.js 'green-predates-run'). Narrower than the Run tab's Reuse button on purpose.
2504
+ if (outcome !== 'green') continue;
2485
2505
  const signed = signedSpecForRun(row, records);
2486
2506
  if (!signed) continue;
2487
2507
  const key = workflowKey(signed.spec);
@@ -2,7 +2,7 @@
2
2
  // hamr's ruling 2026-10-04 (option A): every surface (run cards, expanded run rows, the right-side header, imported
3
3
  // rows) reads `status` from the server; the page never maps an outcome to a word itself.
4
4
  //
5
- // [▶] running · [·] waiting · [✓] passed · [✗] failed · [✗] capped · [✗] stopped · [?] died
5
+ // [▶] running · [·] waiting · [✓] passed · [✗] failed · [✗] capped · [■] stopped · [?] died
6
6
  //
7
7
  // Outcome -> word (every outcome the panel knows; anything not listed is `failed`):
8
8
  // green, already-green, satisfied -> passed
@@ -15,6 +15,8 @@
15
15
  // and every other outcome (refusals, instrument stops) -> failed
16
16
  // `waiting` is the step-level sign for a step that has not started; no run outcome maps to it.
17
17
 
18
+ import { capKindOf } from '../legs.js';
19
+
18
20
  /** the sentence a passed run carries as its reason (the Ended block's `line`) */
19
21
  export const GOAL_MET_LINE = 'goal met';
20
22
 
@@ -27,7 +29,7 @@ export const STATUS = Object.freeze({
27
29
  passed: Object.freeze({ sign: '✓', word: 'passed' }),
28
30
  failed: Object.freeze({ sign: '✗', word: 'failed' }),
29
31
  capped: Object.freeze({ sign: '✗', word: 'capped' }),
30
- stopped: Object.freeze({ sign: '✗', word: 'stopped' }),
32
+ stopped: Object.freeze({ sign: '■', word: 'stopped' }),
31
33
  died: Object.freeze({ sign: '?', word: 'died' }),
32
34
  });
33
35
 
@@ -44,9 +46,8 @@ export function statusFor(o) {
44
46
  if (o.died) key = 'died';
45
47
  else if (out === null || out === undefined) key = 'running';
46
48
  else if (out === 'green' || out === 'already-green' || out === 'satisfied') key = 'passed';
47
- else if (out === 'cap-halt' || out === 'wall-halt') key = 'capped';
49
+ else if (capKindOf(o) !== null) key = 'capped';
48
50
  else if (out === 'stopped') key = 'stopped';
49
- else if (out === 'escalated' && ((o.category === 'cap-halt' && o.moneyHalt) || o.category === 'wall-halt')) key = 'capped';
50
51
  return { key, ...STATUS[key] };
51
52
  }
52
53
 
package/src/planrun.js CHANGED
@@ -4108,7 +4108,7 @@ export async function runPlan(job, { workdir, provider, nativeProvider, provider
4108
4108
  // is not passed — it survives only inside the governor, as the bound for the
4109
4109
  // case the trend instrument is blind.
4110
4110
  middle, judge: async () => judgeClose(), ladder: fixGovernor, emit: emitL, redact: scrub,
4111
- closeTimeoutMs, cwd: workdir, workerWrites: w.workerWrites, closeDir,
4111
+ closeTimeoutMs, cwd: workdir, workerWrites: w.workerWrites, closeDir, phase: 'fix',
4112
4112
  });
4113
4113
  } catch (e) {
4114
4114
  planExecuted();
package/src/ralph.js CHANGED
@@ -562,9 +562,13 @@ export const CLOSE_FAULTS = Object.freeze({
562
562
  * (PRD item 27/M3 Part B), forwarded to the raw `close` path's `runClose`
563
563
  * call unchanged; a caller supplying `judge` instead already closed over
564
564
  * its own `closeDir` and this param does nothing for it.
565
+ * @param {string} [opts.phase] the phase this loop runs in, when it is not the step loop
566
+ * (the fix loop passes `'fix'`): the person's-stop text then names it ("during the fix
567
+ * phase") instead of saying a resume re-enters "this step", which only the step loop has.
568
+ * Text only — it changes no category, outcome or option.
565
569
  * @returns {Promise<'green'|'escalated'>}
566
570
  */
567
- export async function ralph({ middle, close, judge, capRuns, ladder, emit, redact, closeTimeoutMs, cwd, expect, judged, gapKeep, workerWrites, closeDir }) {
571
+ export async function ralph({ middle, close, judge, capRuns, ladder, emit, redact, closeTimeoutMs, cwd, expect, judged, gapKeep, workerWrites, closeDir, phase }) {
568
572
  // PARAM GUARD (the BA-4 class — a caller's malformed argument, which is the one
569
573
  // thing this shell throws for; a WORKER's bad reach comes back as a refusal
570
574
  // RESULT instead, and that rule is about feedback, not about the API contract).
@@ -654,7 +658,9 @@ export async function ralph({ middle, close, judge, capRuns, ladder, emit, redac
654
658
  'revise the goal/spec so the work fits the time (same re-approval)',
655
659
  'abandon the task']],
656
660
  // P5 item 5 — the PERSON asked to stop at a round boundary: nothing failed, nothing is discarded.
657
- 'stopped': ['You stopped the run. Nothing is discarded: the work on disk stands, and a resume re-enters this step.',
661
+ 'stopped': [phase
662
+ ? `You stopped the run during the ${phase} phase. Nothing is discarded: the work on disk stands, and a resume continues the run.`
663
+ : 'You stopped the run. Nothing is discarded: the work on disk stands, and a resume re-enters this step.',
658
664
  ['resume (the same run continues)', 'abandon the task']],
659
665
  // F66 — the STALL WATCHDOG gave up: no round completed for the stall window,
660
666
  // three times over, and reissuing the call did not recover it. It borrows both
package/src/replay.js CHANGED
@@ -104,7 +104,7 @@
104
104
  import { runBehaviour, formatBehaviour } from './behaviour.js';
105
105
  import { shortSha } from './codeversion.js';
106
106
  import { SPEND_RECORD_TYPES, chainSpend } from './ledger.js';
107
- import { legsOf } from './legs.js';
107
+ import { legsOf, capKindOf } from './legs.js';
108
108
 
109
109
  /**
110
110
  * @param {unknown} e
@@ -234,6 +234,16 @@ const ALL_REASON_MAX = 60;
234
234
  * record fired) — printed literally, never blank, never a fabricated 0. */
235
235
  const NONE_RECORDED = 'none recorded';
236
236
 
237
+ /**
238
+ * Did this leg end on a cap? Reads the leg's own last escalation and `money-halt` record.
239
+ * @param {import('./legs.js').Leg} leg
240
+ * @returns {'money'|'time'|null}
241
+ */
242
+ function legCapKind(leg) {
243
+ const esc = leg.outcome === 'escalated' ? leg.records.findLast((r) => r && r.type === 'escalation') : null;
244
+ return capKindOf({ outcome: leg.outcome, category: esc?.category ?? null, moneyHalt: leg.records.some((r) => r && r.type === 'money-halt') });
245
+ }
246
+
237
247
  /**
238
248
  * `stopReason` for a non-green outcome: `category — decision — detail`, every
239
249
  * part present that the escalation actually carries (see the field-site
@@ -737,11 +747,21 @@ export function replayRun(spineEvents, auditEvents = [], { runId = null, auditAv
737
747
  // why this leg stopped the step (code-owned words); null for a step that ended itself or is still running
738
748
  /** @type {string|null} */
739
749
  let stopReason = null;
750
+ // a single-leg run has no leg boundary to cut a step, but a cap still ends it: its open step reads capped too
751
+ // (hamr 2026-10-08), from the same rule (`capKindOf`) the run's own status word uses
752
+ if (!end && myLeg === null) {
753
+ const only = legs[0];
754
+ const capKind = only ? legCapKind(only) : null;
755
+ if (capKind !== null) stopReason = capKind === 'money' ? 'money cap' : 'time cap';
756
+ }
740
757
  if (!end && myLeg !== null) {
741
758
  const o = myLeg.outcome;
742
759
  const isLastLeg = myLegIdx === legs.length - 1;
743
760
  if (cutByLeg || (isLastLeg && o !== null && o !== 'green' && o !== 'already-green')) {
744
- stopReason = o === 'cap-halt' ? 'money cap' : o === 'wall-halt' ? 'time cap' : o === 'provider-red' ? 'provider failed'
761
+ // hamr 2026-10-08: a leg that ended `escalated` on a money-halt / wall-halt reads as the cap, exactly as the
762
+ // run's own status word does (src/panel/status.js statusFor) — derived HERE so every surface follows
763
+ const capKind = legCapKind(myLeg);
764
+ stopReason = capKind === 'money' ? 'money cap' : capKind === 'time' ? 'time cap' : o === 'provider-red' ? 'provider failed'
745
765
  : o === 'step-stalled' ? 'step stalled' : o === 'stopped' ? 'you stopped it' : o === null ? 'died' : o;
746
766
  }
747
767
  }
@@ -832,7 +852,7 @@ export function replayRun(spineEvents, auditEvents = [], { runId = null, auditAv
832
852
  return {
833
853
  id,
834
854
  occurrence: idOccurrence.get(id),
835
- ...(myLeg === null ? {} : { stopReason, continued, tryNumber }),
855
+ ...(myLeg === null && stopReason === null ? {} : { stopReason, continued, tryNumber }),
836
856
  outcome: end ? (end.outcome ?? null) : null,
837
857
  ...buildOccurrenceMetrics(startSeq, startTs, endSeq, endTs, roundsInWindow),
838
858
  checks: { passed, failed },
package/types/legs.d.ts CHANGED
@@ -56,6 +56,18 @@ export function watchdogNotePath(spineFile: string, leg: number): string;
56
56
  * @returns {string}
57
57
  */
58
58
  export function stopFilePath(spineFile: string): string;
59
+ /**
60
+ * Did a run (or one leg) END on a cap? The ONE spelling of the rule every surface follows (hamr 2026-10-08): the
61
+ * outcome is `cap-halt` / `wall-halt`, or it is `escalated` and the last escalation's category names the cap —
62
+ * `wall-halt`, or `cap-halt` backed by a `money-halt` record (a strike-ladder `cap-halt` is not the money cap).
63
+ * @param {{outcome: string|null|undefined, category?: string|null, moneyHalt?: boolean}} o
64
+ * @returns {'money'|'time'|null}
65
+ */
66
+ export function capKindOf(o: {
67
+ outcome: string | null | undefined;
68
+ category?: string | null;
69
+ moneyHalt?: boolean;
70
+ }): "money" | "time" | null;
59
71
  /** The marker record's `type`. */
60
72
  export const LEG_RESUME: "leg-resume";
61
73
  export type Leg = {
@@ -1,5 +1,5 @@
1
1
  /**
2
- * `[✓]`/`[✗]`/`[▶]` — the ONLY vocabulary a result is ever rendered in
2
+ * `[✓]`/`[✗]`/`[■]`/`[▶]` — the ONLY vocabulary a result is ever rendered in
3
3
  * (auto-memory `ui-verdict-words.md`: never the words green/red/soft-green
4
4
  * anywhere in the page). `null` (no `job-end` reached — a killed-mid-run or
5
5
  * still-running spine) reads `▶` — the same "in progress / unresolved" glyph
@@ -7,9 +7,9 @@
7
7
  * running attempt and an archived spine that never reached its own end are
8
8
  * the same fact from this read-only side: no verdict has been recorded yet.
9
9
  * @param {string|null} outcome
10
- * @returns {'✓'|'✗'|'▶'}
10
+ * @returns {'✓'|'✗'|'■'|'▶'}
11
11
  */
12
- export function glyphForOutcome(outcome: string | null): "\u2713" | "\u2717" | "\u25B6";
12
+ export function glyphForOutcome(outcome: string | null): "\u2713" | "\u2717" | "\u25A0" | "\u25B6";
13
13
  /**
14
14
  * `deterministic` (green) / `rubric` (soft-green) / `unknown` (a class this
15
15
  * reader doesn't recognize) — the settled UI wording (PANEL-BUILD.md §6),
@@ -381,7 +381,7 @@ export function reuseLine(record: {
381
381
  * to copy: change the job: Clear the card). `null` when the runid is not listed.
382
382
  * @param {string} runid
383
383
  * @param {{ home?: string, model?: string }} [opts] `model` = the Model box's current Name (the estimate counts only runs on that worker); absent = the card's own Model
384
- * @returns {{ok: true, origin: {runid: string, job: string}, card: Record<string, any>, from: 'signed job',
384
+ * @returns {{ok: true, origin: {runid: string, job: string}, outcome: string|null, card: Record<string, any>, from: 'signed job',
385
385
  * locked: readonly string[], open: readonly string[], note: string|null,
386
386
  * specHash: string, workflowKey: string, spec: any, specPath: string,
387
387
  * trackRecord: ReturnType<typeof trackRecordFor>, line: string}|{ok: false, error: string}|null}
@@ -395,6 +395,7 @@ export function getStartFrom(runid: string, opts?: {
395
395
  runid: string;
396
396
  job: string;
397
397
  };
398
+ outcome: string | null;
398
399
  card: Record<string, any>;
399
400
  from: "signed job";
400
401
  locked: readonly string[];
@@ -60,7 +60,7 @@ export const STATUS: Readonly<{
60
60
  word: "capped";
61
61
  }>;
62
62
  stopped: Readonly<{
63
- sign: "✗";
63
+ sign: "■";
64
64
  word: "stopped";
65
65
  }>;
66
66
  died: Readonly<{
package/types/ralph.d.ts CHANGED
@@ -242,9 +242,13 @@ export function boundGap(s: string, keepPattern?: string): string;
242
242
  * (PRD item 27/M3 Part B), forwarded to the raw `close` path's `runClose`
243
243
  * call unchanged; a caller supplying `judge` instead already closed over
244
244
  * its own `closeDir` and this param does nothing for it.
245
+ * @param {string} [opts.phase] the phase this loop runs in, when it is not the step loop
246
+ * (the fix loop passes `'fix'`): the person's-stop text then names it ("during the fix
247
+ * phase") instead of saying a resume re-enters "this step", which only the step loop has.
248
+ * Text only — it changes no category, outcome or option.
245
249
  * @returns {Promise<'green'|'escalated'>}
246
250
  */
247
- export function ralph({ middle, close, judge, capRuns, ladder, emit, redact, closeTimeoutMs, cwd, expect, judged, gapKeep, workerWrites, closeDir }: {
251
+ export function ralph({ middle, close, judge, capRuns, ladder, emit, redact, closeTimeoutMs, cwd, expect, judged, gapKeep, workerWrites, closeDir, phase }: {
248
252
  middle: (iteration: number, gap?: string) => void | Promise<void>;
249
253
  close?: string[] | undefined;
250
254
  judge?: (() => Promise<{
@@ -270,6 +274,7 @@ export function ralph({ middle, close, judge, capRuns, ladder, emit, redact, clo
270
274
  gapKeep?: string | undefined;
271
275
  workerWrites?: (() => string[]) | undefined;
272
276
  closeDir?: string | null | undefined;
277
+ phase?: string | undefined;
273
278
  }): Promise<"green" | "escalated">;
274
279
  /**
275
280
  * What the close child must NOT be able to read (operator ruling, 2026-07-31: