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 +50 -0
- package/bareloop.context.md +69 -22
- package/package.json +1 -1
- package/src/authorrun.js +5 -5
- package/src/bundlerun.js +4 -8
- package/src/draftspend.js +114 -1
- package/src/exits.js +3 -0
- package/src/index.js +3 -3
- package/src/legs.js +1 -1
- package/src/movefile.js +22 -0
- package/src/npminstall.js +55 -0
- package/src/panel/authorroutes.js +12 -1
- package/src/panel/authorsession.js +95 -28
- package/src/panel/index.html +317 -76
- package/src/panel/server.js +176 -16
- package/src/panel/settingsroutes.js +52 -2
- package/src/panel/status.js +22 -0
- package/src/planrun.js +10 -7
- package/src/ralph.js +1 -1
- package/src/source.js +55 -4
- package/src/u-patient.js +3 -2
- package/src/userrun.js +56 -10
- package/src/worktree.js +120 -0
- package/types/draftspend.d.ts +88 -0
- package/types/legs.d.ts +1 -1
- package/types/movefile.d.ts +9 -0
- package/types/npminstall.d.ts +30 -0
- package/types/panel/authorroutes.d.ts +4 -1
- package/types/panel/authorsession.d.ts +4 -8
- package/types/panel/server.d.ts +30 -2
- package/types/panel/settingsroutes.d.ts +6 -1
- package/types/panel/status.d.ts +20 -0
- package/types/planrun.d.ts +3 -3
- package/types/ralph.d.ts +19 -0
- package/types/source.d.ts +20 -5
- package/types/userrun.d.ts +10 -0
- package/types/worktree.d.ts +48 -0
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
|
|
package/bareloop.context.md
CHANGED
|
@@ -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).
|
|
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)
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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.
|
|
3540
|
-
|
|
3541
|
-
|
|
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
|
|
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.
|
|
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
|
|
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 }`.
|
|
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. **
|
|
3858
|
-
price is
|
|
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 —
|
|
4000
|
-
`npm`/`pnpm`/`yarn`/`bun` on the person's behalf
|
|
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.
|
|
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,
|
|
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
|
-
|
|
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 =
|
|
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 {
|
|
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 =
|
|
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
|
-
|
|
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
|
|
157
|
-
// runner reaches it through `runPlan`'s `judgeProvider
|
|
158
|
-
//
|
|
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
|
|
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
|
package/src/movefile.js
ADDED
|
@@ -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') {
|