bareloop 0.31.0 → 0.32.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 +72 -0
- package/bareloop.context.md +87 -13
- package/bin/bareloop.mjs +0 -1
- package/package.json +1 -1
- package/src/authorrun.js +14 -4
- package/src/cli.js +12 -6
- package/src/config.js +135 -0
- package/src/interviewrun.js +10 -2
- package/src/keysfile.js +156 -0
- package/src/ledger.js +26 -0
- package/src/monthly.js +352 -0
- package/src/panel/authorroutes.js +61 -16
- package/src/panel/authorsession.js +23 -25
- package/src/panel/index.html +287 -13
- package/src/panel/server.js +58 -51
- package/src/panel/settingsroutes.js +230 -0
- package/src/providerrows.js +217 -0
- package/src/providers.js +3 -1
- package/src/runlist.js +13 -0
- package/src/userrun.js +59 -5
- package/types/authorrun.d.ts +5 -0
- package/types/cli.d.ts +2 -1
- package/types/config.d.ts +48 -0
- package/types/interviewrun.d.ts +5 -0
- package/types/keysfile.d.ts +90 -0
- package/types/ledger.d.ts +14 -0
- package/types/monthly.d.ts +192 -0
- package/types/panel/authorroutes.d.ts +4 -2
- package/types/panel/authorsession.d.ts +7 -15
- package/types/panel/server.d.ts +10 -3
- package/types/panel/settingsroutes.d.ts +14 -0
- package/types/providerrows.d.ts +164 -0
- package/types/runlist.d.ts +1 -0
- package/types/userrun.d.ts +5 -0
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,78 @@ 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.32.0] — 2026-09-29
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
|
|
12
|
+
- **Panel Settings (P4a)** — `~/.config/bareloop/.env` keys-file loader (shell env wins; keys
|
|
13
|
+
never reach the page), `config.json` (monthly $ limit, chosen key variables, Anthropic balance
|
|
14
|
+
note), a monthly $ limit that refuses a run start with `Max $X (monthly limit)` at both
|
|
15
|
+
`run-u` and the panel Sign & run, and a Settings view with a Money & limits tab and a Providers
|
|
16
|
+
tab (read, $0 Test, key-name dropdown). Add/edit/remove providers is P4b. There is no monthly
|
|
17
|
+
time limit. **The monthly limit is checked by `run-u` and the panel only — `bareloop run
|
|
18
|
+
<bundle>` does not check it.** An unreadable `config.json` refuses a run start; a month whose
|
|
19
|
+
spend is incomplete reads "at least".
|
|
20
|
+
- **Money & limits tab** — total and this-month spend and tokens as two header tiles ($ and
|
|
21
|
+
tokens, month first), a monthly $ limit that saves on change, and a per-provider breakdown
|
|
22
|
+
table ($, wall minutes, tokens; this month and to date). Wall time is job-start to job-end
|
|
23
|
+
when the run ended, otherwise an "at least" floor; unknown is shown as unknown, never 0. A
|
|
24
|
+
spine with no recorded provider is labelled "not recorded (model X)", never guessed from the
|
|
25
|
+
model name.
|
|
26
|
+
- **`replay-live --live-audit`** (`scripts/replay-live.mjs`) — opt-in: a $0 replay keeps its
|
|
27
|
+
sidecar in a scratch patient dir while it paces, so the panel's live tool-log fallback can be
|
|
28
|
+
seen on screen. Default replay is unchanged.
|
|
29
|
+
- **Panel Providers tab is the keys file (P4b)** — one Settings row per key in
|
|
30
|
+
`~/.config/bareloop/.env` that has a value (the file is created with five empty preset lines,
|
|
31
|
+
`ANTHROPIC_API_KEY` / `DEEPSEEK_API_KEY` / `OPENAI_API_KEY` / `GEMINI_API_KEY` / `LOCAL_API_KEY`,
|
|
32
|
+
when missing; an existing file is never edited). Each row has an editable Name (the model id Chat
|
|
33
|
+
uses), API shape (Anthropic / OpenAI-compatible / Gemini), Base URL, a $0 Test at that shape and
|
|
34
|
+
URL, and a total Tokens used; changes save to `config.json` `keys.<ENV NAME>`. A key value of
|
|
35
|
+
`null` counts as set and means "no real key" (local servers); the provider gets a placeholder,
|
|
36
|
+
and local rounds price as an `estimated` guess, never a real $0. Reload keys re-merges the
|
|
37
|
+
keys file onto the shell environment per request, so edits and removals take effect without a
|
|
38
|
+
restart. Chat's Model menu is the Settings
|
|
39
|
+
rows, and one owner (`src/providerrows.js`) resolves model to key + shape + URL for Chat, the
|
|
40
|
+
author doors and the run doors.
|
|
41
|
+
- **Providers tab names a shell-only key** — a preset key variable exported only in the shell
|
|
42
|
+
(not in the keys file) gets no row and no Chat model; the tab now says so under the table, by
|
|
43
|
+
name only (`GET /api/settings/providers` returns `shellOnly: string[]`, never a value).
|
|
44
|
+
|
|
45
|
+
### Changed
|
|
46
|
+
|
|
47
|
+
- **Panel: no price is displayed** — the Providers table Price column and the Chat job card's
|
|
48
|
+
Token price field are removed. Display only: cost recording, the `estimated` provenance, spend
|
|
49
|
+
sums, caps and the monthly limit are unchanged.
|
|
50
|
+
- The Chat cap row is two equal full-width columns (Cap $ | Time cap) at every width.
|
|
51
|
+
- `config.json` `providers.<row>.key` (P4a key-name dropdown) is replaced by
|
|
52
|
+
`keys.<ENV NAME> = { name, shape, baseUrl }`; the hardcoded `PROVIDER_ROWS` and the panel's fixed
|
|
53
|
+
`MODEL_OPTIONS` are gone.
|
|
54
|
+
- **Monthly limit counts running jobs at their full cap** — a run still in flight (no `job-end`,
|
|
55
|
+
spine written to within the panel's died window) is counted against the limit at its own leg
|
|
56
|
+
cap, not its spend so far, so two runs cannot both start against a limit only one fits. A died
|
|
57
|
+
run still counts its floor. The Money tab's "$ month" stays real spend.
|
|
58
|
+
|
|
59
|
+
### Fixed
|
|
60
|
+
|
|
61
|
+
- **Monthly limit no longer double-counts a resume** — `run-u --resume` on a leg killed within
|
|
62
|
+
`DIED_MTIME_MS` reserved the old leg at its full cap AND asked for the remainder, so a resume
|
|
63
|
+
that fit was refused; the resumed leg now counts its real spend only
|
|
64
|
+
(`checkMonthlyRoom({ resumingSpine })`).
|
|
65
|
+
- **`POST /api/settings/money` no longer clears the limit on a bad body** — an empty or
|
|
66
|
+
unparseable body, or JSON without the `monthlyLimitUsd` key, is a 400 and leaves `config.json`
|
|
67
|
+
alone; only an explicit `null` or blank clears the limit.
|
|
68
|
+
- **A bad `monthlyLimitUsd` no longer reads as "no limit"** — a `monthlyLimitUsd` present in
|
|
69
|
+
`config.json` but not a number above 0 (`"50"`, `-5`, `0`, `true`, `{}`) now refuses the run
|
|
70
|
+
start and shows as a config problem on the Money tab, like an unparseable file. Absent or
|
|
71
|
+
`null` still means no limit.
|
|
72
|
+
- **Panel author routes: `jobsDir` and body size** — the name-collision check's `jobsDir` test
|
|
73
|
+
seam is now server-side only (it was readable off the client's job card, so any
|
|
74
|
+
`/api/author/start` body could repoint it), and POST bodies over 1 MiB get 413 and the request
|
|
75
|
+
is destroyed (the reader had no cap and ran before the click guard).
|
|
76
|
+
- **Providers Test** — a connection failure now reads "nothing answering at <URL>"; the Test
|
|
77
|
+
cell keeps a fixed width and wraps its result. **Tokens used** counts older model-only spines
|
|
78
|
+
toward the row whose Name exactly equals the model.
|
|
79
|
+
|
|
8
80
|
## [0.31.0] — 2026-09-29
|
|
9
81
|
|
|
10
82
|
### Added
|
package/bareloop.context.md
CHANGED
|
@@ -3302,17 +3302,17 @@ interviews of their own. All three are dispatched by name only: `bareloop run-u
|
|
|
3302
3302
|
`baselineKind`/`question`: the plain-English question is derived in `src/panel/server.js`
|
|
3303
3303
|
(`stageQuestionText`) from the signed stage's kind/params only, `null` when no honest
|
|
3304
3304
|
wording exists (the client then shows the stage's own name). Every
|
|
3305
|
-
endpoint is GET/HEAD only (anything else — including every write verb — is `405
|
|
3305
|
+
P1 endpoint is GET/HEAD only (anything else — including every write verb — is `405`, outside the
|
|
3306
|
+
two human-click-guarded write families `/api/author/*` and `/api/settings/*` below); a URL
|
|
3306
3307
|
never joins a path segment into a filesystem read — a runid is looked up in the run list
|
|
3307
3308
|
first (`RUNID_RE`, `src/panel/server.js` — accepts a `~2`-style backfill-disambiguated
|
|
3308
3309
|
suffix too, and every endpoint echoes back the LISTED runid, never a filename-derived one
|
|
3309
3310
|
that can silently drop that suffix), and only the path THAT ROW stores is ever read. A
|
|
3310
3311
|
taken port fails loudly (names the port, exit `1`) — this command never silently tries a
|
|
3311
|
-
different one.
|
|
3312
|
-
|
|
3312
|
+
different one. This read path runs no interview, no author and no run, and reads no key
|
|
3313
|
+
value (the write families below do, server-side, and never return one). One more caller of `src/replayio.js`/`src/runlist.js`, in-process, per the layering
|
|
3313
3314
|
law (`docs/product/PANEL-BUILD.md` §2) — the panel's HTTP handler holds no flow logic of
|
|
3314
|
-
its own. Chat/
|
|
3315
|
-
hiding the gap.
|
|
3315
|
+
its own. Chat/authoring (P3) and Settings (P4a/P4b) are built — see below.
|
|
3316
3316
|
|
|
3317
3317
|
- **Panel P2 (live run view, `docs/product/PANEL-BUILD.md` Addendum 2026-09-27)** → the page
|
|
3318
3318
|
polls, it does not push (no SSE/websocket). One `setInterval` (2s) drives two throttled
|
|
@@ -3331,14 +3331,19 @@ interviews of their own. All three are dispatched by name only: `bareloop run-u
|
|
|
3331
3331
|
`resolveAuditPathForRow` (`src/panel/server.js`), the one shared owner the Run tab's tools
|
|
3332
3332
|
summary and the Audit tab both read through — tried only while the run's own spine has no
|
|
3333
3333
|
`job-end` yet, so a finished run never risks reading a stale leftover file at that path.
|
|
3334
|
-
`scripts/replay-live.mjs <sourceSpine> <outDir> [--speed N]` is a dev-only, unshipped ($0,
|
|
3334
|
+
`scripts/replay-live.mjs <sourceSpine> <outDir> [--speed N] [--live-audit]` is a dev-only, unshipped ($0,
|
|
3335
3335
|
no provider call) instrument that paces a real archived spine's records back out under a
|
|
3336
3336
|
new runid so the panel can be exercised against a "live" run without a paid run — it is not
|
|
3337
3337
|
a substitute for watching a real run start-to-end, which stays a separately authorized step.
|
|
3338
|
-
|
|
3339
|
-
-
|
|
3340
|
-
|
|
3341
|
-
|
|
3338
|
+
`--live-audit` (opt-in) puts the sidecar at a scratch patient dir during the replay so the
|
|
3339
|
+
live-fallback path above is exercised, then moves it to the sibling name at the end.
|
|
3340
|
+
|
|
3341
|
+
- **Panel P3 (chat/authoring, `docs/product/PANEL-BUILD.md` Addendum 2026-09-27)** → one of the
|
|
3342
|
+
panel's TWO families of write routes (the other is `/api/settings/*`, see "Settings" below), this
|
|
3343
|
+
one under `/api/author/*` (`src/panel/authorroutes.js`); every other route stays GET/HEAD-only
|
|
3344
|
+
per P1's own rule. A POST body over 1 MiB is refused `413` before it is parsed, and the job
|
|
3345
|
+
card's `jobsDir` (where an existing job name is looked up) is a server-side seam only, never read
|
|
3346
|
+
off the client's card. Every route
|
|
3342
3347
|
requires the human-click guard: an `x-bareloop-token` header matching a fresh token minted
|
|
3343
3348
|
once per server start (templated into `index.html` like the port) AND an Origin/Host
|
|
3344
3349
|
naming this exact `127.0.0.1:<port>` — a request failing either gets `403`, before the
|
|
@@ -3368,14 +3373,14 @@ interviews of their own. All three are dispatched by name only: `bareloop run-u
|
|
|
3368
3373
|
[`--draft-spend-incomplete`] when the session's drafting spend is > 0 — one cap covers
|
|
3369
3374
|
drafting + run) detached (array argv,
|
|
3370
3375
|
never a shell string), with its own log file inside the session's own dir, and the
|
|
3371
|
-
server's
|
|
3372
|
-
refuses the session at $0, naming only the env var). `src/panel/authorsession.js` takes
|
|
3376
|
+
server's environment with `~/.config/bareloop/.env` merged in per request (a key is never read
|
|
3377
|
+
into or sent to the page — a missing one refuses the session at $0, naming only the env var). `src/panel/authorsession.js` takes
|
|
3373
3378
|
test-only DI seams (`scout`/`generate`/`confirmGenerate`/`authorFn`/`prepareSigningFn`) so
|
|
3374
3379
|
a test can drive the real ask()-channel/revise/hash wiring without a live provider call;
|
|
3375
3380
|
none of them are reachable from `authorroutes.js`'s real construction path. Sessions live
|
|
3376
3381
|
under `~/.config/bareloop/panel-sessions/<id>/` (each one's own `resolved-spec.json` and
|
|
3377
3382
|
`signing.json`, the two files `bareloop author` itself already writes). Edit/re-sign, the
|
|
3378
|
-
`~/.config/bareloop/.env` keys-file loader, and Settings
|
|
3383
|
+
`~/.config/bareloop/.env` keys-file loader, and Settings have landed (P4a/P4b — see "Settings" below).
|
|
3379
3384
|
|
|
3380
3385
|
- **`bareloop run-u <flags…>`** (PANEL-BUILD.md P0 task 2/4) → the person-path run flow
|
|
3381
3386
|
(the JOBS-table/`--spec` runner, resume, the review door — `docs/logs/FINDINGS.md`'s
|
|
@@ -3490,6 +3495,75 @@ provider but `anthropic-api`, spine bundling for `formatReplay` on the minting r
|
|
|
3490
3495
|
`runid` is noted), and tarball packing — a bundle is a plain directory; use `npm pack` on it
|
|
3491
3496
|
yourself if you want a tarball.
|
|
3492
3497
|
|
|
3498
|
+
### Settings — the keys file, `config.json`, the monthly $ limit, the Providers tab (PANEL-BUILD.md P4a, `src/keysfile.js`, `src/config.js`, `src/monthly.js`, `src/providerrows.js`, `src/panel/settingsroutes.js`)
|
|
3499
|
+
|
|
3500
|
+
Two files in `~/.config/bareloop/`, outside any repo:
|
|
3501
|
+
|
|
3502
|
+
- **`.env` — key VALUES only.** Plain `NAME=value` lines (optional `export `, `#` comments, one pair
|
|
3503
|
+
of quotes). `loadKeysEnv({ env?, home? })` merges it over the environment; **the shell wins** —
|
|
3504
|
+
the file only fills names the shell leaves unset. Mode other than 600 = a `warning` string
|
|
3505
|
+
(printed on CLI start, shown in Settings), never a refusal. Values go only to provider
|
|
3506
|
+
construction; the page and every log get NAMES and found / not set. `keysForDoor(deps)` is the
|
|
3507
|
+
door helper: an injected `env` with no `keysHome` skips the file entirely (tests never read the
|
|
3508
|
+
real one). The CLI (`run-u`, `interview`, `author`) and the panel both use it.
|
|
3509
|
+
- **`config.json` — every setting, never a key value.** `readConfig({ home })` /
|
|
3510
|
+
`updateConfig(patch, { home })` (atomic tmp + rename, mode 600, unknown fields kept).
|
|
3511
|
+
Fields: `monthlyLimitUsd` (number above 0; absent or `null` = no limit; any other value present
|
|
3512
|
+
is a `ConfigError`, refused like an unreadable file), `anthropicBalanceNote` (a note the
|
|
3513
|
+
person types; no check ever reads it), `keys.<ENV NAME>` = `{ name, shape, baseUrl }` (the Providers rows' settings). A secret-shaped
|
|
3514
|
+
string anywhere in the document is refused on save. An unreadable file is a `problem` string and
|
|
3515
|
+
a refused run start ($0), never "no limit". There is no monthly TIME limit (dropped).
|
|
3516
|
+
|
|
3517
|
+
**Monthly $ limit.** `checkMonthlyRoom({ capUsd, home, now })` → `{ ok, leftUsd, limitUsd,
|
|
3518
|
+
atLeast }`; `monthlyRefusalText(room)` is the ONE spelling: `Max $<left, 2 decimals> (monthly
|
|
3519
|
+
limit)`. It is called at the run-start seam in `src/userrun.js` `execute` (before any token
|
|
3520
|
+
spends; the CLI prints it to stderr, exit 2) and at the panel's Sign & run (`signRun` in
|
|
3521
|
+
`src/panel/authorroutes.js`, refused server-side whatever the page shows). The page only echoes the
|
|
3522
|
+
same text under `#jf-cap-money` via `GET /api/author/monthly-check?cap=`. "This month" is the local
|
|
3523
|
+
calendar month; a died or incomplete-spend run makes the total an "at least" figure. A run still
|
|
3524
|
+
IN FLIGHT (`job-start`, no `job-end`, spine file written within `DIED_MTIME_MS`, `src/runlist.js`) is
|
|
3525
|
+
counted at its full leg cap (`budgetUsd` less `priorSpentUsd` on its `job-start`), not its spend so
|
|
3526
|
+
far (except the run a resume continues, which counts its real spend), so two runs cannot both start against a limit only one fits; a died run counts its floor.
|
|
3527
|
+
Only the refusal reserves the cap (`monthSpend().reservedUsd`); the Money tab's month figure is
|
|
3528
|
+
real spend.
|
|
3529
|
+
|
|
3530
|
+
**Money & limits tab** (`GET/POST /api/settings/money`): two tiles ($ and tokens, this month / to date, a
|
|
3531
|
+
`≥` prefix when a figure is a floor) and a per-provider table ($, minutes, tokens; a run is grouped
|
|
3532
|
+
by the provider on its `job-start`, an older spine by its model). The limit field auto-saves on
|
|
3533
|
+
change (no Save button); blank clears it. `POST` needs an explicit `monthlyLimitUsd` key (a number
|
|
3534
|
+
above 0, or `null`/blank to clear) — an empty or key-less body is a `400`, never "clear the limit".
|
|
3535
|
+
|
|
3536
|
+
**Providers tab (P4b, `src/providerrows.js`).** One row per key in `.env` that HAS A VALUE — nothing is
|
|
3537
|
+
hardcoded; an empty line is no row, a removed line drops its row on Reload keys. A missing `.env` is
|
|
3538
|
+
created (mode 600) on the first Providers read with five empty preset lines (`ANTHROPIC_API_KEY`,
|
|
3539
|
+
`DEEPSEEK_API_KEY`, `OPENAI_API_KEY`, `GEMINI_API_KEY`, `LOCAL_API_KEY`); an existing file is never
|
|
3540
|
+
edited. A value of `null` counts as SET and means "no real key" (the provider is handed the
|
|
3541
|
+
placeholder `no-key-needed`, never the word `null`). Each row: the env NAME (read-only) + found,
|
|
3542
|
+
**Name** (the model id Chat uses), **API shape** (Anthropic / OpenAI-compatible / Gemini), **Base
|
|
3543
|
+
URL** (blank = the shape's own host), **Test**, **Tokens used** (total, display only), and Balance
|
|
3544
|
+
(DeepSeek fetched, Anthropic a typed note). Saves on change to `config.json`
|
|
3545
|
+
`keys.<ENV NAME> = { name, shape, baseUrl }`. Defaults for a key with no saved entry:
|
|
3546
|
+
`ANTHROPIC_API_KEY` claude-sonnet-5 / Anthropic; `DEEPSEEK_API_KEY` deepseek-flash /
|
|
3547
|
+
OpenAI-compatible / `https://api.deepseek.com/v1`; `OPENAI_API_KEY` OpenAI-compatible;
|
|
3548
|
+
`GEMINI_API_KEY` Gemini; `LOCAL_API_KEY` OpenAI-compatible / `http://127.0.0.1:11434/v1` (Ollama, LM
|
|
3549
|
+
Studio, llama.cpp: change the URL); any other name OpenAI-compatible; Name blank until typed. **No
|
|
3550
|
+
price is shown anywhere** (display only — cost recording, the estimated provenance, spend sums, caps
|
|
3551
|
+
and the monthly limit are unchanged; a local model has no known rate, so its rounds come out as a
|
|
3552
|
+
non-zero loud guess, never a real $0).
|
|
3553
|
+
|
|
3554
|
+
`providerrows.js` is the ONE owner of "model → key + shape + URL": `keyRows`, `findRow` (rows on the
|
|
3555
|
+
same shape + endpoint; the Name breaks a tie), `modelChoiceFor` / `chatModels` (Chat's Model menu IS
|
|
3556
|
+
the rows; a blank Name is not offered), `keyNameFor` / `applyConfiguredKey` (a job on `(provider,
|
|
3557
|
+
baseUrl[, model])` reads the matching row's variable, else the provider's built-in one). All doors
|
|
3558
|
+
(`run-u`, `interview`, `author`, the panel's drafting) use it. Routes (all behind the per-start token
|
|
3559
|
+
and Origin check; the chat can never reach them): `GET /api/settings/providers`, `POST .../row`
|
|
3560
|
+
(`{envName, name, shape, baseUrl}`), `POST .../balance-note`, `POST .../test` (one
|
|
3561
|
+
`checkProviderReachable` models-list GET at the row's shape + URL, $0; a refused connection reads
|
|
3562
|
+
`nothing answering at <URL>`), `GET .../balance?env=`
|
|
3563
|
+
(DeepSeek-hosted row, server-side), and `GET /api/author/models` (Chat's Model menu). The old
|
|
3564
|
+
`providers.<row>.key` config field and the key dropdown are gone. Like the authoring routes, a POST
|
|
3565
|
+
body over 1 MiB is refused `413`.
|
|
3566
|
+
|
|
3493
3567
|
### The source front door — a plain folder/file/URL, no `--patient` (PRD item 33/M2, `src/source.js`)
|
|
3494
3568
|
|
|
3495
3569
|
A repo job still gives `runJob` a `--patient` checkout it owns. A NON-code job (a resume, a
|
package/bin/bareloop.mjs
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "bareloop",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.32.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
|
@@ -100,6 +100,8 @@
|
|
|
100
100
|
// item 34 L17) — `run-interview.mjs` asks for it and writes it
|
|
101
101
|
// in; a draft missing one, or naming one the provider factory
|
|
102
102
|
// does not know, dies here loud, listing the known table.
|
|
103
|
+
import { keysForDoor } from './keysfile.js';
|
|
104
|
+
import { applyConfiguredKey, keyNameFor, rowsForHome } from './providerrows.js';
|
|
103
105
|
import {
|
|
104
106
|
readFileSync, writeFileSync, appendFileSync, mkdirSync, existsSync, renameSync, statSync,
|
|
105
107
|
} from 'node:fs';
|
|
@@ -141,6 +143,7 @@ class ExitSignal extends Error {
|
|
|
141
143
|
/**
|
|
142
144
|
* @typedef {object} Deps
|
|
143
145
|
* @property {Record<string,string|undefined>} [env]
|
|
146
|
+
* @property {string} [keysHome] P4a — test seam: load this home's `.env` keys file even when `env` is injected.
|
|
144
147
|
* @property {NodeJS.ReadStream} [stdin]
|
|
145
148
|
* @property {NodeJS.WritableStream} [stdout]
|
|
146
149
|
* @property {NodeJS.WritableStream} [stderr]
|
|
@@ -162,10 +165,13 @@ class ExitSignal extends Error {
|
|
|
162
165
|
* @returns {Promise<number>} an exit code — never calls process.exit
|
|
163
166
|
*/
|
|
164
167
|
export async function main(argv, deps = {}) {
|
|
165
|
-
|
|
168
|
+
// P4a item 1 — the keys file fills what the shell leaves unset (shell wins).
|
|
169
|
+
const keys = keysForDoor(deps);
|
|
170
|
+
const env = keys.env;
|
|
166
171
|
const stdin = deps.stdin ?? process.stdin;
|
|
167
172
|
const stdout = deps.stdout ?? process.stdout;
|
|
168
173
|
const stderr = deps.stderr ?? process.stderr;
|
|
174
|
+
if (keys.warning && deps.env === undefined) stderr.write(`WARNING: ${keys.warning}\n`);
|
|
169
175
|
const out = (/** @type {string} */ s = '') => { stdout.write(`${s}\n`); };
|
|
170
176
|
const err = (/** @type {string} */ s) => { stderr.write(`${s}\n`); };
|
|
171
177
|
const invokedAs = deps.invokedAs ?? 'node scripts/run-author.mjs';
|
|
@@ -499,8 +505,12 @@ export async function main(argv, deps = {}) {
|
|
|
499
505
|
|
|
500
506
|
// Secrets load from the environment; they never enter argv (a command line is
|
|
501
507
|
// world-readable on /proc) and they are never printed.
|
|
502
|
-
|
|
503
|
-
|
|
508
|
+
// P4a item 4 — the key variable the person picked in Settings (config.json) stands in for
|
|
509
|
+
// the provider's built-in one; none picked = the built-in. Names only.
|
|
510
|
+
const keyCfg = rowsForHome(keys.home);
|
|
511
|
+
const authorEnv = applyConfiguredKey(env, PROVIDER_NAME, baseUrl, keyCfg);
|
|
512
|
+
const AUTHOR_ENV_KEY = keyNameFor(PROVIDER_NAME, baseUrl, keyCfg).name;
|
|
513
|
+
const apiKey = authorEnv[/** @type {NonNullable<typeof providerEntry>} */ (providerEntry).envKey];
|
|
504
514
|
if (!apiKey) { err(`${AUTHOR_ENV_KEY} not set (secrets load from the environment — never the tree, never argv)`); throw new ExitSignal(2); }
|
|
505
515
|
// F181 — a key that carries a line break/control char/stray whitespace (a
|
|
506
516
|
// two-line secret-store entry, e.g.) reads as "set" by the presence check
|
|
@@ -514,7 +524,7 @@ export async function main(argv, deps = {}) {
|
|
|
514
524
|
* contract `scripts/run-u.mjs` keeps, so one story covers both surfaces. When the
|
|
515
525
|
* judge IS the authoring provider this is the key already read above. */
|
|
516
526
|
const judgeKeyFor = (/** @type {string} */ providerName) => (
|
|
517
|
-
env.JUDGE_API_KEY ?? env[resolveProvider(providerName).envKey]
|
|
527
|
+
env.JUDGE_API_KEY ?? (providerName === PROVIDER_NAME ? authorEnv : applyConfiguredKey(env, providerName, undefined, keyCfg))[resolveProvider(providerName).envKey]
|
|
518
528
|
);
|
|
519
529
|
|
|
520
530
|
/** F6 — an unpriced call makes the TOTAL unknown, and unknown is reported as
|
package/src/cli.js
CHANGED
|
@@ -68,6 +68,7 @@ import { panelMain } from './panel/server.js';
|
|
|
68
68
|
// (`~/.config/bareloop/runs.jsonl`) and its backfill scan. `bareloop runs`
|
|
69
69
|
// (list) and `bareloop runs backfill <dir>` (reconstruct rows from archived
|
|
70
70
|
// spines already on disk) are this rung's only new commands.
|
|
71
|
+
import { keysForDoor } from './keysfile.js';
|
|
71
72
|
import { appendRun, readRunList, backfillRuns, formatRunRow } from './runlist.js';
|
|
72
73
|
|
|
73
74
|
// The tier->model tables live in `src/providers.js` now (PRD item 28's
|
|
@@ -653,11 +654,15 @@ async function runMenu(deps, ctx) {
|
|
|
653
654
|
* code, so `bin/bareloop.mjs` can set `process.exitCode` and let node flush
|
|
654
655
|
* queued stdout on its own (F-something: `process.exit()` can discard it).
|
|
655
656
|
* @param {string[]} argv
|
|
656
|
-
* @param {{ env?: Record<string,string|undefined>, stdout?: any, stderr?: any, cwd?: string, provider?: any, providerFor?: any, judgeProvider?: any, judgeModel?: string|null, now?: () => number, stdin?: any, runlistHome?: string }} deps
|
|
657
|
+
* @param {{ env?: Record<string,string|undefined>, stdout?: any, stderr?: any, cwd?: string, provider?: any, providerFor?: any, judgeProvider?: any, judgeModel?: string|null, now?: () => number, stdin?: any, runlistHome?: string, keysHome?: string }} deps
|
|
657
658
|
* @returns {Promise<number>}
|
|
658
659
|
*/
|
|
659
660
|
export async function main(argv, deps = {}) {
|
|
660
|
-
|
|
661
|
+
// P4a item 1 — the keys file (`~/.config/bareloop/.env`) fills what the shell leaves
|
|
662
|
+
// unset; the shell wins. An injected `deps.env` skips the file (tests never read the
|
|
663
|
+
// real one) unless `deps.keysHome` names a home.
|
|
664
|
+
const keys = keysForDoor(deps);
|
|
665
|
+
const env = keys.env;
|
|
661
666
|
const stdout = deps.stdout ?? process.stdout;
|
|
662
667
|
const stderr = deps.stderr ?? process.stderr;
|
|
663
668
|
const cwd = deps.cwd ?? process.cwd();
|
|
@@ -667,6 +672,7 @@ export async function main(argv, deps = {}) {
|
|
|
667
672
|
const ctx = { out, err, cwd, env, now, deps };
|
|
668
673
|
|
|
669
674
|
const [cmd, ...rest] = argv;
|
|
675
|
+
if (keys.warning && cmd && ['run', 'run-u', 'interview', 'author', 'panel'].includes(cmd)) err(`WARNING: ${keys.warning}`);
|
|
670
676
|
if (!cmd) return runMenu({ ...deps, stdin: deps.stdin ?? process.stdin, stdout }, ctx);
|
|
671
677
|
if (cmd === 'export') return doExport(rest, ctx);
|
|
672
678
|
if (cmd === 'run') return doRun(rest, ctx);
|
|
@@ -681,7 +687,7 @@ export async function main(argv, deps = {}) {
|
|
|
681
687
|
// then `env`/`out`/`err` are overridden to the SAME resolved values every
|
|
682
688
|
// other command here prints through, so `run-u`'s output lands on the
|
|
683
689
|
// `stdout`/`stderr` a caller of `main` actually passed.
|
|
684
|
-
if (cmd === 'run-u') return runUMain(rest, { ...deps, env, out, err });
|
|
690
|
+
if (cmd === 'run-u') return runUMain(rest, { ...deps, env, out, err, keysHome: keys.home });
|
|
685
691
|
// `bareloop interview` — the close-authoring interview (PANEL-BUILD.md P0
|
|
686
692
|
// task 3/4). This flow reads a TTY (or a piped stdin) directly rather than
|
|
687
693
|
// through the `out`/`err` line-functions every other command here uses, so
|
|
@@ -691,11 +697,11 @@ export async function main(argv, deps = {}) {
|
|
|
691
697
|
// person actually typed, never a script path they never invoked and may
|
|
692
698
|
// not have on disk (a branch-review nit; `scripts/run-interview.mjs` still
|
|
693
699
|
// supplies none, so it keeps naming itself when run directly).
|
|
694
|
-
if (cmd === 'interview') return interviewMain(rest, { ...deps, env, stdin: deps.stdin ?? process.stdin, stdout, stderr, invokedAs: 'bareloop interview' });
|
|
700
|
+
if (cmd === 'interview') return interviewMain(rest, { ...deps, env, keysHome: keys.home, stdin: deps.stdin ?? process.stdin, stdout, stderr, invokedAs: 'bareloop interview' });
|
|
695
701
|
// `bareloop author` — the authoring pipeline (scout, declaration, D9's
|
|
696
702
|
// gates), same task. Same raw-stream reasoning: its one interactive seam
|
|
697
703
|
// (the confirm turn) reads stdin directly. Same `invokedAs` reasoning too.
|
|
698
|
-
if (cmd === 'author') return authorMain(rest, { ...deps, env, stdin: deps.stdin ?? process.stdin, stdout, stderr, invokedAs: 'bareloop author' });
|
|
704
|
+
if (cmd === 'author') return authorMain(rest, { ...deps, env, keysHome: keys.home, stdin: deps.stdin ?? process.stdin, stdout, stderr, invokedAs: 'bareloop author' });
|
|
699
705
|
// `bareloop replay` — the spine/gate-audit read side (PANEL-BUILD.md P0,
|
|
700
706
|
// last of the rung's four tasks). Synchronous, file-based, no interactive
|
|
701
707
|
// seam — same shape as `doHistory`/`doExport`, not the argv-owning
|
|
@@ -709,7 +715,7 @@ export async function main(argv, deps = {}) {
|
|
|
709
715
|
// Never runs a job, spends money, or reads a key: it serves the run list
|
|
710
716
|
// and spine/gate-audit reads over `127.0.0.1` only. `deps.runlistHome`
|
|
711
717
|
// rides through the same injectable seam `run`/`run-u` already use.
|
|
712
|
-
if (cmd === 'panel') return panelMain(rest, { out, err, runlistHome: deps.runlistHome });
|
|
718
|
+
if (cmd === 'panel') return panelMain(rest, { out, err, runlistHome: deps.runlistHome, env: deps.env });
|
|
713
719
|
err(`unknown command ${JSON.stringify(cmd)} — one of: export, run, history, run-u, interview, author, replay, runs, panel`);
|
|
714
720
|
return 1;
|
|
715
721
|
}
|
package/src/config.js
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
// PANEL-BUILD.md P4a item 2 — the ONE reader/writer of `~/.config/bareloop/config.json`.
|
|
2
|
+
//
|
|
3
|
+
// Every panel/CLI setting lives in this one file (R5): the monthly $ limit, the
|
|
4
|
+
// Anthropic balance note, and the Providers rows' Name / API shape / Base URL, per key variable.
|
|
5
|
+
// Key VALUES never live here — they live in the keys file (`src/keysfile.js`) and the
|
|
6
|
+
// shell env. The write path refuses a document that carries a secret-shaped string
|
|
7
|
+
// (the ONE inventory: `sweepSecretLiterals`, src/validate.js).
|
|
8
|
+
//
|
|
9
|
+
// Shape (unknown fields are kept, never dropped):
|
|
10
|
+
// { "monthlyLimitUsd": <number>, // absent = no limit, no check
|
|
11
|
+
// "anthropicBalanceNote": <number>, // a typed note; no check ever reads it
|
|
12
|
+
// "keys": { "<ENV NAME>": { "name": "<model id>", "shape": "<api shape>", "baseUrl": "<url or blank>" } } }
|
|
13
|
+
//
|
|
14
|
+
// A missing file = defaults. An UNREADABLE file is reported (`problem`), never
|
|
15
|
+
// papered over — the monthly check REFUSES on it rather than silently running with
|
|
16
|
+
// no limit. Written atomically (tmp + rename), mode 600.
|
|
17
|
+
import {
|
|
18
|
+
existsSync, mkdirSync, readFileSync, renameSync, writeFileSync, chmodSync,
|
|
19
|
+
} from 'node:fs';
|
|
20
|
+
import { join, dirname } from 'node:path';
|
|
21
|
+
import { homedir } from 'node:os';
|
|
22
|
+
import { sweepSecretLiterals } from './validate.js';
|
|
23
|
+
|
|
24
|
+
/** A plain-words config failure (bad value, secret-shaped string, unreadable file). */
|
|
25
|
+
export class ConfigError extends Error {}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* @param {string} [home] test seam; production never passes it
|
|
29
|
+
* @returns {string} `<home>/config.json`
|
|
30
|
+
*/
|
|
31
|
+
export function configPath(home) {
|
|
32
|
+
return join(home ?? join(homedir(), '.config', 'bareloop'), 'config.json');
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* @typedef {object} ConfigRead
|
|
37
|
+
* @property {Record<string, any>} config the parsed document ({} when missing/unreadable)
|
|
38
|
+
* @property {boolean} exists
|
|
39
|
+
* @property {string|null} problem plain words when the file is there but unreadable
|
|
40
|
+
*/
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* @param {{ home?: string }} [opts]
|
|
44
|
+
* @returns {ConfigRead}
|
|
45
|
+
*/
|
|
46
|
+
export function readConfig(opts = {}) {
|
|
47
|
+
const file = configPath(opts.home);
|
|
48
|
+
if (!existsSync(file)) return { config: {}, exists: false, problem: null };
|
|
49
|
+
let parsed;
|
|
50
|
+
try {
|
|
51
|
+
parsed = JSON.parse(readFileSync(file, 'utf8'));
|
|
52
|
+
} catch (/** @type {any} */ e) {
|
|
53
|
+
return { config: {}, exists: true, problem: `${file} is not readable JSON (${e?.code ?? 'parse error'}) — fix or remove it` };
|
|
54
|
+
}
|
|
55
|
+
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
56
|
+
return { config: {}, exists: true, problem: `${file} must hold a JSON object — fix or remove it` };
|
|
57
|
+
}
|
|
58
|
+
return { config: parsed, exists: true, problem: null };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const ENV_NAME_RE = /^[A-Za-z_][A-Za-z0-9_]{0,63}$/;
|
|
62
|
+
/** the API shapes a keys row may name (the `src/providers.js` table names) */
|
|
63
|
+
const SHAPE_IDS = ['anthropic-api', 'openai-api', 'gemini-api'];
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Apply a patch and write. `null` deletes a field. Top-level fields shallow-merge;
|
|
67
|
+
* `keys` merges per key variable name, then per field. Validated before anything is
|
|
68
|
+
* written; nothing is written on a refusal.
|
|
69
|
+
*
|
|
70
|
+
* Patch fields understood: `monthlyLimitUsd` (finite > 0), `anthropicBalanceNote`
|
|
71
|
+
* (finite >= 0), `keys.<ENV NAME>.{name,shape,baseUrl}` (Name = a model id, shape = a provider table name, baseUrl = blank or http(s)). Any other
|
|
72
|
+
* field is passed through untouched (kept), but the whole document is swept for
|
|
73
|
+
* secret-shaped strings first.
|
|
74
|
+
* @param {Record<string, any>} patch
|
|
75
|
+
* @param {{ home?: string }} [opts]
|
|
76
|
+
* @returns {Record<string, any>} the document as written
|
|
77
|
+
*/
|
|
78
|
+
export function updateConfig(patch, opts = {}) {
|
|
79
|
+
const cur = readConfig(opts);
|
|
80
|
+
if (cur.problem) throw new ConfigError(cur.problem);
|
|
81
|
+
/** @type {Record<string, any>} */
|
|
82
|
+
const next = { ...cur.config };
|
|
83
|
+
for (const [k, v] of Object.entries(patch)) {
|
|
84
|
+
if (k === 'keys') continue;
|
|
85
|
+
if (v === null) { delete next[k]; continue; }
|
|
86
|
+
if (k === 'monthlyLimitUsd' && !(typeof v === 'number' && Number.isFinite(v) && v > 0)) {
|
|
87
|
+
throw new ConfigError('monthlyLimitUsd must be a number above 0 (remove the limit to have none)');
|
|
88
|
+
}
|
|
89
|
+
if (k === 'anthropicBalanceNote' && !(typeof v === 'number' && Number.isFinite(v) && v >= 0)) {
|
|
90
|
+
throw new ConfigError('anthropicBalanceNote must be a number, 0 or more');
|
|
91
|
+
}
|
|
92
|
+
next[k] = v;
|
|
93
|
+
}
|
|
94
|
+
if (patch.keys !== undefined) {
|
|
95
|
+
if (patch.keys === null || typeof patch.keys !== 'object' || Array.isArray(patch.keys)) {
|
|
96
|
+
throw new ConfigError('keys must be an object of key variable names');
|
|
97
|
+
}
|
|
98
|
+
/** @type {Record<string, any>} */
|
|
99
|
+
const keys = { ...(next.keys && typeof next.keys === 'object' ? next.keys : {}) };
|
|
100
|
+
for (const [envName, fields] of Object.entries(patch.keys)) {
|
|
101
|
+
if (!ENV_NAME_RE.test(envName)) throw new ConfigError(`"${envName}" is not a key variable name`);
|
|
102
|
+
if (fields === null) { delete keys[envName]; continue; }
|
|
103
|
+
if (typeof fields !== 'object' || Array.isArray(fields)) throw new ConfigError(`keys.${envName} must be an object`);
|
|
104
|
+
const merged = { ...(keys[envName] && typeof keys[envName] === 'object' ? keys[envName] : {}) };
|
|
105
|
+
for (const [fk, fv] of Object.entries(/** @type {Record<string, any>} */ (fields))) {
|
|
106
|
+
if (fv === null) { delete merged[fk]; continue; }
|
|
107
|
+
if (fk === 'name' && !(typeof fv === 'string' && fv.length <= 100 && !/\s/.test(fv))) {
|
|
108
|
+
throw new ConfigError(`keys.${envName}.name must be a model id (no spaces, up to 100 characters)`);
|
|
109
|
+
}
|
|
110
|
+
if (fk === 'shape' && !SHAPE_IDS.includes(fv)) {
|
|
111
|
+
throw new ConfigError(`keys.${envName}.shape must be one of ${SHAPE_IDS.join(', ')}`);
|
|
112
|
+
}
|
|
113
|
+
if (fk === 'baseUrl' && !(typeof fv === 'string' && (fv === '' || /^https?:\/\/\S+$/.test(fv)))) {
|
|
114
|
+
throw new ConfigError(`keys.${envName}.baseUrl must be blank or an http(s) URL`);
|
|
115
|
+
}
|
|
116
|
+
merged[fk] = fv;
|
|
117
|
+
}
|
|
118
|
+
keys[envName] = merged;
|
|
119
|
+
}
|
|
120
|
+
next.keys = keys;
|
|
121
|
+
}
|
|
122
|
+
/** @type {string[]} */
|
|
123
|
+
const secretPaths = [];
|
|
124
|
+
sweepSecretLiterals(next, (_code, path) => { secretPaths.push(path); });
|
|
125
|
+
if (secretPaths.length) {
|
|
126
|
+
throw new ConfigError(`config.json never holds a key value — a secret-shaped string at ${secretPaths.join(', ')} was refused (keys live in ~/.config/bareloop/.env)`);
|
|
127
|
+
}
|
|
128
|
+
const file = configPath(opts.home);
|
|
129
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
130
|
+
const tmp = `${file}.tmp-${process.pid}`;
|
|
131
|
+
writeFileSync(tmp, `${JSON.stringify(next, null, 2)}\n`, { mode: 0o600 });
|
|
132
|
+
chmodSync(tmp, 0o600);
|
|
133
|
+
renameSync(tmp, file);
|
|
134
|
+
return next;
|
|
135
|
+
}
|
package/src/interviewrun.js
CHANGED
|
@@ -86,6 +86,8 @@
|
|
|
86
86
|
// question this used to ask separately is GONE, folded into Destination. A
|
|
87
87
|
// non-repo source gets the form up to here and then an honest named stop:
|
|
88
88
|
// bareloop has no checks for that kind of job yet (ruling 7 → M4).
|
|
89
|
+
import { keysForDoor } from './keysfile.js';
|
|
90
|
+
import { applyConfiguredKey, rowsForHome } from './providerrows.js';
|
|
89
91
|
import {
|
|
90
92
|
writeFileSync, mkdirSync, existsSync, readFileSync, statSync,
|
|
91
93
|
} from 'node:fs';
|
|
@@ -123,6 +125,7 @@ class ExitSignal extends Error {
|
|
|
123
125
|
/**
|
|
124
126
|
* @typedef {object} Deps
|
|
125
127
|
* @property {Record<string,string|undefined>} [env]
|
|
128
|
+
* @property {string} [keysHome] P4a — test seam: load this home's `.env` keys file even when `env` is injected.
|
|
126
129
|
* @property {NodeJS.ReadStream} [stdin]
|
|
127
130
|
* @property {NodeJS.WritableStream} [stdout]
|
|
128
131
|
* @property {NodeJS.WritableStream} [stderr]
|
|
@@ -146,10 +149,13 @@ class ExitSignal extends Error {
|
|
|
146
149
|
* @returns {Promise<number>} an exit code — never calls process.exit
|
|
147
150
|
*/
|
|
148
151
|
export async function main(argv, deps = {}) {
|
|
149
|
-
|
|
152
|
+
// P4a item 1 — the keys file fills what the shell leaves unset (shell wins).
|
|
153
|
+
const keys = keysForDoor(deps);
|
|
154
|
+
const env = keys.env;
|
|
150
155
|
const stdin = deps.stdin ?? process.stdin;
|
|
151
156
|
const stdout = deps.stdout ?? process.stdout;
|
|
152
157
|
const stderr = deps.stderr ?? process.stderr;
|
|
158
|
+
if (keys.warning && deps.env === undefined) stderr.write(`WARNING: ${keys.warning}\n`);
|
|
153
159
|
const spawnSyncFn = deps.spawnSync ?? realSpawnSync;
|
|
154
160
|
const invokedAs = deps.invokedAs ?? 'node scripts/run-interview.mjs';
|
|
155
161
|
const out = (/** @type {string} */ s = '') => { stdout.write(`${s}\n`); };
|
|
@@ -765,7 +771,9 @@ export async function main(argv, deps = {}) {
|
|
|
765
771
|
// (a line break/control char/stray whitespace, e.g. a two-line secret-store
|
|
766
772
|
// entry). A malformed key must not count as KEYED: this offer must read the
|
|
767
773
|
// same "will it actually run" question run-author itself asks at its door.
|
|
768
|
-
|
|
774
|
+
// P4a item 4 — the key variable the person picked in Settings stands in for the built-in one
|
|
775
|
+
const keyEnv = keys.home !== undefined ? applyConfiguredKey(env, PROVIDER, draft.baseUrl, rowsForHome(keys.home)) : env;
|
|
776
|
+
const rawKeyValue = providerEnvKey ? keyEnv[providerEnvKey] : undefined;
|
|
769
777
|
const keyProblem = rawKeyValue ? apiKeyProblem(rawKeyValue) : null;
|
|
770
778
|
const KEYED = providerEnvKey !== null && Boolean(rawKeyValue) && !keyProblem;
|
|
771
779
|
if (!KEYED) {
|