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.
- package/CHANGELOG.md +151 -0
- package/README.md +4 -1
- package/bareloop.context.md +232 -85
- package/bin/bareloop.mjs +0 -1
- package/package.json +1 -1
- package/src/authorflow.js +5 -3
- package/src/authorjob.js +3 -1
- package/src/authorrun.js +49 -11
- package/src/authorscout.js +10 -6
- package/src/bundlerun.js +266 -0
- package/src/cli.js +35 -357
- package/src/config.js +135 -0
- package/src/interviewrun.js +13 -4
- package/src/invoke.js +22 -0
- package/src/judged.js +5 -3
- package/src/keysfile.js +156 -0
- package/src/ledger.js +26 -0
- package/src/monthly.js +422 -0
- package/src/panel/authorroutes.js +61 -16
- package/src/panel/authorsession.js +46 -28
- package/src/panel/index.html +287 -13
- package/src/panel/server.js +58 -51
- package/src/panel/settingsroutes.js +230 -0
- package/src/planrun.js +7 -3
- package/src/providerrows.js +301 -0
- package/src/providers.js +3 -1
- package/src/run.js +7 -2
- package/src/runlist.js +92 -7
- package/src/source.js +8 -3
- package/src/u-watchdog.mjs +296 -0
- package/src/userrun.js +230 -54
- package/types/authorflow.d.ts +8 -2
- package/types/authorjob.d.ts +6 -1
- package/types/authorrun.d.ts +5 -0
- package/types/authorscout.d.ts +11 -2
- package/types/bundlerun.d.ts +26 -0
- package/types/cli.d.ts +2 -1
- package/types/config.d.ts +48 -0
- package/types/interviewrun.d.ts +5 -0
- package/types/invoke.d.ts +8 -0
- package/types/judged.d.ts +5 -1
- package/types/keysfile.d.ts +90 -0
- package/types/ledger.d.ts +14 -0
- package/types/monthly.d.ts +227 -0
- package/types/panel/authorroutes.d.ts +4 -2
- package/types/panel/authorsession.d.ts +7 -15
- package/types/panel/server.d.ts +10 -3
- package/types/panel/settingsroutes.d.ts +14 -0
- package/types/planrun.d.ts +13 -1
- package/types/providerrows.d.ts +229 -0
- package/types/run.d.ts +14 -1
- package/types/runlist.d.ts +66 -3
- package/types/source.d.ts +8 -3
- 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
|
-
// `
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
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
|
|
14
|
-
//
|
|
15
|
-
//
|
|
16
|
-
//
|
|
17
|
-
//
|
|
18
|
-
//
|
|
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,
|
|
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,
|
|
34
|
-
runJob, makeSpine, loadRegistry, listingRow, jobSpecHash, resolveWorkerModel, resolveJudge,
|
|
28
|
+
exportBundle, loadRegistry, listingRow,
|
|
35
29
|
} from './index.js';
|
|
36
|
-
|
|
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 {
|
|
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
|
-
*
|
|
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
|
|
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
|
-
|
|
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 (
|
|
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
|
|
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
|
+
}
|