bareloop 0.20.0 → 0.21.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,460 @@
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
+
304
+ let outcome;
305
+ try {
306
+ outcome = await runJob(runSpec, {
307
+ approvals: [{ specHash: approveHash, signer: 'bundle', ts: new Date(now()).toISOString() }],
308
+ workdir: worktree,
309
+ provider,
310
+ providerFor,
311
+ judgeProvider,
312
+ emit,
313
+ shellCapUsd: runSpec.budgetUsd,
314
+ readShim: 'cap',
315
+ scout: true,
316
+ });
317
+ } catch (e) {
318
+ err(`runJob crashed: ${/** @type {Error} */ (e).message}`);
319
+ return 1;
320
+ }
321
+
322
+ // the gate-audit relocation (POC fact 3, run-u.mjs:1293's move)
323
+ const auditSrc = join(worktree, 'gate-audit.jsonl');
324
+ if (existsSync(auditSrc)) renameSync(auditSrc, join(runsDir, 'gate-audit.jsonl'));
325
+
326
+ // the job-end record off THIS run's own spine — never fabricate a 0 for an
327
+ // unknown spend (F6/F12's class).
328
+ const events = readFileSync(spineFile, 'utf8').trimEnd().split('\n').filter(Boolean).map((l) => JSON.parse(l));
329
+ const je = events.findLast((/** @type {any} */ e) => e.type === 'job-end');
330
+ const spentUsd = je?.spentUsd ?? null;
331
+ const spendComplete = je?.spendComplete ?? null;
332
+
333
+ let branch = null;
334
+ try { branch = execFileSync('git', ['-C', worktree, 'rev-parse', '--abbrev-ref', 'HEAD'], { encoding: 'utf8' }).trim(); } catch { /* leave null — reported honestly below */ }
335
+
336
+ // 7. history + (on a first green) the blessing.
337
+ appendHistory(bundleDir, {
338
+ runid,
339
+ at: new Date(now()).toISOString(),
340
+ outcome,
341
+ spentUsd,
342
+ spendComplete,
343
+ budgetUsd: runSpec.budgetUsd,
344
+ maxWallMs: runSpec.maxWallMs ?? null,
345
+ worktree,
346
+ branch,
347
+ bundleHash: manifest.bundleHash,
348
+ approveHash,
349
+ });
350
+ if (outcome === 'green' && !bundle.blessing) {
351
+ bless(bundleDir, {
352
+ bundleHash: manifest.bundleHash, blessedAt: new Date(now()).toISOString(), runid, outcome, host: hostname(),
353
+ });
354
+ }
355
+
356
+ // 8. the tail.
357
+ out(`outcome ${outcome}`);
358
+ out(`spent ${spentUsd == null ? 'UNKNOWN' : `${spendComplete === false ? '≥' : ''}$${spentUsd.toFixed(4)}`} of $${runSpec.budgetUsd}`);
359
+ out(`branch ${branch ?? 'UNKNOWN (could not read the worktree branch)'}`);
360
+ out(`worktree ${worktree}`);
361
+ if (branch) out(`merge git merge ${branch} (merge stays human — this CLI never merges)`);
362
+ out(`the worktree is kept until you remove it: git worktree remove ${worktree}`);
363
+ // exit 0 ONLY for a real green — every other outcome (close-red, plan-red,
364
+ // escalated, cap/wall halts, worker-crash, provider-red, …) exits 1 so a
365
+ // caller scripting `bareloop run` off its exit code cannot mistake a red
366
+ // for a success. The tail print above is unchanged either way.
367
+ return outcome === 'green' || outcome === 'already-green' ? 0 : 1;
368
+ }
369
+
370
+ /**
371
+ * `bareloop history <bundleDir>`
372
+ * @param {string[]} args @param {{ out: (s: string) => void, err: (s: string) => void, cwd: string }} ctx
373
+ */
374
+ function doHistory(args, { out, err, cwd }) {
375
+ const bundleDirArg = args[0];
376
+ if (!bundleDirArg) { err('usage: bareloop history <bundleDir>'); return 1; }
377
+ const dir = resolve(cwd, bundleDirArg);
378
+ const historyFile = join(dir, 'history.jsonl');
379
+ if (existsSync(historyFile)) {
380
+ out('history:');
381
+ for (const line of readFileSync(historyFile, 'utf8').split('\n')) {
382
+ if (line.trim()) out(` ${line.trim()}`);
383
+ }
384
+ } else out('history: (no runs yet)');
385
+ const reg = loadRegistry(join(dir, 'bridges'));
386
+ if (reg.bridges.length > 0) {
387
+ out('bridges:');
388
+ for (const b of reg.bridges) out(` ${JSON.stringify(listingRow(b))}`);
389
+ }
390
+ return 0;
391
+ }
392
+
393
+ /**
394
+ * The bare `bareloop` menu: `1 export 2 run 3 history q quit`, then the
395
+ * SAME code path as the sub-command, asking its args line by line.
396
+ * @param {any} deps @param {{ out: (s: string) => void, err: (s: string) => void, cwd: string, env: any, now: () => number }} ctx
397
+ */
398
+ async function runMenu(deps, ctx) {
399
+ const stdin = deps.stdin;
400
+ const stdout = deps.stdout;
401
+ const rl = createInterface({ input: stdin, output: stdout });
402
+ try {
403
+ ctx.out('1 export 2 run 3 history q quit');
404
+ const choice = (await rl.question('> ')).trim().toLowerCase();
405
+ if (choice === '' || choice === 'q' || choice === 'quit') return 0;
406
+ if (choice === '1') {
407
+ const specPath = (await rl.question('job spec path: ')).trim();
408
+ const registry = (await rl.question('registry dir: ')).trim();
409
+ const outDir = (await rl.question('out dir: ')).trim();
410
+ return doExport([specPath, '--registry', registry, '--out', outDir], ctx);
411
+ }
412
+ if (choice === '2') {
413
+ const bundleDir = (await rl.question('bundle dir: ')).trim();
414
+ const repo = (await rl.question('repo path: ')).trim();
415
+ const budget = (await rl.question('budget (blank to skip): ')).trim();
416
+ const wall = (await rl.question('wall minutes (blank to skip): ')).trim();
417
+ const approve = (await rl.question('approve hash (blank to skip): ')).trim();
418
+ const args = [bundleDir, '--repo', repo];
419
+ if (budget) args.push('--budget', budget);
420
+ if (wall) args.push('--wall', wall);
421
+ if (approve) args.push('--approve', approve);
422
+ return await doRun(args, { ...ctx, deps });
423
+ }
424
+ if (choice === '3') {
425
+ const bundleDir = (await rl.question('bundle dir: ')).trim();
426
+ return doHistory([bundleDir], ctx);
427
+ }
428
+ ctx.err(`unknown choice ${JSON.stringify(choice)} — one of: 1, 2, 3, q`);
429
+ return 1;
430
+ } finally {
431
+ rl.close();
432
+ }
433
+ }
434
+
435
+ /**
436
+ * The one entry point. Never calls `process.exit` — always returns an exit
437
+ * code, so `bin/bareloop.mjs` can set `process.exitCode` and let node flush
438
+ * queued stdout on its own (F-something: `process.exit()` can discard it).
439
+ * @param {string[]} argv
440
+ * @param {{ env?: Record<string,string|undefined>, stdout?: any, stderr?: any, cwd?: string, provider?: any, providerFor?: any, judgeProvider?: any, now?: () => number, stdin?: any }} deps
441
+ * @returns {Promise<number>}
442
+ */
443
+ export async function main(argv, deps = {}) {
444
+ const env = deps.env ?? process.env;
445
+ const stdout = deps.stdout ?? process.stdout;
446
+ const stderr = deps.stderr ?? process.stderr;
447
+ const cwd = deps.cwd ?? process.cwd();
448
+ const now = deps.now ?? (() => Date.now());
449
+ const out = (/** @type {string} */ s) => stdout.write(`${s}\n`);
450
+ const err = (/** @type {string} */ s) => stderr.write(`${s}\n`);
451
+ const ctx = { out, err, cwd, env, now, deps };
452
+
453
+ const [cmd, ...rest] = argv;
454
+ if (!cmd) return runMenu({ ...deps, stdin: deps.stdin ?? process.stdin, stdout }, ctx);
455
+ if (cmd === 'export') return doExport(rest, ctx);
456
+ if (cmd === 'run') return doRun(rest, ctx);
457
+ if (cmd === 'history') return doHistory(rest, ctx);
458
+ err(`unknown command ${JSON.stringify(cmd)} — one of: export, run, history`);
459
+ return 1;
460
+ }
package/src/index.js CHANGED
@@ -81,7 +81,7 @@ export { renderListing, selectionPrompt } from './selection.js';
81
81
  // at a review door, and a door with no row to act on can only describe a held learning
82
82
  // credit it has no way to release. STORAGE ONLY — nothing here selects, promotes or reuses
83
83
  // a bridge; those stay parked on `layer-3-reuse`.
84
- export { validateEnvelope, resolveTrySpec, resolveReuse, reuseSpecHash, selectBridge, runReuse, REUSE_GRADED_RED, readResume, resumeTreeGate, CHECKPOINT_OUTCOMES, PAUSE_TTL_MS, checkpointAgeGate, applyDoorDecision, writeRunGreenRow } from './reuse.js';
84
+ export { validateEnvelope, resolveTrySpec, resolveReuse, reuseSpecHash, selectBridge, runReuse, REUSE_GRADED_RED, readResume, resumeTreeGate, CHECKPOINT_OUTCOMES, PAUSE_TTL_MS, checkpointAgeGate, applyDoorDecision, writeRunGreenRow, shapeForkName } from './reuse.js';
85
85
  // BA-21 pricing provenance rides out with the ledger because it is the same job: reading
86
86
  // a run's own spend record honestly. `rateProvenance`/`spendProvenance` answer "how much of
87
87
  // this was priced by a rate nobody vouched for" — REPORTING ONLY, no halt, no refusal.
@@ -252,3 +252,19 @@ export {
252
252
  // whether a changed file is one. The ONE place this list lives; exported so
253
253
  // bareloop.context.md can point an adopter here instead of re-deriving it.
254
254
  export { PROMPT_REGISTERS, isPromptFile } from './promptregisters.js';
255
+ // Export M1 (`docs/product/EXPORT-BUILD.md`) — the bundle a `bareloop run`
256
+ // (M2) drives from a clean consumer directory: a signed spec (close paths
257
+ // rewritten to `$BARELOOP_BUNDLE/close/<script>`), the close scripts it
258
+ // needs, and the job's whole registry history. `bundle.js` reads this
259
+ // module's own export list at call time (never a hand-kept copy) to judge
260
+ // whether a close script's imports are legal — a cycle by design, safe
261
+ // because the binding is read only inside function bodies.
262
+ export {
263
+ exportBundle, bundleHash, readBundle, resolveBundleSpec, checkEnvelope, bless, verifyBlessing, appendHistory, checkBundleDeps,
264
+ } from './bundle.js';
265
+ // Export M2 (`docs/product/EXPORT-BUILD.md`) — the CLI's one entry point,
266
+ // exported as `cliMain` (never `main`, a name generic enough to collide with
267
+ // an adopter's own). `bin/bareloop.mjs` is the only other caller; this export
268
+ // exists so an adopter driving `bareloop run` from their own script can do so
269
+ // without spawning `bin/bareloop.mjs` as a child process.
270
+ export { main as cliMain } from './cli.js';
package/src/reuse.js CHANGED
@@ -167,7 +167,7 @@ function sameCloseShape(entry, stageNames) {
167
167
  * to prevent.
168
168
  * @param {string} name @param {string[]} stageNames @returns {string}
169
169
  */
170
- function shapeForkName(name, stageNames) {
170
+ export function shapeForkName(name, stageNames) {
171
171
  return `${name}-${createHash('sha256').update(stageNames.join('\n')).digest('hex').slice(0, 8)}`;
172
172
  }
173
173