bareloop 0.19.1 → 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/planrun.js CHANGED
@@ -642,6 +642,17 @@ ${scoutBlob || '(no scout notes)'}`;
642
642
  * it.
643
643
  * @param {number} [opts.closeTimeoutMs] close/check wall-clock cap (shell territory)
644
644
  * @param {number} [opts.maxStepRounds] the shell's per-step rounds ceiling (validatePlan's bound)
645
+ * @param {boolean} [opts.scout=true] operator-only switch (docs/product/SCOUT-CONTRAST.md):
646
+ * `false` on a FRESH run (no `resumeSeed`) skips the survey entirely — `scoutBlob`
647
+ * stays `''`, no `scout-start`/`scout-result`/`scout-empty` is emitted, and a single
648
+ * `scout-skipped {reason:'operator-off'}` records it (a RECORD, never silence — the
649
+ * resume skip's own rule). The planner then drafts from `(no scout notes)`
650
+ * (line ~587) exactly as it does today when a scout returns an empty blob — OFF is
651
+ * byte-identical to today's run everywhere but the survey. Precedence: when
652
+ * `resumeSeed` is set the existing resume skip fires first and wins its own reason
653
+ * (`resumed`); `operator-off` is only ever emitted on a fresh run. A non-boolean
654
+ * THROWS a TypeError at entry (the `readShimArm` guard class) — a knob that
655
+ * silently coerced would mislabel a bench row.
645
656
  * @param {number} [opts.scoutRounds] the read-only survey's round bound (F59: the LAST round is
646
657
  * reserved — a scout that spends every round on tools gets one toolless round to write its
647
658
  * survey, because the bound halts it mid-tool-use and text is its only deliverable)
@@ -805,7 +816,7 @@ ${scoutBlob || '(no scout notes)'}`;
805
816
  * 'branch-red' | 'cap-halt' | 'wall-halt' | 'provider-red' | 'interpreter-red' |
806
817
  * 'step-stalled' | 'hitl-pause' | 'hitl-decision-red' | `step-red:<id>`
807
818
  */
808
- export async function runPlan(job, { workdir, provider, nativeProvider, providerFor, judgeProvider = null, emit, remainingUsd, isUnpriced = () => false, spendComplete = () => true, capRuns = 3, strikeLimit = STRIKE_LIMIT, closeTimeoutMs, maxStepRounds = 40, layerRoot = false, readShim = false, scoutRounds = SCOUT_ROUNDS, bridge = null, now, priorWallMs = 0, resumeSeed = null, resumeGrades = [], resumeReplans = null, resumeBranch = null, humanRuling = null, heldRuling = null, priorSpentUsd = 0, reviewDoor = null, doorRerun = null }) {
819
+ export async function runPlan(job, { workdir, provider, nativeProvider, providerFor, judgeProvider = null, emit, remainingUsd, isUnpriced = () => false, spendComplete = () => true, capRuns = 3, strikeLimit = STRIKE_LIMIT, closeTimeoutMs, maxStepRounds = 40, layerRoot = false, readShim = false, scout = true, scoutRounds = SCOUT_ROUNDS, bridge = null, now, priorWallMs = 0, resumeSeed = null, resumeGrades = [], resumeReplans = null, resumeBranch = null, humanRuling = null, heldRuling = null, priorSpentUsd = 0, reviewDoor = null, doorRerun = null }) {
809
820
  // MEMORY-CACHE: what the read shim (src/readshim.js) saved THIS run, summed across
810
821
  // every mkWorker's own shim instance (scout, drafter, each step's worker, the fix
811
822
  // worker) — one accumulator closed over by all of them, because the shim's ledger
@@ -822,6 +833,11 @@ export async function runPlan(job, { workdir, provider, nativeProvider, provider
822
833
  // would be wrong and would look fine). Only the guard runs here; the flag itself
823
834
  // is still threaded onward as written.
824
835
  readShimArm(readShim);
836
+ // The scout switch's guard, same class and same door: a mis-typed value
837
+ // coerced by truthiness would run the scout under an "off" label (or skip it
838
+ // under an "on" one) and no reading of the results afterwards could recover
839
+ // which arm actually ran (docs/product/SCOUT-CONTRAST.md).
840
+ if (typeof scout !== 'boolean') throw new TypeError(`scout: expected boolean, got ${JSON.stringify(scout)}`);
825
841
  // G1's OTHER half, refused at the same $0 door. `validatePlan` reds `read-blind`
826
842
  // when a step grants `read` without the retrieval pair — but a step cannot grant
827
843
  // what the SIGNED CEILING does not offer, so against a spec whose `tools` lack
@@ -2456,6 +2472,15 @@ export async function runPlan(job, { workdir, provider, nativeProvider, provider
2456
2472
  reason: 'resumed', phase: resumeSeed.phase ?? null,
2457
2473
  meaning: 'the killed run already paid for this survey; its plan is reloaded, not re-drafted. A replan after this point drafts from an empty survey plus the failure brief.',
2458
2474
  });
2475
+ } else if (scout === false) {
2476
+ // The operator-off arm (docs/product/SCOUT-CONTRAST.md): a fresh run whose
2477
+ // operator asked for no survey. Precedence already settled above — the
2478
+ // resume branch fires first and wins its own reason, so this arm is only
2479
+ // ever reached when there is nothing to resume from.
2480
+ emit('scout-skipped', {
2481
+ reason: 'operator-off',
2482
+ meaning: 'the operator ran with --scout off; the planner drafts from "(no scout notes)"; no survey was paid for.',
2483
+ });
2459
2484
  } else {
2460
2485
  emit('scout-start', { rounds: scoutRounds });
2461
2486
  try {
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
 
package/src/run.js CHANGED
@@ -171,6 +171,12 @@ async function primitiveSmoke(workdir) {
171
171
  * frozen A0 baseline arm has to be exactly today, so a guard firing under a disabled
172
172
  * shim would make the baseline a treatment arm. The default-flip belongs to a
173
173
  * paid contrast nobody has approved yet (the `layerRoot` precedent, F41).
174
+ * @param {boolean} [opts.scout=true] operator-only switch (docs/product/SCOUT-CONTRAST.md),
175
+ * forwarded verbatim to the plan flow: `false` on a fresh run (no `resumeSeed`) skips the
176
+ * read-only survey and records `scout-skipped {reason:'operator-off'}` instead — the
177
+ * planner drafts from `(no scout notes)`. Runner territory exactly like `readShim`: the
178
+ * spec names no scout, so the signed hash is unaffected. A non-boolean THROWS at this
179
+ * entry, before the ledger, before the approval gate (the `readShim` guard class).
174
180
  * @param {boolean} [opts.layerRoot=false] Layer R (within-run ratchet) — shell
175
181
  * territory, threaded to the plan flow. Defaults OFF
176
182
  * (decided 2026-07-21): fixation is extinct on every current job (F41), so
@@ -202,7 +208,7 @@ async function primitiveSmoke(workdir) {
202
208
  * 'interpreter-red' | 'cap-halt' | 'wall-halt' | 'step-stalled' |
203
209
  * 'hitl-pause' | 'hitl-decision-red' | `step-red:<id>`
204
210
  */
205
- export async function runJob(rawSpec, { approvals, workdir, provider, nativeProvider, providerFor, judgeProvider = null, emit, capRuns = 3, strikeLimit, shellCapUsd = 2, closeTimeoutMs, layerRoot = false, readShim = false, bridge = null, priorSpentUsd = 0, priorSpendComplete = true, priorWallMs = 0, resumeSeed = null, resumeGrades = [], resumeReplans = null, resumeBranch = null, humanRuling = null, heldRuling = null, reviewDoor = null, doorRerun = null }) {
211
+ export async function runJob(rawSpec, { approvals, workdir, provider, nativeProvider, providerFor, judgeProvider = null, emit, capRuns = 3, strikeLimit, shellCapUsd = 2, closeTimeoutMs, layerRoot = false, readShim = false, scout = true, bridge = null, priorSpentUsd = 0, priorSpendComplete = true, priorWallMs = 0, resumeSeed = null, resumeGrades = [], resumeReplans = null, resumeBranch = null, humanRuling = null, heldRuling = null, reviewDoor = null, doorRerun = null }) {
206
212
  // THE READ SHIM's ARM, resolved at the door — the FIRST thing this entry does,
207
213
  // before the ledger, before the approval gate, before a byte of the spec is read.
208
214
  // An unrecognised spelling throws here at zero cost instead of being coerced by
@@ -211,6 +217,10 @@ export async function runJob(rawSpec, { approvals, workdir, provider, nativeProv
211
217
  // blind-instrument failure this programme keeps logging. Only the guard runs
212
218
  // here; the flag itself is threaded onward to the plan flow as written.
213
219
  readShimArm(readShim);
220
+ // The scout switch's guard, same class and same door (docs/product/SCOUT-CONTRAST.md):
221
+ // a mis-coerced value would run under the wrong label with no way to recover which arm
222
+ // actually fired from the results afterwards.
223
+ if (typeof scout !== 'boolean') throw new TypeError(`scout: expected boolean, got ${JSON.stringify(scout)}`);
214
224
  // 0. the ledger's counters, declared FIRST so that every job-end — including
215
225
  // the pre-token reds below — can state a real figure. An omitted `spentUsd` is
216
226
  // not a zero: a consumer reads `undefined` and either crashes or launders it
@@ -405,7 +415,7 @@ export async function runJob(rawSpec, { approvals, workdir, provider, nativeProv
405
415
  // accounts it natively (F12) and the job-end money contract is unchanged.
406
416
  {
407
417
  const outcome = await runPlan(job, {
408
- workdir, provider, nativeProvider, providerFor, judgeProvider, emit: meter, capRuns, ...(strikeLimit !== undefined ? { strikeLimit } : {}), closeTimeoutMs, layerRoot, readShim, bridge, priorWallMs: chainWallMs, resumeSeed, resumeGrades, resumeReplans, resumeBranch, humanRuling, heldRuling, reviewDoor, doorRerun, priorSpentUsd: chainFoldUsd,
418
+ workdir, provider, nativeProvider, providerFor, judgeProvider, emit: meter, capRuns, ...(strikeLimit !== undefined ? { strikeLimit } : {}), closeTimeoutMs, layerRoot, readShim, scout, bridge, priorWallMs: chainWallMs, resumeSeed, resumeGrades, resumeReplans, resumeBranch, humanRuling, heldRuling, reviewDoor, doorRerun, priorSpentUsd: chainFoldUsd,
409
419
  remainingUsd: () => Math.min(shellCapUsd, job.budgetUsd - spentUsd),
410
420
  isUnpriced: () => unpriced, // F6: let the plan flow bail in-flight, not just after it returns
411
421
  spendComplete, // …and let its money-halt readout say whether the remaining it quotes is exact