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 +27 -0
- package/bareloop.context.md +36 -8
- package/package.json +1 -1
- package/src/authorrun.js +3 -2
- package/src/bundlerun.js +4 -8
- package/src/draftspend.js +116 -1
- 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/authorsession.js +87 -17
- package/src/panel/index.html +137 -43
- package/src/panel/server.js +130 -14
- package/src/panel/settingsroutes.js +52 -2
- package/src/ralph.js +1 -1
- package/src/source.js +43 -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/authorsession.d.ts +2 -1
- package/types/panel/server.d.ts +13 -2
- package/types/panel/settingsroutes.d.ts +6 -1
- package/types/ralph.d.ts +19 -0
- package/types/source.d.ts +13 -5
- package/types/userrun.d.ts +10 -0
- package/types/worktree.d.ts +48 -0
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
|
package/bareloop.context.md
CHANGED
|
@@ -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)
|
|
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
|
|
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 }`.
|
|
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. **
|
|
3858
|
-
price is
|
|
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 —
|
|
4000
|
-
`npm`/`pnpm`/`yarn`/`bun` on the person's behalf
|
|
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.
|
|
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,
|
|
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
|
-
|
|
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 {
|
|
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,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
|
|
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
|
+
}
|