bareloop 0.32.0 → 0.34.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 CHANGED
@@ -3,37 +3,32 @@
3
3
  // bareloop.mjs` is a ~10-line adapter that supplies the real `deps` and turns
4
4
  // the returned exit code into `process.exitCode`.
5
5
  //
6
- // `deps.provider` (and `providerFor`/`judgeProvider`) is the ONE test seam: a
7
- // caller that supplies it skips the real-key check entirely (a scripted
8
- // provider IS the run, same as `tests/planrun.test.js`'s pattern). A caller
9
- // that does NOT supply one goes through the real path M1's frozen spec names:
10
- // a key from `ANTHROPIC_API_KEY` only, absent means print the operator
11
- // questions and spend nothing (`bareloop run`, step 3).
6
+ // `bareloop run <bundle>` is a thin door (`src/bundlerun.js`) to the same engine `bareloop
7
+ // run-u` drives (`src/userrun.js`): providers, keys (the keys file / env, any provider the
8
+ // bundle's spec names), judge, monthly limit and readout are that engine's. The test seam is
9
+ // the engine's too: `deps.provider` (and `providerFor`/`judgeProvider`) supplies a scripted
10
+ // provider and skips the real-key check (same as `tests/planrun.test.js`'s pattern).
12
11
  //
13
- // Money/arbiter rules this module must not bend: `--budget`/`--wall` only
14
- // TIGHTEN a bundle's signed ceilings (`checkEnvelope`, M1); a bundle's
15
- // `bundleHash` (the human-facing signature) never changes when the runner
16
- // tightens — the SPEC that actually executes does, and this module mints a
17
- // FRESH `approvals` record over that tightened, `$BARELOOP_BUNDLE`-resolved
18
- // spec so `checkApproval` inside `runJob` always sees the spec it is about to
19
- // run, never the unresolved bundleHash. `history.jsonl` records both hashes
20
- // side by side (POC fact 2) so the pairing can never drift silently.
12
+ // Money/arbiter rules the bundle door keeps: `--budget`/`--wall` only TIGHTEN a bundle's
13
+ // signed ceilings (`checkEnvelope`, M1); a bundle's `bundleHash` (the human-facing
14
+ // signature) never changes when the runner tightens — the SPEC that actually executes does,
15
+ // and the door hands the engine the hash of that tightened, `$BARELOOP_BUNDLE`-resolved
16
+ // spec. `history.jsonl` records both hashes side by side (POC fact 2) so the pairing can
17
+ // never drift silently.
21
18
 
22
19
  import { createInterface } from 'node:readline/promises';
23
- import { hostname } from 'node:os';
24
- import { execFileSync } from 'node:child_process';
25
20
  import {
26
- existsSync, mkdirSync, readFileSync, renameSync,
21
+ existsSync, readFileSync,
27
22
  } from 'node:fs';
28
23
  import {
29
24
  dirname, isAbsolute, join, resolve,
30
25
  } from 'node:path';
31
26
 
32
27
  import {
33
- exportBundle, readBundle, resolveBundleSpec, checkEnvelope, bless, verifyBlessing, appendHistory, checkBundleDeps,
34
- runJob, makeSpine, loadRegistry, listingRow, jobSpecHash, resolveWorkerModel, resolveJudge,
28
+ exportBundle, loadRegistry, listingRow,
35
29
  } from './index.js';
36
- import { resolveProvider, buildRunnerProviders, apiKeyProblem } from './providers.js';
30
+ // One runner: `bareloop run <bundle>` is a thin door to the `run-u` engine (`src/userrun.js`).
31
+ import { bundleMain } from './bundlerun.js';
37
32
  // PANEL-BUILD.md P0 task 2/4 — `bareloop run-u` wraps the person-path run
38
33
  // flow's own argv-parsing entry (`src/userrun.js`'s `main(argv, deps)`,
39
34
  // itself the lift target of P0 task 1). This is the SAME function
@@ -69,87 +64,11 @@ import { panelMain } from './panel/server.js';
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
66
  import { keysForDoor } from './keysfile.js';
72
- import { appendRun, readRunList, backfillRuns, formatRunRow } from './runlist.js';
73
-
74
- // The tier->model tables live in `src/providers.js` now (PRD item 28's
75
- // factory) — one seam instead of a copy hardcoded in each runner. A
76
- // `bareloop run` has no `--model` flag (not in the frozen spec), so
77
- // `flagModel` is always undefined below and only the spec's own `model`
78
- // (if signed) or the resolved provider's default tier is ever in play.
67
+ import { readRunList, backfillRuns, formatRunRow } from './runlist.js';
79
68
 
80
69
  /** @param {unknown} v @returns {v is Record<string, any>} */
81
70
  const isObj = (v) => typeof v === 'object' && v !== null;
82
71
 
83
- /**
84
- * Build the real providers from an API key, exactly the way `scripts/run-
85
- * u.mjs` does (provider/providerFor/judgeProvider from `bare-agent/
86
- * providers`'s `AnthropicProvider`). `AnthropicProvider` itself lives in
87
- * `bare-agent` (the one production dependency already budgeted for provider
88
- * calls); this function only wires it up the same way the u-runner does —
89
- * it is not arbiter logic (no budget/verdict/merge decision lives here).
90
- * @param {string} apiKey
91
- * @param {any} spec the (already `$BARELOOP_BUNDLE`-resolved) bundle spec
92
- */
93
- function buildProviders(apiKey, spec) {
94
- // `bareloop run`'s frozen key contract (module header above) is
95
- // ANTHROPIC_API_KEY only — this `apiKey` is always that key. A bundle
96
- // whose spec names a provider that reads a DIFFERENT env key therefore
97
- // has no seam into this flow yet, and the one thing this must never do is
98
- // construct that provider with the Anthropic key: the run would fail at
99
- // the first call with a vendor 401, which reads as a credential problem
100
- // rather than as the unbuilt seam it actually is. So it REFUSES here, at
101
- // $0, naming the key it would have needed. Widening the contract (a
102
- // signed per-provider key story) is PRD item 28 part (2)/(3), the
103
- // arbiter's, not this factory rewire's — the runner that actually fires
104
- // openai-api jobs today is `scripts/run-u.mjs`, which IS key-aware.
105
- const providerName = isObj(spec) && typeof spec.provider === 'string' ? spec.provider : 'anthropic-api';
106
- const entry = resolveProvider(providerName);
107
- if (entry.envKey !== 'ANTHROPIC_API_KEY') {
108
- throw new Error(
109
- `bareloop run: this bundle's spec names provider "${providerName}", which reads `
110
- + `${entry.envKey}. The bundle runner's key contract is ANTHROPIC_API_KEY only, so it `
111
- + 'refuses rather than calling that provider with the wrong key (PRD item 28 part 2 — '
112
- + 'a signed per-provider key story is not built). Run this job through scripts/run-u.mjs.',
113
- );
114
- }
115
- const modelResolution = resolveWorkerModel({
116
- specModel: isObj(spec) && typeof spec.model === 'string' ? spec.model : undefined,
117
- flagModel: undefined,
118
- defaultModel: entry.tiers.sonnet,
119
- });
120
- const MODEL = modelResolution.model;
121
- const tierModels = modelResolution.source === 'spec' ? { ...entry.tiers, sonnet: MODEL } : entry.tiers;
122
- const baseUrl = isObj(spec) && typeof spec.baseUrl === 'string' ? spec.baseUrl : undefined;
123
- // THE JUDGE, RESOLVED RATHER THAN PINNED (PRD item 32.1/32.2). The bundle
124
- // runner's key contract is `ANTHROPIC_API_KEY`-only and the refusal above has
125
- // already established that the WORKER provider reads that key — so a judge
126
- // defaulting to the worker's own identity reads the same key by construction,
127
- // and a signed `judge` naming a different provider would need a key this
128
- // runner does not have. That case refuses here, at $0, by name — the same
129
- // shape and the same reason as the worker refusal above, never a call made
130
- // with the wrong vendor's key.
131
- const judge = resolveJudge({
132
- specJudge: isObj(spec) ? spec.judge : undefined,
133
- workerProvider: providerName,
134
- workerModel: MODEL,
135
- });
136
- const judgeEntry = resolveProvider(judge.provider);
137
- if (judgeEntry.envKey !== 'ANTHROPIC_API_KEY') {
138
- throw new Error(
139
- `bareloop run: this bundle's spec names judge provider "${judge.provider}", which reads `
140
- + `${judgeEntry.envKey}. The bundle runner's key contract is ANTHROPIC_API_KEY only, so it `
141
- + 'refuses rather than calling that provider with the wrong key. Run this job through scripts/run-u.mjs.',
142
- );
143
- }
144
- return {
145
- ...buildRunnerProviders({
146
- providerName, apiKey, model: MODEL, tierModels, baseUrl,
147
- judgeApiKey: apiKey, judgeModel: judge.model, judgeProviderName: judge.provider, judgeBaseUrl: baseUrl,
148
- }),
149
- judgeModel: judge.model,
150
- };
151
- }
152
-
153
72
  /** @param {string[]} args @returns {{ positional: string[], flags: Record<string, string|true> }} */
154
73
  function parseFlags(args) {
155
74
  /** @type {string[]} */
@@ -248,255 +167,6 @@ function doExport(args, { out, err, cwd }) {
248
167
  return 0;
249
168
  }
250
169
 
251
- /**
252
- * `bareloop run <bundleDir> --repo <path> [--budget N] [--wall MIN] [--approve <bundleHash>]`
253
- * — the exact five-step order the frozen spec names; no step may be reordered.
254
- * @param {string[]} args
255
- * @param {{ out: (s: string) => void, err: (s: string) => void, cwd: string, env: Record<string,string|undefined>, now: () => number, deps: any }} ctx
256
- */
257
- async function doRun(args, { out, err, cwd, env, now, deps }) {
258
- const { positional, flags } = parseFlags(args);
259
- const bundleDirArg = positional[0];
260
- const repoArg = typeof flags.repo === 'string' ? flags.repo : undefined;
261
- if (!bundleDirArg || !repoArg) {
262
- err('usage: bareloop run <bundleDir> --repo <path> [--budget N] [--wall MIN] [--approve <bundleHash>]');
263
- return 1;
264
- }
265
- const bundleDir = resolve(cwd, bundleDirArg);
266
- const repo = resolve(cwd, repoArg);
267
-
268
- // 1. readBundle — N4: this must complete, clean, before anything else
269
- // touches the bundle or the repo. Any red (incl. bundle-tampered) stops here.
270
- const bundle = readBundle(bundleDir);
271
- if (!bundle.ok) { printReds(bundle.reds, err); return 1; }
272
-
273
- // 1b. F128 — the bundle's OWN node_modules must carry `bareloop` before
274
- // anything else runs. Left unchecked, the close crashes deep inside
275
- // runJob's close-first precheck with a bare ERR_MODULE_NOT_FOUND, reported
276
- // as an unhelpful generic close-red with no cure line.
277
- const depsCheck = checkBundleDeps(bundleDir);
278
- if (!depsCheck.ok) { printReds(depsCheck.reds, err); return 1; }
279
-
280
- // 2. checkEnvelope — tighten-only, wall given in minutes -> ms.
281
- /** @type {{ budgetUsd?: number, maxWallMs?: number }} */
282
- const envelope = {};
283
- if (flags.budget !== undefined) {
284
- const n = Number(flags.budget);
285
- if (!Number.isFinite(n)) { err(`--budget must be a number, got ${JSON.stringify(flags.budget)}`); return 1; }
286
- envelope.budgetUsd = n;
287
- }
288
- if (flags.wall !== undefined) {
289
- const n = Number(flags.wall);
290
- if (!Number.isFinite(n)) { err(`--wall must be a number of minutes, got ${JSON.stringify(flags.wall)}`); return 1; }
291
- envelope.maxWallMs = n * 60_000;
292
- }
293
- const envCheck = checkEnvelope(bundle.spec, envelope);
294
- if (!envCheck.ok) { printReds(envCheck.reds, err); return 1; }
295
-
296
- // 3. the key — ANTHROPIC_API_KEY only, UNLESS a caller already handed in a
297
- // provider (the test seam). Absent and no injected provider: print the
298
- // operator questions (the bundle's own README) and the hash, spend nothing.
299
- let { provider, providerFor, judgeProvider } = deps;
300
- // the judge IDENTITY travels with the judge PROVIDER (PRD item 32.1). A test
301
- // injecting `deps.judgeProvider` may name it too; absent, it is resolved
302
- // alongside the real providers below, and a judged stage with neither stops as
303
- // a wiring gap rather than grading under a name nobody wrote.
304
- let judgeModel = /** @type {string|null} */ (deps.judgeModel ?? null);
305
- if (!provider) {
306
- const apiKey = env.ANTHROPIC_API_KEY;
307
- if (!apiKey) {
308
- let readme = null;
309
- try { readme = readFileSync(join(bundleDir, 'README.md'), 'utf8'); } catch { /* fall through to the hash alone */ }
310
- if (readme) out(readme);
311
- out(`bundleHash: ${bundle.manifest.bundleHash}`);
312
- return 0;
313
- }
314
- // F181 — a key that reads as "set" above can still carry a line break/
315
- // control char/stray whitespace and crash Node's own header-encode
316
- // inside the paid span. Refused here, at the same door, before any
317
- // provider is constructed; never echoes the value.
318
- const keyProblem = apiKeyProblem(apiKey);
319
- if (keyProblem) {
320
- err(`ANTHROPIC_API_KEY ${keyProblem} — refusing rather than crashing mid-call (never trimmed or repaired; fix the value at its source)`);
321
- return 1;
322
- }
323
- try {
324
- ({ provider, providerFor, judgeProvider, judgeModel } = buildProviders(apiKey, bundle.spec));
325
- } catch (e) {
326
- // a refusal from the key contract above: name it and spend nothing
327
- err(String(/** @type {any} */ (e)?.message ?? e));
328
- return 1;
329
- }
330
- }
331
-
332
- // 4. blessing.
333
- const { manifest } = bundle;
334
- if (!bundle.blessing) {
335
- // The runid that ACTUALLY minted this bundle's own signed spec — the
336
- // bridge VERSION (base or shape fork; both ship into bridges/) whose
337
- // recorded specHash equals the resolved bundle spec's jobSpecHash. NOT
338
- // the bridge's first history row: a job can be re-exported/rebased many
339
- // times, and only the version at THIS exact hash proves anything for
340
- // THIS bundle.
341
- const { approveHash: mintMatchHash } = resolveBundleSpec(bundle, bundleDir);
342
- let mintRunid = null;
343
- for (const b of bundle.bridges) {
344
- const versions = Array.isArray(b.versions) ? b.versions : [];
345
- const v = versions.find((/** @type {any} */ ver) => ver.specHash === mintMatchHash);
346
- if (v) { mintRunid = v.runid; break; }
347
- }
348
- out('first run — this bundle has never been blessed on this machine.');
349
- out(mintRunid ? `minting run: ${mintRunid} (spine not bundled in v1)` : 'no version at this hash');
350
- out(`bundleHash: ${manifest.bundleHash}`);
351
- if (flags.approve !== manifest.bundleHash) {
352
- err(`--approve ${manifest.bundleHash} is required for the first run of an unblessed bundle`);
353
- return 1;
354
- }
355
- } else {
356
- const vb = verifyBlessing(bundle);
357
- if (!vb.ok) { printReds(vb.reds, err); return 1; }
358
- if (flags.approve) out('already blessed, --approve ignored');
359
- }
360
-
361
- // 5. the worktree — refuse a non-repo, a repo with no commit, or a collision;
362
- // ALWAYS fresh, never reused (a reused worktree reads last run's edits as
363
- // "already-green" — the negative POC's exact finding).
364
- try {
365
- execFileSync('git', ['-C', repo, 'rev-parse', 'HEAD'], { encoding: 'utf8' });
366
- } catch (e) {
367
- err(`--repo ${repoArg} must be a git repository with at least one commit: ${/** @type {Error} */ (e).message}`);
368
- return 1;
369
- }
370
- const runid = now().toString(36);
371
- const worktree = join(repo, '.bareloop', 'wt', runid);
372
- if (existsSync(worktree)) { err(`worktree already exists: ${worktree}`); return 1; }
373
- mkdirSync(dirname(worktree), { recursive: true });
374
- try {
375
- execFileSync('git', ['-C', repo, 'worktree', 'add', '--detach', worktree, 'HEAD'], { encoding: 'utf8' });
376
- } catch (e) {
377
- err(`git worktree add failed: ${/** @type {Error} */ (e).message}`);
378
- return 1;
379
- }
380
-
381
- // 6. resolve (in-memory $BARELOOP_BUNDLE substitution) + tighten + run. The
382
- // envelope tightens the ACTUAL spec that runs (budgetUsd/maxWallMs have no
383
- // separate runJob override — the library reads them off the spec itself),
384
- // so `approveHash` is recomputed AFTER tightening: the runner mints its own
385
- // fresh approval over the exact spec it is about to hand to runJob, never
386
- // over the untightened one `resolveBundleSpec` first returned. The
387
- // bundleHash a human approved never changes; only the executed spec's own
388
- // hash does, and that hash is minted here, not signed by a person.
389
- const resolved = resolveBundleSpec(bundle, bundleDir);
390
- const runSpec = resolved.spec;
391
- if (envelope.budgetUsd !== undefined) runSpec.budgetUsd = envelope.budgetUsd;
392
- if (envelope.maxWallMs !== undefined) runSpec.maxWallMs = envelope.maxWallMs;
393
- const approveHash = jobSpecHash(runSpec);
394
-
395
- const runsDir = join(bundleDir, 'runs', runid);
396
- mkdirSync(runsDir, { recursive: true });
397
- const spineFile = join(runsDir, 'spine.jsonl');
398
- const emit = makeSpine(spineFile);
399
- // PRD item 27/M3 Part B — the close's own books directory, inside the run's
400
- // own directory so the bundle stays one self-contained unit (hamr,
401
- // 2026-09-07: "export holds as one unit of itself"). `runJob` autosets the
402
- // close timeout itself (no `closeTimeoutMs` passed — the bundle CLI has
403
- // always run on the library default/autoset path, never run-u's operator
404
- // knob).
405
- const closeDir = join(runsDir, 'close');
406
- mkdirSync(closeDir, { recursive: true });
407
-
408
- // PANEL-BUILD.md P1 — one row in the run list, BEFORE the first paid call
409
- // (runJob, right below). A list-append failure must never block a real,
410
- // already-signed run: caught and named loudly, never rethrown.
411
- // `deps.runlistHome` — the SAME test seam shape `deps.provider`/`deps.now`
412
- // already are on this ctx (never a new env var, see src/runlist.js's own
413
- // header comment): a caller that injects it (every test in
414
- // tests/cli.test.js that reaches this line) writes its row under a temp
415
- // home instead of the real `~/.config/bareloop`; production never sets it,
416
- // so `appendRun` falls through to `runlistHome()`'s own `os.homedir()`.
417
- try {
418
- appendRun({
419
- at: new Date(now()).toISOString(), runid, job: runSpec.job, spine: spineFile, patient: worktree, via: 'bundle',
420
- }, { home: deps.runlistHome });
421
- } catch (e) {
422
- 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.`);
423
- }
424
-
425
- let outcome;
426
- try {
427
- outcome = await runJob(runSpec, {
428
- approvals: [{ specHash: approveHash, signer: 'bundle', ts: new Date(now()).toISOString() }],
429
- workdir: worktree,
430
- provider,
431
- providerFor,
432
- judgeProvider,
433
- judgeModel,
434
- emit,
435
- closeDir,
436
- shellCapUsd: runSpec.budgetUsd,
437
- readShim: 'cap',
438
- scout: true,
439
- // F130/PRD item 27(c) — the bundle CLI has no `--resume` (v1); the
440
- // honest tail says so instead of naming a flag that does not exist.
441
- resumable: false,
442
- });
443
- } catch (e) {
444
- err(`runJob crashed: ${/** @type {Error} */ (e).message}`);
445
- return 1;
446
- }
447
-
448
- // the gate-audit relocation (POC fact 3, run-u.mjs:1293's move)
449
- const auditSrc = join(worktree, 'gate-audit.jsonl');
450
- if (existsSync(auditSrc)) renameSync(auditSrc, join(runsDir, 'gate-audit.jsonl'));
451
-
452
- // the job-end record off THIS run's own spine — never fabricate a 0 for an
453
- // unknown spend (F6/F12's class). PANEL-BUILD.md P0: read via the same
454
- // named library reader `doHistory`/`doReplay` already use (`parseJsonl`,
455
- // `src/replayio.js`) instead of hand-rolling the parse again here — it
456
- // tolerates a malformed/truncated line (a process killed mid-append)
457
- // instead of throwing uncaught and crashing this run's exit tail.
458
- const { records: events } = parseJsonl(spineFile);
459
- const je = events.findLast((/** @type {any} */ e) => e.type === 'job-end');
460
- const spentUsd = je?.spentUsd ?? null;
461
- const spendComplete = je?.spendComplete ?? null;
462
-
463
- let branch = null;
464
- try { branch = execFileSync('git', ['-C', worktree, 'rev-parse', '--abbrev-ref', 'HEAD'], { encoding: 'utf8' }).trim(); } catch { /* leave null — reported honestly below */ }
465
-
466
- // 7. history + (on a first green) the blessing.
467
- appendHistory(bundleDir, {
468
- runid,
469
- at: new Date(now()).toISOString(),
470
- outcome,
471
- spentUsd,
472
- spendComplete,
473
- budgetUsd: runSpec.budgetUsd,
474
- maxWallMs: runSpec.maxWallMs ?? null,
475
- worktree,
476
- branch,
477
- bundleHash: manifest.bundleHash,
478
- approveHash,
479
- });
480
- if (outcome === 'green' && !bundle.blessing) {
481
- bless(bundleDir, {
482
- bundleHash: manifest.bundleHash, blessedAt: new Date(now()).toISOString(), runid, outcome, host: hostname(),
483
- });
484
- }
485
-
486
- // 8. the tail.
487
- out(`outcome ${outcome}`);
488
- out(`spent ${spentUsd == null ? 'UNKNOWN' : `${spendComplete === false ? '≥' : ''}$${spentUsd.toFixed(4)}`} of $${runSpec.budgetUsd}`);
489
- out(`branch ${branch ?? 'UNKNOWN (could not read the worktree branch)'}`);
490
- out(`worktree ${worktree}`);
491
- if (branch) out(`merge git merge ${branch} (merge stays human — this CLI never merges)`);
492
- out(`the worktree is kept until you remove it: git worktree remove ${worktree}`);
493
- // exit 0 ONLY for a real green — every other outcome (close-red, plan-red,
494
- // escalated, cap/wall halts, worker-crash, provider-red, …) exits 1 so a
495
- // caller scripting `bareloop run` off its exit code cannot mistake a red
496
- // for a success. The tail print above is unchanged either way.
497
- return outcome === 'green' || outcome === 'already-green' ? 0 : 1;
498
- }
499
-
500
170
  /**
501
171
  * `bareloop history <bundleDir>`
502
172
  * @param {string[]} args @param {{ out: (s: string) => void, err: (s: string) => void, cwd: string }} ctx
@@ -609,9 +279,11 @@ function doRuns(args, { out, err, cwd }) {
609
279
  * inventing new interactive UX, not wiring, and that judgement stands. This
610
280
  * menu is fixed instead: it now says those four commands exist and how to
611
281
  * reach them, rather than implying only three commands do at all.
612
- * @param {any} deps @param {{ out: (s: string) => void, err: (s: string) => void, cwd: string, env: any, now: () => number }} ctx
282
+ * `keysHome` is the home `main` already resolved (`keysForDoor`), handed on so choice 2 reads the
283
+ * SAME config.json (monthly limit, price, key row) as `bareloop run <bundle>` — never resolved twice.
284
+ * @param {any} deps @param {{ out: (s: string) => void, err: (s: string) => void, cwd: string, env: any, now: () => number }} ctx @param {string|undefined} keysHome
613
285
  */
614
- async function runMenu(deps, ctx) {
286
+ async function runMenu(deps, ctx, keysHome) {
615
287
  const stdin = deps.stdin;
616
288
  const stdout = deps.stdout;
617
289
  const rl = createInterface({ input: stdin, output: stdout });
@@ -636,7 +308,7 @@ async function runMenu(deps, ctx) {
636
308
  if (budget) args.push('--budget', budget);
637
309
  if (wall) args.push('--wall', wall);
638
310
  if (approve) args.push('--approve', approve);
639
- return await doRun(args, { ...ctx, deps });
311
+ return await bundleMain(args, { ...ctx, deps, keysHome, parseFlags, printReds });
640
312
  }
641
313
  if (choice === '3') {
642
314
  const bundleDir = (await rl.question('bundle dir: ')).trim();
@@ -673,9 +345,9 @@ export async function main(argv, deps = {}) {
673
345
 
674
346
  const [cmd, ...rest] = argv;
675
347
  if (keys.warning && cmd && ['run', 'run-u', 'interview', 'author', 'panel'].includes(cmd)) err(`WARNING: ${keys.warning}`);
676
- if (!cmd) return runMenu({ ...deps, stdin: deps.stdin ?? process.stdin, stdout }, ctx);
348
+ if (!cmd) return runMenu({ ...deps, stdin: deps.stdin ?? process.stdin, stdout }, ctx, keys.home);
677
349
  if (cmd === 'export') return doExport(rest, ctx);
678
- if (cmd === 'run') return doRun(rest, ctx);
350
+ if (cmd === 'run') return bundleMain(rest, { ...ctx, keysHome: keys.home, parseFlags, printReds });
679
351
  if (cmd === 'history') return doHistory(rest, ctx);
680
352
  // `bareloop run-u` — the person-path run flow (PANEL-BUILD.md P0 task
681
353
  // 2/4). `rest` is handed straight to `src/userrun.js`'s own `main(argv,
@@ -687,7 +359,7 @@ export async function main(argv, deps = {}) {
687
359
  // then `env`/`out`/`err` are overridden to the SAME resolved values every
688
360
  // other command here prints through, so `run-u`'s output lands on the
689
361
  // `stdout`/`stderr` a caller of `main` actually passed.
690
- if (cmd === 'run-u') return runUMain(rest, { ...deps, env, out, err, keysHome: keys.home });
362
+ if (cmd === 'run-u') return runUMain(rest, { ...deps, env, out, err, keysHome: keys.home, invokedAs: 'bareloop run-u' });
691
363
  // `bareloop interview` — the close-authoring interview (PANEL-BUILD.md P0
692
364
  // task 3/4). This flow reads a TTY (or a piped stdin) directly rather than
693
365
  // through the `out`/`err` line-functions every other command here uses, so
@@ -109,6 +109,7 @@ import { scanSecrets, redactSecrets } from './validate.js';
109
109
  import { detectLanguage } from './detectlang.js';
110
110
  import { prepareSource, proveDestination, looksLikeRepoSource, missingDependencies } from './source.js';
111
111
  import { parseCeiling, ceilingLine } from './authorreadout.js';
112
+ import { commandFor } from './invoke.js';
112
113
 
113
114
  /** Thrown to unwind `main` to an exit code without ever calling
114
115
  * `process.exit()` (constraint: a library function returns a code / throws,
@@ -157,7 +158,7 @@ export async function main(argv, deps = {}) {
157
158
  const stderr = deps.stderr ?? process.stderr;
158
159
  if (keys.warning && deps.env === undefined) stderr.write(`WARNING: ${keys.warning}\n`);
159
160
  const spawnSyncFn = deps.spawnSync ?? realSpawnSync;
160
- const invokedAs = deps.invokedAs ?? 'node scripts/run-interview.mjs';
161
+ const invokedAs = deps.invokedAs ?? commandFor('interview', undefined);
161
162
  const out = (/** @type {string} */ s = '') => { stdout.write(`${s}\n`); };
162
163
  const err = (/** @type {string} */ s) => { stderr.write(`${s}\n`); };
163
164
  const arg = (/** @type {string} */ n) => { const i = argv.indexOf(`--${n}`); return i === -1 ? null : (argv[i + 1] ?? ''); };
@@ -721,7 +722,8 @@ export async function main(argv, deps = {}) {
721
722
  }
722
723
 
723
724
  // ── 6. the paid step, which is a DIFFERENT process under a DIFFERENT ceiling ─
724
- const RUN_AUTHOR = fileURLToPath(new URL('../scripts/run-author.mjs', import.meta.url));
725
+ // the PACKAGED door (`bin/` ships; `scripts/` does not): `bareloop author` takes the same flags as run-author
726
+ const BARELOOP_BIN = fileURLToPath(new URL('../bin/bareloop.mjs', import.meta.url));
725
727
  const childArgs = [
726
728
  '--source', TREE, '--answers', answersFile, '--draft', draftFile,
727
729
  '--verdict', VERDICT, '--out', OUT,
@@ -754,7 +756,7 @@ export async function main(argv, deps = {}) {
754
756
  let providerEntry = null;
755
757
  try { providerEntry = resolveProvider(PROVIDER); } catch { providerEntry = null; }
756
758
  const providerEnvKey = providerEntry?.envKey ?? null;
757
- say(` ${providerEnvKey ?? 'YOUR_PROVIDER_API_KEY'}=... node scripts/run-author.mjs ${childArgs.join(' ')}`);
759
+ say(` ${providerEnvKey ?? 'YOUR_PROVIDER_API_KEY'}=... ${commandFor('author', deps.invokedAs)} ${childArgs.join(' ')}`);
758
760
  // An `openai-api` + `baseUrl` draft (DeepSeek, today's one secondary, reached
759
761
  // this way) still reads its key from `OPENAI_API_KEY` — there is no separate
760
762
  // "DeepSeek key" env var, and a person pointed only at the command above could
@@ -831,7 +833,7 @@ export async function main(argv, deps = {}) {
831
833
  exitCode = 0;
832
834
  } else {
833
835
  say('');
834
- const child = spawnSyncFn(process.execPath, [RUN_AUTHOR, ...childArgs], { stdio: 'inherit' });
836
+ const child = spawnSyncFn(process.execPath, [BARELOOP_BIN, 'author', ...childArgs], { stdio: 'inherit' });
835
837
  if (child.error) {
836
838
  say(`run-author could not be started (${child.error.message}) — the command above still stands.`);
837
839
  exitCode = 2;
package/src/invoke.js ADDED
@@ -0,0 +1,22 @@
1
+ // The one owner of how a printed re-invocation is spelled. The package ships `src/` and `bin/`
2
+ // only, so a person who reached a flow through the `bareloop` CLI must be told
3
+ // `bareloop run-u ...`, never `node scripts/run-u.mjs ...` (a path their install does not have).
4
+ // Each flow's `main(argv, deps)` takes `deps.invokedAs` — the command it was actually reached
5
+ // through (`src/cli.js` passes `'bareloop <name>'`; the `scripts/*.mjs` adapters pass none).
6
+
7
+ /** @typedef {'run-u' | 'interview' | 'author'} Flow */
8
+
9
+ /** the script adapter each flow keeps for a source-tree run */
10
+ const SCRIPT = /** @type {const} */ ({ 'run-u': 'run-u', interview: 'run-interview', author: 'run-author' });
11
+
12
+ /**
13
+ * The command that runs `flow`, spelled the way the caller reached this flow: `bareloop <flow>`
14
+ * when `invokedAs` names the CLI, else the source-tree script.
15
+ * @param {Flow} flow @param {string|undefined} invokedAs
16
+ * @returns {string}
17
+ */
18
+ export function commandFor(flow, invokedAs) {
19
+ return invokedAs !== undefined && /^bareloop(\s|$)/.test(invokedAs)
20
+ ? `bareloop ${flow}`
21
+ : `node scripts/${SCRIPT[flow]}.mjs`;
22
+ }
package/src/job.js CHANGED
@@ -289,8 +289,8 @@ export function validateJob(input, { shellCapUsd = 2 } = {}) {
289
289
  }
290
290
 
291
291
  if (spec.budgetUsd === undefined) red('missing-required', 'budgetUsd');
292
- else if (!(typeof spec.budgetUsd === 'number' && spec.budgetUsd > 0 && spec.budgetUsd <= shellCapUsd)) {
293
- red('bounds', 'budgetUsd', `0 < budget <= shell cap ${shellCapUsd} (cap-not-estimate; no self-adjusted budgets, ever)`);
292
+ else if (!(typeof spec.budgetUsd === 'number' && Number.isFinite(spec.budgetUsd) && spec.budgetUsd > 0 && spec.budgetUsd <= shellCapUsd)) {
293
+ red('bounds', 'budgetUsd', `a finite 0 < budget <= shell cap ${shellCapUsd} (cap-not-estimate; no self-adjusted budgets, ever)`);
294
294
  }
295
295
 
296
296
  // T (PRD v1.27/v1.29) — TIME, with exactly budgetUsd's status: operator input,
package/src/judged.js CHANGED
@@ -791,10 +791,30 @@ function topLevelFunctionNames(artifactText) {
791
791
  * A `Loop`, NOT `provider.generate()`: the POC measured that a bare provider
792
792
  * result carries no `costUsd` at all, and under F6 an unpriced call is a red,
793
793
  * so the priced seam is the only legal one.
794
- * @param {{provider: any, system: string}} o */
795
- export const defaultJudgeLoop = ({ provider, system }) => {
794
+ * `rates` is the customer's own price for the judge's row (USD per 1K, `ratesFor`'s
795
+ * `rates`); null/absent = bare-agent's built-in guess, exactly as before.
796
+ * @param {{provider: any, system: string, rates?: {in: number, out: number}|null}} o
797
+ * @returns {{run: (msgs: any[], tools: any[], opts?: any) => Promise<any>}} */
798
+ export const defaultJudgeLoop = ({ provider, system, rates = null }) => {
796
799
  const { Loop } = require('bare-agent');
797
- return new Loop({ provider, system });
800
+ // BA-21: the Loop's run RESULT carries no `rateSource` — only the per-call metering event
801
+ // does (`onLlmResult`). Capture it there and ride it on the result, so `runLocate` can
802
+ // forward it to the judge-round record beside `costUsd`. Absent (no round metered) = no key.
803
+ /** @type {{rateSource: string|null}|null} */
804
+ let seen = null;
805
+ const loop = new Loop({
806
+ provider, system, ...(rates ? { rates } : {}),
807
+ onLlmResult: (/** @type {{rateSource?: string|null}} */ m) => {
808
+ if (m && typeof m === 'object' && 'rateSource' in m) seen = { rateSource: m.rateSource ?? null };
809
+ },
810
+ });
811
+ return {
812
+ run: async (/** @type {any[]} */ msgs, /** @type {any[]} */ tools, /** @type {any} */ opts) => {
813
+ seen = null;
814
+ const res = await loop.run(msgs, tools, opts);
815
+ return res && typeof res === 'object' && seen ? { ...res, rateSource: /** @type {any} */ (seen).rateSource } : res;
816
+ },
817
+ };
798
818
  };
799
819
 
800
820
  // ── LOCATE: one attempt ─────────────────────────────────────────────────────
@@ -819,7 +839,7 @@ export const defaultJudgeLoop = ({ provider, system }) => {
819
839
  * On every route out, red or clean, `onCost` fires exactly once with the honest
820
840
  * read: a paid call that leaves no meter record is F12 wearing a judge's coat.
821
841
  * @param {{artifactText: string, card: any, loopFactory: (o: {provider?: any, system: string}) => any,
822
- * maxTokens?: number, attempt?: number, onCost?: (c: {costUsd: number|null, unpricedRounds: number}) => void,
842
+ * maxTokens?: number, attempt?: number, onCost?: (c: {costUsd: number|null, unpricedRounds: number, rateSource?: string|null}) => void,
823
843
  * callBounds?: {timeoutMs?: number, deadlineMs?: number}}} o
824
844
  * @returns {Promise<{ok: boolean, facts: any|null, red: {axis: string, detail: string}|null,
825
845
  * costUsd: number|null, unpricedRounds: number, truncated: boolean, parseError: boolean,
@@ -850,7 +870,9 @@ export async function runLocate({
850
870
  /** @param {{axis: string, detail: string}|null} red @param {any} facts @param {any} r @param {boolean} truncated @param {boolean} parseError */
851
871
  const out = (red, facts, r, truncated, parseError) => {
852
872
  const { costUsd, unpricedRounds } = r === null ? { costUsd: null, unpricedRounds: 0 } : priceOf(r);
853
- onCost({ costUsd, unpricedRounds });
873
+ // BA-21: the round's provenance rides beside the cost when the loop carried it (see
874
+ // `defaultJudgeLoop`); a loop that never metered a rate adds no key.
875
+ onCost({ costUsd, unpricedRounds, ...(r && typeof r === 'object' && 'rateSource' in r ? { rateSource: r.rateSource } : {}) });
854
876
  return {
855
877
  ok: red === null, facts, red, costUsd, unpricedRounds, truncated, parseError,
856
878
  raw: scrubRaw({
package/src/kinds.js CHANGED
@@ -1871,6 +1871,7 @@ async function runJudgedFloor(stage, ctx) {
1871
1871
  ctx.onJudgeCost({
1872
1872
  stage: stage?.name ?? null, kind: stage?.kind ?? null, path: rel, attempt,
1873
1873
  label: LOCATE_LABEL, model: judgeModel, costUsd: c.costUsd, unpricedRounds: c.unpricedRounds,
1874
+ ...('rateSource' in c ? { rateSource: c.rateSource } : {}),
1874
1875
  });
1875
1876
  },
1876
1877
  });
package/src/ledger.js CHANGED
@@ -410,10 +410,9 @@ export function updateLedger({ ledgerFile, spineFiles }) {
410
410
  // and the plan flow forwards it verbatim onto `worker-round`/`judge-round`/`worker-turn`
411
411
  // (`rateSourceFields`, src/planrun.js — the write side).
412
412
  //
413
- // NAMED rather than papered over: `judge-round`'s own payload (src/kinds.js's
414
- // `onJudgeCost({...})`) does not carry `rateSource` yet, so every judge round reads
415
- // UNKNOWN provenance today — correctly, by the same rule that governs the archive, and
416
- // the day that payload forwards the field the emit site already spreads it.
413
+ // `judge-round`'s payload (src/kinds.js's `onJudgeCost({...})`) forwards `rateSource` from the
414
+ // judge Loop's per-call metering (`defaultJudgeLoop`, src/judged.js); a judge round whose loop
415
+ // carried none — and every judge round archived before this — reads UNKNOWN, by the archive rule.
417
416
  //
418
417
  // `pricing` is UNCHANGED and still strictly two-valued ('priced'|'unpriced'). Nothing
419
418
  // below reads it, nothing below decides anything, and no new escalation category exists: