bareloop 0.31.0 → 0.33.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 (54) hide show
  1. package/CHANGELOG.md +151 -0
  2. package/README.md +4 -1
  3. package/bareloop.context.md +232 -85
  4. package/bin/bareloop.mjs +0 -1
  5. package/package.json +1 -1
  6. package/src/authorflow.js +5 -3
  7. package/src/authorjob.js +3 -1
  8. package/src/authorrun.js +49 -11
  9. package/src/authorscout.js +10 -6
  10. package/src/bundlerun.js +266 -0
  11. package/src/cli.js +35 -357
  12. package/src/config.js +135 -0
  13. package/src/interviewrun.js +13 -4
  14. package/src/invoke.js +22 -0
  15. package/src/judged.js +5 -3
  16. package/src/keysfile.js +156 -0
  17. package/src/ledger.js +26 -0
  18. package/src/monthly.js +422 -0
  19. package/src/panel/authorroutes.js +61 -16
  20. package/src/panel/authorsession.js +46 -28
  21. package/src/panel/index.html +287 -13
  22. package/src/panel/server.js +58 -51
  23. package/src/panel/settingsroutes.js +230 -0
  24. package/src/planrun.js +7 -3
  25. package/src/providerrows.js +301 -0
  26. package/src/providers.js +3 -1
  27. package/src/run.js +7 -2
  28. package/src/runlist.js +92 -7
  29. package/src/source.js +8 -3
  30. package/src/u-watchdog.mjs +296 -0
  31. package/src/userrun.js +230 -54
  32. package/types/authorflow.d.ts +8 -2
  33. package/types/authorjob.d.ts +6 -1
  34. package/types/authorrun.d.ts +5 -0
  35. package/types/authorscout.d.ts +11 -2
  36. package/types/bundlerun.d.ts +26 -0
  37. package/types/cli.d.ts +2 -1
  38. package/types/config.d.ts +48 -0
  39. package/types/interviewrun.d.ts +5 -0
  40. package/types/invoke.d.ts +8 -0
  41. package/types/judged.d.ts +5 -1
  42. package/types/keysfile.d.ts +90 -0
  43. package/types/ledger.d.ts +14 -0
  44. package/types/monthly.d.ts +227 -0
  45. package/types/panel/authorroutes.d.ts +4 -2
  46. package/types/panel/authorsession.d.ts +7 -15
  47. package/types/panel/server.d.ts +10 -3
  48. package/types/panel/settingsroutes.d.ts +14 -0
  49. package/types/planrun.d.ts +13 -1
  50. package/types/providerrows.d.ts +229 -0
  51. package/types/run.d.ts +14 -1
  52. package/types/runlist.d.ts +66 -3
  53. package/types/source.d.ts +8 -3
  54. package/types/userrun.d.ts +54 -0
package/src/cli.js CHANGED
@@ -3,37 +3,32 @@
3
3
  // bareloop.mjs` is a ~10-line adapter that supplies the real `deps` and turns
4
4
  // the returned exit code into `process.exitCode`.
5
5
  //
6
- // `deps.provider` (and `providerFor`/`judgeProvider`) is the ONE test seam: a
7
- // caller that supplies it skips the real-key check entirely (a scripted
8
- // provider IS the run, same as `tests/planrun.test.js`'s pattern). A caller
9
- // that does NOT supply one goes through the real path M1's frozen spec names:
10
- // a key from `ANTHROPIC_API_KEY` only, absent means print the operator
11
- // questions and spend nothing (`bareloop run`, step 3).
6
+ // `bareloop run <bundle>` is a thin door (`src/bundlerun.js`) to the same engine `bareloop
7
+ // run-u` drives (`src/userrun.js`): providers, keys (the keys file / env, any provider the
8
+ // bundle's spec names), judge, monthly limit and readout are that engine's. The test seam is
9
+ // the engine's too: `deps.provider` (and `providerFor`/`judgeProvider`) supplies a scripted
10
+ // provider and skips the real-key check (same as `tests/planrun.test.js`'s pattern).
12
11
  //
13
- // Money/arbiter rules this module must not bend: `--budget`/`--wall` only
14
- // TIGHTEN a bundle's signed ceilings (`checkEnvelope`, M1); a bundle's
15
- // `bundleHash` (the human-facing signature) never changes when the runner
16
- // tightens — the SPEC that actually executes does, and this module mints a
17
- // FRESH `approvals` record over that tightened, `$BARELOOP_BUNDLE`-resolved
18
- // spec so `checkApproval` inside `runJob` always sees the spec it is about to
19
- // run, never the unresolved bundleHash. `history.jsonl` records both hashes
20
- // side by side (POC fact 2) so the pairing can never drift silently.
12
+ // Money/arbiter rules the bundle door keeps: `--budget`/`--wall` only TIGHTEN a bundle's
13
+ // signed ceilings (`checkEnvelope`, M1); a bundle's `bundleHash` (the human-facing
14
+ // signature) never changes when the runner tightens — the SPEC that actually executes does,
15
+ // and the door hands the engine the hash of that tightened, `$BARELOOP_BUNDLE`-resolved
16
+ // spec. `history.jsonl` records both hashes side by side (POC fact 2) so the pairing can
17
+ // never drift silently.
21
18
 
22
19
  import { createInterface } from 'node:readline/promises';
23
- import { hostname } from 'node:os';
24
- import { execFileSync } from 'node:child_process';
25
20
  import {
26
- existsSync, mkdirSync, readFileSync, renameSync,
21
+ existsSync, readFileSync,
27
22
  } from 'node:fs';
28
23
  import {
29
24
  dirname, isAbsolute, join, resolve,
30
25
  } from 'node:path';
31
26
 
32
27
  import {
33
- exportBundle, readBundle, resolveBundleSpec, checkEnvelope, bless, verifyBlessing, appendHistory, checkBundleDeps,
34
- runJob, makeSpine, loadRegistry, listingRow, jobSpecHash, resolveWorkerModel, resolveJudge,
28
+ exportBundle, loadRegistry, listingRow,
35
29
  } from './index.js';
36
- import { resolveProvider, buildRunnerProviders, apiKeyProblem } from './providers.js';
30
+ // One runner: `bareloop run <bundle>` is a thin door to the `run-u` engine (`src/userrun.js`).
31
+ import { bundleMain } from './bundlerun.js';
37
32
  // PANEL-BUILD.md P0 task 2/4 — `bareloop run-u` wraps the person-path run
38
33
  // flow's own argv-parsing entry (`src/userrun.js`'s `main(argv, deps)`,
39
34
  // itself the lift target of P0 task 1). This is the SAME function
@@ -68,87 +63,12 @@ import { panelMain } from './panel/server.js';
68
63
  // (`~/.config/bareloop/runs.jsonl`) and its backfill scan. `bareloop runs`
69
64
  // (list) and `bareloop runs backfill <dir>` (reconstruct rows from archived
70
65
  // spines already on disk) are this rung's only new commands.
71
- import { appendRun, readRunList, backfillRuns, formatRunRow } from './runlist.js';
72
-
73
- // The tier->model tables live in `src/providers.js` now (PRD item 28's
74
- // factory) — one seam instead of a copy hardcoded in each runner. A
75
- // `bareloop run` has no `--model` flag (not in the frozen spec), so
76
- // `flagModel` is always undefined below and only the spec's own `model`
77
- // (if signed) or the resolved provider's default tier is ever in play.
66
+ import { keysForDoor } from './keysfile.js';
67
+ import { readRunList, backfillRuns, formatRunRow } from './runlist.js';
78
68
 
79
69
  /** @param {unknown} v @returns {v is Record<string, any>} */
80
70
  const isObj = (v) => typeof v === 'object' && v !== null;
81
71
 
82
- /**
83
- * Build the real providers from an API key, exactly the way `scripts/run-
84
- * u.mjs` does (provider/providerFor/judgeProvider from `bare-agent/
85
- * providers`'s `AnthropicProvider`). `AnthropicProvider` itself lives in
86
- * `bare-agent` (the one production dependency already budgeted for provider
87
- * calls); this function only wires it up the same way the u-runner does —
88
- * it is not arbiter logic (no budget/verdict/merge decision lives here).
89
- * @param {string} apiKey
90
- * @param {any} spec the (already `$BARELOOP_BUNDLE`-resolved) bundle spec
91
- */
92
- function buildProviders(apiKey, spec) {
93
- // `bareloop run`'s frozen key contract (module header above) is
94
- // ANTHROPIC_API_KEY only — this `apiKey` is always that key. A bundle
95
- // whose spec names a provider that reads a DIFFERENT env key therefore
96
- // has no seam into this flow yet, and the one thing this must never do is
97
- // construct that provider with the Anthropic key: the run would fail at
98
- // the first call with a vendor 401, which reads as a credential problem
99
- // rather than as the unbuilt seam it actually is. So it REFUSES here, at
100
- // $0, naming the key it would have needed. Widening the contract (a
101
- // signed per-provider key story) is PRD item 28 part (2)/(3), the
102
- // arbiter's, not this factory rewire's — the runner that actually fires
103
- // openai-api jobs today is `scripts/run-u.mjs`, which IS key-aware.
104
- const providerName = isObj(spec) && typeof spec.provider === 'string' ? spec.provider : 'anthropic-api';
105
- const entry = resolveProvider(providerName);
106
- if (entry.envKey !== 'ANTHROPIC_API_KEY') {
107
- throw new Error(
108
- `bareloop run: this bundle's spec names provider "${providerName}", which reads `
109
- + `${entry.envKey}. The bundle runner's key contract is ANTHROPIC_API_KEY only, so it `
110
- + 'refuses rather than calling that provider with the wrong key (PRD item 28 part 2 — '
111
- + 'a signed per-provider key story is not built). Run this job through scripts/run-u.mjs.',
112
- );
113
- }
114
- const modelResolution = resolveWorkerModel({
115
- specModel: isObj(spec) && typeof spec.model === 'string' ? spec.model : undefined,
116
- flagModel: undefined,
117
- defaultModel: entry.tiers.sonnet,
118
- });
119
- const MODEL = modelResolution.model;
120
- const tierModels = modelResolution.source === 'spec' ? { ...entry.tiers, sonnet: MODEL } : entry.tiers;
121
- const baseUrl = isObj(spec) && typeof spec.baseUrl === 'string' ? spec.baseUrl : undefined;
122
- // THE JUDGE, RESOLVED RATHER THAN PINNED (PRD item 32.1/32.2). The bundle
123
- // runner's key contract is `ANTHROPIC_API_KEY`-only and the refusal above has
124
- // already established that the WORKER provider reads that key — so a judge
125
- // defaulting to the worker's own identity reads the same key by construction,
126
- // and a signed `judge` naming a different provider would need a key this
127
- // runner does not have. That case refuses here, at $0, by name — the same
128
- // shape and the same reason as the worker refusal above, never a call made
129
- // with the wrong vendor's key.
130
- const judge = resolveJudge({
131
- specJudge: isObj(spec) ? spec.judge : undefined,
132
- workerProvider: providerName,
133
- workerModel: MODEL,
134
- });
135
- const judgeEntry = resolveProvider(judge.provider);
136
- if (judgeEntry.envKey !== 'ANTHROPIC_API_KEY') {
137
- throw new Error(
138
- `bareloop run: this bundle's spec names judge provider "${judge.provider}", which reads `
139
- + `${judgeEntry.envKey}. The bundle runner's key contract is ANTHROPIC_API_KEY only, so it `
140
- + 'refuses rather than calling that provider with the wrong key. Run this job through scripts/run-u.mjs.',
141
- );
142
- }
143
- return {
144
- ...buildRunnerProviders({
145
- providerName, apiKey, model: MODEL, tierModels, baseUrl,
146
- judgeApiKey: apiKey, judgeModel: judge.model, judgeProviderName: judge.provider, judgeBaseUrl: baseUrl,
147
- }),
148
- judgeModel: judge.model,
149
- };
150
- }
151
-
152
72
  /** @param {string[]} args @returns {{ positional: string[], flags: Record<string, string|true> }} */
153
73
  function parseFlags(args) {
154
74
  /** @type {string[]} */
@@ -247,255 +167,6 @@ function doExport(args, { out, err, cwd }) {
247
167
  return 0;
248
168
  }
249
169
 
250
- /**
251
- * `bareloop run <bundleDir> --repo <path> [--budget N] [--wall MIN] [--approve <bundleHash>]`
252
- * — the exact five-step order the frozen spec names; no step may be reordered.
253
- * @param {string[]} args
254
- * @param {{ out: (s: string) => void, err: (s: string) => void, cwd: string, env: Record<string,string|undefined>, now: () => number, deps: any }} ctx
255
- */
256
- async function doRun(args, { out, err, cwd, env, now, deps }) {
257
- const { positional, flags } = parseFlags(args);
258
- const bundleDirArg = positional[0];
259
- const repoArg = typeof flags.repo === 'string' ? flags.repo : undefined;
260
- if (!bundleDirArg || !repoArg) {
261
- err('usage: bareloop run <bundleDir> --repo <path> [--budget N] [--wall MIN] [--approve <bundleHash>]');
262
- return 1;
263
- }
264
- const bundleDir = resolve(cwd, bundleDirArg);
265
- const repo = resolve(cwd, repoArg);
266
-
267
- // 1. readBundle — N4: this must complete, clean, before anything else
268
- // touches the bundle or the repo. Any red (incl. bundle-tampered) stops here.
269
- const bundle = readBundle(bundleDir);
270
- if (!bundle.ok) { printReds(bundle.reds, err); return 1; }
271
-
272
- // 1b. F128 — the bundle's OWN node_modules must carry `bareloop` before
273
- // anything else runs. Left unchecked, the close crashes deep inside
274
- // runJob's close-first precheck with a bare ERR_MODULE_NOT_FOUND, reported
275
- // as an unhelpful generic close-red with no cure line.
276
- const depsCheck = checkBundleDeps(bundleDir);
277
- if (!depsCheck.ok) { printReds(depsCheck.reds, err); return 1; }
278
-
279
- // 2. checkEnvelope — tighten-only, wall given in minutes -> ms.
280
- /** @type {{ budgetUsd?: number, maxWallMs?: number }} */
281
- const envelope = {};
282
- if (flags.budget !== undefined) {
283
- const n = Number(flags.budget);
284
- if (!Number.isFinite(n)) { err(`--budget must be a number, got ${JSON.stringify(flags.budget)}`); return 1; }
285
- envelope.budgetUsd = n;
286
- }
287
- if (flags.wall !== undefined) {
288
- const n = Number(flags.wall);
289
- if (!Number.isFinite(n)) { err(`--wall must be a number of minutes, got ${JSON.stringify(flags.wall)}`); return 1; }
290
- envelope.maxWallMs = n * 60_000;
291
- }
292
- const envCheck = checkEnvelope(bundle.spec, envelope);
293
- if (!envCheck.ok) { printReds(envCheck.reds, err); return 1; }
294
-
295
- // 3. the key — ANTHROPIC_API_KEY only, UNLESS a caller already handed in a
296
- // provider (the test seam). Absent and no injected provider: print the
297
- // operator questions (the bundle's own README) and the hash, spend nothing.
298
- let { provider, providerFor, judgeProvider } = deps;
299
- // the judge IDENTITY travels with the judge PROVIDER (PRD item 32.1). A test
300
- // injecting `deps.judgeProvider` may name it too; absent, it is resolved
301
- // alongside the real providers below, and a judged stage with neither stops as
302
- // a wiring gap rather than grading under a name nobody wrote.
303
- let judgeModel = /** @type {string|null} */ (deps.judgeModel ?? null);
304
- if (!provider) {
305
- const apiKey = env.ANTHROPIC_API_KEY;
306
- if (!apiKey) {
307
- let readme = null;
308
- try { readme = readFileSync(join(bundleDir, 'README.md'), 'utf8'); } catch { /* fall through to the hash alone */ }
309
- if (readme) out(readme);
310
- out(`bundleHash: ${bundle.manifest.bundleHash}`);
311
- return 0;
312
- }
313
- // F181 — a key that reads as "set" above can still carry a line break/
314
- // control char/stray whitespace and crash Node's own header-encode
315
- // inside the paid span. Refused here, at the same door, before any
316
- // provider is constructed; never echoes the value.
317
- const keyProblem = apiKeyProblem(apiKey);
318
- if (keyProblem) {
319
- err(`ANTHROPIC_API_KEY ${keyProblem} — refusing rather than crashing mid-call (never trimmed or repaired; fix the value at its source)`);
320
- return 1;
321
- }
322
- try {
323
- ({ provider, providerFor, judgeProvider, judgeModel } = buildProviders(apiKey, bundle.spec));
324
- } catch (e) {
325
- // a refusal from the key contract above: name it and spend nothing
326
- err(String(/** @type {any} */ (e)?.message ?? e));
327
- return 1;
328
- }
329
- }
330
-
331
- // 4. blessing.
332
- const { manifest } = bundle;
333
- if (!bundle.blessing) {
334
- // The runid that ACTUALLY minted this bundle's own signed spec — the
335
- // bridge VERSION (base or shape fork; both ship into bridges/) whose
336
- // recorded specHash equals the resolved bundle spec's jobSpecHash. NOT
337
- // the bridge's first history row: a job can be re-exported/rebased many
338
- // times, and only the version at THIS exact hash proves anything for
339
- // THIS bundle.
340
- const { approveHash: mintMatchHash } = resolveBundleSpec(bundle, bundleDir);
341
- let mintRunid = null;
342
- for (const b of bundle.bridges) {
343
- const versions = Array.isArray(b.versions) ? b.versions : [];
344
- const v = versions.find((/** @type {any} */ ver) => ver.specHash === mintMatchHash);
345
- if (v) { mintRunid = v.runid; break; }
346
- }
347
- out('first run — this bundle has never been blessed on this machine.');
348
- out(mintRunid ? `minting run: ${mintRunid} (spine not bundled in v1)` : 'no version at this hash');
349
- out(`bundleHash: ${manifest.bundleHash}`);
350
- if (flags.approve !== manifest.bundleHash) {
351
- err(`--approve ${manifest.bundleHash} is required for the first run of an unblessed bundle`);
352
- return 1;
353
- }
354
- } else {
355
- const vb = verifyBlessing(bundle);
356
- if (!vb.ok) { printReds(vb.reds, err); return 1; }
357
- if (flags.approve) out('already blessed, --approve ignored');
358
- }
359
-
360
- // 5. the worktree — refuse a non-repo, a repo with no commit, or a collision;
361
- // ALWAYS fresh, never reused (a reused worktree reads last run's edits as
362
- // "already-green" — the negative POC's exact finding).
363
- try {
364
- execFileSync('git', ['-C', repo, 'rev-parse', 'HEAD'], { encoding: 'utf8' });
365
- } catch (e) {
366
- err(`--repo ${repoArg} must be a git repository with at least one commit: ${/** @type {Error} */ (e).message}`);
367
- return 1;
368
- }
369
- const runid = now().toString(36);
370
- const worktree = join(repo, '.bareloop', 'wt', runid);
371
- if (existsSync(worktree)) { err(`worktree already exists: ${worktree}`); return 1; }
372
- mkdirSync(dirname(worktree), { recursive: true });
373
- try {
374
- execFileSync('git', ['-C', repo, 'worktree', 'add', '--detach', worktree, 'HEAD'], { encoding: 'utf8' });
375
- } catch (e) {
376
- err(`git worktree add failed: ${/** @type {Error} */ (e).message}`);
377
- return 1;
378
- }
379
-
380
- // 6. resolve (in-memory $BARELOOP_BUNDLE substitution) + tighten + run. The
381
- // envelope tightens the ACTUAL spec that runs (budgetUsd/maxWallMs have no
382
- // separate runJob override — the library reads them off the spec itself),
383
- // so `approveHash` is recomputed AFTER tightening: the runner mints its own
384
- // fresh approval over the exact spec it is about to hand to runJob, never
385
- // over the untightened one `resolveBundleSpec` first returned. The
386
- // bundleHash a human approved never changes; only the executed spec's own
387
- // hash does, and that hash is minted here, not signed by a person.
388
- const resolved = resolveBundleSpec(bundle, bundleDir);
389
- const runSpec = resolved.spec;
390
- if (envelope.budgetUsd !== undefined) runSpec.budgetUsd = envelope.budgetUsd;
391
- if (envelope.maxWallMs !== undefined) runSpec.maxWallMs = envelope.maxWallMs;
392
- const approveHash = jobSpecHash(runSpec);
393
-
394
- const runsDir = join(bundleDir, 'runs', runid);
395
- mkdirSync(runsDir, { recursive: true });
396
- const spineFile = join(runsDir, 'spine.jsonl');
397
- const emit = makeSpine(spineFile);
398
- // PRD item 27/M3 Part B — the close's own books directory, inside the run's
399
- // own directory so the bundle stays one self-contained unit (hamr,
400
- // 2026-09-07: "export holds as one unit of itself"). `runJob` autosets the
401
- // close timeout itself (no `closeTimeoutMs` passed — the bundle CLI has
402
- // always run on the library default/autoset path, never run-u's operator
403
- // knob).
404
- const closeDir = join(runsDir, 'close');
405
- mkdirSync(closeDir, { recursive: true });
406
-
407
- // PANEL-BUILD.md P1 — one row in the run list, BEFORE the first paid call
408
- // (runJob, right below). A list-append failure must never block a real,
409
- // already-signed run: caught and named loudly, never rethrown.
410
- // `deps.runlistHome` — the SAME test seam shape `deps.provider`/`deps.now`
411
- // already are on this ctx (never a new env var, see src/runlist.js's own
412
- // header comment): a caller that injects it (every test in
413
- // tests/cli.test.js that reaches this line) writes its row under a temp
414
- // home instead of the real `~/.config/bareloop`; production never sets it,
415
- // so `appendRun` falls through to `runlistHome()`'s own `os.homedir()`.
416
- try {
417
- appendRun({
418
- at: new Date(now()).toISOString(), runid, job: runSpec.job, spine: spineFile, patient: worktree, via: 'bundle',
419
- }, { home: deps.runlistHome });
420
- } catch (e) {
421
- err(`WARNING: could not add this run to ~/.config/bareloop/runs.jsonl (${/** @type {Error} */ (e).message}) — the run continues; the panel's list will be missing this row.`);
422
- }
423
-
424
- let outcome;
425
- try {
426
- outcome = await runJob(runSpec, {
427
- approvals: [{ specHash: approveHash, signer: 'bundle', ts: new Date(now()).toISOString() }],
428
- workdir: worktree,
429
- provider,
430
- providerFor,
431
- judgeProvider,
432
- judgeModel,
433
- emit,
434
- closeDir,
435
- shellCapUsd: runSpec.budgetUsd,
436
- readShim: 'cap',
437
- scout: true,
438
- // F130/PRD item 27(c) — the bundle CLI has no `--resume` (v1); the
439
- // honest tail says so instead of naming a flag that does not exist.
440
- resumable: false,
441
- });
442
- } catch (e) {
443
- err(`runJob crashed: ${/** @type {Error} */ (e).message}`);
444
- return 1;
445
- }
446
-
447
- // the gate-audit relocation (POC fact 3, run-u.mjs:1293's move)
448
- const auditSrc = join(worktree, 'gate-audit.jsonl');
449
- if (existsSync(auditSrc)) renameSync(auditSrc, join(runsDir, 'gate-audit.jsonl'));
450
-
451
- // the job-end record off THIS run's own spine — never fabricate a 0 for an
452
- // unknown spend (F6/F12's class). PANEL-BUILD.md P0: read via the same
453
- // named library reader `doHistory`/`doReplay` already use (`parseJsonl`,
454
- // `src/replayio.js`) instead of hand-rolling the parse again here — it
455
- // tolerates a malformed/truncated line (a process killed mid-append)
456
- // instead of throwing uncaught and crashing this run's exit tail.
457
- const { records: events } = parseJsonl(spineFile);
458
- const je = events.findLast((/** @type {any} */ e) => e.type === 'job-end');
459
- const spentUsd = je?.spentUsd ?? null;
460
- const spendComplete = je?.spendComplete ?? null;
461
-
462
- let branch = null;
463
- try { branch = execFileSync('git', ['-C', worktree, 'rev-parse', '--abbrev-ref', 'HEAD'], { encoding: 'utf8' }).trim(); } catch { /* leave null — reported honestly below */ }
464
-
465
- // 7. history + (on a first green) the blessing.
466
- appendHistory(bundleDir, {
467
- runid,
468
- at: new Date(now()).toISOString(),
469
- outcome,
470
- spentUsd,
471
- spendComplete,
472
- budgetUsd: runSpec.budgetUsd,
473
- maxWallMs: runSpec.maxWallMs ?? null,
474
- worktree,
475
- branch,
476
- bundleHash: manifest.bundleHash,
477
- approveHash,
478
- });
479
- if (outcome === 'green' && !bundle.blessing) {
480
- bless(bundleDir, {
481
- bundleHash: manifest.bundleHash, blessedAt: new Date(now()).toISOString(), runid, outcome, host: hostname(),
482
- });
483
- }
484
-
485
- // 8. the tail.
486
- out(`outcome ${outcome}`);
487
- out(`spent ${spentUsd == null ? 'UNKNOWN' : `${spendComplete === false ? '≥' : ''}$${spentUsd.toFixed(4)}`} of $${runSpec.budgetUsd}`);
488
- out(`branch ${branch ?? 'UNKNOWN (could not read the worktree branch)'}`);
489
- out(`worktree ${worktree}`);
490
- if (branch) out(`merge git merge ${branch} (merge stays human — this CLI never merges)`);
491
- out(`the worktree is kept until you remove it: git worktree remove ${worktree}`);
492
- // exit 0 ONLY for a real green — every other outcome (close-red, plan-red,
493
- // escalated, cap/wall halts, worker-crash, provider-red, …) exits 1 so a
494
- // caller scripting `bareloop run` off its exit code cannot mistake a red
495
- // for a success. The tail print above is unchanged either way.
496
- return outcome === 'green' || outcome === 'already-green' ? 0 : 1;
497
- }
498
-
499
170
  /**
500
171
  * `bareloop history <bundleDir>`
501
172
  * @param {string[]} args @param {{ out: (s: string) => void, err: (s: string) => void, cwd: string }} ctx
@@ -608,9 +279,11 @@ function doRuns(args, { out, err, cwd }) {
608
279
  * inventing new interactive UX, not wiring, and that judgement stands. This
609
280
  * menu is fixed instead: it now says those four commands exist and how to
610
281
  * reach them, rather than implying only three commands do at all.
611
- * @param {any} deps @param {{ out: (s: string) => void, err: (s: string) => void, cwd: string, env: any, now: () => number }} ctx
282
+ * `keysHome` is the home `main` already resolved (`keysForDoor`), handed on so choice 2 reads the
283
+ * SAME config.json (monthly limit, price, key row) as `bareloop run <bundle>` — never resolved twice.
284
+ * @param {any} deps @param {{ out: (s: string) => void, err: (s: string) => void, cwd: string, env: any, now: () => number }} ctx @param {string|undefined} keysHome
612
285
  */
613
- async function runMenu(deps, ctx) {
286
+ async function runMenu(deps, ctx, keysHome) {
614
287
  const stdin = deps.stdin;
615
288
  const stdout = deps.stdout;
616
289
  const rl = createInterface({ input: stdin, output: stdout });
@@ -635,7 +308,7 @@ async function runMenu(deps, ctx) {
635
308
  if (budget) args.push('--budget', budget);
636
309
  if (wall) args.push('--wall', wall);
637
310
  if (approve) args.push('--approve', approve);
638
- return await doRun(args, { ...ctx, deps });
311
+ return await bundleMain(args, { ...ctx, deps, keysHome, parseFlags, printReds });
639
312
  }
640
313
  if (choice === '3') {
641
314
  const bundleDir = (await rl.question('bundle dir: ')).trim();
@@ -653,11 +326,15 @@ async function runMenu(deps, ctx) {
653
326
  * code, so `bin/bareloop.mjs` can set `process.exitCode` and let node flush
654
327
  * queued stdout on its own (F-something: `process.exit()` can discard it).
655
328
  * @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
329
+ * @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
330
  * @returns {Promise<number>}
658
331
  */
659
332
  export async function main(argv, deps = {}) {
660
- const env = deps.env ?? process.env;
333
+ // P4a item 1 — the keys file (`~/.config/bareloop/.env`) fills what the shell leaves
334
+ // unset; the shell wins. An injected `deps.env` skips the file (tests never read the
335
+ // real one) unless `deps.keysHome` names a home.
336
+ const keys = keysForDoor(deps);
337
+ const env = keys.env;
661
338
  const stdout = deps.stdout ?? process.stdout;
662
339
  const stderr = deps.stderr ?? process.stderr;
663
340
  const cwd = deps.cwd ?? process.cwd();
@@ -667,9 +344,10 @@ export async function main(argv, deps = {}) {
667
344
  const ctx = { out, err, cwd, env, now, deps };
668
345
 
669
346
  const [cmd, ...rest] = argv;
670
- if (!cmd) return runMenu({ ...deps, stdin: deps.stdin ?? process.stdin, stdout }, ctx);
347
+ if (keys.warning && cmd && ['run', 'run-u', 'interview', 'author', 'panel'].includes(cmd)) err(`WARNING: ${keys.warning}`);
348
+ if (!cmd) return runMenu({ ...deps, stdin: deps.stdin ?? process.stdin, stdout }, ctx, keys.home);
671
349
  if (cmd === 'export') return doExport(rest, ctx);
672
- if (cmd === 'run') return doRun(rest, ctx);
350
+ if (cmd === 'run') return bundleMain(rest, { ...ctx, keysHome: keys.home, parseFlags, printReds });
673
351
  if (cmd === 'history') return doHistory(rest, ctx);
674
352
  // `bareloop run-u` — the person-path run flow (PANEL-BUILD.md P0 task
675
353
  // 2/4). `rest` is handed straight to `src/userrun.js`'s own `main(argv,
@@ -681,7 +359,7 @@ export async function main(argv, deps = {}) {
681
359
  // then `env`/`out`/`err` are overridden to the SAME resolved values every
682
360
  // other command here prints through, so `run-u`'s output lands on the
683
361
  // `stdout`/`stderr` a caller of `main` actually passed.
684
- if (cmd === 'run-u') return runUMain(rest, { ...deps, env, out, err });
362
+ if (cmd === 'run-u') return runUMain(rest, { ...deps, env, out, err, keysHome: keys.home, invokedAs: 'bareloop run-u' });
685
363
  // `bareloop interview` — the close-authoring interview (PANEL-BUILD.md P0
686
364
  // task 3/4). This flow reads a TTY (or a piped stdin) directly rather than
687
365
  // through the `out`/`err` line-functions every other command here uses, so
@@ -691,11 +369,11 @@ export async function main(argv, deps = {}) {
691
369
  // person actually typed, never a script path they never invoked and may
692
370
  // not have on disk (a branch-review nit; `scripts/run-interview.mjs` still
693
371
  // 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' });
372
+ if (cmd === 'interview') return interviewMain(rest, { ...deps, env, keysHome: keys.home, stdin: deps.stdin ?? process.stdin, stdout, stderr, invokedAs: 'bareloop interview' });
695
373
  // `bareloop author` — the authoring pipeline (scout, declaration, D9's
696
374
  // gates), same task. Same raw-stream reasoning: its one interactive seam
697
375
  // (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' });
376
+ if (cmd === 'author') return authorMain(rest, { ...deps, env, keysHome: keys.home, stdin: deps.stdin ?? process.stdin, stdout, stderr, invokedAs: 'bareloop author' });
699
377
  // `bareloop replay` — the spine/gate-audit read side (PANEL-BUILD.md P0,
700
378
  // last of the rung's four tasks). Synchronous, file-based, no interactive
701
379
  // seam — same shape as `doHistory`/`doExport`, not the argv-owning
@@ -709,7 +387,7 @@ export async function main(argv, deps = {}) {
709
387
  // Never runs a job, spends money, or reads a key: it serves the run list
710
388
  // and spine/gate-audit reads over `127.0.0.1` only. `deps.runlistHome`
711
389
  // rides through the same injectable seam `run`/`run-u` already use.
712
- if (cmd === 'panel') return panelMain(rest, { out, err, runlistHome: deps.runlistHome });
390
+ if (cmd === 'panel') return panelMain(rest, { out, err, runlistHome: deps.runlistHome, env: deps.env });
713
391
  err(`unknown command ${JSON.stringify(cmd)} — one of: export, run, history, run-u, interview, author, replay, runs, panel`);
714
392
  return 1;
715
393
  }
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
+ }