bareloop 0.30.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 +154 -0
- package/bareloop.context.md +205 -10
- package/bin/bareloop.mjs +0 -1
- package/package.json +1 -1
- package/src/authorflow.js +22 -42
- package/src/authorjob.js +5 -8
- package/src/authorrun.js +42 -16
- 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 +328 -0
- package/src/panel/authorsession.js +555 -0
- package/src/panel/index.html +1247 -50
- package/src/panel/server.js +449 -51
- package/src/panel/settingsroutes.js +230 -0
- package/src/planrun.js +61 -5
- package/src/promptregisters.js +3 -0
- package/src/providerrows.js +217 -0
- package/src/providers.js +84 -0
- package/src/replay.js +80 -2
- package/src/replayio.js +14 -3
- package/src/run.js +48 -2
- package/src/runlist.js +13 -0
- package/src/userrun.js +134 -13
- package/types/authorflow.d.ts +4 -21
- 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 +67 -0
- package/types/panel/authorsession.d.ts +53 -0
- package/types/panel/server.d.ts +42 -5
- package/types/panel/settingsroutes.d.ts +14 -0
- package/types/providerrows.d.ts +164 -0
- package/types/providers.d.ts +34 -0
- package/types/replay.d.ts +31 -0
- package/types/replayio.d.ts +12 -1
- package/types/run.d.ts +26 -1
- package/types/runlist.d.ts +1 -0
- package/types/userrun.d.ts +22 -0
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';
|
|
@@ -348,8 +354,9 @@ export async function main(argv, deps = {}) {
|
|
|
348
354
|
// Only meaningful for a REPO source (PRD item 33 M3 piece 4, step S6): a
|
|
349
355
|
// plain folder has no genre to detect and no code-shaped manifest to walk —
|
|
350
356
|
// `LANG`/`langResult` stay at their "nothing to see" values and the confirm
|
|
351
|
-
// turn further down runs with `isRepo: false` (D5), which is what
|
|
352
|
-
//
|
|
357
|
+
// turn further down runs with `isRepo: false` (D5), which is what skips
|
|
358
|
+
// the language pick (the old repo-only "worse than before" ask is retired
|
|
359
|
+
// entirely as of 2026-09-28 — see src/authorflow.js's own retirement note).
|
|
353
360
|
let langResult = /** @type {ReturnType<typeof detectLanguage>|null} */ (null);
|
|
354
361
|
let LANG = 'none-detected';
|
|
355
362
|
if (IS_REPO_SOURCE) {
|
|
@@ -498,8 +505,12 @@ export async function main(argv, deps = {}) {
|
|
|
498
505
|
|
|
499
506
|
// Secrets load from the environment; they never enter argv (a command line is
|
|
500
507
|
// world-readable on /proc) and they are never printed.
|
|
501
|
-
|
|
502
|
-
|
|
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];
|
|
503
514
|
if (!apiKey) { err(`${AUTHOR_ENV_KEY} not set (secrets load from the environment — never the tree, never argv)`); throw new ExitSignal(2); }
|
|
504
515
|
// F181 — a key that carries a line break/control char/stray whitespace (a
|
|
505
516
|
// two-line secret-store entry, e.g.) reads as "set" by the presence check
|
|
@@ -513,7 +524,7 @@ export async function main(argv, deps = {}) {
|
|
|
513
524
|
* contract `scripts/run-u.mjs` keeps, so one story covers both surfaces. When the
|
|
514
525
|
* judge IS the authoring provider this is the key already read above. */
|
|
515
526
|
const judgeKeyFor = (/** @type {string} */ providerName) => (
|
|
516
|
-
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]
|
|
517
528
|
);
|
|
518
529
|
|
|
519
530
|
/** F6 — an unpriced call makes the TOTAL unknown, and unknown is reported as
|
|
@@ -702,9 +713,10 @@ export async function main(argv, deps = {}) {
|
|
|
702
713
|
return String(value);
|
|
703
714
|
};
|
|
704
715
|
/** one free-text answer, possibly several lines; a blank line ends it.
|
|
705
|
-
* `allowBlank` lets the FIRST line be blank —
|
|
706
|
-
*
|
|
707
|
-
*
|
|
716
|
+
* `allowBlank` lets the FIRST line be blank — kept as a general option for
|
|
717
|
+
* a future optional free-text step; every current step below passes
|
|
718
|
+
* `false` (the old "worse than before" ask, retired 2026-09-28, was the
|
|
719
|
+
* only caller that ever passed `true`).
|
|
708
720
|
* @param {boolean} allowBlank @returns {Promise<string|null>} */
|
|
709
721
|
const readFreeText = async (allowBlank) => {
|
|
710
722
|
/** @type {string[]} */
|
|
@@ -729,11 +741,6 @@ export async function main(argv, deps = {}) {
|
|
|
729
741
|
* @param {{kind: string, [k: string]: any}} step @returns {Promise<string|null>} */
|
|
730
742
|
const ask = async (step) => {
|
|
731
743
|
out('');
|
|
732
|
-
if (step.kind === 'worseThanBefore') {
|
|
733
|
-
out(step.field.prompt);
|
|
734
|
-
out(' (press Enter on a blank line for "nothing beyond Guardrails")');
|
|
735
|
-
return readFreeText(true);
|
|
736
|
-
}
|
|
737
744
|
if (step.kind === 'language') {
|
|
738
745
|
out(step.field.prompt);
|
|
739
746
|
for (const c of step.candidates ?? []) out(` · ${c}`);
|
|
@@ -1201,7 +1208,15 @@ export async function main(argv, deps = {}) {
|
|
|
1201
1208
|
// buys a real judge call per case, so a line saying "the gates spend no
|
|
1202
1209
|
// tokens" would have been false the moment a judged close reached here.
|
|
1203
1210
|
out(`authoring ${costLine(authored.cost)}`);
|
|
1204
|
-
|
|
1211
|
+
const totalCost = costSoFar();
|
|
1212
|
+
out(`total cost ${costLine(totalCost)}${judges ? ' (includes the calibration gate\'s judge calls)' : ' (gates 1-3 spend no tokens — they run commands)'}`);
|
|
1213
|
+
// hamr's ruling 2026-09-28 ("one cap covers drafting + run") — this
|
|
1214
|
+
// run's own known FLOOR (never $0 on an unpriced call, F6), handed to
|
|
1215
|
+
// the run-u command below as `--draft-spent-usd` so the SIGNED run's
|
|
1216
|
+
// own enforced ceiling becomes Cap $ minus what THIS pipeline already
|
|
1217
|
+
// spent, computed in the one place (`src/run.js`'s `remainingUsd`)
|
|
1218
|
+
// that arithmetic lives — never re-derived here.
|
|
1219
|
+
const draftSpentUsd = typeof totalCost.costUsd === 'number' ? totalCost.costUsd : totalCost.knownUsd;
|
|
1205
1220
|
|
|
1206
1221
|
if (!signing.ok) {
|
|
1207
1222
|
out('\nSIGNING NOT PREPARED — the close did not clear D9\'s gates. Nothing was signed and nothing was run.');
|
|
@@ -1222,8 +1237,19 @@ export async function main(argv, deps = {}) {
|
|
|
1222
1237
|
// person needs to run their own job, nothing left to hand off.
|
|
1223
1238
|
// `providerEntry.envKey` (never a hardcoded ANTHROPIC_API_KEY) is the
|
|
1224
1239
|
// same F187 rule scripts/run-u.mjs's own hint already follows.
|
|
1240
|
+
// hamr's ruling 2026-09-28 (2nd addendum) — `totalCost.costUsd === null`
|
|
1241
|
+
// means this pipeline's own metered list carried at least one unpriced
|
|
1242
|
+
// call (`tallyCalls`'s `spendComplete: false`), so the drafting fold is
|
|
1243
|
+
// a FLOOR, and the run-u command below must say so via its own
|
|
1244
|
+
// presence-flag rather than let the next leg's job-start read it as
|
|
1245
|
+
// exact.
|
|
1246
|
+
const draftIncomplete = totalCost.costUsd === null;
|
|
1247
|
+
if (draftSpentUsd > 0) {
|
|
1248
|
+
out(`\n drafting spent ${draftIncomplete ? `at least $${draftSpentUsd.toFixed(6)}` : `$${draftSpentUsd.toFixed(6)}`} — the run's own cap is the SAME signed $${spec.budgetUsd}, `
|
|
1249
|
+
+ 'and run-u will enforce the remainder (Cap $ minus this) as ITS ceiling — pass --draft-spent-usd exactly as shown below, never a rounded or re-typed figure.');
|
|
1250
|
+
}
|
|
1225
1251
|
out('\nTo run it (the same signature and gates as any other job — nothing here bypasses them):');
|
|
1226
|
-
out(` ${providerEntry.envKey}=... node scripts/run-u.mjs --spec ${specFile} --approve ${hash}`);
|
|
1252
|
+
out(` ${providerEntry.envKey}=... node scripts/run-u.mjs --spec ${specFile} --approve ${hash}${draftSpentUsd > 0 ? ` --draft-spent-usd ${draftSpentUsd}${draftIncomplete ? ' --draft-spend-incomplete' : ''}` : ''}`);
|
|
1227
1253
|
emit('author-end', { outcome: 'prepared', specHash: hash });
|
|
1228
1254
|
}
|
|
1229
1255
|
}
|
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) {
|
package/src/keysfile.js
ADDED
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
// PANEL-BUILD.md P4a item 1 — the keys file loader (`~/.config/bareloop/.env`).
|
|
2
|
+
//
|
|
3
|
+
// Key VALUES live only in this file (chmod 600, outside any repo) and in the shell
|
|
4
|
+
// environment — never in config.json, the spine, runs.jsonl, a log, or a page.
|
|
5
|
+
// This is the ONE reader of that file. Plain `NAME=value` lines (an optional
|
|
6
|
+
// `export ` prefix, `#` full-line comments, one pair of matching quotes around the
|
|
7
|
+
// value); no dependency.
|
|
8
|
+
//
|
|
9
|
+
// Precedence: the SHELL wins. An explicit `export FOO=…` beats the file; the file
|
|
10
|
+
// only fills names the shell leaves unset (or empty). The merged env is a NEW
|
|
11
|
+
// object — `process.env` is never mutated.
|
|
12
|
+
//
|
|
13
|
+
// Values leave this module in exactly one place: the `env` of `loadKeysEnv`, which
|
|
14
|
+
// goes to provider construction. Everything else it returns (`names`, `warning`) is
|
|
15
|
+
// value-free by construction, and a parse problem never quotes the offending line.
|
|
16
|
+
import { existsSync, readFileSync, statSync, writeFileSync, mkdirSync } from 'node:fs';
|
|
17
|
+
import { join } from 'node:path';
|
|
18
|
+
import { homedir } from 'node:os';
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* `~/.config/bareloop` (or the injected override) — same home every other
|
|
22
|
+
* per-person file (runs.jsonl, config.json) lives in.
|
|
23
|
+
* @param {string} [home] test seam; production never passes it
|
|
24
|
+
* @returns {string}
|
|
25
|
+
*/
|
|
26
|
+
export function keysHome(home) {
|
|
27
|
+
return home ?? join(homedir(), '.config', 'bareloop');
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* @param {string} [home]
|
|
32
|
+
* @returns {string} `<home>/.env`
|
|
33
|
+
*/
|
|
34
|
+
export function keysFilePath(home) {
|
|
35
|
+
return join(keysHome(home), '.env');
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const NAME_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Parse the file's text. Lines that are not `NAME=value` are skipped silently
|
|
42
|
+
* (never echoed — a malformed line may hold a secret).
|
|
43
|
+
* @param {string} text
|
|
44
|
+
* @returns {Record<string,string>}
|
|
45
|
+
*/
|
|
46
|
+
export function parseKeysText(text) {
|
|
47
|
+
/** @type {Record<string,string>} */
|
|
48
|
+
const out = {};
|
|
49
|
+
for (const raw of text.split(/\r?\n/)) {
|
|
50
|
+
const line = raw.trim();
|
|
51
|
+
if (line === '' || line.startsWith('#')) continue;
|
|
52
|
+
const body = line.startsWith('export ') ? line.slice(7).trimStart() : line;
|
|
53
|
+
const eq = body.indexOf('=');
|
|
54
|
+
if (eq <= 0) continue;
|
|
55
|
+
const name = body.slice(0, eq).trim();
|
|
56
|
+
if (!NAME_RE.test(name)) continue;
|
|
57
|
+
let value = body.slice(eq + 1).trim();
|
|
58
|
+
if (value.length >= 2 && (value[0] === '"' || value[0] === "'") && value.endsWith(value[0])) {
|
|
59
|
+
value = value.slice(1, -1);
|
|
60
|
+
}
|
|
61
|
+
out[name] = value;
|
|
62
|
+
}
|
|
63
|
+
return out;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* The NAMES in the file that carry a non-empty value, in file order — the Providers rows
|
|
68
|
+
* (an empty preset line is not a row). Names only; a missing/unreadable file = none.
|
|
69
|
+
* @param {string} [home]
|
|
70
|
+
* @returns {string[]}
|
|
71
|
+
*/
|
|
72
|
+
export function filledKeyNames(home) {
|
|
73
|
+
try {
|
|
74
|
+
return Object.entries(parseKeysText(readFileSync(keysFilePath(home), 'utf8'))).filter(([, v]) => v !== '').map(([n]) => n);
|
|
75
|
+
} catch { return []; }
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Create the keys file with the five empty preset lines when — and only when — it does not
|
|
80
|
+
* exist (mode 600). An existing file is never touched.
|
|
81
|
+
* @param {readonly string[]} presetNames the names to seed, in order
|
|
82
|
+
* @param {string} [home]
|
|
83
|
+
* @returns {boolean} true if it was created
|
|
84
|
+
*/
|
|
85
|
+
export function ensureKeysFile(presetNames, home) {
|
|
86
|
+
const file = keysFilePath(home);
|
|
87
|
+
if (existsSync(file)) return false;
|
|
88
|
+
mkdirSync(keysHome(home), { recursive: true });
|
|
89
|
+
try {
|
|
90
|
+
writeFileSync(file, presetNames.map((n) => `${n}=\n`).join(''), { mode: 0o600, flag: 'wx' });
|
|
91
|
+
} catch (/** @type {any} */ e) {
|
|
92
|
+
if (e?.code === 'EEXIST') return false; // lost a race: the file is there, untouched
|
|
93
|
+
throw e;
|
|
94
|
+
}
|
|
95
|
+
return true;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* @typedef {object} KeysLoad
|
|
100
|
+
* @property {boolean} exists the file is there
|
|
101
|
+
* @property {string[]} names the NAMES the file defines (sorted; never values)
|
|
102
|
+
* @property {string|null} warning a plain-words warning (file readable by others, or
|
|
103
|
+
* unreadable), else null — a warning, never a refusal
|
|
104
|
+
* @property {Record<string,string|undefined>} env shell env with the file filling the
|
|
105
|
+
* names the shell does not set. THE ONLY place values leave this module.
|
|
106
|
+
*/
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Load the keys file over an env. Missing file is fine (no names, env unchanged).
|
|
110
|
+
* @param {{ env?: Record<string,string|undefined>, home?: string }} [opts]
|
|
111
|
+
* @returns {KeysLoad}
|
|
112
|
+
*/
|
|
113
|
+
export function loadKeysEnv(opts = {}) {
|
|
114
|
+
const base = opts.env ?? process.env;
|
|
115
|
+
const file = keysFilePath(opts.home);
|
|
116
|
+
if (!existsSync(file)) return { exists: false, names: [], warning: null, env: { ...base } };
|
|
117
|
+
/** @type {string|null} */
|
|
118
|
+
let warning = null;
|
|
119
|
+
try {
|
|
120
|
+
const mode = statSync(file).mode & 0o777;
|
|
121
|
+
if ((mode & 0o077) !== 0) {
|
|
122
|
+
warning = `${file} is readable by other users (mode ${mode.toString(8)}) — run: chmod 600 ${file}`;
|
|
123
|
+
}
|
|
124
|
+
} catch { /* the read below reports it */ }
|
|
125
|
+
/** @type {Record<string,string>} */
|
|
126
|
+
let parsed = {};
|
|
127
|
+
try {
|
|
128
|
+
parsed = parseKeysText(readFileSync(file, 'utf8'));
|
|
129
|
+
} catch (/** @type {any} */ e) {
|
|
130
|
+
return { exists: true, names: [], warning: `${file} could not be read (${e?.code ?? 'error'})`, env: { ...base } };
|
|
131
|
+
}
|
|
132
|
+
/** @type {Record<string,string|undefined>} */
|
|
133
|
+
const env = { ...base };
|
|
134
|
+
for (const [name, value] of Object.entries(parsed)) {
|
|
135
|
+
if (!env[name]) env[name] = value;
|
|
136
|
+
}
|
|
137
|
+
return { exists: true, names: Object.keys(parsed).sort(), warning, env };
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* The door helper the CLI and the panel call: load the file only when the caller is
|
|
142
|
+
* on the REAL environment (no injected env) or names a keys home explicitly — so a
|
|
143
|
+
* test that injects `env` alone never reads the real `~/.config/bareloop/.env`.
|
|
144
|
+
* @param {{ env?: Record<string,string|undefined>, keysHome?: string }} deps
|
|
145
|
+
* @returns {KeysLoad & { env: Record<string,string|undefined>, home: string|undefined }}
|
|
146
|
+
* `home` = the home the file was loaded from (the real `~/.config/bareloop` when none was
|
|
147
|
+
* injected) — or `undefined` when loading was skipped (injected env, no keysHome). The
|
|
148
|
+
* door passes it on so config.json (src/config.js) is read from the SAME place, and never
|
|
149
|
+
* from the real home in a test.
|
|
150
|
+
*/
|
|
151
|
+
export function keysForDoor(deps) {
|
|
152
|
+
if (deps.env !== undefined && deps.keysHome === undefined) {
|
|
153
|
+
return { exists: false, names: [], warning: null, env: deps.env, home: undefined };
|
|
154
|
+
}
|
|
155
|
+
return { ...loadKeysEnv({ env: deps.env, home: deps.keysHome }), home: keysHome(deps.keysHome) };
|
|
156
|
+
}
|
package/src/ledger.js
CHANGED
|
@@ -506,3 +506,29 @@ export function spendProvenance(events) {
|
|
|
506
506
|
}
|
|
507
507
|
return out;
|
|
508
508
|
}
|
|
509
|
+
|
|
510
|
+
/**
|
|
511
|
+
* The priced-rounds spend floor + first-record→last-record wall floor,
|
|
512
|
+
* derived straight off the spine's raw records — the same derivation a
|
|
513
|
+
* died run's "at least …" figures use, factored out so build item 6
|
|
514
|
+
* (2026-09-28) can hand a genuinely-still-running spine the identical
|
|
515
|
+
* floor instead of "unknown" on the Run tab, without a second copy of this
|
|
516
|
+
* math.
|
|
517
|
+
* @param {any[]} records raw parsed spine records
|
|
518
|
+
* @returns {{spendFloorUsd: number|null, wallFloorMs: number|null}}
|
|
519
|
+
*/
|
|
520
|
+
export function floorsFromRecords(records) {
|
|
521
|
+
let spendSum = 0;
|
|
522
|
+
let pricedCount = 0;
|
|
523
|
+
for (const r of records) {
|
|
524
|
+
if (!r || typeof r !== 'object' || !SPEND_RECORD_TYPES.includes(r.type)) continue; // worker-result echoes excluded by construction — not a spend type
|
|
525
|
+
if (typeof r.costUsd === 'number' && Number.isFinite(r.costUsd)) { spendSum += r.costUsd; pricedCount += 1; }
|
|
526
|
+
}
|
|
527
|
+
const spendFloorUsd = pricedCount > 0 ? spendSum : null;
|
|
528
|
+
|
|
529
|
+
const withTs = records.filter((r) => r && typeof r === 'object' && typeof r.ts === 'string');
|
|
530
|
+
const firstMs = withTs.length ? Date.parse(withTs[0].ts) : NaN;
|
|
531
|
+
const lastMs = withTs.length ? Date.parse(withTs[withTs.length - 1].ts) : NaN;
|
|
532
|
+
const wallFloorMs = Number.isFinite(firstMs) && Number.isFinite(lastMs) && lastMs >= firstMs ? lastMs - firstMs : null;
|
|
533
|
+
return { spendFloorUsd, wallFloorMs };
|
|
534
|
+
}
|