bareloop 0.36.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.
@@ -53,7 +53,7 @@ import { rowsForHome, findRow, modelChoiceFor } from '../providerrows.js';
53
53
  import { resolveProvider } from '../providers.js';
54
54
  import { REUSE_LOCKED_FIELDS, REUSE_OPEN_FIELDS } from './authorsession.js';
55
55
  import { readResume, checkpointAgeGate, CHECKPOINT_OUTCOMES } from '../reuse.js';
56
- import { statusFor, GOAL_MET_LINE } from './status.js';
56
+ import { statusFor, partStatusFor, GOAL_MET_LINE } from './status.js';
57
57
  import { createSettingsRoutes } from './settingsroutes.js';
58
58
 
59
59
  const HERE = dirname(fileURLToPath(import.meta.url));
@@ -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
  };
@@ -897,7 +914,7 @@ function getRunDetailBody(runid, opts) {
897
914
  const timelineKind = summary.timelineKind;
898
915
  // the drafting part (see draftingFor) heads the parts list; the enrichment below touches the spine's own parts only
899
916
  const draftingPartHere = draftingFor(row, summary, opts);
900
- const draftingShiftHere = draftingPartHere ? 1 : 0;
917
+ const draftingShiftHere = draftingShift(row, summary, opts);
901
918
  const enrichedParts = enrichPartsWithStageKind(summary.parts, stageKindMetaFromSpec(resolveSpecForRow(row)));
902
919
  const death = deriveDeath(row, rawSpineRecords, summary.outcome);
903
920
  const { ended, resume, status } = endedForRow(row, rawSpineRecords, summary, death, true);
@@ -1117,7 +1134,9 @@ function getRunDetailBody(runid, opts) {
1117
1134
  // {@link enrichPartsWithStageKind}. `kindMeta` is `null` (every stage
1118
1135
  // passes through unchanged) whenever no spec resolves at all, so the two
1119
1136
  // tabs still never disagree about order/counts/blocked-call figures.
1120
- parts: draftingPartHere && Array.isArray(enrichedParts) ? [draftingPartHere, ...enrichedParts] : enrichedParts,
1137
+ // a run that DIED (no job-end, runner gone — `death`, decided once above) leaves its last part in flight: that part reads
1138
+ // `died`, never the "happened, so passed" default. Replay alone cannot tell died from still running, so the verdict lives here.
1139
+ parts: (draftingPartHere && Array.isArray(enrichedParts) ? [draftingPartHere, ...enrichedParts] : enrichedParts)?.map((p, i, all) => ({ ...p, status: partStatusFor(death.died && i === all.length - 1 && p.kind !== 'drafting' && p.outcome == null && !p.stopReason ? { ...p, stopReason: 'died' } : p) })),
1121
1140
  replans: summary.replans,
1122
1141
  close: summary.close,
1123
1142
  branch: summary.branch,
@@ -2406,7 +2425,7 @@ export function reuseLine(record) {
2406
2425
  * to copy: change the job: Clear the card). `null` when the runid is not listed.
2407
2426
  * @param {string} runid
2408
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
2409
- * @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',
2410
2429
  * locked: readonly string[], open: readonly string[], note: string|null,
2411
2430
  * specHash: string, workflowKey: string, spec: any, specPath: string,
2412
2431
  * trackRecord: ReturnType<typeof trackRecordFor>, line: string}|{ok: false, error: string}|null}
@@ -2445,6 +2464,8 @@ export function getStartFrom(runid, opts = {}) {
2445
2464
  return {
2446
2465
  ok: true,
2447
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,
2448
2469
  card,
2449
2470
  from: 'signed job',
2450
2471
  locked: REUSE_LOCKED_FIELDS,
@@ -2459,6 +2480,40 @@ export function getStartFrom(runid, opts = {}) {
2459
2480
  };
2460
2481
  }
2461
2482
 
2483
+ /**
2484
+ * The Reuse workflow picker's list: one entry per JOB (same-job identity = `workflowKey`, the one `getStartFrom` uses)
2485
+ * that has at least one GREEN local run, newest green first. `runid` = that job's newest green run, so the page hands
2486
+ * it to the existing start-from flow unchanged; `line` is `getStartFrom`'s own estimate line (never a second
2487
+ * spelling). Local runs only — an imported job keeps its Reuse button in the Import view. $0, read-only.
2488
+ * @param {{ home?: string, model?: string }} [opts] `model` passes through to the estimate line, as start-from's does
2489
+ * @returns {{job: string, runid: string, checkType: 'deterministic'|'rubric', line: string}[]}
2490
+ */
2491
+ export function listReuseJobs(opts = {}) {
2492
+ const { rows } = readRunList(opts);
2493
+ const seen = new Set();
2494
+ /** @type {{job: string, runid: string, checkType: 'deterministic'|'rubric', line: string}[]} */
2495
+ const jobs = [];
2496
+ for (let i = rows.length - 1; i >= 0; i -= 1) {
2497
+ const row = rows[i];
2498
+ if (!row || !existsSync(row.spine)) continue;
2499
+ let records;
2500
+ try { records = parseJsonl(row.spine).records; } catch { continue; }
2501
+ const outcome = legsOf(records).at(-1)?.outcome ?? null;
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;
2505
+ const signed = signedSpecForRun(row, records);
2506
+ if (!signed) continue;
2507
+ const key = workflowKey(signed.spec);
2508
+ if (seen.has(key)) continue;
2509
+ const pre = getStartFrom(row.runid, opts);
2510
+ if (!pre || !pre.ok) continue;
2511
+ seen.add(key);
2512
+ jobs.push({ job: pre.card.jobName, runid: row.runid, checkType: pre.card.checkType === 'rubric' ? 'rubric' : 'deterministic', line: pre.line });
2513
+ }
2514
+ return jobs;
2515
+ }
2516
+
2462
2517
  /**
2463
2518
  * The reuse card for a signed spec: locked boxes from the spec through the Job tab's own readers, open boxes from
2464
2519
  * the spec's own fence and caps (Source and the saved text from the caller — a spec carries neither).
@@ -2636,7 +2691,15 @@ export function handleRequest(req, res, opts) {
2636
2691
  /** @type {any} */
2637
2692
  let body = null;
2638
2693
  if (raw.length > 0) { try { body = JSON.parse(raw); } catch { body = null; } }
2639
- routes.handle(req, res, pathname, body);
2694
+ // this callback runs OUTSIDE createServer's try/catch (which covers only the synchronous GET path), so a
2695
+ // synchronous throw in a route handler (a full disk, a permission fault) would be an uncaught exception that
2696
+ // takes the whole panel down: answer 500 JSON instead and keep serving
2697
+ try {
2698
+ routes.handle(req, res, pathname, body);
2699
+ } catch (e) {
2700
+ if (res.headersSent) { res.end(); return; }
2701
+ sendJson(res, 500, { ok: false, error: `internal error: ${/** @type {Error} */ (e)?.message ?? String(e)}` });
2702
+ }
2640
2703
  });
2641
2704
  return;
2642
2705
  }
@@ -2801,6 +2864,7 @@ export function createPanelServer(opts = {}) {
2801
2864
  startFrom: {
2802
2865
  get: (runid, o = {}) => getStartFrom(runid, { home, ...o }),
2803
2866
  getImport: (id, o = {}) => getStartFromImport(id, { home, ...o }),
2867
+ listJobs: (o = {}) => listReuseJobs({ home, ...o }),
2804
2868
  },
2805
2869
  });
2806
2870
  settingsRoutes = createSettingsRoutes({
@@ -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,8 +46,29 @@ 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
  }
53
+
54
+ /**
55
+ * The word one PART of a run wears on the step map, the step cards and the Audit rows (hamr 2026-10-07: `[✓] passed` /
56
+ * `[✗] failed`, never "done"). Reads the same table; `running` is the page's call (it knows which part is live), so a
57
+ * part with no verdict and no cut-off is `passed` here.
58
+ * drafting, green/satisfied/already-green outcome, or a part that simply happened (scout/plan/replan/judge) -> passed
59
+ * a red outcome -> its run-outcome word (failed; a cap/stop outcome keeps capped/stopped)
60
+ * a part its leg's halt cut off (`stopReason`): 'money cap' / 'time cap' -> capped, 'you stopped it' -> stopped,
61
+ * 'died' -> died, anything else (provider failed, step stalled, a raw outcome) -> failed
62
+ * @param {{kind?: string, outcome?: string|null, stopReason?: string|null}} part
63
+ * @returns {{key: StatusKey, sign: string, word: string}}
64
+ */
65
+ export function partStatusFor(part) {
66
+ if (part.kind === 'drafting') return statusFor({ outcome: 'green' });
67
+ if (typeof part.outcome === 'string') return statusFor({ outcome: part.outcome });
68
+ const why = part.stopReason;
69
+ if (why) {
70
+ const key = why === 'money cap' || why === 'time cap' ? 'capped' : why === 'you stopped it' ? 'stopped' : why === 'died' ? 'died' : 'failed';
71
+ return { key, ...STATUS[key] };
72
+ }
73
+ return statusFor({ outcome: 'green' });
74
+ }
package/src/planrun.js CHANGED
@@ -970,10 +970,10 @@ ${scoutBlob || '(no scout notes)'}`;
970
970
  * the resume-plan-red option, the fix-loop terminal) say "resume is `run-u`-only
971
971
  * in v1" instead of naming a flag that would fail if typed.
972
972
  * @param {string|null} [opts.stopFile=null] P5 item 5 — the run's STOP REQUEST file
973
- * (`stopFilePath(spine)`, src/legs.js). Read at ONE seam, the round boundary of a step worker
974
- * (the `metered` callback, where the money cap binds): present = emit `stop-requested`, consume
973
+ * (`stopFilePath(spine)`, src/legs.js). Read at ONE seam, the round boundary of EVERY worker
974
+ * (scout, plan/replan drafter, step, close-fix: the `metered` callback, where the money cap binds): present = emit `stop-requested`, consume
975
975
  * the file, end the Loop after that round, and end the leg `stopped` (a checkpoint outcome, filed
976
- * the way a mid-step cap-halt is; resume re-enters that step). Nothing is in flight at that
976
+ * the way a mid-step cap-halt is; resume re-enters that step, or continues the phase). Nothing is in flight at that
977
977
  * point, so the spend is exact. `null` = no stop surface (the default).
978
978
  * @returns {Promise<string>} 'green' | 'already-green' | 'escalated' | 'plan-red' |
979
979
  * 'check-red' | 'close-red' | 'close-unsupported' | 'recipe-stale' | 'pricing-red' |
@@ -2745,11 +2745,12 @@ export async function runPlan(job, { workdir, provider, nativeProvider, provider
2745
2745
  // (nothing in flight, the round just metered). It is the ONE owner of the stop request. The file is
2746
2746
  // consumed here; `self.stop()` ends the Loop after this round exactly as a bound does; `ask`/`askFrom`
2747
2747
  // then throw category `stopped`, which the step loop files as the checkpoint outcome `stopped` the way a
2748
- // mid-step cap-halt is filed. Step workers only (scout/plan/fix never read it).
2749
- if (stopFile !== null && !stopHit && phase.startsWith('step:') && existsSync(stopFile)) {
2748
+ // mid-step cap-halt is filed. EVERY phase that runs rounds reads it here (scout, plan/replan drafter, step,
2749
+ // close-fix) — the one seam the money cap binds at; `relay` files the non-ralph phases' throw as `stopped`.
2750
+ if (stopFile !== null && !stopHit && existsSync(stopFile)) {
2750
2751
  try { unlinkSync(stopFile); } catch { /* already consumed */ }
2751
2752
  stopHit = true;
2752
- emit('stop-requested', { phase, step: phase.slice('step:'.length), iteration: roundIteration, round: roundsThisAttempt, meaning: 'the person asked to stop; the leg ends after this round and a resume re-enters this step' });
2753
+ emit('stop-requested', { phase, ...(phase.startsWith('step:') ? { step: phase.slice('step:'.length) } : {}), iteration: roundIteration, round: roundsThisAttempt, meaning: 'the person asked to stop; the leg ends after this round and a resume continues the run' });
2753
2754
  self.stop();
2754
2755
  } else if (clock.expired()) {
2755
2756
  attemptBounded = { iteration: roundIteration, cause: 'wall', reason: null };
@@ -2903,6 +2904,8 @@ export async function runPlan(job, { workdir, provider, nativeProvider, provider
2903
2904
  // instead and every such fault came back `provider-red` — a broken runner
2904
2905
  // reported as the provider's fault, and an upstream ask aimed at a library
2905
2906
  // for our own missing wiring (the step-stalled lesson).
2907
+ // P5 item 5 — the PERSON's stop, honoured at a round boundary outside a step (scout, plan drafting, replan): nothing failed
2908
+ 'stopped': [`You stopped the run during ${phase}. Nothing is discarded: the work on disk stands, and a resume continues the run.`, ['resume (the same run continues)', 'abandon the task']],
2906
2909
  'interpreter-red': [`The ${phase} phase could not be set up — the runner or a primitive it was handed is not correctly bound.`, ['fix the wiring the detail names', 'retry the run', 'abandon the run']],
2907
2910
  };
2908
2911
  const [decision, options] = (Object.hasOwn(DECIDE, category) ? DECIDE[category] : undefined)
@@ -2914,7 +2917,7 @@ export async function runPlan(job, { workdir, provider, nativeProvider, provider
2914
2917
  // `step-stalled` rides out as ITSELF, not as provider-red. Naming the
2915
2918
  // escalation while returning a casualty label would launder a governance stop
2916
2919
  // into transport noise at exactly the layer the readout reads.
2917
- return category === 'cap-halt' || category === 'wall-halt' || category === 'step-stalled' || category === 'interpreter-red'
2920
+ return category === 'cap-halt' || category === 'wall-halt' || category === 'step-stalled' || category === 'interpreter-red' || category === 'stopped'
2918
2921
  ? category : 'provider-red';
2919
2922
  };
2920
2923
 
@@ -4105,7 +4108,7 @@ export async function runPlan(job, { workdir, provider, nativeProvider, provider
4105
4108
  // is not passed — it survives only inside the governor, as the bound for the
4106
4109
  // case the trend instrument is blind.
4107
4110
  middle, judge: async () => judgeClose(), ladder: fixGovernor, emit: emitL, redact: scrub,
4108
- closeTimeoutMs, cwd: workdir, workerWrites: w.workerWrites, closeDir,
4111
+ closeTimeoutMs, cwd: workdir, workerWrites: w.workerWrites, closeDir, phase: 'fix',
4109
4112
  });
4110
4113
  } catch (e) {
4111
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/src/source.js CHANGED
@@ -1148,3 +1148,15 @@ export function frontDoorFromManifest(read) {
1148
1148
  if (typeof destination !== 'string' || !destination) return null;
1149
1149
  return { destination };
1150
1150
  }
1151
+
1152
+ /**
1153
+ * The $0 refusal for a non-repo source, worded by the kind the source door recorded (`prep.manifest.kind`) — a single
1154
+ * file is not a "folder", and Source must be the repo folder with the file named in Destination (hamr 2026-10-05).
1155
+ * @param {string} kind `prep.manifest.kind`: 'file' | 'folder' | 'url' (a 'repo' never reaches this)
1156
+ * @returns {string}
1157
+ */
1158
+ export function nonRepoSourceMessage(kind) {
1159
+ if (kind === 'file') return 'Source is a single file. Source must be the repo folder — put the file in Destination, like src/digest.js. Nothing spent.';
1160
+ if (kind === 'folder') return "Source is a plain folder, not a code project (no git repo found). bareloop can't check this kind of job yet. Nothing spent.";
1161
+ return "Source is not a code project (no git repo found). bareloop can't check this kind of job yet. Nothing spent.";
1162
+ }
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 = {
@@ -41,7 +41,7 @@ export function checkHumanGuard(req: import("node:http").IncomingMessage, { toke
41
41
  * @param {{ port: number, token: string, env?: Record<string,string|undefined>,
42
42
  * sessionsRoot?: string, spawnFn?: typeof realSpawn, bareloopBin?: string,
43
43
  * jobsDir?: string, fetchImpl?: typeof fetch, home?: string,
44
- * startFrom?: {get: (runid: string, o?: {model?: string}) => any, getImport: (id: string, o?: {model?: string}) => any} }} opts
44
+ * startFrom?: {get: (runid: string, o?: {model?: string}) => any, getImport: (id: string, o?: {model?: string}) => any, listJobs?: (o?: {model?: string}) => any[]} }} opts
45
45
  */
46
46
  export function createAuthorRoutes(opts: {
47
47
  port: number;
@@ -60,6 +60,9 @@ export function createAuthorRoutes(opts: {
60
60
  getImport: (id: string, o?: {
61
61
  model?: string;
62
62
  }) => any;
63
+ listJobs?: (o?: {
64
+ model?: string;
65
+ }) => any[];
63
66
  };
64
67
  }): {
65
68
  handle: (req: import("node:http").IncomingMessage, res: import("node:http").ServerResponse, pathname: string, body: any) => boolean;
@@ -67,13 +67,6 @@ export function validateReuseCard(card: any, opts?: {
67
67
  ok: false;
68
68
  error: string;
69
69
  };
70
- /**
71
- * The $0 refusal for a non-repo source, worded by the kind the source door recorded (`prep.manifest.kind`) — a single
72
- * file is not a "folder", and Source must be the repo folder with the file named in Destination (hamr 2026-10-05).
73
- * @param {string} kind `prep.manifest.kind`: 'file' | 'folder' | 'url' (a 'repo' never reaches this)
74
- * @returns {string}
75
- */
76
- export function nonRepoSourceMessage(kind: string): string;
77
70
  /**
78
71
  * The ONE Destination rule for a REPO source (fresh card and Reuse card alike): there Destination is the write fence,
79
72
  * relative to the repo, so an absolute path is refused — at $0, before any copy or model call. A FOLDER source (or a
@@ -153,9 +146,11 @@ export const CARD_FIELDS: readonly string[];
153
146
  export const REUSE_OPEN_FIELDS: readonly string[];
154
147
  /** every card field that is NOT open on a reuse card, in card order */
155
148
  export const REUSE_LOCKED_FIELDS: readonly string[];
149
+ export { nonRepoSourceMessage };
156
150
  export type StepLine = {
157
151
  id: string;
158
152
  label: string;
159
153
  status: "running" | "done" | "failed";
160
154
  detail: string;
161
155
  };
156
+ import { nonRepoSourceMessage } from '../source.js';
@@ -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[];
@@ -410,6 +411,23 @@ export function getStartFrom(runid: string, opts?: {
410
411
  ok: false;
411
412
  error: string;
412
413
  } | null;
414
+ /**
415
+ * The Reuse workflow picker's list: one entry per JOB (same-job identity = `workflowKey`, the one `getStartFrom` uses)
416
+ * that has at least one GREEN local run, newest green first. `runid` = that job's newest green run, so the page hands
417
+ * it to the existing start-from flow unchanged; `line` is `getStartFrom`'s own estimate line (never a second
418
+ * spelling). Local runs only — an imported job keeps its Reuse button in the Import view. $0, read-only.
419
+ * @param {{ home?: string, model?: string }} [opts] `model` passes through to the estimate line, as start-from's does
420
+ * @returns {{job: string, runid: string, checkType: 'deterministic'|'rubric', line: string}[]}
421
+ */
422
+ export function listReuseJobs(opts?: {
423
+ home?: string;
424
+ model?: string;
425
+ }): {
426
+ job: string;
427
+ runid: string;
428
+ checkType: "deterministic" | "rubric";
429
+ line: string;
430
+ }[];
413
431
  /**
414
432
  * Reuse workflow, for an IMPORTED job: the bundle is read AGAIN now and must be clean (`readBundle` — its own hash
415
433
  * covers every close script, so a swapped script is `bundle-tampered` here), must resolve its own `bareloop`
@@ -14,6 +14,26 @@ export function statusFor(o: {
14
14
  sign: string;
15
15
  word: string;
16
16
  };
17
+ /**
18
+ * The word one PART of a run wears on the step map, the step cards and the Audit rows (hamr 2026-10-07: `[✓] passed` /
19
+ * `[✗] failed`, never "done"). Reads the same table; `running` is the page's call (it knows which part is live), so a
20
+ * part with no verdict and no cut-off is `passed` here.
21
+ * drafting, green/satisfied/already-green outcome, or a part that simply happened (scout/plan/replan/judge) -> passed
22
+ * a red outcome -> its run-outcome word (failed; a cap/stop outcome keeps capped/stopped)
23
+ * a part its leg's halt cut off (`stopReason`): 'money cap' / 'time cap' -> capped, 'you stopped it' -> stopped,
24
+ * 'died' -> died, anything else (provider failed, step stalled, a raw outcome) -> failed
25
+ * @param {{kind?: string, outcome?: string|null, stopReason?: string|null}} part
26
+ * @returns {{key: StatusKey, sign: string, word: string}}
27
+ */
28
+ export function partStatusFor(part: {
29
+ kind?: string;
30
+ outcome?: string | null;
31
+ stopReason?: string | null;
32
+ }): {
33
+ key: StatusKey;
34
+ sign: string;
35
+ word: string;
36
+ };
17
37
  /** the sentence a passed run carries as its reason (the Ended block's `line`) */
18
38
  export const GOAL_MET_LINE: "goal met";
19
39
  /** @typedef {'running'|'waiting'|'passed'|'failed'|'capped'|'stopped'|'died'} StatusKey */
@@ -40,7 +60,7 @@ export const STATUS: Readonly<{
40
60
  word: "capped";
41
61
  }>;
42
62
  stopped: Readonly<{
43
- sign: "✗";
63
+ sign: "■";
44
64
  word: "stopped";
45
65
  }>;
46
66
  died: Readonly<{
@@ -397,10 +397,10 @@ export function planPrompt(job: any, scoutBlob: string, reds: any[] | null, maxS
397
397
  * the resume-plan-red option, the fix-loop terminal) say "resume is `run-u`-only
398
398
  * in v1" instead of naming a flag that would fail if typed.
399
399
  * @param {string|null} [opts.stopFile=null] P5 item 5 — the run's STOP REQUEST file
400
- * (`stopFilePath(spine)`, src/legs.js). Read at ONE seam, the round boundary of a step worker
401
- * (the `metered` callback, where the money cap binds): present = emit `stop-requested`, consume
400
+ * (`stopFilePath(spine)`, src/legs.js). Read at ONE seam, the round boundary of EVERY worker
401
+ * (scout, plan/replan drafter, step, close-fix: the `metered` callback, where the money cap binds): present = emit `stop-requested`, consume
402
402
  * the file, end the Loop after that round, and end the leg `stopped` (a checkpoint outcome, filed
403
- * the way a mid-step cap-halt is; resume re-enters that step). Nothing is in flight at that
403
+ * the way a mid-step cap-halt is; resume re-enters that step, or continues the phase). Nothing is in flight at that
404
404
  * point, so the spend is exact. `null` = no stop surface (the default).
405
405
  * @returns {Promise<string>} 'green' | 'already-green' | 'escalated' | 'plan-red' |
406
406
  * 'check-red' | 'close-red' | 'close-unsupported' | 'recipe-stale' | 'pricing-red' |
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: