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.
package/CHANGELOG.md CHANGED
@@ -4,6 +4,50 @@ All notable changes to bareloop are documented here. Format:
4
4
  [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); versioning:
5
5
  [SemVer](https://semver.org/spec/v2.0.0.html). Pre-1.0: **minor** = a ladder rung or
6
6
  feature lands, **patch** = docs, fixes, scaffolding.
7
+ ## [0.38.0] - 2026-10-08
8
+
9
+ ### Added
10
+
11
+ - **Edit in chat on every run that did not finish green.** The Chat card opens filled from the run's signed job with every box open; Start drafting works as for any new job. The Ended block's NEXT line points at the button, and a refused Resume says so too.
12
+ - **Resume moves to the left Chat card.** Resume mode locks the signed job and opens only the two caps (money and time); Sign & resume posts the same body as before.
13
+ - **Expanded jobs show their latest 7 other runs;** older runs are reached by search.
14
+
15
+ ### Changed
16
+
17
+ - The run page's Job tab is read-only on every run; the Job-tab resume mode is gone.
18
+ - A stopped run wears `[■]` (red), not `[✗]`, on every surface.
19
+ - Reuse workflow is offered only from a run that earned its green. The Reuse list shows only jobs with a real green run; an already-green run offers Edit in chat instead; `POST /api/author/start` refuses a reuse of any local run that did not finish green (the server owns the rule, not just the pickers); and a delivery-refused ending offers Reuse only on a run that earned its green.
20
+ - The Reuse picker reads "Loading…" until its list arrives, and switching the Check type radios no longer blanks the New job boxes.
21
+
22
+ ### Fixed
23
+
24
+ - A run cut by the money or wall cap reads `capped` on every surface: a leg that ended escalated on a money-halt or wall-halt, and a cap-cut open step on a single-leg run, no longer read as failed. One helper (`capKindOf`) owns the rule.
25
+ - A Stop clicked in the fix loop reads "during the fix phase ... a resume continues the run" instead of the step text "a resume re-enters this step", which was wrong where no step exists (found on run muxwwrgs).
26
+ - Small cleanups with unchanged behaviour: the `npm ci` comment matches the code, a dead pid above `pid_max` is handled, and tests follow the new panel behaviour.
27
+
28
+ ## [0.37.0] - 2026-10-07
29
+
30
+ ### Added
31
+
32
+ - **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.
33
+
34
+ ### Fixed
35
+
36
+ - **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).
37
+ - A throwing panel POST handler answers a 500 JSON error instead of killing the panel.
38
+ - 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."
39
+ - A died run's in-flight part on the Run map reads `[?] died`, not passed.
40
+ - 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).
41
+ - The drafting progress line reads "drafting (retry N of M)" on a drafting call after the first.
42
+ - The CLI `run-author` non-repo refusal uses the same message as the panel (`nonRepoSourceMessage`, now in `src/source.js`).
43
+ - A `tree-changed` close detail on a single-file write scope names the file, not the glob path.
44
+ - The Providers table row's closing `</tr>` is no longer missing.
45
+
46
+ ### Changed
47
+
48
+ - Refactors with unchanged output: `getRunDetailBody` takes its drafting shift from `draftingShift` (one owner); the dead `orphanCalls` accumulator in `readDraftSteps` is dropped.
49
+ - 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.
50
+
7
51
 
8
52
  ## [0.36.0] - 2026-10-06
9
53
 
@@ -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
@@ -3501,13 +3503,21 @@ key you want first (the panel picks the row by the Model menu).
3501
3503
  result glyphs are unchanged. The reuse button is described under Reuse workflow, below.
3502
3504
  **Status words (click-through 2026-10-04)** → every run row, run detail and imported row carries `status`
3503
3505
  `{key, sign, word}` from ONE table (`src/panel/status.js`): `[▶] running · [·] waiting · [✓] passed · [✗] failed ·
3504
- [✗] capped · [✗] stopped · [?] died`; `glyph` is that sign. Mapping: green/already-green/satisfied → passed; no
3506
+ [✗] capped · [■] stopped · [?] died`; `glyph` is that sign (the Runs `✗` filter chip also finds a `[■]` run). Mapping: green/already-green/satisfied → passed; no
3505
3507
  job-end + runner alive → running, gone → died; cap-halt, wall-halt, and an escalation that is a money-halt or wall-halt
3506
- → capped; `stopped` → stopped; every other outcome (plan-red, check-red, step-red, close-red, provider-red,
3508
+ → capped (ONE rule, `capKindOf` in `src/legs.js`, shared by the run status, a leg's stop reason and the part cards); `stopped` → stopped; every other outcome (plan-red, check-red, step-red, close-red, provider-red,
3507
3509
  step-stalled, other escalations, refusals) → failed. The detail also carries `startedAt` and `endedAt` (the last
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 — also a leg that ended `escalated` on a money-halt or wall-halt, and the open step of a single-leg run that ended on a cap), stopped (you stopped it),
3516
+ 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
@@ -3524,32 +3534,45 @@ key you want first (the panel picks the row by the Model menu).
3524
3534
  the log tail is returned as `error` verbatim; otherwise `{ok:true, runid, specHash, capsChanged,
3525
3535
  log}`. The run keeps its runid and its one list row: the resumed leg appends to the same spine (P5-R,
3526
3536
  `leg-start` in the run list), it never appears as a new run. The page's Resume button opens the
3527
- caps form (prefilled, "spent so far") and `Sign & resume` is the human click.
3537
+ Chat card in RESUME mode (title `RESUME <job> (run-N)`, filled from `GET /api/author/start-from`, every box locked
3538
+ except the two caps, "spent so far" shown, Clear/Start drafting hidden) and `Sign & resume` on that card is the human
3539
+ click, posting this same route; the run's Job tab is read-only on every run.
3528
3540
  **Stop (item 5)** → `POST /api/runs/:runid/stop` (`src/panel/runroutes.js`, `checkHumanGuard`, no body).
3529
3541
  It writes the run's STOP REQUEST, a file `<spine>.stop` (`stopFilePath(spine)`, `src/legs.js` — the one
3530
3542
  spelling), and signals nothing. `404` unknown run; `409` when the run's LATEST leg is not live
3531
3543
  (`runIsAlive`) or its spine does not exist yet (still starting). The ENGINE half is library code, so
3532
3544
  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
3545
+ panel's spawn pass `<spine>.stop`. The seam is the ROUND BOUNDARY of EVERY worker (scout, plan/replan drafter, step,
3546
+ close-fix) — where the money cap binds
3534
3547
  (`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
3548
+ {phase, step?, iteration, round}` (`step` only on a step phase), end the Loop after that round; `ask` then throws category `stopped`
3549
+ 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
3550
+ close-fix phases file it through `relay`, which returns `stopped` as itself, never `provider-red`)
3537
3551
  (`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
3552
+ 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
3553
  leg ends (`finally`), and at the start of the next leg (a click that raced a leg's end never stops a
3540
3554
  resume). `stopped` is in `CHECKPOINT_OUTCOMES`: resumable like a `cap-halt`, the same run continues as a
3541
3555
  new leg at the next step. `GET /api/runs/:runid` carries `live` and `stopping` (a stop request is on
3542
3556
  disk and the leg is live); Ended row `stopped` = "You stopped it." / "Resume." / `[Resume]`, card line
3543
3557
  `stopped — resume`, Audit divider `stopped: you stopped it · resumed <when>`. The page shows `[Stop]`
3544
3558
  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.
3549
- **Reuse workflow (replaces P5 item 3's Start from this, 2026-10-03)** — a button on GREEN runs only (the Run tab's
3550
- action row and the Ended block; `ended.actions` carries `{id:'reuse', label:'Reuse workflow'}` on green and
3551
- 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
3559
+ `[Resume]` there once the engine would accept one. A Stop clicked while the run is in the
3560
+ scout, plan or close-fix phase is honoured at that phase's next round boundary, the same as in a step (it used to be read in
3561
+ step workers only). A stop in the close-fix loop reads "You stopped the run during the fix phase … a resume continues the
3562
+ run" (`ralph`'s `phase` option; the step loop keeps "a resume re-enters this step").
3563
+ **Edit in chat (2026-10-08)** — `ended.actions` carries `{id:'edit', label:'Edit in chat'}` on EVERY ending that is not a
3564
+ real green (failed, capped, stopped, died, every other red, and an already-green or delivery-refused already-green),
3565
+ after `resume` when both show. The button reads `GET /api/author/start-from` and opens the Chat card filled from the
3566
+ signed job with EVERY box open and the normal Start drafting path (no reuse state, no borrowed track record), under a
3567
+ code-owned origin line ("Copied from <job> (run-N) — <status word>. …").
3568
+ **Reuse workflow (replaces P5 item 3's Start from this, 2026-10-03)** — a button on runs that EARNED their green only
3569
+ (the Run tab's action row and the Ended block; `ended.actions` carries `{id:'reuse', label:'Reuse workflow'}` on `green`
3570
+ and green-with-destination-refused `green` rows, and on no other: an `already-green` run (the check passed before any
3571
+ work) offers Edit in chat instead, and a red row's next line is "Change the job: press Edit in chat."; stopped/capped/died
3572
+ rows offer `resume` and `edit`) and on every imported job; the Chat card's Check type is one radio group of three
3573
+ (Deterministic · Rubric · Reuse workflow), and the third opens a search picker over `GET /api/author/reuse-jobs` (below) that
3574
+ fills the card through this same path (a pick, then `change` returns to the empty search; Deterministic or Rubric leaves reuse).
3575
+ It opens the Chat tab's New job card
3553
3576
  filled from the SIGNED job: a NEW run (new runid), never a rerun, nothing drafted ($0). **Only five boxes are
3554
3577
  open — Source, Destination, Model, $ cap, Time cap**; every other box (check type, model, job name, goal, success,
3555
3578
  guardrails, judge examples) is the signed workflow, greyed, and the SERVER refuses a start that changed one
@@ -3560,8 +3583,13 @@ key you want first (the panel picks the row by the Model menu).
3560
3583
  green, notGreen, live, finished, avgSpendUsd, avgWallMs}, line}` (`404` unknown, `409` with an `error` when
3561
3584
  there is nothing to copy: no log, the run's signed job is not on disk, or an imported job that changed, lost its
3562
3585
  dependency or no longer matches its signed bytes); `POST /api/author/start` accepts `startFrom: <runid>` or
3563
- `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
3586
+ `startFrom: {importId}` beside the card and answers `{ok, sessionId, reuse, state}`; a local `startFrom: <runid>` whose
3587
+ latest outcome is not `green` (`start-from` carries that `outcome`) is refused `409 "only a run that finished green can
3588
+ be reused — this one did not"` — the server owns the rule, not just the pickers. The old
3589
+ `POST /api/author/start-from-check` and the "Changed — new job" mode are gone. `GET /api/author/reuse-jobs[?model=<Name>]` →
3590
+ `{ok, jobs:[{job, runid, checkType:'deterministic'|'rubric', line}]}` is the Chat card's Reuse workflow picker list: one entry per
3591
+ job (same `workflowKey`) with at least one real `green` local run (`already-green` is not a reuse source), `runid` = its newest green run (hand it to `start-from`), `line` =
3592
+ start-from's own estimate line, newest green first; imported jobs are not listed. **Identity, two keys:** the
3565
3593
  signature hash `jobSpecHash` is unchanged (it covers the caps and the write fence — what the person signs);
3566
3594
  `workflowKey(spec)` (`src/job.js`, not exported from the package root) is a sha256 over the signed spec WITHOUT
3567
3595
  `source`, `destination`, `writeScope`, `budgetUsd` and `maxWallMs` (`REUSE_OPEN_SPEC_FIELDS`) AND without `provider`,
@@ -3587,7 +3615,8 @@ key you want first (the panel picks the row by the Model menu).
3587
3615
  is still written beside `resolved-spec.json` when a session reaches `prepared`.
3588
3616
  **The progress list (2026-10-04)** — `GET /api/author/:id` state carries `steps: [{id, label, status:
3589
3617
  '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
3618
+ `STEP_LABELS`, `src/panel/authorsession.js`); `progressLabel` is the running step's label. A drafting call after the first
3619
+ relabels the ONE drafting line `drafting (retry N of M)` (same line, never a second one). A refusal sets
3591
3620
  `state.error` and fails exactly one step with the same text as its `detail`; pipeline text is never a chat message
3592
3621
  (`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
3622
  fence (relative `writeScope` globs, never proven as a directory); for a folder source an absolute directory. Both
@@ -3595,7 +3624,7 @@ key you want first (the panel picks the row by the Model menu).
3595
3624
  Destination on a repo source → `Destination must be a path inside the repo, like src/digest.js` (before the
3596
3625
  copy); a source that is not a git repo → a plain refusal worded by the kind the source door recorded (a single
3597
3626
  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)").
3627
+ project (no git repo found)") — `nonRepoSourceMessage` (`src/source.js`), the one spelling the CLI `run-author` uses too.
3599
3628
  **Import, read only (item 4)** — `src/panel/importroutes.js`. The Workflows toolbar's `[Import]` opens a folder
3600
3629
  browser plus a paste box; an imported job is an exported bundle folder the person can LOOK at: nothing runs,
3601
3630
  nothing is signed, the folder is never written. Routes, all behind `checkHumanGuard` (token + own address —
@@ -3858,7 +3887,8 @@ URL** (blank = the shape's own host), **Test**, **Tokens used** (total, display
3858
3887
  (DeepSeek fetched, Anthropic a typed note). Saves on change to `config.json`
3859
3888
  `keys.<ENV NAME> = { name, shape, baseUrl, priceInPerM?, priceOutPerM? }`. The last two columns are the customer's own price
3860
3889
  (**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).
3890
+ error and saves nothing, and the page saves NOTHING in a row with exactly one price box filled, showing "Fill both price boxes
3891
+ or clear both." when the person leaves that box, until both boxes are filled or both cleared).
3862
3892
  **Open keys folder** (`POST /api/settings/open-keys-folder`) opens `~/.config/bareloop` in the file manager (`xdg-open` / `open`,
3863
3893
  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
3894
  `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.38.0",
4
4
  "description": "Workflows that earn their own design, with receipts — agent-authored workflows for repeated, long, verifiable jobs that improve across runs under an un-gameable gate.",
5
5
  "type": "module",
6
6
  "main": "./src/index.js",
package/src/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
 
package/src/legs.js CHANGED
@@ -169,3 +169,18 @@ export function watchdogNotePath(spineFile, leg) {
169
169
  export function stopFilePath(spineFile) {
170
170
  return `${spineFile}.stop`;
171
171
  }
172
+
173
+ /**
174
+ * Did a run (or one leg) END on a cap? The ONE spelling of the rule every surface follows (hamr 2026-10-08): the
175
+ * outcome is `cap-halt` / `wall-halt`, or it is `escalated` and the last escalation's category names the cap —
176
+ * `wall-halt`, or `cap-halt` backed by a `money-halt` record (a strike-ladder `cap-halt` is not the money cap).
177
+ * @param {{outcome: string|null|undefined, category?: string|null, moneyHalt?: boolean}} o
178
+ * @returns {'money'|'time'|null}
179
+ */
180
+ export function capKindOf(o) {
181
+ if (o.outcome === 'cap-halt') return 'money';
182
+ if (o.outcome === 'wall-halt') return 'time';
183
+ if (o.outcome !== 'escalated') return null;
184
+ if (o.category === 'wall-halt') return 'time';
185
+ return o.category === 'cap-halt' && o.moneyHalt ? 'money' : null;
186
+ }
@@ -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') {
@@ -243,6 +254,13 @@ export function createAuthorRoutes(opts) {
243
254
  const pre = opts.startFrom ? (fromImport !== null ? opts.startFrom.getImport(fromImport) : opts.startFrom.get(fromRunid)) : null;
244
255
  if (pre === null || pre === undefined) { send(404, { ok: false, error: 'no such run to reuse' }); return true; }
245
256
  if (!pre.ok) { send(409, pre); return true; }
257
+ // only a run that earned a real green is a reuse source (hamr 2026-10-08) — the pickers already filter, but a
258
+ // hand-made POST must not sign a reuse of an already-green, red or still-running run. Imports are green by ruling;
259
+ // Edit in chat and Resume only READ start-from (GET) and never post a startFrom, so this gate never touches them.
260
+ if (fromImport === null && pre.outcome !== 'green') {
261
+ send(409, { ok: false, error: 'only a run that finished green can be reused — this one did not' });
262
+ return true;
263
+ }
246
264
  const locked = lockedFieldChanged(card, pre.card);
247
265
  if (locked !== null) { send(400, { ok: false, error: `${locked} is locked on a reused workflow — Clear the card to change it` }); return true; }
248
266
  const rv = validateReuseCard(card, { rows });
@@ -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.
@@ -644,10 +641,11 @@ export function createSession(card, deps = {}) {
644
641
  // a detached checkout of the person's current commit, inside their
645
642
  // repo), so a JS/TS repo's tree never carries `node_modules` until
646
643
  // someone installs there. Refusing outright here was ITSELF the F182
647
- // class of bug one layer up. bareloop still never runs an install
648
- // itself — this only re-checks the SAME worktree on demand, via the
649
- // "Check again" button (`checkDeps()`, below), instead of ending the
650
- // session. (P6 item 3: for an npm lock file bareloop runs `npm ci` itself first, above.)
644
+ // class of bug one layer up. For an npm lock file bareloop
645
+ // runs `npm ci --ignore-scripts` itself first (P6 item 3, below); any other
646
+ // gap (or a failed install) waits on the person: this re-checks the SAME
647
+ // worktree on demand, via the "Check again" button (`checkDeps()`, below),
648
+ // instead of ending the session.
651
649
  let depsGap = missingDependencies(prep.tree, prep.manifest.sourceSubdir ?? '');
652
650
  // P6 item 3: a committed npm lock file -> bareloop runs `npm ci --ignore-scripts` itself, $0, before
653
651
  // any model call. Any failure (or another lock file) falls through to the wait below, unchanged.