bareloop 0.36.0 → 0.37.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,29 @@ 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.37.0] - 2026-10-07
8
+
9
+ ### Added
10
+
11
+ - **Reuse workflow picker.** The panel's Check type is one radio group of three, the third being Reuse workflow: a search list of jobs that have a green local run. Picking one goes through the existing reuse door (change and leave-reuse included); the Start button is gated on a pick. The origin line is retired. Backed by `GET /api/author/reuse-jobs`, one entry per job with its newest green run id and the same estimate line "Start from this" shows.
12
+
13
+ ### Fixed
14
+
15
+ - **Stop is honoured at every round seam.** The stop request was read only in step rounds; the scout, plan and close-fix rounds ignored it while the page said "stopping". It is now read at each of them (found on run mup3h70u).
16
+ - A throwing panel POST handler answers a 500 JSON error instead of killing the panel.
17
+ - Settings: a price row with exactly one of the two boxes filled saves nothing in that row and says "Fill both price boxes or clear both."
18
+ - A died run's in-flight part on the Run map reads `[?] died`, not passed.
19
+ - Panel status wording comes from the one status table: a step box reads `[sign] word` (passed / failed / capped / stopped / died), never "done"; step cards and Audit rows show the word only (the attempt glyph is their sign).
20
+ - The drafting progress line reads "drafting (retry N of M)" on a drafting call after the first.
21
+ - The CLI `run-author` non-repo refusal uses the same message as the panel (`nonRepoSourceMessage`, now in `src/source.js`).
22
+ - A `tree-changed` close detail on a single-file write scope names the file, not the glob path.
23
+ - The Providers table row's closing `</tr>` is no longer missing.
24
+
25
+ ### Changed
26
+
27
+ - Refactors with unchanged output: `getRunDetailBody` takes its drafting shift from `draftingShift` (one owner); the dead `orphanCalls` accumulator in `readDraftSteps` is dropped.
28
+ - Tests: page-extraction pins follow the picker; the drafting part is pinned on a running panel-authored run; the resumed already-green worktree path is pinned (it held, no fix needed). Docs: PRD addenda pointer to v1.89; `bareloop.context.md` and docs sweeps.
29
+
7
30
 
8
31
  ## [0.36.0] - 2026-10-06
9
32
 
@@ -1118,7 +1118,8 @@ prefix, F212: a readdir on a file used to read as an empty tree forever). `evalE
1118
1118
  and never short-circuits — the result names EVERY failing wall (`{ pass, results }`,
1119
1119
  each result `{ type, pass, detail?, fault? }`). `tree-changed` reads OUTCOME (bytes vs
1120
1120
  the snapshot): an identical re-write is NOT a change (F43) and git status is never
1121
- consulted (F45). `artifact-written` rejects zero-byte files. `check-passes` delegates
1121
+ consulted (F45). A single-file scope's pass detail names the file (`src/x.js changed`); a folder scope keeps
1122
+ `N file(s) changed under <scope>`. `artifact-written` rejects zero-byte files. `check-passes` delegates
1122
1123
  through the `runCheck` seam (the runner wires runClose); an unwired or crashed seam
1123
1124
  fails CLOSED with `fault` carrying a runClose verdict name — an instrument fault
1124
1125
  escalates through `CLOSE_FAULTS`, never masquerades as worker feedback. Failing details are
@@ -3386,7 +3387,8 @@ key you want first (the panel picks the row by the Model menu).
3386
3387
  GET/HEAD answers only when its `Host` header is the panel's own `127.0.0.1:<port>` (`checkHostGuard`,
3387
3388
  `src/panel/authorroutes.js`; any other Host is `403`, no token needed to read). Every
3388
3389
  P1 endpoint is GET/HEAD only (anything else — including every write verb — is `405`, outside the
3389
- two human-click-guarded write families `/api/author/*` and `/api/settings/*` below); a URL
3390
+ two human-click-guarded write families `/api/author/*` and `/api/settings/*` below; a POST handler that throws answers `500
3391
+ {ok:false,error}` and the panel keeps serving); a URL
3390
3392
  never joins a path segment into a filesystem read — a runid is looked up in the run list
3391
3393
  first (`RUNID_RE`, `src/panel/server.js` — accepts a `~2`-style backfill-disambiguated
3392
3394
  suffix too, and every endpoint echoes back the LISTED runid, never a filename-derived one
@@ -3508,6 +3510,14 @@ key you want first (the panel picks the row by the Model menu).
3508
3510
  `job-end` timestamp, null while live or died). `GET /api/imports` rows and `GET /api/imports/:id` carry `runStatus`
3509
3511
  (passed, or null when the bundle shows no green) and `runReason` (`goal met`). The Ended block's lines for died and
3510
3512
  stopped runs are `no ending recorded` and `you pressed Stop` (each `— resume` when Resume is offered).
3513
+ Every PART of a run detail (`parts[]`) also carries its own `status` `{key, sign, word}` from the same table
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),
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
+ and no cut-off, the server marks it `died` (`[?] died`), never the "happened, so passed" default — replay alone cannot tell
3518
+ died from still running, so `getRunDetail` decides it from the same `death` verdict as the run's status. A live run's open
3519
+ part stays running; a part that finished before the death keeps its own status. The page's map box reads `[sign] word` (the live part reads `[▶] running`); the step cards and Audit rows
3520
+ show the word only; no surface says "done".
3511
3521
  **Resume (item 2)** → `POST /api/runs/:runid/resume` (`src/panel/runroutes.js`), behind
3512
3522
  `checkHumanGuard` (token + own address, like `/api/author/*`); body `{budgetUsd?, maxWallMin?}`
3513
3523
  (blank = the signed caps). `404` unknown run; `409` when `resumePlanFor` says the engine would
@@ -3530,26 +3540,30 @@ key you want first (the panel picks the row by the Model menu).
3530
3540
  spelling), and signals nothing. `404` unknown run; `409` when the run's LATEST leg is not live
3531
3541
  (`runIsAlive`) or its spine does not exist yet (still starting). The ENGINE half is library code, so
3532
3542
  CLI runs honour the file too (no new CLI command): `runJob`/`runPlan` take `stopFile`, and `run-u`/the
3533
- panel's spawn pass `<spine>.stop`. The seam is the ROUND BOUNDARY of a step worker — where the money cap binds
3543
+ panel's spawn pass `<spine>.stop`. The seam is the ROUND BOUNDARY of EVERY worker (scout, plan/replan drafter, step,
3544
+ close-fix) — where the money cap binds
3534
3545
  (`src/planrun.js`, the `metered` round callback): file present = delete it, emit `stop-requested
3535
- {phase, step, iteration, round}`, end the Loop after that round; `ask` then throws category `stopped`
3536
- and the step loop files it as job-end outcome `stopped` the way a mid-step cap-halt is filed
3546
+ {phase, step?, iteration, round}` (`step` only on a step phase), end the Loop after that round; `ask` then throws category `stopped`
3547
+ and the step loop files it as job-end outcome `stopped` the way a mid-step cap-halt is filed (the scout, plan, replan and
3548
+ close-fix phases file it through `relay`, which returns `stopped` as itself, never `provider-red`)
3537
3549
  (`spendComplete` exact — nothing is in flight at a round boundary). One owner, one check: a one-step run
3538
- and a long step both stop within one turn. Resume re-enters that step. The request file is cleared three times: when honoured, when the
3550
+ and a long step both stop within one turn. Resume re-enters that step, or continues the phase. The request file is cleared three times: when honoured, when the
3539
3551
  leg ends (`finally`), and at the start of the next leg (a click that raced a leg's end never stops a
3540
3552
  resume). `stopped` is in `CHECKPOINT_OUTCOMES`: resumable like a `cap-halt`, the same run continues as a
3541
3553
  new leg at the next step. `GET /api/runs/:runid` carries `live` and `stopping` (a stop request is on
3542
3554
  disk and the leg is live); Ended row `stopped` = "You stopped it." / "Resume." / `[Resume]`, card line
3543
3555
  `stopped — resume`, Audit divider `stopped: you stopped it · resumed <when>`. The page shows `[Stop]`
3544
3556
  in the Run tab's action area while live, "stopping after this turn…" after the click, and the same
3545
- `[Resume]` there once the engine would accept one. The seam reads the file in STEP workers only
3546
- (`phase` `step:<id>`): the scout, plan and close-fix rounds never read it, so a Stop clicked while the run
3547
- is in one of those phases is not honoured until a step round runs again, and is otherwise cleared when
3548
- the leg ends — the money and time caps still bind throughout.
3557
+ `[Resume]` there once the engine would accept one. A Stop clicked while the run is in the
3558
+ 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).
3549
3560
  **Reuse workflow (replaces P5 item 3's Start from this, 2026-10-03)** — a button on GREEN runs only (the Run tab's
3550
3561
  action row and the Ended block; `ended.actions` carries `{id:'reuse', label:'Reuse workflow'}` on green and
3551
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
3552
- stopped/capped/died rows offer `resume` only) and on every imported job. It opens the Chat tab's New job card
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
3564
+ (Deterministic · Rubric · Reuse workflow), and the third opens a search picker over `GET /api/author/reuse-jobs` (below) that
3565
+ fills the card through this same path (a pick, then `change` returns to the empty search; Deterministic or Rubric leaves reuse).
3566
+ It opens the Chat tab's New job card
3553
3567
  filled from the SIGNED job: a NEW run (new runid), never a rerun, nothing drafted ($0). **Only five boxes are
3554
3568
  open — Source, Destination, Model, $ cap, Time cap**; every other box (check type, model, job name, goal, success,
3555
3569
  guardrails, judge examples) is the signed workflow, greyed, and the SERVER refuses a start that changed one
@@ -3561,7 +3575,10 @@ key you want first (the panel picks the row by the Model menu).
3561
3575
  there is nothing to copy: no log, the run's signed job is not on disk, or an imported job that changed, lost its
3562
3576
  dependency or no longer matches its signed bytes); `POST /api/author/start` accepts `startFrom: <runid>` or
3563
3577
  `startFrom: {importId}` beside the card and answers `{ok, sessionId, reuse, state}`. The old
3564
- `POST /api/author/start-from-check` and the "Changed — new job" mode are gone. **Identity, two keys:** the
3578
+ `POST /api/author/start-from-check` and the "Changed — new job" mode are gone. `GET /api/author/reuse-jobs[?model=<Name>]` →
3579
+ `{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` =
3581
+ start-from's own estimate line, newest green first; imported jobs are not listed. **Identity, two keys:** the
3565
3582
  signature hash `jobSpecHash` is unchanged (it covers the caps and the write fence — what the person signs);
3566
3583
  `workflowKey(spec)` (`src/job.js`, not exported from the package root) is a sha256 over the signed spec WITHOUT
3567
3584
  `source`, `destination`, `writeScope`, `budgetUsd` and `maxWallMs` (`REUSE_OPEN_SPEC_FIELDS`) AND without `provider`,
@@ -3587,7 +3604,8 @@ key you want first (the panel picks the row by the Model menu).
3587
3604
  is still written beside `resolved-spec.json` when a session reaches `prepared`.
3588
3605
  **The progress list (2026-10-04)** — `GET /api/author/:id` state carries `steps: [{id, label, status:
3589
3606
  'running'|'done'|'failed', detail}]`, one entry per pipeline step in first-seen order, updated in place (ids/labels:
3590
- `STEP_LABELS`, `src/panel/authorsession.js`); `progressLabel` is the running step's label. A refusal sets
3607
+ `STEP_LABELS`, `src/panel/authorsession.js`); `progressLabel` is the running step's label. A drafting call after the first
3608
+ relabels the ONE drafting line `drafting (retry N of M)` (same line, never a second one). A refusal sets
3591
3609
  `state.error` and fails exactly one step with the same text as its `detail`; pipeline text is never a chat message
3592
3610
  (`messages` holds person/model turns only). Two last steps, `hash` ("generating hash", detail `spec hash <full hash>`, done at sign-prepare) and `signed` ("signed hash", pushed by `signRun` when the sign is accepted); the old "SIGNING PREPARED" / "signed — … run starting detached" thread bubbles are gone. The page draws each step's `detail` on its own `> ` line under the step. Destination follows the Source: for a repo source it is the write
3593
3611
  fence (relative `writeScope` globs, never proven as a directory); for a folder source an absolute directory. Both
@@ -3595,7 +3613,7 @@ key you want first (the panel picks the row by the Model menu).
3595
3613
  Destination on a repo source → `Destination must be a path inside the repo, like src/digest.js` (before the
3596
3614
  copy); a source that is not a git repo → a plain refusal worded by the kind the source door recorded (a single
3597
3615
  file: "Source must be the repo folder — put the file in Destination"; a plain folder or a URL: "not a code
3598
- project (no git repo found)").
3616
+ project (no git repo found)") — `nonRepoSourceMessage` (`src/source.js`), the one spelling the CLI `run-author` uses too.
3599
3617
  **Import, read only (item 4)** — `src/panel/importroutes.js`. The Workflows toolbar's `[Import]` opens a folder
3600
3618
  browser plus a paste box; an imported job is an exported bundle folder the person can LOOK at: nothing runs,
3601
3619
  nothing is signed, the folder is never written. Routes, all behind `checkHumanGuard` (token + own address —
@@ -3858,7 +3876,8 @@ URL** (blank = the shape's own host), **Test**, **Tokens used** (total, display
3858
3876
  (DeepSeek fetched, Anthropic a typed note). Saves on change to `config.json`
3859
3877
  `keys.<ENV NAME> = { name, shape, baseUrl, priceInPerM?, priceOutPerM? }`. The last two columns are the customer's own price
3860
3878
  (**In $/1M**, **Out $/1M**, plain boxes; blank = not set, a number >= 0 otherwise; the server refuses a half-set pair with a plain
3861
- error and saves nothing, and the page leaves a half-typed pair out of the save until both boxes are filled or both cleared).
3879
+ error and saves nothing, and the page saves NOTHING in a row with exactly one price box filled, showing "Fill both price boxes
3880
+ or clear both." when the person leaves that box, until both boxes are filled or both cleared).
3862
3881
  **Open keys folder** (`POST /api/settings/open-keys-folder`) opens `~/.config/bareloop` in the file manager (`xdg-open` / `open`,
3863
3882
  argv array, never the file itself); where it cannot, the page shows the path as text. Defaults for a key with no saved entry:
3864
3883
  `ANTHROPIC_API_KEY` claude-sonnet-5 / Anthropic; `DEEPSEEK_API_KEY` deepseek-flash /
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bareloop",
3
- "version": "0.36.0",
3
+ "version": "0.37.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/authorrun.js CHANGED
@@ -124,7 +124,7 @@ import { moveFile } from './movefile.js';
124
124
  import { closeJudges, GATE_AUDIT_FILE } from './kinds.js';
125
125
  import { resolveProvider, buildRunnerProviders, apiKeyProblem } from './providers.js';
126
126
  import { commandFor } from './invoke.js';
127
- import { readSourceManifest, missingDependencies } from './source.js';
127
+ import { readSourceManifest, missingDependencies, nonRepoSourceMessage } from './source.js';
128
128
  import { tallyCalls } from './text.js';
129
129
  import {
130
130
  declarationLines, rubricLines, calibrationLines, parseCeiling, ceilingLine, crashRecord, phaseLine,
@@ -836,8 +836,7 @@ export async function main(argv, deps = {}) {
836
836
  // confirm turn, no model call — a plain folder's spine is exactly
837
837
  // `author-start` → `author-end{outcome:'not-authored', stop:'non-code-source'}`.
838
838
  if (!IS_REPO_SOURCE) {
839
- const message = "This is a plain folder, not a code project. bareloop can't check this kind of "
840
- + 'job yet. Nothing was spent. Your source was not changed.';
839
+ const message = nonRepoSourceMessage(manifestRead.manifest.kind);
841
840
  out(`\n${message}`);
842
841
  const red = {
843
842
  code: 'request-red', path: 'source', verb: 'non-code-source', lib: 'bareloop',
package/src/draftspend.js CHANGED
@@ -136,8 +136,6 @@ export function readDraftSteps(dir) {
136
136
  try { text = readFileSync(join(dir, DRAFT_LOG_FILE), 'utf8'); } catch { return []; }
137
137
  /** @type {Map<number, any>} */
138
138
  const byNo = new Map();
139
- /** @type {any[]} */
140
- const orphanCalls = [];
141
139
  for (const line of text.split('\n')) {
142
140
  if (!line.trim()) continue;
143
141
  let ev;
@@ -156,7 +154,7 @@ export function readDraftSteps(dir) {
156
154
  x.endedAt = null;
157
155
  } else if (ev.kind === 'call') {
158
156
  const c = { label: String(ev.label), model: typeof ev.model === 'string' ? ev.model : null, costUsd: typeof ev.costUsd === 'number' ? ev.costUsd : null, unpricedRounds: Number(ev.unpricedRounds) || 0, at: String(ev.at) };
159
- (byNo.get(ev.no)?.calls ?? orphanCalls).push(c);
157
+ byNo.get(ev.no)?.calls.push(c);
160
158
  }
161
159
  }
162
160
  const steps = [...byNo.values()].sort((a, b) => a.no - b.no);
package/src/exits.js CHANGED
@@ -145,6 +145,9 @@ async function evalOne(e, { dir, snapshot, runCheck }) {
145
145
  if (changed === 0) {
146
146
  return { type: e.type, pass: false, detail: `0 files changed under ${e.scope} — the tree is byte-identical to the step start (an identical re-write is not a change)` };
147
147
  }
148
+ // a file-valued writeScope (`src/x.js/**`) snapshots that one file keyed by the prefix itself: name the file,
149
+ // never the glob path that does not exist on disk
150
+ if (snapshot.has(prefix) || now.has(prefix)) return { type: e.type, pass: true, detail: `${prefix} changed` };
148
151
  return { type: e.type, pass: true, detail: `${changed} file(s) changed under ${e.scope}` };
149
152
  }
150
153
 
@@ -91,7 +91,7 @@ const TERMINAL_PHASES = new Set(['refused', 'abandoned', 'error', 'signed', 'sig
91
91
  * @param {{ port: number, token: string, env?: Record<string,string|undefined>,
92
92
  * sessionsRoot?: string, spawnFn?: typeof realSpawn, bareloopBin?: string,
93
93
  * jobsDir?: string, fetchImpl?: typeof fetch, home?: string,
94
- * startFrom?: {get: (runid: string, o?: {model?: string}) => any, getImport: (id: string, o?: {model?: string}) => any} }} opts
94
+ * startFrom?: {get: (runid: string, o?: {model?: string}) => any, getImport: (id: string, o?: {model?: string}) => any, listJobs?: (o?: {model?: string}) => any[]} }} opts
95
95
  */
96
96
  export function createAuthorRoutes(opts) {
97
97
  // the RAW env with the keys file re-merged on every use, so an edited file takes effect
@@ -217,6 +217,17 @@ export function createAuthorRoutes(opts) {
217
217
  return true;
218
218
  }
219
219
 
220
+ // The Reuse workflow picker's list: one entry per job with a green local run (newest green's runid + the SAME
221
+ // estimate line start-from returns). $0, read-only; `?model=` passes through exactly as start-from's does.
222
+ if (pathname === '/api/author/reuse-jobs') {
223
+ if (!opts.startFrom || !opts.startFrom.listJobs) { send(404, { ok: false, error: 'not found' }); return true; }
224
+ if (req.method !== 'GET') { send(405, { ok: false, error: 'GET only' }); return true; }
225
+ const sp = new URL(/** @type {string} */ (req.url), 'http://127.0.0.1').searchParams;
226
+ const asModel = sp.has('model') ? { model: sp.get('model') ?? '' } : {};
227
+ send(200, { ok: true, jobs: opts.startFrom.listJobs(asModel) });
228
+ return true;
229
+ }
230
+
220
231
  // A refreshed page re-attaches to the one live (non-terminal) session: its id and state (card, messages, steps),
221
232
  // or `null`. The server's session map is the only truth; nothing is stored page-side.
222
233
  if (pathname === '/api/author/live') {
@@ -27,7 +27,7 @@ import {
27
27
  } from 'node:fs';
28
28
  import { join, resolve, dirname } from 'node:path';
29
29
  import {
30
- prepareSource, proveDestination, missingDependencies, looksLikeRepoSource, nearestGitAncestor,
30
+ prepareSource, proveDestination, missingDependencies, looksLikeRepoSource, nearestGitAncestor, nonRepoSourceMessage,
31
31
  } from '../source.js';
32
32
  import {
33
33
  worktreePath, uncommittedCount, shortHead, removeWorktree,
@@ -258,17 +258,7 @@ export function validateReuseCard(card, opts = {}) {
258
258
  /** A Destination written as an absolute path (POSIX or a Windows drive path). @param {string} destination @returns {boolean} */
259
259
  const isAbsoluteDestination = (destination) => /^(\/|[a-zA-Z]:[\\/])/.test(destination);
260
260
 
261
- /**
262
- * The $0 refusal for a non-repo source, worded by the kind the source door recorded (`prep.manifest.kind`) — a single
263
- * file is not a "folder", and Source must be the repo folder with the file named in Destination (hamr 2026-10-05).
264
- * @param {string} kind `prep.manifest.kind`: 'file' | 'folder' | 'url' (a 'repo' never reaches this)
265
- * @returns {string}
266
- */
267
- export function nonRepoSourceMessage(kind) {
268
- 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.';
269
- 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.";
270
- return "Source is not a code project (no git repo found). bareloop can't check this kind of job yet. Nothing spent.";
271
- }
261
+ export { nonRepoSourceMessage };
272
262
 
273
263
  /**
274
264
  * The ONE Destination rule for a REPO source (fresh card and Reuse card alike): there Destination is the write fence,
@@ -491,6 +481,13 @@ export function createSession(card, deps = {}) {
491
481
  // person needs to read; the step-by-step detail lives in one line.
492
482
  // ONE owner of the drafting line's detail (model + cap): the drafting line is started here, by the real author phase
493
483
  if (PHASE_STEP[name]) stepStart(PHASE_STEP[name], PHASE_STEP[name] === 'draft' ? `${card.model}, $${card.capUsd.toFixed(2)} cap` : undefined);
484
+ // a drafting call after the first (`author-call` i >= 1, of up to `of + 1`) is a RETRY: the line says so in plain words, so a
485
+ // long silence on call 2 of 3 does not read as a hang. The same one line, relabelled — never a second line.
486
+ if (name === 'author-call' && Number.isInteger(data.i) && data.i >= 1 && Number.isInteger(data.of)) {
487
+ const label = `${STEP_LABELS.draft} (retry ${data.i + 1} of ${data.of + 1})`;
488
+ stepLabel('draft', label);
489
+ state.progressLabel = label;
490
+ }
494
491
  };
495
492
  // ABANDON (hamr's ruling 2026-10-05): once abandoned the phase is frozen at 'abandoned' — whatever the in-flight
496
493
  // run() does afterwards (a late refuse, a phase write) never overwrites it, so the one-at-a-time lock stays released.
@@ -272,10 +272,21 @@
272
272
  .import-view .imp-row{display:flex;gap:10px;flex-wrap:wrap;align-items:baseline;}
273
273
  .import-view .imp-label{color:var(--text-faint);font-size:11px;letter-spacing:0.08em;min-width:84px;}
274
274
  .import-view .imp-text{flex:1 1 220px;min-width:0;overflow-wrap:anywhere;}
275
- .startfrom-line{margin:2px 0 8px;font-size:12px;color:var(--accent);overflow-wrap:anywhere;}
275
+ .startfrom-line{margin:4px 0 0;font-size:11px;color:var(--text-dim);overflow-wrap:anywhere;}
276
276
  .job-card .locked{opacity:.55;cursor:not-allowed;}
277
- .job-card.reuse .radio-row label{opacity:.55;cursor:not-allowed;}
278
- .startfrom-origin{font-size:12px;font-weight:600;color:var(--text-dim);margin:0 0 4px;padding-right:112px;overflow-wrap:anywhere;}
277
+ /* Reuse workflow picker (hamr 2026-10-06): the third Check type radio; search box + list, then the picked box */
278
+ .radio-row label{white-space:nowrap;}
279
+ .rw-box{position:relative;margin-top:6px;}
280
+ .rw-box input{width:100%;box-sizing:border-box;}
281
+ .rw-list{list-style:none;margin:0;padding:0;border:1px solid var(--border-strong);border-top:none;background:var(--bg);max-height:220px;overflow:auto;}
282
+ .rw-list li{padding:7px 8px;border-bottom:1px dotted var(--border);cursor:pointer;}
283
+ .rw-list li:hover,.rw-list li.hl{background:var(--panel2);}
284
+ .rw-name{font-weight:700;overflow-wrap:anywhere;} .rw-name mark{background:none;color:var(--accent);text-decoration:underline;}
285
+ .rw-meta{font-size:11px;color:var(--text-dim);margin-top:2px;overflow-wrap:anywhere;}
286
+ .rw-empty{padding:7px 8px;font-size:12px;color:var(--text-dim);}
287
+ .rw-picked{display:flex;justify-content:space-between;align-items:center;gap:8px;margin-top:6px;padding:6px 8px;border:1px solid var(--accent);}
288
+ .rw-picked span{overflow-wrap:anywhere;}
289
+ .rw-picked a{font-size:12px;cursor:pointer;flex:none;}
279
290
  .run-actions{display:flex;gap:10px;align-items:center;flex-wrap:wrap;margin-bottom:12px;}
280
291
  .run-actions[hidden]{display:none;}
281
292
  .run-actions .stopping{color:var(--amber);font-size:13px;overflow-wrap:anywhere;}
@@ -519,10 +530,8 @@
519
530
  <!-- hamr 2026-10-05: the empty card is ALWAYS there when Chat opens (no + New). The top-right button reads
520
531
  "Abandon" while a session is live and unsigned, else "Clear"; both leave an empty card. -->
521
532
  <div class="job-card compact" id="job-card" data-testid="job-card">
522
- <div class="startfrom-origin" id="chat-startfrom-origin" data-testid="startfrom-origin" hidden></div>
523
533
  <h4 id="job-card-title">Job card — draft</h4>
524
534
  <button class="btn card-clear" type="button" id="chat-clear-btn" data-testid="chat-clear">Clear</button>
525
- <div class="startfrom-line" id="chat-startfrom-line" data-testid="startfrom-line" hidden></div>
526
535
  <div class="hint" id="chat-startfrom-note" data-testid="startfrom-note" hidden></div>
527
536
 
528
537
  <div class="field">
@@ -530,6 +539,15 @@
530
539
  <div class="radio-row" role="radiogroup" aria-labelledby="jf-verdict-label">
531
540
  <label><input type="radio" name="jf-verdict" value="deterministic" checked> Deterministic</label>
532
541
  <label><input type="radio" name="jf-verdict" value="rubric"> Rubric</label>
542
+ <label><input type="radio" name="jf-verdict" value="reuse" data-testid="jf-verdict-reuse"> Reuse workflow</label>
543
+ </div>
544
+ <div id="rw" data-testid="rw" hidden>
545
+ <div class="rw-box" id="rw-search" data-testid="rw-search">
546
+ <input id="rw-q" data-testid="rw-q" type="text" placeholder="type a job name…" autocomplete="off">
547
+ <ul class="rw-list" id="rw-list" data-testid="rw-list"></ul>
548
+ </div>
549
+ <div class="rw-picked" id="rw-picked" data-testid="rw-picked" hidden><span id="rw-picked-txt" data-testid="rw-picked-txt"></span><a id="rw-change" data-testid="rw-change" role="button" tabindex="0">change</a></div>
550
+ <div class="startfrom-line" id="chat-startfrom-line" data-testid="startfrom-line" hidden></div>
533
551
  </div>
534
552
  </div>
535
553
  <div class="field">
@@ -955,6 +973,7 @@
955
973
  if(part.outcome === "green" || part.outcome === "satisfied" || part.outcome === "already-green") return "done";
956
974
  if(typeof part.outcome === "string") return "stopped";
957
975
  // P5-R: its leg's halt cut this part off — the map's existing "stopped" state, never "done"
976
+ if(part.status && part.status.key === "died") return "died";
958
977
  if(part.stopReason) return "stopped";
959
978
  if(isLastLivePart) return "running";
960
979
  return "done"; // scout/plan/replan/judge: no per-part verdict, but the part itself already happened
@@ -993,6 +1012,8 @@
993
1012
  partIndex: idx,
994
1013
  title: part.label + occSuffix,
995
1014
  state: partBoxState(part, isLastLivePart),
1015
+ // the word this box wears: the server's table entry for the part (src/panel/status.js partStatusFor), except the live part, which is running
1016
+ status: isLastLivePart && partBoxState(part, true) === "running" ? {sign:"\u25B6", word:"running"} : (part.status || null),
996
1017
  noNumber: part.kind !== "step",
997
1018
  // display-only attempts (map inline text + card/audit result glyph
998
1019
  // fallback) — [] for a no-verdict part kind even though `part.attempts`
@@ -1007,6 +1028,14 @@
1007
1028
  };
1008
1029
  });
1009
1030
  }
1031
+ // ONE spelling of a part's shown state: `[sign] word` from the server's table; the raw state only when no table entry came
1032
+ function boxStatusWord(box){
1033
+ return box.status && box.status.word ? box.status.word : String(box.state);
1034
+ }
1035
+ // the map box adds the sign; step cards and Audit rows show the word only (an attempt glyph already sits beside them)
1036
+ function boxStatusText(box){
1037
+ return box.status && box.status.word ? "[" + box.status.sign + "] " + boxStatusWord(box) : boxStatusWord(box);
1038
+ }
1010
1039
  function naturalBoxWidth(steps){
1011
1040
  var numIdx = stepNumberIndices(steps);
1012
1041
  var maxChars = 0;
@@ -1107,7 +1136,7 @@
1107
1136
  '" text-anchor="middle" font-size="12" fill="var(--text)">' + escapeXml(lines[1]) + '</text>';
1108
1137
  }
1109
1138
  body += '<text x="' + (x + boxW / 2) + '" y="' + stateY +
1110
- '" text-anchor="middle" font-size="10" fill="' + colors.textFill + '">' + escapeXml(s.state) + '</text>';
1139
+ '" text-anchor="middle" font-size="10" fill="' + colors.textFill + '">' + escapeXml(boxStatusText(s)) + '</text>';
1111
1140
  // P2 (2026-09-27 ruling): a running box gets the mockup's pulsing
1112
1141
  // amber dot (design/panel-mockup.html ~line 1648) — same fixed color
1113
1142
  // and animation, never re-derived from the state's own stroke color.
@@ -2360,8 +2389,9 @@
2360
2389
  var stopAsked = {};
2361
2390
  // Reuse workflow (replaces P5 item 3's Start from this) — ask the server for the prefill (the signed job), then
2362
2391
  // hand it to the Chat tab's New job card. The page decides nothing: the server owns what is locked and what is open.
2363
- function reuseWorkflow(runid){
2364
- authorGet("/api/author/start-from?runid=" + encodeURIComponent(runid)).then(function(r){
2392
+ // `model` (optional) = the Chat card's Model box, when the picker asks: the estimate line then counts only that worker's runs.
2393
+ function reuseWorkflow(runid, model){
2394
+ authorGet("/api/author/start-from?runid=" + encodeURIComponent(runid) + (model ? "&model=" + encodeURIComponent(model) : "")).then(function(r){
2365
2395
  if(!r || !r.ok){ window.alert((r && r.error) || "nothing to start from"); return; }
2366
2396
  document.getElementById("tab-chat").click();
2367
2397
  document.dispatchEvent(new CustomEvent("bareloop-reuse", {detail: {runid: runid, prefill: r}}));
@@ -2650,7 +2680,7 @@
2650
2680
  // 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
2651
2681
  function stepStateHTML(box, testid){
2652
2682
  var cls = box.state === "done" ? "green" : (box.state === "stopped" ? "red" : (box.state === "running" ? "amber" : (box.state === "died" ? "magenta" : "grey")));
2653
- return '<span class="step-state" data-testid="' + testid + '"><span class="dot ' + cls + '"></span><b class="st-word">' + escapeXml(box.state) + '</b></span>';
2683
+ return '<span class="step-state" data-testid="' + testid + '"><span class="dot ' + cls + '"></span><b class="st-word">' + escapeXml(boxStatusWord(box)) + '</b></span>';
2654
2684
  }
2655
2685
  // build item C: Audit tab attempt row -> its rounds (server-built,
2656
2686
  // lazy-fetched on expand via /api/runs/:runid/rounds?part=<i>&attempt=<n>).
@@ -3569,7 +3599,7 @@
3569
3599
  + '<td data-testid="pv-tokens-' + e + '">' + escapeXml(tokensText(row.tokens)) + "</td>"
3570
3600
  + "<td>" + bal + "</td>"
3571
3601
  + '<td class="pv-price-cell"><input type="text" inputmode="decimal" class="pv-price-in" data-testid="pv-price-in-' + e + '" placeholder="not set" value="' + (row.priceInPerM === null ? "" : escapeXml(String(row.priceInPerM))) + '"></td>'
3572
- + '<td class="pv-price-cell"><input type="text" inputmode="decimal" class="pv-price-out" data-testid="pv-price-out-' + e + '" placeholder="not set" value="' + (row.priceOutPerM === null ? "" : escapeXml(String(row.priceOutPerM))) + '"></td>';
3602
+ + '<td class="pv-price-cell"><input type="text" inputmode="decimal" class="pv-price-out" data-testid="pv-price-out-' + e + '" placeholder="not set" value="' + (row.priceOutPerM === null ? "" : escapeXml(String(row.priceOutPerM))) + '"></td></tr>';
3573
3603
  }).join("") : '<tr><td colspan="9" class="hint" data-testid="pv-empty">no keys with a value yet — add NAME=key to the keys file, then Reload keys</td></tr>';
3574
3604
  pvRows.querySelectorAll(".pv-balance-fetch").forEach(function(fetchEl){
3575
3605
  authorGet("/api/settings/providers/balance?env=" + encodeURIComponent(fetchEl.getAttribute("data-env"))).then(function(b){
@@ -3584,15 +3614,15 @@
3584
3614
  document.dispatchEvent(new CustomEvent("bareloop-providers-changed"));
3585
3615
  }).catch(function(){ pvNote.textContent = "could not read providers"; });
3586
3616
  }
3587
- // The row's POST body. A price is both boxes or neither (the server refuses a half-set one), so while exactly one
3588
- // is filled — the normal middle of typing a price — the price fields are LEFT OUT (the server keeps what is saved)
3589
- // and the other boxes still save; the price posts once both are filled, or both are cleared.
3617
+ // The row's POST body, or null when exactly ONE price box is filled: a price is both boxes or neither (the server
3618
+ // refuses a half-set one), so nothing in that row saves. This runs only when a save would happen (the row's
3619
+ // change event), so typing the first box never flashes the message; it shows when the person leaves that box.
3620
+ var PV_HALF_PRICE = "Fill both price boxes or clear both.";
3590
3621
  function pvRowBody(env, v){
3591
3622
  var pin = String(v.priceIn).trim();
3592
3623
  var pout = String(v.priceOut).trim();
3593
- var body = { envName: env, name: v.name, shape: v.shape, baseUrl: v.baseUrl };
3594
- if((pin === "") === (pout === "")){ body.priceInPerM = pin; body.priceOutPerM = pout; }
3595
- return body;
3624
+ if((pin === "") !== (pout === "")) return null;
3625
+ return { envName: env, name: v.name, shape: v.shape, baseUrl: v.baseUrl, priceInPerM: pin, priceOutPerM: pout };
3596
3626
  }
3597
3627
  pvRows.addEventListener("change", function(e){
3598
3628
  var t = e.target;
@@ -3606,13 +3636,15 @@
3606
3636
  }
3607
3637
  if(!(t.classList.contains("pv-name") || t.classList.contains("pv-shape") || t.classList.contains("pv-url") || t.classList.contains("pv-price-in") || t.classList.contains("pv-price-out"))) return;
3608
3638
  var env = tr.getAttribute("data-env");
3609
- authorPost("/api/settings/providers/row", pvRowBody(env, {
3639
+ var rowBody = pvRowBody(env, {
3610
3640
  name: tr.querySelector(".pv-name").value,
3611
3641
  shape: tr.querySelector(".pv-shape").value,
3612
3642
  baseUrl: tr.querySelector(".pv-url").value,
3613
3643
  priceIn: tr.querySelector(".pv-price-in").value,
3614
3644
  priceOut: tr.querySelector(".pv-price-out").value,
3615
- })).then(function(r){
3645
+ });
3646
+ if(!rowBody){ pvNote.textContent = PV_HALF_PRICE; return; }
3647
+ authorPost("/api/settings/providers/row", rowBody).then(function(r){
3616
3648
  pvNote.textContent = r.body && r.body.ok ? ("Saved — " + env + ".") : ((r.body && r.body.error) || "could not save");
3617
3649
  // no re-render: it would steal focus mid-edit; Chat's Model menu just re-reads
3618
3650
  document.dispatchEvent(new CustomEvent("bareloop-providers-changed"));
@@ -3692,9 +3724,11 @@
3692
3724
  // Reuse workflow — set when the card was opened by [Reuse workflow]: the run (or imported job) it came from.
3693
3725
  // Only Source, Destination and the two caps are open on that card; the SERVER refuses any other change.
3694
3726
  var startFrom = null;
3727
+ // the picked job's OWN check type: with the Check type radio on "Reuse workflow" the card's checkType comes from here, never from the radio
3728
+ var reuseCheckType = "deterministic";
3729
+ var reusePickedName = "";
3695
3730
  var reuseSession = false;
3696
3731
  var autoSigned = false;
3697
- var sfOrigin = document.getElementById("chat-startfrom-origin");
3698
3732
  var sfLine = document.getElementById("chat-startfrom-line");
3699
3733
  var sfNote = document.getElementById("chat-startfrom-note");
3700
3734
  // the locked boxes of a reuse card: the signed workflow, greyed and not editable (the server refuses a change anyway)
@@ -3709,7 +3743,8 @@
3709
3743
  LOCKED_IDS.forEach(function(id){ setLock(id, reuseOn || live); });
3710
3744
  OPEN_IDS.forEach(function(id){ setLock(id, live); });
3711
3745
  document.getElementById("jf-model").disabled = live;
3712
- document.querySelectorAll('input[name="jf-verdict"]').forEach(function(r){ r.disabled = reuseOn || 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; });
3713
3748
  document.getElementById("job-card").classList.toggle("reuse", reuseOn);
3714
3749
  if(!reuseOn && !live){ var ck = document.querySelector('input[name="jf-verdict"]:checked'); document.getElementById("jf-judge").disabled = !ck || ck.value !== "rubric"; }
3715
3750
  }
@@ -3720,11 +3755,105 @@
3720
3755
  ["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 = ""; });
3721
3756
  document.querySelectorAll('input[name="jf-verdict"]').forEach(function(r){ r.checked = (r.value === "deterministic"); });
3722
3757
  }
3723
- startFrom = null; reuseSession = false; autoSigned = false;
3724
- sfOrigin.hidden = true; sfLine.hidden = true; sfNote.hidden = true;
3758
+ startFrom = null; reuseSession = false; autoSigned = false; reuseCheckType = "deterministic";
3759
+ sfLine.hidden = true; sfNote.hidden = true;
3725
3760
  setReuseLocked(false);
3761
+ renderPicker();
3726
3762
  renderMain();
3727
3763
  }
3764
+ // ---- Reuse workflow picker (hamr 2026-10-06): Check type is ONE radio group of three; "Reuse workflow" shows a search
3765
+ // box over the jobs that have a green local run (GET /api/author/reuse-jobs), a pick fills the card through the SAME
3766
+ // path the Run tab's button uses (reuseWorkflow -> bareloop-reuse -> applyReuse), then a picked box with `change`.
3767
+ var CARD_BOX_IDS = ["jf-name", "jf-goal", "jf-source", "jf-dest", "jf-success", "jf-guardrails", "jf-judge", "jf-cap-money", "jf-cap-time"];
3768
+ var rwBox = document.getElementById("rw");
3769
+ var rwSearch = document.getElementById("rw-search");
3770
+ var rwQ = document.getElementById("rw-q");
3771
+ var rwList = document.getElementById("rw-list");
3772
+ var rwPicked = document.getElementById("rw-picked");
3773
+ var rwPickedTxt = document.getElementById("rw-picked-txt");
3774
+ var rwJobs = null;
3775
+ var rwFailed = false;
3776
+ var rwShown = [];
3777
+ var rwHl = 0;
3778
+ function verdictValue(){
3779
+ var ck = document.querySelector('input[name="jf-verdict"]:checked');
3780
+ return ck ? ck.value : "deterministic";
3781
+ }
3782
+ function setVerdict(v){
3783
+ document.querySelectorAll('input[name="jf-verdict"]').forEach(function(r){ r.checked = (r.value === v); });
3784
+ }
3785
+ function clearCardBoxes(){
3786
+ CARD_BOX_IDS.forEach(function(id){ document.getElementById(id).value = ""; });
3787
+ document.getElementById("jf-cap-note").textContent = "";
3788
+ }
3789
+ // ONE function shows/hides the picker: visible only while the radio is on Reuse workflow; the search box before a
3790
+ // pick, the picked box (+ the estimate line under it) after. A re-attached reuse session shows as picked too.
3791
+ function renderPicker(){
3792
+ var on = verdictValue() === "reuse";
3793
+ var picked = on && (startFrom !== null || reuseSession);
3794
+ rwBox.hidden = !on;
3795
+ rwSearch.hidden = picked;
3796
+ rwPicked.hidden = !picked;
3797
+ rwPickedTxt.innerHTML = picked ? "<b>" + escapeXml(reusePickedName) + "</b>" : "";
3798
+ if(!picked) sfLine.hidden = true;
3799
+ }
3800
+ function rwMark(name, q){
3801
+ var i = q ? name.toLowerCase().indexOf(q.toLowerCase()) : -1;
3802
+ if(i < 0) return escapeXml(name);
3803
+ return escapeXml(name.slice(0, i)) + "<mark>" + escapeXml(name.slice(i, i + q.length)) + "</mark>" + escapeXml(name.slice(i + q.length));
3804
+ }
3805
+ // the server's estimate line, prefixed with the job's check type (the line itself does not carry it)
3806
+ function rwMeta(j){ return j.checkType + " · " + String(j.line || "").replace(/^Same job — /, ""); }
3807
+ function renderReuseList(){
3808
+ var q = rwQ.value.trim();
3809
+ var all = rwJobs || [];
3810
+ rwShown = all.filter(function(j){ return j.job.toLowerCase().indexOf(q.toLowerCase()) !== -1; });
3811
+ if(rwHl >= rwShown.length) rwHl = 0;
3812
+ if(rwShown.length){
3813
+ rwList.innerHTML = rwShown.map(function(j, i){
3814
+ return '<li data-i="' + i + '" data-testid="rw-item" class="' + (i === rwHl ? "hl" : "") + '"><div class="rw-name">' + rwMark(j.job, q) + '</div><div class="rw-meta">' + escapeXml(rwMeta(j)) + "</div></li>";
3815
+ }).join("");
3816
+ } else {
3817
+ 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>";
3819
+ }
3820
+ }
3821
+ function loadReuseJobs(){
3822
+ authorGet("/api/author/reuse-jobs" + (modelSelect.value ? "?model=" + encodeURIComponent(modelSelect.value) : "")).then(function(r){
3823
+ rwFailed = !(r && r.ok);
3824
+ rwJobs = rwFailed ? null : r.jobs;
3825
+ renderReuseList();
3826
+ }).catch(function(){ rwFailed = true; rwJobs = null; renderReuseList(); });
3827
+ }
3828
+ // the empty search state: card emptied, nothing picked, Sign & run disabled until a job is picked
3829
+ function openPicker(){
3830
+ clearCardBoxes();
3831
+ rwQ.value = ""; rwHl = 0;
3832
+ document.getElementById("jf-judge").disabled = true;
3833
+ renderPicker();
3834
+ renderReuseList();
3835
+ loadReuseJobs();
3836
+ refreshStartEnabled();
3837
+ rwQ.focus();
3838
+ }
3839
+ function pickReuseJob(j){ if(j) reuseWorkflow(j.runid, modelSelect.value); }
3840
+ rwQ.addEventListener("input", function(){ rwHl = 0; renderReuseList(); });
3841
+ rwQ.addEventListener("keydown", function(e){
3842
+ if(e.key === "ArrowDown"){ rwHl = Math.min(rwHl + 1, rwShown.length - 1); renderReuseList(); e.preventDefault(); }
3843
+ else if(e.key === "ArrowUp"){ rwHl = Math.max(rwHl - 1, 0); renderReuseList(); e.preventDefault(); }
3844
+ else if(e.key === "Enter" && rwShown[rwHl]){ e.preventDefault(); pickReuseJob(rwShown[rwHl]); }
3845
+ });
3846
+ rwList.addEventListener("click", function(e){
3847
+ var li = e.target.closest ? e.target.closest("li") : null;
3848
+ if(li) pickReuseJob(rwShown[Number(li.getAttribute("data-i"))]);
3849
+ });
3850
+ // `change` = back to the empty search state; the radio stays on Reuse workflow
3851
+ function unpickReuse(){
3852
+ clearStartFrom();
3853
+ setVerdict("reuse");
3854
+ openPicker();
3855
+ }
3856
+ document.getElementById("rw-change").addEventListener("click", unpickReuse);
3728
3857
  function openNewCard(){
3729
3858
  card.hidden = false;
3730
3859
  thread.innerHTML = "";
@@ -3755,12 +3884,12 @@
3755
3884
  // back to a fresh empty card: every box, check type deterministic, the default (first) Model, origin/estimate/notes
3756
3885
  // hidden, progress list and thread cleared, reuse lock lifted
3757
3886
  function resetCard(){
3758
- ["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 = ""; });
3759
- document.querySelectorAll('input[name="jf-verdict"]').forEach(function(r){ r.checked = (r.value === "deterministic"); });
3887
+ clearCardBoxes();
3888
+ setVerdict("deterministic");
3760
3889
  document.getElementById("jf-judge").disabled = true;
3761
3890
  modelSelect.selectedIndex = 0;
3762
3891
  msgInput.value = "";
3763
- document.getElementById("jf-cap-note").textContent = "";
3892
+ rwQ.value = "";
3764
3893
  lastPhase = null;
3765
3894
  clearStartFrom();
3766
3895
  openNewCard();
@@ -3782,7 +3911,8 @@
3782
3911
  var moneyCap = Number(document.getElementById("jf-cap-money").value);
3783
3912
  var timeCapMin = document.getElementById("jf-cap-time").value.trim();
3784
3913
  return {
3785
- checkType: document.querySelector('input[name="jf-verdict"]:checked').value === "rubric" ? "rubric" : "deterministic",
3914
+ // on "Reuse workflow" the type is the picked job's own, never the radio's
3915
+ checkType: (verdictValue() === "reuse" ? reuseCheckType : verdictValue()) === "rubric" ? "rubric" : "deterministic",
3786
3916
  model: document.getElementById("jf-model").value,
3787
3917
  jobName: document.getElementById("jf-name").value.trim(),
3788
3918
  goal: document.getElementById("jf-goal").value.trim(),
@@ -3806,7 +3936,8 @@
3806
3936
  function refreshStartEnabled(){
3807
3937
  var v = Number(moneyCapInput.value);
3808
3938
  var capOk = moneyCapInput.value.trim() !== "" && Number.isFinite(v) && v > 0;
3809
- startOk = capOk && !sessionLive && keyOk;
3939
+ // on Reuse workflow nothing starts before a job is picked
3940
+ startOk = capOk && !sessionLive && keyOk && (verdictValue() !== "reuse" || startFrom !== null);
3810
3941
  syncCardLock();
3811
3942
  renderMain();
3812
3943
  refreshClearBtn();
@@ -3829,11 +3960,21 @@
3829
3960
  });
3830
3961
  // the limit changed in Settings: re-read the note for whatever cap is typed
3831
3962
  document.addEventListener("bareloop-limit-changed", refreshCapNote);
3963
+ // 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).
3965
+ function onVerdictChange(v){
3966
+ if(v === "reuse"){ if(rwBox.hidden) openPicker(); return; }
3967
+ if(!rwBox.hidden){
3968
+ clearStartFrom();
3969
+ clearCardBoxes();
3970
+ setVerdict(v);
3971
+ renderPicker();
3972
+ }
3973
+ document.getElementById("jf-judge").disabled = v !== "rubric";
3974
+ refreshStartEnabled();
3975
+ }
3832
3976
  document.querySelectorAll('input[name="jf-verdict"]').forEach(function(r){
3833
- r.addEventListener("change", function(){
3834
- var isRubric = document.querySelector('input[name="jf-verdict"]:checked').value === "rubric";
3835
- document.getElementById("jf-judge").disabled = !isRubric;
3836
- });
3977
+ r.addEventListener("change", function(){ onVerdictChange(r.value); });
3837
3978
  });
3838
3979
 
3839
3980
  // build item 7 / build item 5 (2026-09-28) — key + reachability, $0,
@@ -3906,10 +4047,11 @@
3906
4047
  var q = (startFrom.importId ? "import=" + encodeURIComponent(startFrom.importId) : "runid=" + encodeURIComponent(startFrom.runid)) +
3907
4048
  "&model=" + encodeURIComponent(modelSelect.value);
3908
4049
  authorGet("/api/author/start-from?" + q).then(function(r){
3909
- if(startFrom && r && r.ok){ sfLine.textContent = r.line || ""; }
4050
+ if(startFrom && r && r.ok){ sfLine.textContent = r.line ? rwMeta({checkType: reuseCheckType, line: r.line}) : ""; }
3910
4051
  }).catch(function(){ /* the line keeps what it had */ });
3911
4052
  }
3912
4053
  modelSelect.addEventListener("change", refreshReuseLine);
4054
+ modelSelect.addEventListener("change", function(){ if(!rwBox.hidden && startFrom === null && !reuseSession) loadReuseJobs(); });
3913
4055
  // P4b — the Model menu IS the Settings rows: every Name, in keys-file order (a blank Name
3914
4056
  // is never offered). Re-read whenever Settings loads or saves, keeping the pick if it survives.
3915
4057
  function loadModels(){
@@ -4028,7 +4170,7 @@
4028
4170
  var open = askBoxOpenFor(st);
4029
4171
  msgInput.disabled = !open;
4030
4172
  if(!open) msgInput.value = "";
4031
- var b = mainButtonFor(st, !msgInput.value.trim(), !!startFrom);
4173
+ var b = mainButtonFor(st, !msgInput.value.trim(), !!startFrom || verdictValue() === "reuse");
4032
4174
  mainBtn.textContent = b.text;
4033
4175
  mainBtn.disabled = b.disabled;
4034
4176
  mainAction = b.action;
@@ -4148,7 +4290,9 @@
4148
4290
  reuseSession = st.reuse === true;
4149
4291
  autoSigned = false;
4150
4292
  signClickedOnce = reuseSession;
4151
- document.querySelectorAll('input[name="jf-verdict"]').forEach(function(r){ r.checked = (r.value === (c.checkType === "rubric" ? "rubric" : "deterministic")); });
4293
+ reuseCheckType = c.checkType === "rubric" ? "rubric" : "deterministic";
4294
+ reusePickedName = c.jobName || "job";
4295
+ setVerdict(reuseSession ? "reuse" : reuseCheckType);
4152
4296
  if(c.model){
4153
4297
  if(!Array.prototype.some.call(modelSelect.options, function(o){ return o.value === c.model; })){
4154
4298
  modelSelect.insertAdjacentHTML("beforeend", '<option value="' + escapeXml(c.model) + '">' + escapeXml(c.model) + "</option>");
@@ -4161,6 +4305,7 @@
4161
4305
  setVal("jf-cap-time", typeof c.maxWallMs === "number" ? Math.round(c.maxWallMs / 60000) : "");
4162
4306
  document.getElementById("jf-judge").disabled = c.checkType !== "rubric";
4163
4307
  if(reuseSession) setReuseLocked(true);
4308
+ renderPicker();
4164
4309
  fitCardBoxes();
4165
4310
  renderMessages(st);
4166
4311
  renderActions(st);
@@ -4180,15 +4325,17 @@
4180
4325
 
4181
4326
  // ---- Reuse workflow — the card opens filled from the SIGNED job: four open boxes, the rest greyed ----
4182
4327
  function setVal(id, v){ document.getElementById(id).value = (v === undefined || v === null) ? "" : String(v); }
4183
- document.addEventListener("bareloop-reuse", function(e){
4184
- var d = e.detail || {};
4328
+ // ONE owner of the reuse fill: the Run tab's / Import view's button and the picker both arrive here (the
4329
+ // `bareloop-reuse` event), so the card ends up in the same picked state whichever door was used.
4330
+ function applyReuse(d){
4185
4331
  var pre = d.prefill || {};
4186
4332
  var c = pre.card || {};
4187
4333
  clearStartFrom();
4188
4334
  openNewCard();
4189
4335
  startFrom = d.importId ? {importId: d.importId} : {runid: d.runid};
4190
- var radios = document.querySelectorAll('input[name="jf-verdict"]');
4191
- radios.forEach(function(r){ r.checked = (r.value === (c.checkType === "rubric" ? "rubric" : "deterministic")); });
4336
+ reuseCheckType = c.checkType === "rubric" ? "rubric" : "deterministic";
4337
+ reusePickedName = c.jobName || (pre.origin && pre.origin.job) || "job";
4338
+ setVerdict("reuse");
4192
4339
  if(c.model){ modelSelect.value = c.model; }
4193
4340
  setVal("jf-name", c.jobName); setVal("jf-goal", c.goal); setVal("jf-source", c.source);
4194
4341
  setVal("jf-dest", c.destination); setVal("jf-success", c.success); setVal("jf-guardrails", c.guardrails);
@@ -4196,10 +4343,9 @@
4196
4343
  setVal("jf-cap-time", typeof c.maxWallMs === "number" ? Math.round(c.maxWallMs / 60000) : "");
4197
4344
  document.getElementById("jf-judge").disabled = c.checkType !== "rubric";
4198
4345
  setReuseLocked(true);
4199
- sfOrigin.hidden = false;
4200
- sfOrigin.textContent = "Reuse: " + ((pre.origin && pre.origin.job) || "job") + (d.runid ? " (run " + d.runid + ")" : " (imported)");
4346
+ renderPicker();
4201
4347
  sfLine.hidden = false;
4202
- sfLine.textContent = pre.line || "";
4348
+ sfLine.textContent = pre.line ? rwMeta({checkType: reuseCheckType, line: pre.line}) : "";
4203
4349
  sfLine.className = "startfrom-line";
4204
4350
  sfNote.hidden = !pre.note;
4205
4351
  sfNote.textContent = pre.note || "";
@@ -4207,7 +4353,8 @@
4207
4353
  refreshReuseLine();
4208
4354
  refreshCapNote();
4209
4355
  refreshStartEnabled();
4210
- });
4356
+ }
4357
+ document.addEventListener("bareloop-reuse", function(e){ applyReuse(e.detail || {}); });
4211
4358
 
4212
4359
  // build item 1 — the "lost answer" fix. ROOT CAUSE: `renderActions`
4213
4360
  // (fed by the every-2s `poll()`) unconditionally overwrites
@@ -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));
@@ -897,7 +897,7 @@ function getRunDetailBody(runid, opts) {
897
897
  const timelineKind = summary.timelineKind;
898
898
  // the drafting part (see draftingFor) heads the parts list; the enrichment below touches the spine's own parts only
899
899
  const draftingPartHere = draftingFor(row, summary, opts);
900
- const draftingShiftHere = draftingPartHere ? 1 : 0;
900
+ const draftingShiftHere = draftingShift(row, summary, opts);
901
901
  const enrichedParts = enrichPartsWithStageKind(summary.parts, stageKindMetaFromSpec(resolveSpecForRow(row)));
902
902
  const death = deriveDeath(row, rawSpineRecords, summary.outcome);
903
903
  const { ended, resume, status } = endedForRow(row, rawSpineRecords, summary, death, true);
@@ -1117,7 +1117,9 @@ function getRunDetailBody(runid, opts) {
1117
1117
  // {@link enrichPartsWithStageKind}. `kindMeta` is `null` (every stage
1118
1118
  // passes through unchanged) whenever no spec resolves at all, so the two
1119
1119
  // tabs still never disagree about order/counts/blocked-call figures.
1120
- parts: draftingPartHere && Array.isArray(enrichedParts) ? [draftingPartHere, ...enrichedParts] : enrichedParts,
1120
+ // a run that DIED (no job-end, runner gone — `death`, decided once above) leaves its last part in flight: that part reads
1121
+ // `died`, never the "happened, so passed" default. Replay alone cannot tell died from still running, so the verdict lives here.
1122
+ 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
1123
  replans: summary.replans,
1122
1124
  close: summary.close,
1123
1125
  branch: summary.branch,
@@ -2459,6 +2461,39 @@ export function getStartFrom(runid, opts = {}) {
2459
2461
  };
2460
2462
  }
2461
2463
 
2464
+ /**
2465
+ * The Reuse workflow picker's list: one entry per JOB (same-job identity = `workflowKey`, the one `getStartFrom` uses)
2466
+ * that has at least one GREEN local run, newest green first. `runid` = that job's newest green run, so the page hands
2467
+ * it to the existing start-from flow unchanged; `line` is `getStartFrom`'s own estimate line (never a second
2468
+ * spelling). Local runs only — an imported job keeps its Reuse button in the Import view. $0, read-only.
2469
+ * @param {{ home?: string, model?: string }} [opts] `model` passes through to the estimate line, as start-from's does
2470
+ * @returns {{job: string, runid: string, checkType: 'deterministic'|'rubric', line: string}[]}
2471
+ */
2472
+ export function listReuseJobs(opts = {}) {
2473
+ const { rows } = readRunList(opts);
2474
+ const seen = new Set();
2475
+ /** @type {{job: string, runid: string, checkType: 'deterministic'|'rubric', line: string}[]} */
2476
+ const jobs = [];
2477
+ for (let i = rows.length - 1; i >= 0; i -= 1) {
2478
+ const row = rows[i];
2479
+ if (!row || !existsSync(row.spine)) continue;
2480
+ let records;
2481
+ try { records = parseJsonl(row.spine).records; } catch { continue; }
2482
+ 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;
2485
+ const signed = signedSpecForRun(row, records);
2486
+ if (!signed) continue;
2487
+ const key = workflowKey(signed.spec);
2488
+ if (seen.has(key)) continue;
2489
+ const pre = getStartFrom(row.runid, opts);
2490
+ if (!pre || !pre.ok) continue;
2491
+ seen.add(key);
2492
+ jobs.push({ job: pre.card.jobName, runid: row.runid, checkType: pre.card.checkType === 'rubric' ? 'rubric' : 'deterministic', line: pre.line });
2493
+ }
2494
+ return jobs;
2495
+ }
2496
+
2462
2497
  /**
2463
2498
  * The reuse card for a signed spec: locked boxes from the spec through the Job tab's own readers, open boxes from
2464
2499
  * the spec's own fence and caps (Source and the saved text from the caller — a spec carries neither).
@@ -2636,7 +2671,15 @@ export function handleRequest(req, res, opts) {
2636
2671
  /** @type {any} */
2637
2672
  let body = null;
2638
2673
  if (raw.length > 0) { try { body = JSON.parse(raw); } catch { body = null; } }
2639
- routes.handle(req, res, pathname, body);
2674
+ // this callback runs OUTSIDE createServer's try/catch (which covers only the synchronous GET path), so a
2675
+ // synchronous throw in a route handler (a full disk, a permission fault) would be an uncaught exception that
2676
+ // takes the whole panel down: answer 500 JSON instead and keep serving
2677
+ try {
2678
+ routes.handle(req, res, pathname, body);
2679
+ } catch (e) {
2680
+ if (res.headersSent) { res.end(); return; }
2681
+ sendJson(res, 500, { ok: false, error: `internal error: ${/** @type {Error} */ (e)?.message ?? String(e)}` });
2682
+ }
2640
2683
  });
2641
2684
  return;
2642
2685
  }
@@ -2801,6 +2844,7 @@ export function createPanelServer(opts = {}) {
2801
2844
  startFrom: {
2802
2845
  get: (runid, o = {}) => getStartFrom(runid, { home, ...o }),
2803
2846
  getImport: (id, o = {}) => getStartFromImport(id, { home, ...o }),
2847
+ listJobs: (o = {}) => listReuseJobs({ home, ...o }),
2804
2848
  },
2805
2849
  });
2806
2850
  settingsRoutes = createSettingsRoutes({
@@ -49,3 +49,25 @@ export function statusFor(o) {
49
49
  else if (out === 'escalated' && ((o.category === 'cap-halt' && o.moneyHalt) || o.category === 'wall-halt')) key = 'capped';
50
50
  return { key, ...STATUS[key] };
51
51
  }
52
+
53
+ /**
54
+ * The word one PART of a run wears on the step map, the step cards and the Audit rows (hamr 2026-10-07: `[✓] passed` /
55
+ * `[✗] failed`, never "done"). Reads the same table; `running` is the page's call (it knows which part is live), so a
56
+ * part with no verdict and no cut-off is `passed` here.
57
+ * drafting, green/satisfied/already-green outcome, or a part that simply happened (scout/plan/replan/judge) -> passed
58
+ * a red outcome -> its run-outcome word (failed; a cap/stop outcome keeps capped/stopped)
59
+ * a part its leg's halt cut off (`stopReason`): 'money cap' / 'time cap' -> capped, 'you stopped it' -> stopped,
60
+ * 'died' -> died, anything else (provider failed, step stalled, a raw outcome) -> failed
61
+ * @param {{kind?: string, outcome?: string|null, stopReason?: string|null}} part
62
+ * @returns {{key: StatusKey, sign: string, word: string}}
63
+ */
64
+ export function partStatusFor(part) {
65
+ if (part.kind === 'drafting') return statusFor({ outcome: 'green' });
66
+ if (typeof part.outcome === 'string') return statusFor({ outcome: part.outcome });
67
+ const why = part.stopReason;
68
+ if (why) {
69
+ const key = why === 'money cap' || why === 'time cap' ? 'capped' : why === 'you stopped it' ? 'stopped' : why === 'died' ? 'died' : 'failed';
70
+ return { key, ...STATUS[key] };
71
+ }
72
+ return statusFor({ outcome: 'green' });
73
+ }
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
 
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
+ }
@@ -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';
@@ -410,6 +410,23 @@ export function getStartFrom(runid: string, opts?: {
410
410
  ok: false;
411
411
  error: string;
412
412
  } | null;
413
+ /**
414
+ * The Reuse workflow picker's list: one entry per JOB (same-job identity = `workflowKey`, the one `getStartFrom` uses)
415
+ * that has at least one GREEN local run, newest green first. `runid` = that job's newest green run, so the page hands
416
+ * it to the existing start-from flow unchanged; `line` is `getStartFrom`'s own estimate line (never a second
417
+ * spelling). Local runs only — an imported job keeps its Reuse button in the Import view. $0, read-only.
418
+ * @param {{ home?: string, model?: string }} [opts] `model` passes through to the estimate line, as start-from's does
419
+ * @returns {{job: string, runid: string, checkType: 'deterministic'|'rubric', line: string}[]}
420
+ */
421
+ export function listReuseJobs(opts?: {
422
+ home?: string;
423
+ model?: string;
424
+ }): {
425
+ job: string;
426
+ runid: string;
427
+ checkType: "deterministic" | "rubric";
428
+ line: string;
429
+ }[];
413
430
  /**
414
431
  * Reuse workflow, for an IMPORTED job: the bundle is read AGAIN now and must be clean (`readBundle` — its own hash
415
432
  * 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 */
@@ -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/source.d.ts CHANGED
@@ -297,6 +297,13 @@ export function frontDoorFromManifest(read: {
297
297
  }): {
298
298
  destination: string;
299
299
  } | null;
300
+ /**
301
+ * The $0 refusal for a non-repo source, worded by the kind the source door recorded (`prep.manifest.kind`) — a single
302
+ * file is not a "folder", and Source must be the repo folder with the file named in Destination (hamr 2026-10-05).
303
+ * @param {string} kind `prep.manifest.kind`: 'file' | 'folder' | 'url' (a 'repo' never reaches this)
304
+ * @returns {string}
305
+ */
306
+ export function nonRepoSourceMessage(kind: string): string;
300
307
  /**
301
308
  * a named, non-throwing stop
302
309
  */