bareloop 0.20.0 → 0.22.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/src/cli.js ADDED
@@ -0,0 +1,472 @@
1
+ // Export M2 (`docs/product/EXPORT-BUILD.md`) — the `bareloop` CLI. All logic
2
+ // lives HERE, testable at $0 with an in-process `main(argv, deps)`; `bin/
3
+ // bareloop.mjs` is a ~10-line adapter that supplies the real `deps` and turns
4
+ // the returned exit code into `process.exitCode`.
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).
12
+ //
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.
21
+
22
+ import { createRequire } from 'node:module';
23
+ import { createInterface } from 'node:readline/promises';
24
+ import { hostname } from 'node:os';
25
+ import { execFileSync } from 'node:child_process';
26
+ import {
27
+ existsSync, mkdirSync, readFileSync, renameSync,
28
+ } from 'node:fs';
29
+ import {
30
+ dirname, isAbsolute, join, resolve,
31
+ } from 'node:path';
32
+
33
+ import {
34
+ exportBundle, readBundle, resolveBundleSpec, checkEnvelope, bless, verifyBlessing, appendHistory, checkBundleDeps,
35
+ runJob, makeSpine, loadRegistry, listingRow, jobSpecHash, resolveWorkerModel, JUDGE_MODEL,
36
+ } from './index.js';
37
+
38
+ const require = createRequire(import.meta.url);
39
+
40
+ // The runner's own tier menu, the same spelling `scripts/run-u.mjs` uses (not
41
+ // exported from `src/` — the tier->model mapping is runner territory, not the
42
+ // library's). A `bareloop run` has no `--model` flag (not in the frozen
43
+ // spec), so `flagModel` is always undefined and only the spec's own `model`
44
+ // (if signed) or this default is ever in play.
45
+ const DEFAULT_TIER_MODELS = { sonnet: 'claude-sonnet-5', haiku: 'claude-haiku-4-5-20251001' };
46
+
47
+ /** @param {unknown} v @returns {v is Record<string, any>} */
48
+ const isObj = (v) => typeof v === 'object' && v !== null;
49
+
50
+ /**
51
+ * Build the real providers from an API key, exactly the way `scripts/run-
52
+ * u.mjs` does (provider/providerFor/judgeProvider from `bare-agent/
53
+ * providers`'s `AnthropicProvider`). `AnthropicProvider` itself lives in
54
+ * `bare-agent` (the one production dependency already budgeted for provider
55
+ * calls); this function only wires it up the same way the u-runner does —
56
+ * it is not arbiter logic (no budget/verdict/merge decision lives here).
57
+ * @param {string} apiKey
58
+ * @param {any} spec the (already `$BARELOOP_BUNDLE`-resolved) bundle spec
59
+ */
60
+ function buildProviders(apiKey, spec) {
61
+ const { AnthropicProvider } = require('bare-agent/providers');
62
+ const modelResolution = resolveWorkerModel({
63
+ specModel: isObj(spec) && typeof spec.model === 'string' ? spec.model : undefined,
64
+ flagModel: undefined,
65
+ defaultModel: DEFAULT_TIER_MODELS.sonnet,
66
+ });
67
+ const MODEL = modelResolution.model;
68
+ const provider = new AnthropicProvider({ apiKey, model: MODEL });
69
+ const TIER_MODELS = modelResolution.source === 'spec' ? { ...DEFAULT_TIER_MODELS, sonnet: MODEL } : DEFAULT_TIER_MODELS;
70
+ /** @type {Record<string, any>} */
71
+ const tierCache = {};
72
+ const providerFor = (/** @type {string} */ tier) => (tierCache[tier] ??= (TIER_MODELS[/** @type {keyof typeof TIER_MODELS} */ (tier)] === MODEL ? provider : new AnthropicProvider({ apiKey, model: TIER_MODELS[/** @type {keyof typeof TIER_MODELS} */ (tier)] })));
73
+ const judgeProvider = new AnthropicProvider({ apiKey, model: JUDGE_MODEL });
74
+ return { provider, providerFor, judgeProvider };
75
+ }
76
+
77
+ /** @param {string[]} args @returns {{ positional: string[], flags: Record<string, string|true> }} */
78
+ function parseFlags(args) {
79
+ /** @type {string[]} */
80
+ const positional = [];
81
+ /** @type {Record<string, string|true>} */
82
+ const flags = {};
83
+ for (let i = 0; i < args.length; i++) {
84
+ const a = args[i];
85
+ if (a.startsWith('--')) {
86
+ const key = a.slice(2);
87
+ const next = args[i + 1];
88
+ if (next !== undefined && !next.startsWith('--')) { flags[key] = next; i++; } else flags[key] = true;
89
+ } else positional.push(a);
90
+ }
91
+ return { positional, flags };
92
+ }
93
+
94
+ /** @returns {string} this package's own version, for the manifest's `bareloopVersion` */
95
+ function readPackageVersion() {
96
+ const pkgUrl = new URL('../package.json', import.meta.url);
97
+ return JSON.parse(readFileSync(pkgUrl, 'utf8')).version;
98
+ }
99
+
100
+ /**
101
+ * Build the `closeScripts` map `exportBundle` expects (path -> source) and a
102
+ * copy of the spec whose `close[].cmd` paths are ABSOLUTE — M1's
103
+ * `exportBundle` only relocates `node <absolute path>.mjs …`. A relative path
104
+ * in a hand-authored spec is resolved against the spec FILE's own directory
105
+ * (the same "the spec's own location is the reference point" the u-runner
106
+ * uses when it resolves `jobs/<name>.json` off `import.meta.url`), not the
107
+ * process cwd.
108
+ * @param {any} spec @param {string} specFile absolute path to the spec file
109
+ */
110
+ function loadCloseScripts(spec, specFile) {
111
+ const specDir = dirname(specFile);
112
+ const stages = Array.isArray(spec.close) ? spec.close : [];
113
+ const resolvedClose = stages.map((/** @type {any} */ stage) => {
114
+ if (!isObj(stage) || typeof stage.cmd !== 'string') return stage;
115
+ const parts = stage.cmd.trim().split(/\s+/);
116
+ if (parts[0] !== 'node' || parts.length < 2) return stage;
117
+ const scriptPath = parts[1];
118
+ const abs = isAbsolute(scriptPath) ? scriptPath : resolve(specDir, scriptPath);
119
+ return { ...stage, cmd: ['node', abs, ...parts.slice(2)].join(' ') };
120
+ });
121
+ /** @type {Record<string, string>} */
122
+ const closeScripts = {};
123
+ for (const stage of resolvedClose) {
124
+ if (!isObj(stage) || typeof stage.cmd !== 'string') continue;
125
+ const parts = stage.cmd.trim().split(/\s+/);
126
+ if (parts[0] === 'node' && parts[1] && isAbsolute(parts[1]) && existsSync(parts[1])) {
127
+ closeScripts[parts[1]] = readFileSync(parts[1], 'utf8');
128
+ }
129
+ }
130
+ return { resolvedSpec: { ...spec, close: resolvedClose }, closeScripts };
131
+ }
132
+
133
+ /** @param {import('./bundle.js').Red[]} reds @param {(s: string) => void} err */
134
+ function printReds(reds, err) {
135
+ for (const r of reds) err(`${r.code} at ${r.path}: ${r.detail ?? ''}`.trim());
136
+ }
137
+
138
+ /**
139
+ * `bareloop export <jobs/x.json> --registry <dir> --out <dir>`
140
+ * @param {string[]} args @param {{ out: (s: string) => void, err: (s: string) => void, cwd: string }} ctx
141
+ */
142
+ function doExport(args, { out, err, cwd }) {
143
+ const { positional, flags } = parseFlags(args);
144
+ const specPathArg = positional[0];
145
+ const registryDir = typeof flags.registry === 'string' ? flags.registry : undefined;
146
+ const outDirArg = typeof flags.out === 'string' ? flags.out : undefined;
147
+ if (!specPathArg || !registryDir || !outDirArg) {
148
+ err('usage: bareloop export <jobs/x.json> --registry <dir> --out <dir>');
149
+ return 1;
150
+ }
151
+ const specFile = resolve(cwd, specPathArg);
152
+ let specText;
153
+ try { specText = readFileSync(specFile, 'utf8'); } catch (e) {
154
+ err(`cannot read job spec at ${specFile}: ${/** @type {Error} */ (e).message}`);
155
+ return 1;
156
+ }
157
+ let spec;
158
+ try { spec = JSON.parse(specText); } catch (e) {
159
+ err(`job spec at ${specFile} is not valid JSON: ${/** @type {Error} */ (e).message}`);
160
+ return 1;
161
+ }
162
+ const { resolvedSpec, closeScripts } = loadCloseScripts(spec, specFile);
163
+ const bareloopVersion = readPackageVersion();
164
+ const r = exportBundle({
165
+ spec: resolvedSpec, closeScripts, registryDir: resolve(cwd, registryDir), outDir: resolve(cwd, outDirArg), bareloopVersion,
166
+ });
167
+ if (!r.ok) { printReds(r.reds, err); return 1; }
168
+ out(`bundleHash: ${r.bundleHash}`);
169
+ out('files:');
170
+ for (const f of Object.keys(r.manifest.files).sort()) out(` ${f}`);
171
+ out("this bundle is unblessed until its first run greens on the importer's machine.");
172
+ return 0;
173
+ }
174
+
175
+ /**
176
+ * `bareloop run <bundleDir> --repo <path> [--budget N] [--wall MIN] [--approve <bundleHash>]`
177
+ * — the exact five-step order the frozen spec names; no step may be reordered.
178
+ * @param {string[]} args
179
+ * @param {{ out: (s: string) => void, err: (s: string) => void, cwd: string, env: Record<string,string|undefined>, now: () => number, deps: any }} ctx
180
+ */
181
+ async function doRun(args, { out, err, cwd, env, now, deps }) {
182
+ const { positional, flags } = parseFlags(args);
183
+ const bundleDirArg = positional[0];
184
+ const repoArg = typeof flags.repo === 'string' ? flags.repo : undefined;
185
+ if (!bundleDirArg || !repoArg) {
186
+ err('usage: bareloop run <bundleDir> --repo <path> [--budget N] [--wall MIN] [--approve <bundleHash>]');
187
+ return 1;
188
+ }
189
+ const bundleDir = resolve(cwd, bundleDirArg);
190
+ const repo = resolve(cwd, repoArg);
191
+
192
+ // 1. readBundle — N4: this must complete, clean, before anything else
193
+ // touches the bundle or the repo. Any red (incl. bundle-tampered) stops here.
194
+ const bundle = readBundle(bundleDir);
195
+ if (!bundle.ok) { printReds(bundle.reds, err); return 1; }
196
+
197
+ // 1b. F128 — the bundle's OWN node_modules must carry `bareloop` before
198
+ // anything else runs. Left unchecked, the close crashes deep inside
199
+ // runJob's close-first precheck with a bare ERR_MODULE_NOT_FOUND, reported
200
+ // as an unhelpful generic close-red with no cure line.
201
+ const depsCheck = checkBundleDeps(bundleDir);
202
+ if (!depsCheck.ok) { printReds(depsCheck.reds, err); return 1; }
203
+
204
+ // 2. checkEnvelope — tighten-only, wall given in minutes -> ms.
205
+ /** @type {{ budgetUsd?: number, maxWallMs?: number }} */
206
+ const envelope = {};
207
+ if (flags.budget !== undefined) {
208
+ const n = Number(flags.budget);
209
+ if (!Number.isFinite(n)) { err(`--budget must be a number, got ${JSON.stringify(flags.budget)}`); return 1; }
210
+ envelope.budgetUsd = n;
211
+ }
212
+ if (flags.wall !== undefined) {
213
+ const n = Number(flags.wall);
214
+ if (!Number.isFinite(n)) { err(`--wall must be a number of minutes, got ${JSON.stringify(flags.wall)}`); return 1; }
215
+ envelope.maxWallMs = n * 60_000;
216
+ }
217
+ const envCheck = checkEnvelope(bundle.spec, envelope);
218
+ if (!envCheck.ok) { printReds(envCheck.reds, err); return 1; }
219
+
220
+ // 3. the key — ANTHROPIC_API_KEY only, UNLESS a caller already handed in a
221
+ // provider (the test seam). Absent and no injected provider: print the
222
+ // operator questions (the bundle's own README) and the hash, spend nothing.
223
+ let { provider, providerFor, judgeProvider } = deps;
224
+ if (!provider) {
225
+ const apiKey = env.ANTHROPIC_API_KEY;
226
+ if (!apiKey) {
227
+ let readme = null;
228
+ try { readme = readFileSync(join(bundleDir, 'README.md'), 'utf8'); } catch { /* fall through to the hash alone */ }
229
+ if (readme) out(readme);
230
+ out(`bundleHash: ${bundle.manifest.bundleHash}`);
231
+ return 0;
232
+ }
233
+ ({ provider, providerFor, judgeProvider } = buildProviders(apiKey, bundle.spec));
234
+ }
235
+
236
+ // 4. blessing.
237
+ const { manifest } = bundle;
238
+ if (!bundle.blessing) {
239
+ // The runid that ACTUALLY minted this bundle's own signed spec — the
240
+ // bridge VERSION (base or shape fork; both ship into bridges/) whose
241
+ // recorded specHash equals the resolved bundle spec's jobSpecHash. NOT
242
+ // the bridge's first history row: a job can be re-exported/rebased many
243
+ // times, and only the version at THIS exact hash proves anything for
244
+ // THIS bundle.
245
+ const { approveHash: mintMatchHash } = resolveBundleSpec(bundle, bundleDir);
246
+ let mintRunid = null;
247
+ for (const b of bundle.bridges) {
248
+ const versions = Array.isArray(b.versions) ? b.versions : [];
249
+ const v = versions.find((/** @type {any} */ ver) => ver.specHash === mintMatchHash);
250
+ if (v) { mintRunid = v.runid; break; }
251
+ }
252
+ out('first run — this bundle has never been blessed on this machine.');
253
+ out(mintRunid ? `minting run: ${mintRunid} (spine not bundled in v1)` : 'no version at this hash');
254
+ out(`bundleHash: ${manifest.bundleHash}`);
255
+ if (flags.approve !== manifest.bundleHash) {
256
+ err(`--approve ${manifest.bundleHash} is required for the first run of an unblessed bundle`);
257
+ return 1;
258
+ }
259
+ } else {
260
+ const vb = verifyBlessing(bundle);
261
+ if (!vb.ok) { printReds(vb.reds, err); return 1; }
262
+ if (flags.approve) out('already blessed, --approve ignored');
263
+ }
264
+
265
+ // 5. the worktree — refuse a non-repo, a repo with no commit, or a collision;
266
+ // ALWAYS fresh, never reused (a reused worktree reads last run's edits as
267
+ // "already-green" — the negative POC's exact finding).
268
+ try {
269
+ execFileSync('git', ['-C', repo, 'rev-parse', 'HEAD'], { encoding: 'utf8' });
270
+ } catch (e) {
271
+ err(`--repo ${repoArg} must be a git repository with at least one commit: ${/** @type {Error} */ (e).message}`);
272
+ return 1;
273
+ }
274
+ const runid = now().toString(36);
275
+ const worktree = join(repo, '.bareloop', 'wt', runid);
276
+ if (existsSync(worktree)) { err(`worktree already exists: ${worktree}`); return 1; }
277
+ mkdirSync(dirname(worktree), { recursive: true });
278
+ try {
279
+ execFileSync('git', ['-C', repo, 'worktree', 'add', '--detach', worktree, 'HEAD'], { encoding: 'utf8' });
280
+ } catch (e) {
281
+ err(`git worktree add failed: ${/** @type {Error} */ (e).message}`);
282
+ return 1;
283
+ }
284
+
285
+ // 6. resolve (in-memory $BARELOOP_BUNDLE substitution) + tighten + run. The
286
+ // envelope tightens the ACTUAL spec that runs (budgetUsd/maxWallMs have no
287
+ // separate runJob override — the library reads them off the spec itself),
288
+ // so `approveHash` is recomputed AFTER tightening: the runner mints its own
289
+ // fresh approval over the exact spec it is about to hand to runJob, never
290
+ // over the untightened one `resolveBundleSpec` first returned. The
291
+ // bundleHash a human approved never changes; only the executed spec's own
292
+ // hash does, and that hash is minted here, not signed by a person.
293
+ const resolved = resolveBundleSpec(bundle, bundleDir);
294
+ const runSpec = resolved.spec;
295
+ if (envelope.budgetUsd !== undefined) runSpec.budgetUsd = envelope.budgetUsd;
296
+ if (envelope.maxWallMs !== undefined) runSpec.maxWallMs = envelope.maxWallMs;
297
+ const approveHash = jobSpecHash(runSpec);
298
+
299
+ const runsDir = join(bundleDir, 'runs', runid);
300
+ mkdirSync(runsDir, { recursive: true });
301
+ const spineFile = join(runsDir, 'spine.jsonl');
302
+ const emit = makeSpine(spineFile);
303
+ // PRD item 27/M3 Part B — the close's own books directory, inside the run's
304
+ // own directory so the bundle stays one self-contained unit (hamr,
305
+ // 2026-09-07: "export holds as one unit of itself"). `runJob` autosets the
306
+ // close timeout itself (no `closeTimeoutMs` passed — the bundle CLI has
307
+ // always run on the library default/autoset path, never run-u's operator
308
+ // knob).
309
+ const closeDir = join(runsDir, 'close');
310
+ mkdirSync(closeDir, { recursive: true });
311
+
312
+ let outcome;
313
+ try {
314
+ outcome = await runJob(runSpec, {
315
+ approvals: [{ specHash: approveHash, signer: 'bundle', ts: new Date(now()).toISOString() }],
316
+ workdir: worktree,
317
+ provider,
318
+ providerFor,
319
+ judgeProvider,
320
+ emit,
321
+ closeDir,
322
+ shellCapUsd: runSpec.budgetUsd,
323
+ readShim: 'cap',
324
+ scout: true,
325
+ // F130/PRD item 27(c) — the bundle CLI has no `--resume` (v1); the
326
+ // honest tail says so instead of naming a flag that does not exist.
327
+ resumable: false,
328
+ });
329
+ } catch (e) {
330
+ err(`runJob crashed: ${/** @type {Error} */ (e).message}`);
331
+ return 1;
332
+ }
333
+
334
+ // the gate-audit relocation (POC fact 3, run-u.mjs:1293's move)
335
+ const auditSrc = join(worktree, 'gate-audit.jsonl');
336
+ if (existsSync(auditSrc)) renameSync(auditSrc, join(runsDir, 'gate-audit.jsonl'));
337
+
338
+ // the job-end record off THIS run's own spine — never fabricate a 0 for an
339
+ // unknown spend (F6/F12's class).
340
+ const events = readFileSync(spineFile, 'utf8').trimEnd().split('\n').filter(Boolean).map((l) => JSON.parse(l));
341
+ const je = events.findLast((/** @type {any} */ e) => e.type === 'job-end');
342
+ const spentUsd = je?.spentUsd ?? null;
343
+ const spendComplete = je?.spendComplete ?? null;
344
+
345
+ let branch = null;
346
+ try { branch = execFileSync('git', ['-C', worktree, 'rev-parse', '--abbrev-ref', 'HEAD'], { encoding: 'utf8' }).trim(); } catch { /* leave null — reported honestly below */ }
347
+
348
+ // 7. history + (on a first green) the blessing.
349
+ appendHistory(bundleDir, {
350
+ runid,
351
+ at: new Date(now()).toISOString(),
352
+ outcome,
353
+ spentUsd,
354
+ spendComplete,
355
+ budgetUsd: runSpec.budgetUsd,
356
+ maxWallMs: runSpec.maxWallMs ?? null,
357
+ worktree,
358
+ branch,
359
+ bundleHash: manifest.bundleHash,
360
+ approveHash,
361
+ });
362
+ if (outcome === 'green' && !bundle.blessing) {
363
+ bless(bundleDir, {
364
+ bundleHash: manifest.bundleHash, blessedAt: new Date(now()).toISOString(), runid, outcome, host: hostname(),
365
+ });
366
+ }
367
+
368
+ // 8. the tail.
369
+ out(`outcome ${outcome}`);
370
+ out(`spent ${spentUsd == null ? 'UNKNOWN' : `${spendComplete === false ? '≥' : ''}$${spentUsd.toFixed(4)}`} of $${runSpec.budgetUsd}`);
371
+ out(`branch ${branch ?? 'UNKNOWN (could not read the worktree branch)'}`);
372
+ out(`worktree ${worktree}`);
373
+ if (branch) out(`merge git merge ${branch} (merge stays human — this CLI never merges)`);
374
+ out(`the worktree is kept until you remove it: git worktree remove ${worktree}`);
375
+ // exit 0 ONLY for a real green — every other outcome (close-red, plan-red,
376
+ // escalated, cap/wall halts, worker-crash, provider-red, …) exits 1 so a
377
+ // caller scripting `bareloop run` off its exit code cannot mistake a red
378
+ // for a success. The tail print above is unchanged either way.
379
+ return outcome === 'green' || outcome === 'already-green' ? 0 : 1;
380
+ }
381
+
382
+ /**
383
+ * `bareloop history <bundleDir>`
384
+ * @param {string[]} args @param {{ out: (s: string) => void, err: (s: string) => void, cwd: string }} ctx
385
+ */
386
+ function doHistory(args, { out, err, cwd }) {
387
+ const bundleDirArg = args[0];
388
+ if (!bundleDirArg) { err('usage: bareloop history <bundleDir>'); return 1; }
389
+ const dir = resolve(cwd, bundleDirArg);
390
+ const historyFile = join(dir, 'history.jsonl');
391
+ if (existsSync(historyFile)) {
392
+ out('history:');
393
+ for (const line of readFileSync(historyFile, 'utf8').split('\n')) {
394
+ if (line.trim()) out(` ${line.trim()}`);
395
+ }
396
+ } else out('history: (no runs yet)');
397
+ const reg = loadRegistry(join(dir, 'bridges'));
398
+ if (reg.bridges.length > 0) {
399
+ out('bridges:');
400
+ for (const b of reg.bridges) out(` ${JSON.stringify(listingRow(b))}`);
401
+ }
402
+ return 0;
403
+ }
404
+
405
+ /**
406
+ * The bare `bareloop` menu: `1 export 2 run 3 history q quit`, then the
407
+ * SAME code path as the sub-command, asking its args line by line.
408
+ * @param {any} deps @param {{ out: (s: string) => void, err: (s: string) => void, cwd: string, env: any, now: () => number }} ctx
409
+ */
410
+ async function runMenu(deps, ctx) {
411
+ const stdin = deps.stdin;
412
+ const stdout = deps.stdout;
413
+ const rl = createInterface({ input: stdin, output: stdout });
414
+ try {
415
+ ctx.out('1 export 2 run 3 history q quit');
416
+ const choice = (await rl.question('> ')).trim().toLowerCase();
417
+ if (choice === '' || choice === 'q' || choice === 'quit') return 0;
418
+ if (choice === '1') {
419
+ const specPath = (await rl.question('job spec path: ')).trim();
420
+ const registry = (await rl.question('registry dir: ')).trim();
421
+ const outDir = (await rl.question('out dir: ')).trim();
422
+ return doExport([specPath, '--registry', registry, '--out', outDir], ctx);
423
+ }
424
+ if (choice === '2') {
425
+ const bundleDir = (await rl.question('bundle dir: ')).trim();
426
+ const repo = (await rl.question('repo path: ')).trim();
427
+ const budget = (await rl.question('budget (blank to skip): ')).trim();
428
+ const wall = (await rl.question('wall minutes (blank to skip): ')).trim();
429
+ const approve = (await rl.question('approve hash (blank to skip): ')).trim();
430
+ const args = [bundleDir, '--repo', repo];
431
+ if (budget) args.push('--budget', budget);
432
+ if (wall) args.push('--wall', wall);
433
+ if (approve) args.push('--approve', approve);
434
+ return await doRun(args, { ...ctx, deps });
435
+ }
436
+ if (choice === '3') {
437
+ const bundleDir = (await rl.question('bundle dir: ')).trim();
438
+ return doHistory([bundleDir], ctx);
439
+ }
440
+ ctx.err(`unknown choice ${JSON.stringify(choice)} — one of: 1, 2, 3, q`);
441
+ return 1;
442
+ } finally {
443
+ rl.close();
444
+ }
445
+ }
446
+
447
+ /**
448
+ * The one entry point. Never calls `process.exit` — always returns an exit
449
+ * code, so `bin/bareloop.mjs` can set `process.exitCode` and let node flush
450
+ * queued stdout on its own (F-something: `process.exit()` can discard it).
451
+ * @param {string[]} argv
452
+ * @param {{ env?: Record<string,string|undefined>, stdout?: any, stderr?: any, cwd?: string, provider?: any, providerFor?: any, judgeProvider?: any, now?: () => number, stdin?: any }} deps
453
+ * @returns {Promise<number>}
454
+ */
455
+ export async function main(argv, deps = {}) {
456
+ const env = deps.env ?? process.env;
457
+ const stdout = deps.stdout ?? process.stdout;
458
+ const stderr = deps.stderr ?? process.stderr;
459
+ const cwd = deps.cwd ?? process.cwd();
460
+ const now = deps.now ?? (() => Date.now());
461
+ const out = (/** @type {string} */ s) => stdout.write(`${s}\n`);
462
+ const err = (/** @type {string} */ s) => stderr.write(`${s}\n`);
463
+ const ctx = { out, err, cwd, env, now, deps };
464
+
465
+ const [cmd, ...rest] = argv;
466
+ if (!cmd) return runMenu({ ...deps, stdin: deps.stdin ?? process.stdin, stdout }, ctx);
467
+ if (cmd === 'export') return doExport(rest, ctx);
468
+ if (cmd === 'run') return doRun(rest, ctx);
469
+ if (cmd === 'history') return doHistory(rest, ctx);
470
+ err(`unknown command ${JSON.stringify(cmd)} — one of: export, run, history`);
471
+ return 1;
472
+ }