bareloop 0.35.0 → 0.36.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
@@ -5,6 +5,33 @@ All notable changes to bareloop are documented here. Format:
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
7
 
8
+ ## [0.36.0] - 2026-10-06
9
+
10
+ ### Added
11
+
12
+ - **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.
13
+ - **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.
14
+ - **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)`.
15
+ - **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.
16
+ - **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.
17
+ - **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`.
18
+ - **Runs list cards are two lines**: workflow card `job (run-N) word / check, cost, wall, date`; sub-card `(run-N) word / cost, wall, date`.
19
+
20
+ ### Fixed
21
+
22
+ - 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).
23
+ - 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.
24
+ - An already-green worktree run removes its folder (and commits a resumed leg's uncommitted work) instead of leaving it behind.
25
+ - Panel: a price box typed alone no longer flashes the half-set error; card textareas auto-grow as you type.
26
+ - Tests no longer write settled rows into the real run list (`cli.test.js` passes `keysHome` with `runlistHome`). Test-only.
27
+
28
+ ### Changed
29
+
30
+ - 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.
31
+ - The link-login item is dropped; the panel's token-in-page model is unchanged.
32
+ - The progress step label "copying source" reads "making worktree".
33
+ - `prepareSource`'s manifest type is `Record<string, any>` (types only).
34
+
8
35
  ## [0.35.0] - 2026-10-05
9
36
 
10
37
  ### Added
@@ -3363,7 +3363,12 @@ key you want first (the panel picks the row by the Model menu).
3363
3363
  list by job for the left pane's "Workflows" view and renders it flat for "History", both
3364
3364
  behind one `Runs` tab toggle; there is no separate `/api/workflows` endpoint, deleted in
3365
3365
  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,
3366
+ summary, model/provider/judge). Every `GET /api/runs` row and the detail carry `runNo` (P6 item 7): runs of one job are numbered
3367
+ oldest `at` first by ONE server function (`runNumbers`), and the page names a run `job (run-N)`; the run id stays in the Audit
3368
+ 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
3369
+ `detail.parts` (`kind:'drafting'`, built from the session's `draft-log.jsonl`, `src/draftspend.js`): every other index a reader
3370
+ is handed (`legDividers.beforePart`, the flat Audit rows' `partIndex`, the rounds endpoint's `part`) lives in that one shifted
3371
+ 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
3372
  ts-windowed to this run alone even when the sidecar file is shared with other runs, plus
3368
3373
  the matching raw-log text windowed the same way — when one resolves); `GET
3369
3374
  /api/runs/:runid/rounds?part=&attempt=&offset=&limit=` (one attempt's rounds, lazily
@@ -3436,7 +3441,8 @@ key you want first (the panel picks the row by the Model menu).
3436
3441
  completion; the page sees the key's NAME and a status word, never the value). `POST
3437
3442
  /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
3443
  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
3444
+ same worktree — the AGENT never installs; for a committed npm lock file the panel door first runs `npm ci --ignore-scripts` itself,
3445
+ $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
3446
  LIVE, unsigned session: phase becomes the terminal `abandoned` (releasing the one-at-a-time lock), money already booked
3441
3447
  stays booked (`draftSpentUsd` is never zeroed; a call in flight still books its usage), no NEW model call starts;
3442
3448
  `400` on a session that is not live, `404` unknown id, same guard as the other author routes. After `signed` the card's
@@ -3850,12 +3856,16 @@ placeholder `no-key-needed`, never the word `null`). Each row: the env NAME (rea
3850
3856
  **Name** (the model id Chat uses), **API shape** (Anthropic / OpenAI-compatible / Gemini), **Base
3851
3857
  URL** (blank = the shape's own host), **Test**, **Tokens used** (total, display only), and Balance
3852
3858
  (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:
3859
+ `keys.<ENV NAME> = { name, shape, baseUrl, priceInPerM?, priceOutPerM? }`. The last two columns are the customer's own price
3860
+ (**In $/1M**, **Out $/1M**, plain boxes; blank = not set, a number >= 0 otherwise; the server refuses a half-set pair with a plain
3861
+ error and saves nothing, and the page leaves a half-typed pair out of the save until both boxes are filled or both cleared).
3862
+ **Open keys folder** (`POST /api/settings/open-keys-folder`) opens `~/.config/bareloop` in the file manager (`xdg-open` / `open`,
3863
+ 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
3864
  `ANTHROPIC_API_KEY` claude-sonnet-5 / Anthropic; `DEEPSEEK_API_KEY` deepseek-flash /
3855
3865
  OpenAI-compatible / `https://api.deepseek.com/v1`; `OPENAI_API_KEY` OpenAI-compatible;
3856
3866
  `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
3867
+ Studio, llama.cpp: change the URL); any other name OpenAI-compatible; Name blank until typed. **The
3868
+ only price shown is the customer's own, typed in the two price boxes** (cost recording, the estimated provenance, spend sums, caps
3859
3869
  and the monthly limit are unchanged; a local model has no known rate, so its rounds come out as a
3860
3870
  non-zero loud guess, never a real $0).
3861
3871
 
@@ -3895,6 +3905,23 @@ const prepared = await prepareSource({
3895
3905
  Or the CLI: `node scripts/prep-source.mjs --source <path-or-url> --into <dir> [--destination
3896
3906
  <absolute-directory>]` — prints the tree path, the seed, and the exact next command.
3897
3907
 
3908
+ **A REPO source on a worktree (P6 item 1, the panel's repo jobs).** Pass `worktree: '<repo>/.bareloop/wt/<id>'` to
3909
+ `prepareSource` and a repo source is NOT copied: its tree is a detached `git worktree` of the person's own repo at their current
3910
+ commit (`src/worktree.js`, the one spelling the bundle door shares). `prepared.tree` is that worktree; `into` holds only
3911
+ `source.json`, which records `worktree`, `repo` and `seed` (= the repo's HEAD — no seed commit is made, no `output/` scaffold
3912
+ is written). Uncommitted edits in the person's repo are not in it (the panel says so at drafting start: "N uncommitted
3913
+ 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
3914
+ `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
3915
+ (`run-u --spec`) reads `worktree` from that manifest; its spine and books stay in the session directory beside `source.json`,
3916
+ 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
3917
+ leg) ALWAYS commits the run's work on its `bareloop-<job>` work branch (authored by `bareloop`, hooks off, arbiter books and
3918
+ `node_modules` left out) — including a green that opened a review door or whose spine leaked — and removes the worktree folder,
3919
+ 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
3920
+ merge stays the person's (`git merge <branch>` / `git branch -D <branch>` are shown as text). Any other ending (stopped,
3921
+ capped, died, red) leaves the folder so Resume can re-enter it; a drafting session that ends without ever running (refused,
3922
+ abandoned) removes its worktree. A panel restart mid-drafting loses the in-memory session and leaves its worktree behind
3923
+ (`git worktree list` shows it; `git worktree remove --force <folder>` clears it).
3924
+
3898
3925
  > **If your source is a folder, read this first (hamr's ruling):** make a new folder, put
3899
3926
  > only the file(s) this job needs in it, point bareloop at that — never your original
3900
3927
  > folder. Everything in the folder you name is frozen, read, and paid for. `prep-source`
@@ -3996,13 +4023,14 @@ the run's own scratch tree.
3996
4023
  **`prepareSource` copies ONLY git-tracked files** (hamr's ruling, unchanged) — a JS/TS repo's
3997
4024
  prepared copy therefore never carries `node_modules`, and every close stage that needs a tool
3998
4025
  (`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,
4026
+ NEVER runs an install itself for the agent or the CLI doors — the agent only touches gated primitives, never a shell-out to
4027
+ `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
4028
+ npm lock file, P6 item 3, `src/npminstall.js`; it is never a verb the agent holds). `missingDependencies` is exported ($0,
4001
4029
  JS/TS only for now — other languages return `null`, M3b's job): it takes `(treeDir,
4002
4030
  sourceSubdir?)` and walks from `sourceSubdir` up to `treeDir` (the same nearest-manifest rule
4003
4031
  `detectLanguage` uses, done independently to avoid a `source.js` ⇄ `detectlang.js` import
4004
4032
  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
4033
+ 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
4034
  line (`npm ci`/`pnpm install --frozen-lockfile`/`yarn install --frozen-lockfile`/`bun install
4007
4035
  --frozen-lockfile` by lockfile, else `npm install`), `cd`-prefixed for a subfolder job. An
4008
4036
  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.36.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,6 +120,7 @@ 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';
@@ -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}`);
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,97 @@ 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
+ /** @type {any[]} */
140
+ const orphanCalls = [];
141
+ for (const line of text.split('\n')) {
142
+ if (!line.trim()) continue;
143
+ let ev;
144
+ try { ev = JSON.parse(line); } catch { continue; }
145
+ if (!ev || typeof ev !== 'object' || !Number.isInteger(ev.no)) continue;
146
+ if (ev.kind === 'step-start') {
147
+ 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: [] });
148
+ } else if (ev.kind === 'step-end' && byNo.has(ev.no)) {
149
+ const x = byNo.get(ev.no);
150
+ x.status = ev.status === 'failed' ? 'failed' : 'done';
151
+ x.endedAt = ev.at;
152
+ if (typeof ev.label === 'string') x.label = ev.label;
153
+ } else if (ev.kind === 'step-reopen' && byNo.has(ev.no)) {
154
+ const x = byNo.get(ev.no);
155
+ x.status = 'running';
156
+ x.endedAt = null;
157
+ } else if (ev.kind === 'call') {
158
+ const c = { label: String(ev.label), model: typeof ev.model === 'string' ? ev.model : null, costUsd: typeof ev.costUsd === 'number' ? ev.costUsd : null, unpricedRounds: Number(ev.unpricedRounds) || 0, at: String(ev.at) };
159
+ (byNo.get(ev.no)?.calls ?? orphanCalls).push(c);
160
+ }
161
+ }
162
+ const steps = [...byNo.values()].sort((a, b) => a.no - b.no);
163
+ for (const x of steps) {
164
+ const a = Date.parse(x.startedAt);
165
+ const b = x.endedAt === null ? NaN : Date.parse(x.endedAt);
166
+ x.wallMs = Number.isFinite(a) && Number.isFinite(b) ? Math.max(0, b - a) : null;
167
+ }
168
+ return steps;
169
+ }
170
+
171
+ /**
172
+ * 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
173
+ * drafting log. `null` when the run did not come out of a panel session, or the log carries no metered call (a Reuse
174
+ * workflow run, a CLI run, a run older than the log). The part's money is the SPINE's figure (`job-start.draftSpentUsd`,
175
+ * passed in) — the log's calls are shown beside it, never summed into a second total; `null` spine figure = no part.
176
+ * @param {{spine?: string, patient?: string|null}} row
177
+ * @param {{draftSpentUsd: number|null, draftSpendComplete: boolean|null}} spine the run's own drafting figures
178
+ * @param {{ home?: string, sessionsRoot?: string }} [opts]
179
+ * @returns {any|null}
180
+ */
181
+ export function draftingPartFor(row, spine, opts = {}) {
182
+ if (spine.draftSpentUsd === null) return null;
183
+ const dir = sessionDirForRow(row, opts);
184
+ if (!dir) return null;
185
+ const steps = readDraftSteps(dir);
186
+ const calls = steps.reduce((n, x) => n + x.calls.length, 0);
187
+ if (calls === 0) return null;
188
+ const starts = steps.map((x) => Date.parse(x.startedAt)).filter(Number.isFinite);
189
+ const ends = steps.map((x) => (x.endedAt === null ? NaN : Date.parse(x.endedAt))).filter(Number.isFinite);
190
+ return {
191
+ kind: 'drafting', id: 'drafting', label: 'drafting', occurrence: 1, leg: 1, outcome: null, blocked: 0,
192
+ attempts: [], rounds: calls, toolCalls: 0, byTool: null,
193
+ wallMs: starts.length && ends.length ? Math.max(0, Math.max(...ends) - Math.min(...starts)) : null,
194
+ spentUsd: spine.draftSpentUsd, spendComplete: spine.draftSpendComplete !== false, unpricedRounds: 0,
195
+ draftSteps: steps,
196
+ };
197
+ }
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
+ }