bareloop 0.35.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,56 @@ 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
+
30
+
31
+ ## [0.36.0] - 2026-10-06
32
+
33
+ ### Added
34
+
35
+ - **Repo jobs run in a git worktree in the person's own repo.** A panel drafting session makes `<repo>/.bareloop/wt/<session>` (detached at HEAD, hidden through the repo's private exclude, with a notice for uncommitted changes) instead of copying the source. `git worktree add` and the green-end commit run none of the person's git hooks. `node_modules/` is hidden the same way as `/.bareloop/`, so installed packages never read as worker writes. A GREEN run commits its work to the work branch and removes the folder (kept only while a review door is open); any other end keeps it. The Ended block gives the `git merge` / `git branch -D` text on green and names the folder otherwise; Resume is withdrawn once the folder is gone. The bundle door shares the one worktree-making module.
36
+ - **bareloop installs npm dependencies itself.** For an npm lock file, bareloop runs `npm ci --ignore-scripts` at $0, before any token, with a secret-stripped environment. Any failure, or another lock file (yarn, pnpm), falls back to the install-needed wait. `missingDependencies` now carries `dir` and `lockFile`. The panel step reads "installing packages" while it runs and "Installed packages (npm ci)." on success.
37
+ - **Run names `job (run-N)`.** Runs are numbered per job, oldest first (`runNo` in the run list and run detail); rows, cards, the workflow row and the header use the name. The real run id stays in the Resume and Reuse texts. Runs search also finds `run-N` and `job (run-N)`.
38
+ - **Settings: price per key row.** The Providers table takes In $/1M and Out $/1M per key (blank = not set; a half-set price is refused; the price posts once both boxes are filled or both are empty). Columns are Key, Name, API shape, Base URL, Test, Tokens used, Balance, In $/1M, Out $/1M.
39
+ - **Settings: Open keys folder button** (`POST /api/settings/open-keys-folder`) replaces the keys path text; the path shows only when the folder cannot be opened.
40
+ - **Drafting part.** A panel-authored run serves its drafting as the first part on the Run map, its card and the Audit grouped view, with a per-step table (time, calls, model, cost, total) read from a per-session `draft-log.jsonl`.
41
+ - **Runs list cards are two lines**: workflow card `job (run-N) word / check, cost, wall, date`; sub-card `(run-N) word / cost, wall, date`.
42
+
43
+ ### Fixed
44
+
45
+ - A gate-audit move across devices no longer crashes a worktree run: `moveFile` falls back to copy and unlink on `EXDEV` (the workdir can sit on another drive than the spine dir).
46
+ - A green worktree run always commits its work to the branch, including door-open and leak greens, and does so right after the gate-audit move, before any later readout step can throw.
47
+ - An already-green worktree run removes its folder (and commits a resumed leg's uncommitted work) instead of leaving it behind.
48
+ - Panel: a price box typed alone no longer flashes the half-set error; card textareas auto-grow as you type.
49
+ - Tests no longer write settled rows into the real run list (`cli.test.js` passes `keysHome` with `runlistHome`). Test-only.
50
+
51
+ ### Changed
52
+
53
+ - A secret already in the person's own repo no longer refuses a worktree repo source: it is masked wherever bareloop records it. A `.env` file by name is still refused.
54
+ - The link-login item is dropped; the panel's token-in-page model is unchanged.
55
+ - The progress step label "copying source" reads "making worktree".
56
+ - `prepareSource`'s manifest type is `Record<string, any>` (types only).
7
57
 
8
58
  ## [0.35.0] - 2026-10-05
9
59
 
@@ -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
@@ -3363,7 +3364,12 @@ key you want first (the panel picks the row by the Model menu).
3363
3364
  list by job for the left pane's "Workflows" view and renders it flat for "History", both
3364
3365
  behind one `Runs` tab toggle; there is no separate `/api/workflows` endpoint, deleted in
3365
3366
  the P1 Runs-tab merge); `GET /api/runs/:runid` (the full replay — parts/steps, counters,
3366
- summary, model/provider/judge); `GET /api/runs/:runid/audit` (the gate-audit sidecar rows,
3367
+ summary, model/provider/judge). Every `GET /api/runs` row and the detail carry `runNo` (P6 item 7): runs of one job are numbered
3368
+ oldest `at` first by ONE server function (`runNumbers`), and the page names a run `job (run-N)`; the run id stays in the Audit
3369
+ tab and every command line. A run that came out of a panel drafting session also carries the DRAFTING part as the FIRST element of
3370
+ `detail.parts` (`kind:'drafting'`, built from the session's `draft-log.jsonl`, `src/draftspend.js`): every other index a reader
3371
+ is handed (`legDividers.beforePart`, the flat Audit rows' `partIndex`, the rounds endpoint's `part`) lives in that one shifted
3372
+ index space, and the drafting part has no rounds table (`rounds?part=0` is a 404). `GET /api/runs/:runid/audit` (the gate-audit sidecar rows,
3367
3373
  ts-windowed to this run alone even when the sidecar file is shared with other runs, plus
3368
3374
  the matching raw-log text windowed the same way — when one resolves); `GET
3369
3375
  /api/runs/:runid/rounds?part=&attempt=&offset=&limit=` (one attempt's rounds, lazily
@@ -3381,7 +3387,8 @@ key you want first (the panel picks the row by the Model menu).
3381
3387
  GET/HEAD answers only when its `Host` header is the panel's own `127.0.0.1:<port>` (`checkHostGuard`,
3382
3388
  `src/panel/authorroutes.js`; any other Host is `403`, no token needed to read). Every
3383
3389
  P1 endpoint is GET/HEAD only (anything else — including every write verb — is `405`, outside the
3384
- 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
3385
3392
  never joins a path segment into a filesystem read — a runid is looked up in the run list
3386
3393
  first (`RUNID_RE`, `src/panel/server.js` — accepts a `~2`-style backfill-disambiguated
3387
3394
  suffix too, and every endpoint echoes back the LISTED runid, never a filename-derived one
@@ -3436,7 +3443,8 @@ key you want first (the panel picks the row by the Model menu).
3436
3443
  completion; the page sees the key's NAME and a status word, never the value). `POST
3437
3444
  /api/author/:id/check-deps` is the install-gap's "Check again" (the main button's wording while waiting on an install) (`phase:'install-needed'`:
3438
3445
  the session waits on the person's own install, then re-runs `missingDependencies` on the
3439
- same copy — bareloop never installs). `POST /api/author/:id/abandon` (the Chat card's **Abandon** button, 2026-10-05) ends a
3446
+ same worktree — the AGENT never installs; for a committed npm lock file the panel door first runs `npm ci --ignore-scripts` itself,
3447
+ $0, before any token (`src/npminstall.js`, credential env stripped), and only a failure, a timeout or another lock file lands here). `POST /api/author/:id/abandon` (the Chat card's **Abandon** button, 2026-10-05) ends a
3440
3448
  LIVE, unsigned session: phase becomes the terminal `abandoned` (releasing the one-at-a-time lock), money already booked
3441
3449
  stays booked (`draftSpentUsd` is never zeroed; a call in flight still books its usage), no NEW model call starts;
3442
3450
  `400` on a session that is not live, `404` unknown id, same guard as the other author routes. After `signed` the card's
@@ -3502,6 +3510,14 @@ key you want first (the panel picks the row by the Model menu).
3502
3510
  `job-end` timestamp, null while live or died). `GET /api/imports` rows and `GET /api/imports/:id` carry `runStatus`
3503
3511
  (passed, or null when the bundle shows no green) and `runReason` (`goal met`). The Ended block's lines for died and
3504
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".
3505
3521
  **Resume (item 2)** → `POST /api/runs/:runid/resume` (`src/panel/runroutes.js`), behind
3506
3522
  `checkHumanGuard` (token + own address, like `/api/author/*`); body `{budgetUsd?, maxWallMin?}`
3507
3523
  (blank = the signed caps). `404` unknown run; `409` when `resumePlanFor` says the engine would
@@ -3524,26 +3540,30 @@ key you want first (the panel picks the row by the Model menu).
3524
3540
  spelling), and signals nothing. `404` unknown run; `409` when the run's LATEST leg is not live
3525
3541
  (`runIsAlive`) or its spine does not exist yet (still starting). The ENGINE half is library code, so
3526
3542
  CLI runs honour the file too (no new CLI command): `runJob`/`runPlan` take `stopFile`, and `run-u`/the
3527
- 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
3528
3545
  (`src/planrun.js`, the `metered` round callback): file present = delete it, emit `stop-requested
3529
- {phase, step, iteration, round}`, end the Loop after that round; `ask` then throws category `stopped`
3530
- 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`)
3531
3549
  (`spendComplete` exact — nothing is in flight at a round boundary). One owner, one check: a one-step run
3532
- 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
3533
3551
  leg ends (`finally`), and at the start of the next leg (a click that raced a leg's end never stops a
3534
3552
  resume). `stopped` is in `CHECKPOINT_OUTCOMES`: resumable like a `cap-halt`, the same run continues as a
3535
3553
  new leg at the next step. `GET /api/runs/:runid` carries `live` and `stopping` (a stop request is on
3536
3554
  disk and the leg is live); Ended row `stopped` = "You stopped it." / "Resume." / `[Resume]`, card line
3537
3555
  `stopped — resume`, Audit divider `stopped: you stopped it · resumed <when>`. The page shows `[Stop]`
3538
3556
  in the Run tab's action area while live, "stopping after this turn…" after the click, and the same
3539
- `[Resume]` there once the engine would accept one. The seam reads the file in STEP workers only
3540
- (`phase` `step:<id>`): the scout, plan and close-fix rounds never read it, so a Stop clicked while the run
3541
- is in one of those phases is not honoured until a step round runs again, and is otherwise cleared when
3542
- 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).
3543
3560
  **Reuse workflow (replaces P5 item 3's Start from this, 2026-10-03)** — a button on GREEN runs only (the Run tab's
3544
3561
  action row and the Ended block; `ended.actions` carries `{id:'reuse', label:'Reuse workflow'}` on green and
3545
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
3546
- 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
3547
3567
  filled from the SIGNED job: a NEW run (new runid), never a rerun, nothing drafted ($0). **Only five boxes are
3548
3568
  open — Source, Destination, Model, $ cap, Time cap**; every other box (check type, model, job name, goal, success,
3549
3569
  guardrails, judge examples) is the signed workflow, greyed, and the SERVER refuses a start that changed one
@@ -3555,7 +3575,10 @@ key you want first (the panel picks the row by the Model menu).
3555
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
3556
3576
  dependency or no longer matches its signed bytes); `POST /api/author/start` accepts `startFrom: <runid>` or
3557
3577
  `startFrom: {importId}` beside the card and answers `{ok, sessionId, reuse, state}`. The old
3558
- `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
3559
3582
  signature hash `jobSpecHash` is unchanged (it covers the caps and the write fence — what the person signs);
3560
3583
  `workflowKey(spec)` (`src/job.js`, not exported from the package root) is a sha256 over the signed spec WITHOUT
3561
3584
  `source`, `destination`, `writeScope`, `budgetUsd` and `maxWallMs` (`REUSE_OPEN_SPEC_FIELDS`) AND without `provider`,
@@ -3581,7 +3604,8 @@ key you want first (the panel picks the row by the Model menu).
3581
3604
  is still written beside `resolved-spec.json` when a session reaches `prepared`.
3582
3605
  **The progress list (2026-10-04)** — `GET /api/author/:id` state carries `steps: [{id, label, status:
3583
3606
  'running'|'done'|'failed', detail}]`, one entry per pipeline step in first-seen order, updated in place (ids/labels:
3584
- `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
3585
3609
  `state.error` and fails exactly one step with the same text as its `detail`; pipeline text is never a chat message
3586
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
3587
3611
  fence (relative `writeScope` globs, never proven as a directory); for a folder source an absolute directory. Both
@@ -3589,7 +3613,7 @@ key you want first (the panel picks the row by the Model menu).
3589
3613
  Destination on a repo source → `Destination must be a path inside the repo, like src/digest.js` (before the
3590
3614
  copy); a source that is not a git repo → a plain refusal worded by the kind the source door recorded (a single
3591
3615
  file: "Source must be the repo folder — put the file in Destination"; a plain folder or a URL: "not a code
3592
- project (no git repo found)").
3616
+ project (no git repo found)") — `nonRepoSourceMessage` (`src/source.js`), the one spelling the CLI `run-author` uses too.
3593
3617
  **Import, read only (item 4)** — `src/panel/importroutes.js`. The Workflows toolbar's `[Import]` opens a folder
3594
3618
  browser plus a paste box; an imported job is an exported bundle folder the person can LOOK at: nothing runs,
3595
3619
  nothing is signed, the folder is never written. Routes, all behind `checkHumanGuard` (token + own address —
@@ -3850,12 +3874,17 @@ placeholder `no-key-needed`, never the word `null`). Each row: the env NAME (rea
3850
3874
  **Name** (the model id Chat uses), **API shape** (Anthropic / OpenAI-compatible / Gemini), **Base
3851
3875
  URL** (blank = the shape's own host), **Test**, **Tokens used** (total, display only), and Balance
3852
3876
  (DeepSeek fetched, Anthropic a typed note). Saves on change to `config.json`
3853
- `keys.<ENV NAME> = { name, shape, baseUrl }`. Defaults for a key with no saved entry:
3877
+ `keys.<ENV NAME> = { name, shape, baseUrl, priceInPerM?, priceOutPerM? }`. The last two columns are the customer's own price
3878
+ (**In $/1M**, **Out $/1M**, plain boxes; blank = not set, a number >= 0 otherwise; the server refuses a half-set pair with a plain
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).
3881
+ **Open keys folder** (`POST /api/settings/open-keys-folder`) opens `~/.config/bareloop` in the file manager (`xdg-open` / `open`,
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:
3854
3883
  `ANTHROPIC_API_KEY` claude-sonnet-5 / Anthropic; `DEEPSEEK_API_KEY` deepseek-flash /
3855
3884
  OpenAI-compatible / `https://api.deepseek.com/v1`; `OPENAI_API_KEY` OpenAI-compatible;
3856
3885
  `GEMINI_API_KEY` Gemini; `LOCAL_API_KEY` OpenAI-compatible / `http://127.0.0.1:11434/v1` (Ollama, LM
3857
- Studio, llama.cpp: change the URL); any other name OpenAI-compatible; Name blank until typed. **No
3858
- price is shown anywhere** (display only — cost recording, the estimated provenance, spend sums, caps
3886
+ Studio, llama.cpp: change the URL); any other name OpenAI-compatible; Name blank until typed. **The
3887
+ only price shown is the customer's own, typed in the two price boxes** (cost recording, the estimated provenance, spend sums, caps
3859
3888
  and the monthly limit are unchanged; a local model has no known rate, so its rounds come out as a
3860
3889
  non-zero loud guess, never a real $0).
3861
3890
 
@@ -3895,6 +3924,23 @@ const prepared = await prepareSource({
3895
3924
  Or the CLI: `node scripts/prep-source.mjs --source <path-or-url> --into <dir> [--destination
3896
3925
  <absolute-directory>]` — prints the tree path, the seed, and the exact next command.
3897
3926
 
3927
+ **A REPO source on a worktree (P6 item 1, the panel's repo jobs).** Pass `worktree: '<repo>/.bareloop/wt/<id>'` to
3928
+ `prepareSource` and a repo source is NOT copied: its tree is a detached `git worktree` of the person's own repo at their current
3929
+ commit (`src/worktree.js`, the one spelling the bundle door shares). `prepared.tree` is that worktree; `into` holds only
3930
+ `source.json`, which records `worktree`, `repo` and `seed` (= the repo's HEAD — no seed commit is made, no `output/` scaffold
3931
+ is written). Uncommitted edits in the person's repo are not in it (the panel says so at drafting start: "N uncommitted
3932
+ change(s) in your repo are not in this job — it starts from commit <sha>"). `.bareloop/` and `node_modules/` (any depth, so packages installed in the worktree never read as worker writes) are hidden from the person's own
3933
+ `git status` through the repo's private `<common-git-dir>/info/exclude`, never their tracked `.gitignore`. The person's git hooks never run when bareloop makes the worktree (either door). The signed run
3934
+ (`run-u --spec`) reads `worktree` from that manifest; its spine and books stay in the session directory beside `source.json`,
3935
+ never inside the person's repo. At the end: GREEN (an already-green run too, which has nothing to commit unless it is a resumed
3936
+ leg) ALWAYS commits the run's work on its `bareloop-<job>` work branch (authored by `bareloop`, hooks off, arbiter books and
3937
+ `node_modules` left out) — including a green that opened a review door or whose spine leaked — and removes the worktree folder,
3938
+ except while a review door is open: that folder is KEPT (a door accept re-runs the mechanical stages against it). The branch stays and the
3939
+ merge stays the person's (`git merge <branch>` / `git branch -D <branch>` are shown as text). Any other ending (stopped,
3940
+ capped, died, red) leaves the folder so Resume can re-enter it; a drafting session that ends without ever running (refused,
3941
+ abandoned) removes its worktree. A panel restart mid-drafting loses the in-memory session and leaves its worktree behind
3942
+ (`git worktree list` shows it; `git worktree remove --force <folder>` clears it).
3943
+
3898
3944
  > **If your source is a folder, read this first (hamr's ruling):** make a new folder, put
3899
3945
  > only the file(s) this job needs in it, point bareloop at that — never your original
3900
3946
  > folder. Everything in the folder you name is frozen, read, and paid for. `prep-source`
@@ -3996,13 +4042,14 @@ the run's own scratch tree.
3996
4042
  **`prepareSource` copies ONLY git-tracked files** (hamr's ruling, unchanged) — a JS/TS repo's
3997
4043
  prepared copy therefore never carries `node_modules`, and every close stage that needs a tool
3998
4044
  (`tsc`, a test runner) would instrument-stop with nothing telling the person why. bareloop
3999
- NEVER runs an install itself — it only ever touches gated primitives, never a shell-out to
4000
- `npm`/`pnpm`/`yarn`/`bun` on the person's behalf. `missingDependencies` is exported ($0,
4045
+ NEVER runs an install itself for the agent or the CLI doors — the agent only touches gated primitives, never a shell-out to
4046
+ `npm`/`pnpm`/`yarn`/`bun` on the person's behalf (the one exception is the panel door's own pre-token `npm ci --ignore-scripts` for an
4047
+ npm lock file, P6 item 3, `src/npminstall.js`; it is never a verb the agent holds). `missingDependencies` is exported ($0,
4001
4048
  JS/TS only for now — other languages return `null`, M3b's job): it takes `(treeDir,
4002
4049
  sourceSubdir?)` and walks from `sourceSubdir` up to `treeDir` (the same nearest-manifest rule
4003
4050
  `detectLanguage` uses, done independently to avoid a `source.js` ⇄ `detectlang.js` import
4004
4051
  cycle), and when the nearest `package.json` names a non-empty `dependencies`/`devDependencies`
4005
- with no sibling `node_modules`, it returns `{manager, command, reason}` — the exact install
4052
+ with no sibling `node_modules`, it returns `{dir, lockFile, manager, command, reason}` (`dir` = the folder holding the manifest, `lockFile` = the committed lock file or `null`) — the exact install
4006
4053
  line (`npm ci`/`pnpm install --frozen-lockfile`/`yarn install --frozen-lockfile`/`bun install
4007
4054
  --frozen-lockfile` by lockfile, else `npm install`), `cd`-prefixed for a subfolder job. An
4008
4055
  invalid `package.json`, no manifest found, or `node_modules` already present all return `null`
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bareloop",
3
- "version": "0.35.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
@@ -104,7 +104,7 @@ import { keysForDoor } from './keysfile.js';
104
104
  import { applyConfiguredKey, judgeRatesFor, keyNameFor, ratesFor, rowsForHome } from './providerrows.js';
105
105
  import { ConfigError } from './config.js';
106
106
  import {
107
- readFileSync, writeFileSync, appendFileSync, mkdirSync, existsSync, renameSync, statSync,
107
+ readFileSync, writeFileSync, appendFileSync, mkdirSync, existsSync, statSync,
108
108
  } from 'node:fs';
109
109
  import { dirname, join, resolve } from 'node:path';
110
110
  import { createInterface } from 'node:readline';
@@ -120,10 +120,11 @@ import { defaultJudgeLoop, resolveJobJudge } from './judged.js';
120
120
  import { validateJob, jobSpecHash, resolveWorkerModel } from './job.js';
121
121
  import { scanSecrets, redactSecrets } from './validate.js';
122
122
  import { detectLanguage } from './detectlang.js';
123
+ import { moveFile } from './movefile.js';
123
124
  import { closeJudges, GATE_AUDIT_FILE } from './kinds.js';
124
125
  import { resolveProvider, buildRunnerProviders, apiKeyProblem } from './providers.js';
125
126
  import { commandFor } from './invoke.js';
126
- import { readSourceManifest, missingDependencies } from './source.js';
127
+ import { readSourceManifest, missingDependencies, nonRepoSourceMessage } from './source.js';
127
128
  import { tallyCalls } from './text.js';
128
129
  import {
129
130
  declarationLines, rubricLines, calibrationLines, parseCeiling, ceilingLine, crashRecord, phaseLine,
@@ -288,7 +289,7 @@ export async function main(argv, deps = {}) {
288
289
  if (!existsSync(treeAudit)) return;
289
290
  const archived = join(OUT, `author-${runid}-${GATE_AUDIT_FILE}`);
290
291
  try {
291
- renameSync(treeAudit, archived);
292
+ moveFile(treeAudit, archived);
292
293
  out(`gate audit ${archived} (moved out of the patient tree — F186)`);
293
294
  } catch (e) {
294
295
  err(`gate audit could not be archived out of the patient tree: ${/** @type {NodeJS.ErrnoException} */ (e)?.message ?? e}`);
@@ -835,8 +836,7 @@ export async function main(argv, deps = {}) {
835
836
  // confirm turn, no model call — a plain folder's spine is exactly
836
837
  // `author-start` → `author-end{outcome:'not-authored', stop:'non-code-source'}`.
837
838
  if (!IS_REPO_SOURCE) {
838
- const message = "This is a plain folder, not a code project. bareloop can't check this kind of "
839
- + 'job yet. Nothing was spent. Your source was not changed.';
839
+ const message = nonRepoSourceMessage(manifestRead.manifest.kind);
840
840
  out(`\n${message}`);
841
841
  const red = {
842
842
  code: 'request-red', path: 'source', verb: 'non-code-source', lib: 'bareloop',
package/src/bundlerun.js CHANGED
@@ -16,12 +16,13 @@
16
16
  import { hostname } from 'node:os';
17
17
  import { execFileSync } from 'node:child_process';
18
18
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
19
- import { dirname, join, resolve } from 'node:path';
19
+ import { join, resolve } from 'node:path';
20
20
 
21
21
  import {
22
22
  readBundle, resolveBundleSpec, checkEnvelope, bless, verifyBlessing, appendHistory, checkBundleDeps,
23
23
  } from './bundle.js';
24
24
  import { jobSpecHash } from './job.js';
25
+ import { addWorktree, worktreePath } from './worktree.js';
25
26
  import { parseJsonl } from './replayio.js';
26
27
  import { legsOf } from './legs.js';
27
28
  import { startRun, resumeRun } from './userrun.js';
@@ -165,7 +166,7 @@ export async function bundleMain(args, {
165
166
  err(`--repo ${repoArg} must be a git repository with at least one commit: ${/** @type {Error} */ (e).message}`);
166
167
  return 1;
167
168
  }
168
- worktree = join(repo, '.bareloop', 'wt', runid);
169
+ worktree = worktreePath(repo, runid);
169
170
  if (existsSync(worktree)) { err(`worktree already exists: ${worktree}`); return 1; }
170
171
  }
171
172
 
@@ -186,12 +187,7 @@ export async function bundleMain(args, {
186
187
  // at job-end (a killed run has none) and the run list is machine-global.
187
188
  const prepareTree = () => {
188
189
  if (deadSpine === null) {
189
- mkdirSync(dirname(worktree), { recursive: true });
190
- try {
191
- execFileSync('git', ['-C', repo, 'worktree', 'add', '--detach', worktree, 'HEAD'], { encoding: 'utf8' });
192
- } catch (e) {
193
- throw new Error(`git worktree add failed: ${/** @type {Error} */ (e).message}`);
194
- }
190
+ addWorktree(repo, worktree);
195
191
  }
196
192
  mkdirSync(runDir, { recursive: true });
197
193
  writeFileSync(join(runDir, 'run.json'), `${JSON.stringify({
package/src/draftspend.js CHANGED
@@ -12,11 +12,32 @@
12
12
  // Month attribution is the file's `startedAt` (the first metered call): fixed once written, so a session's
13
13
  // figure never moves between months as spend continues, the same start-date rule a run's leg uses.
14
14
  // `spendComplete: false` = "at least", never $0 (F6).
15
- import { existsSync, readdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
15
+ import { appendFileSync, existsSync, readdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
16
16
  import { join, resolve, sep } from 'node:path';
17
17
  import { readRunList, runlistHome } from './runlist.js';
18
18
 
19
19
  export const DRAFT_SPEND_FILE = 'draft-spend.json';
20
+ /** the session's drafting LOG: an append-only JSONL beside draft-spend.json (one owner: the session, src/panel/authorsession.js). Three event kinds, see {@link DraftEvent}. */
21
+ export const DRAFT_LOG_FILE = 'draft-log.jsonl';
22
+
23
+ /**
24
+ * One line of the drafting log. `no` = the step's position in the session's progress list (a step id can repeat).
25
+ * - `step-start` {no, id, label, at}
26
+ * - `step-end` {no, status: 'done'|'failed', label, at} (a re-opened step writes `step-reopen` {no, at} first)
27
+ * - `call` {no, step, label, model, costUsd (null stays null), unpricedRounds, at}: one METERED model call
28
+ * (no token counts: the call path does not carry them without reworking the library, so none are recorded)
29
+ * @typedef {{kind: string, no: number, at: string, [k: string]: any}} DraftEvent
30
+ */
31
+
32
+ /**
33
+ * Append one event to `<dir>/draft-log.jsonl`. Best-effort like draft-spend.json: a disk fault never stops the draft.
34
+ * @param {string} dir the session's folder
35
+ * @param {DraftEvent} ev
36
+ * @returns {void}
37
+ */
38
+ export function appendDraftLog(dir, ev) {
39
+ try { appendFileSync(join(dir, DRAFT_LOG_FILE), `${JSON.stringify(ev)}\n`); } catch { /* money already spent stays in memory */ }
40
+ }
20
41
 
21
42
  /**
22
43
  * @typedef {object} DraftSpend
@@ -80,3 +101,95 @@ export function readOrphanDraftSpends(opts = {}) {
80
101
  }
81
102
  return out;
82
103
  }
104
+
105
+ /**
106
+ * The session folder a run-list row became a run in: the folder under `<home>/panel-sessions` that carries a drafting
107
+ * log and holds the row's `spine` or `patient` ("became a run", the same rule as {@link readOrphanDraftSpends}).
108
+ * A Reuse-workflow run, a CLI run and an imported run sit in no such folder.
109
+ * @param {{spine?: string, patient?: string|null}} row
110
+ * @param {{ home?: string, sessionsRoot?: string }} [opts]
111
+ * @returns {string|null}
112
+ */
113
+ export function sessionDirForRow(row, opts = {}) {
114
+ const root = resolve(opts.sessionsRoot ?? join(runlistHome(opts.home), 'panel-sessions'));
115
+ /** @type {string[]} */
116
+ let names;
117
+ try { names = readdirSync(root); } catch { return null; }
118
+ for (const name of names) {
119
+ const dir = join(root, name);
120
+ if (!existsSync(join(dir, DRAFT_LOG_FILE))) continue;
121
+ if (inside(/** @type {string} */ (row.spine), dir) || (row.patient && inside(row.patient, dir))) return dir;
122
+ }
123
+ return null;
124
+ }
125
+
126
+ /**
127
+ * Fold `<dir>/draft-log.jsonl` into one record per drafting step, each with its calls. A line that cannot be parsed is
128
+ * skipped (a torn last line must not hide the rest).
129
+ * @param {string} dir
130
+ * @returns {{no: number, id: string, label: string, status: 'running'|'done'|'failed', startedAt: string, endedAt: string|null, wallMs: number|null,
131
+ * calls: {label: string, model: string|null, costUsd: number|null, unpricedRounds: number, at: string}[]}[]}
132
+ */
133
+ export function readDraftSteps(dir) {
134
+ /** @type {string} */
135
+ let text;
136
+ try { text = readFileSync(join(dir, DRAFT_LOG_FILE), 'utf8'); } catch { return []; }
137
+ /** @type {Map<number, any>} */
138
+ const byNo = new Map();
139
+ for (const line of text.split('\n')) {
140
+ if (!line.trim()) continue;
141
+ let ev;
142
+ try { ev = JSON.parse(line); } catch { continue; }
143
+ if (!ev || typeof ev !== 'object' || !Number.isInteger(ev.no)) continue;
144
+ if (ev.kind === 'step-start') {
145
+ byNo.set(ev.no, { no: ev.no, id: String(ev.id), label: String(ev.label), status: 'running', startedAt: ev.at, endedAt: null, wallMs: null, calls: [] });
146
+ } else if (ev.kind === 'step-end' && byNo.has(ev.no)) {
147
+ const x = byNo.get(ev.no);
148
+ x.status = ev.status === 'failed' ? 'failed' : 'done';
149
+ x.endedAt = ev.at;
150
+ if (typeof ev.label === 'string') x.label = ev.label;
151
+ } else if (ev.kind === 'step-reopen' && byNo.has(ev.no)) {
152
+ const x = byNo.get(ev.no);
153
+ x.status = 'running';
154
+ x.endedAt = null;
155
+ } else if (ev.kind === 'call') {
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) };
157
+ byNo.get(ev.no)?.calls.push(c);
158
+ }
159
+ }
160
+ const steps = [...byNo.values()].sort((a, b) => a.no - b.no);
161
+ for (const x of steps) {
162
+ const a = Date.parse(x.startedAt);
163
+ const b = x.endedAt === null ? NaN : Date.parse(x.endedAt);
164
+ x.wallMs = Number.isFinite(a) && Number.isFinite(b) ? Math.max(0, b - a) : null;
165
+ }
166
+ return steps;
167
+ }
168
+
169
+ /**
170
+ * The DRAFTING PART of a run (hamr 2026-10-06, option A): the first part of the run's views, built from the session's
171
+ * drafting log. `null` when the run did not come out of a panel session, or the log carries no metered call (a Reuse
172
+ * workflow run, a CLI run, a run older than the log). The part's money is the SPINE's figure (`job-start.draftSpentUsd`,
173
+ * passed in) — the log's calls are shown beside it, never summed into a second total; `null` spine figure = no part.
174
+ * @param {{spine?: string, patient?: string|null}} row
175
+ * @param {{draftSpentUsd: number|null, draftSpendComplete: boolean|null}} spine the run's own drafting figures
176
+ * @param {{ home?: string, sessionsRoot?: string }} [opts]
177
+ * @returns {any|null}
178
+ */
179
+ export function draftingPartFor(row, spine, opts = {}) {
180
+ if (spine.draftSpentUsd === null) return null;
181
+ const dir = sessionDirForRow(row, opts);
182
+ if (!dir) return null;
183
+ const steps = readDraftSteps(dir);
184
+ const calls = steps.reduce((n, x) => n + x.calls.length, 0);
185
+ if (calls === 0) return null;
186
+ const starts = steps.map((x) => Date.parse(x.startedAt)).filter(Number.isFinite);
187
+ const ends = steps.map((x) => (x.endedAt === null ? NaN : Date.parse(x.endedAt))).filter(Number.isFinite);
188
+ return {
189
+ kind: 'drafting', id: 'drafting', label: 'drafting', occurrence: 1, leg: 1, outcome: null, blocked: 0,
190
+ attempts: [], rounds: calls, toolCalls: 0, byTool: null,
191
+ wallMs: starts.length && ends.length ? Math.max(0, Math.max(...ends) - Math.min(...starts)) : null,
192
+ spentUsd: spine.draftSpentUsd, spendComplete: spine.draftSpendComplete !== false, unpricedRounds: 0,
193
+ draftSteps: steps,
194
+ };
195
+ }
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/index.js CHANGED
@@ -153,9 +153,9 @@ export { answerReviewDoor } from './reviewdoor.js';
153
153
  // but no longer read by any grading path), the rulebook a card SELECTS from,
154
154
  // the card gate, and the two halves themselves so a calibration harness can
155
155
  // grade the whole pipe without running a close.
156
- // `defaultJudgeLoop` is the one spelling of how this repo drives a judge; the
157
- // runner reaches it through `runPlan`'s `judgeProvider`, and it is exported so a
158
- // caller building its own harness does not spell a second one.
156
+ // `defaultJudgeLoop` (src/judged.js) is the one spelling of how this repo drives a
157
+ // judge; the runner reaches it through `runPlan`'s `judgeProvider`. It is internal:
158
+ // the package root does not export it.
159
159
  export {
160
160
  JUDGE_MODEL, resolveJudge, JUDGE_RULES, JUDGE_RULE_IDS,
161
161
  runLocate, decide,
package/src/legs.js CHANGED
@@ -160,7 +160,7 @@ export function watchdogNotePath(spineFile, leg) {
160
160
 
161
161
  /**
162
162
  * P5 item 5 (Stop) — where a run's STOP REQUEST lives: a file beside its spine. The panel's stop route writes it;
163
- * the engine reads it at the between-steps seam (src/planrun.js) and consumes it. One spelling, here, so the route
163
+ * the engine reads it at the round seam of a step worker (src/planrun.js `metered`) and consumes it. One spelling, here, so the route
164
164
  * and the engine can never name two files. A request belongs to ONE leg: the engine deletes it when it honours it,
165
165
  * when the leg ends, and again at the start of the next leg (src/userrun.js).
166
166
  * @param {string} spineFile
@@ -0,0 +1,22 @@
1
+ // Move a file between two paths that may sit on DIFFERENT filesystems.
2
+ // `renameSync` cannot cross a device (EXDEV): a worktree inside the person's own repo can be on /tmp or
3
+ // another drive while the spine / output dir stays under ~/.config. EXDEV only falls back to copy + unlink;
4
+ // every other error rethrows untouched. Renames inside one directory (atomic tmp -> final writes) don't need this.
5
+ import { renameSync, copyFileSync, unlinkSync } from 'node:fs';
6
+
7
+ /**
8
+ * @param {string} src
9
+ * @param {string} dst
10
+ * @param {{ rename?: (a: string, b: string) => void }} [deps] test seam: the rename to try first
11
+ * @returns {void}
12
+ */
13
+ export function moveFile(src, dst, deps = {}) {
14
+ const rename = deps.rename ?? renameSync;
15
+ try {
16
+ rename(src, dst);
17
+ } catch (e) {
18
+ if (/** @type {NodeJS.ErrnoException} */ (e)?.code !== 'EXDEV') throw e;
19
+ copyFileSync(src, dst);
20
+ unlinkSync(src);
21
+ }
22
+ }
@@ -0,0 +1,55 @@
1
+ // P6 item 3 (signed 2026-10-05): bareloop installs a JS repo's packages ITSELF, $0, before any token,
2
+ // for the one case whose command is exact and offline-reproducible: a committed `package-lock.json` /
3
+ // `npm-shrinkwrap.json` -> `npm ci --ignore-scripts`. It is the panel door's own step, never a verb the
4
+ // agent is granted (the tool menu is untouched): the agent never sees, asks for, or triggers it.
5
+ // Anything else (another lock file, no lock file, npm missing, a failure, a timeout) returns `ok:false`
6
+ // and the caller falls back to the install-needed wait with the real command.
7
+
8
+ import { existsSync } from 'node:fs';
9
+ import { spawnClose, CLOSE_ENV_DENY } from './ralph.js';
10
+ import { redactSecrets } from './validate.js';
11
+
12
+ /** Wall-clock cap for one `npm ci`: 10 minutes. A cold install of a large repo is minutes, never an hour;
13
+ * past this the person is better served by the printed command in their own terminal. Operator-set. */
14
+ export const NPM_CI_TIMEOUT_MS = 600_000;
15
+
16
+ /** the lock files whose gap command is `npm ci` (the rule table in src/source.js, LOCKFILE_COMMANDS) */
17
+ export const NPM_CI_LOCKS = Object.freeze(['package-lock.json', 'npm-shrinkwrap.json']);
18
+
19
+ /**
20
+ * The env npm sees: the panel's own env minus every credential `CLOSE_ENV_DENY` names (never re-spelled).
21
+ * `npm ci` needs no provider key; a private registry token (NPM_TOKEN) is stripped too, so such a repo
22
+ * simply falls back to the person's own install.
23
+ * @param {NodeJS.ProcessEnv} base
24
+ * @returns {NodeJS.ProcessEnv}
25
+ */
26
+ export function npmEnv(base) {
27
+ const env = { ...base };
28
+ delete env.NODE_TEST_CONTEXT;
29
+ for (const name of Object.keys(env)) {
30
+ if (CLOSE_ENV_DENY.names.includes(name)
31
+ || CLOSE_ENV_DENY.prefixes.some((p) => name.startsWith(p))
32
+ || CLOSE_ENV_DENY.shape.test(name)) delete env[name];
33
+ }
34
+ return env;
35
+ }
36
+
37
+ /**
38
+ * Run `npm ci --ignore-scripts` in `cwd`. Async (never blocks the panel's event loop); argv array, no shell.
39
+ * @param {string} cwd the directory holding package.json + the lock file
40
+ * @param {{timeoutMs?: number, env?: NodeJS.ProcessEnv, run?: typeof spawnClose}} [o] `run` is the test seam
41
+ * @returns {Promise<{ok: true}|{ok: false, reason: string}>}
42
+ */
43
+ export async function runNpmCi(cwd, { timeoutMs = NPM_CI_TIMEOUT_MS, env = process.env, run = spawnClose } = {}) {
44
+ const r = await run('npm', ['ci', '--ignore-scripts'], { env: npmEnv(env), cwd, timeoutMs });
45
+ if (r.error) {
46
+ const code = r.error.code;
47
+ if (code === 'ETIMEDOUT') return { ok: false, reason: `timed out after ${Math.round(timeoutMs / 1000)}s` };
48
+ if (code === 'ENOENT') return existsSync(cwd) ? { ok: false, reason: 'npm was not found' } : { ok: false, reason: 'the install folder does not exist' };
49
+ return { ok: false, reason: redactSecrets(String(r.error.message ?? r.error)).slice(0, 200) };
50
+ }
51
+ if (r.status === 0) return { ok: true };
52
+ const lines = String(r.stderr || r.stdout || '').split('\n').map((l) => l.trim()).filter(Boolean);
53
+ const last = redactSecrets(lines[lines.length - 1] ?? '').slice(0, 200);
54
+ return { ok: false, reason: last || (r.status === null ? `killed by ${r.signal ?? 'a signal'}` : `exit ${r.status}`) };
55
+ }
@@ -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') {