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.
Files changed (46) hide show
  1. package/CHANGELOG.md +154 -0
  2. package/bareloop.context.md +205 -10
  3. package/bin/bareloop.mjs +0 -1
  4. package/package.json +1 -1
  5. package/src/authorflow.js +22 -42
  6. package/src/authorjob.js +5 -8
  7. package/src/authorrun.js +42 -16
  8. package/src/cli.js +12 -6
  9. package/src/config.js +135 -0
  10. package/src/interviewrun.js +10 -2
  11. package/src/keysfile.js +156 -0
  12. package/src/ledger.js +26 -0
  13. package/src/monthly.js +352 -0
  14. package/src/panel/authorroutes.js +328 -0
  15. package/src/panel/authorsession.js +555 -0
  16. package/src/panel/index.html +1247 -50
  17. package/src/panel/server.js +449 -51
  18. package/src/panel/settingsroutes.js +230 -0
  19. package/src/planrun.js +61 -5
  20. package/src/promptregisters.js +3 -0
  21. package/src/providerrows.js +217 -0
  22. package/src/providers.js +84 -0
  23. package/src/replay.js +80 -2
  24. package/src/replayio.js +14 -3
  25. package/src/run.js +48 -2
  26. package/src/runlist.js +13 -0
  27. package/src/userrun.js +134 -13
  28. package/types/authorflow.d.ts +4 -21
  29. package/types/authorrun.d.ts +5 -0
  30. package/types/cli.d.ts +2 -1
  31. package/types/config.d.ts +48 -0
  32. package/types/interviewrun.d.ts +5 -0
  33. package/types/keysfile.d.ts +90 -0
  34. package/types/ledger.d.ts +14 -0
  35. package/types/monthly.d.ts +192 -0
  36. package/types/panel/authorroutes.d.ts +67 -0
  37. package/types/panel/authorsession.d.ts +53 -0
  38. package/types/panel/server.d.ts +42 -5
  39. package/types/panel/settingsroutes.d.ts +14 -0
  40. package/types/providerrows.d.ts +164 -0
  41. package/types/providers.d.ts +34 -0
  42. package/types/replay.d.ts +31 -0
  43. package/types/replayio.d.ts +12 -1
  44. package/types/run.d.ts +26 -1
  45. package/types/runlist.d.ts +1 -0
  46. 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
- const env = deps.env ?? process.env;
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 already
352
- // skips the repo-only "worse than before" ask and the language pick alike.
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
- const AUTHOR_ENV_KEY = /** @type {NonNullable<typeof providerEntry>} */ (providerEntry).envKey;
502
- const apiKey = env[AUTHOR_ENV_KEY];
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 — `worseThanBefore`'s "nothing
706
- * beyond Guardrails" is a legal, non-required answer, unlike every other
707
- * free-text step below.
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
- out(`total cost ${costLine(costSoFar())}${judges ? ' (includes the calibration gate\'s judge calls)' : ' (gates 1-3 spend no tokens — they run commands)'}`);
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
- const env = deps.env ?? process.env;
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
+ }
@@ -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
- const env = deps.env ?? process.env;
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
- const rawKeyValue = providerEnvKey ? env[providerEnvKey] : undefined;
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) {
@@ -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
+ }