bareloop 0.31.0 → 0.33.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. package/CHANGELOG.md +151 -0
  2. package/README.md +4 -1
  3. package/bareloop.context.md +232 -85
  4. package/bin/bareloop.mjs +0 -1
  5. package/package.json +1 -1
  6. package/src/authorflow.js +5 -3
  7. package/src/authorjob.js +3 -1
  8. package/src/authorrun.js +49 -11
  9. package/src/authorscout.js +10 -6
  10. package/src/bundlerun.js +266 -0
  11. package/src/cli.js +35 -357
  12. package/src/config.js +135 -0
  13. package/src/interviewrun.js +13 -4
  14. package/src/invoke.js +22 -0
  15. package/src/judged.js +5 -3
  16. package/src/keysfile.js +156 -0
  17. package/src/ledger.js +26 -0
  18. package/src/monthly.js +422 -0
  19. package/src/panel/authorroutes.js +61 -16
  20. package/src/panel/authorsession.js +46 -28
  21. package/src/panel/index.html +287 -13
  22. package/src/panel/server.js +58 -51
  23. package/src/panel/settingsroutes.js +230 -0
  24. package/src/planrun.js +7 -3
  25. package/src/providerrows.js +301 -0
  26. package/src/providers.js +3 -1
  27. package/src/run.js +7 -2
  28. package/src/runlist.js +92 -7
  29. package/src/source.js +8 -3
  30. package/src/u-watchdog.mjs +296 -0
  31. package/src/userrun.js +230 -54
  32. package/types/authorflow.d.ts +8 -2
  33. package/types/authorjob.d.ts +6 -1
  34. package/types/authorrun.d.ts +5 -0
  35. package/types/authorscout.d.ts +11 -2
  36. package/types/bundlerun.d.ts +26 -0
  37. package/types/cli.d.ts +2 -1
  38. package/types/config.d.ts +48 -0
  39. package/types/interviewrun.d.ts +5 -0
  40. package/types/invoke.d.ts +8 -0
  41. package/types/judged.d.ts +5 -1
  42. package/types/keysfile.d.ts +90 -0
  43. package/types/ledger.d.ts +14 -0
  44. package/types/monthly.d.ts +227 -0
  45. package/types/panel/authorroutes.d.ts +4 -2
  46. package/types/panel/authorsession.d.ts +7 -15
  47. package/types/panel/server.d.ts +10 -3
  48. package/types/panel/settingsroutes.d.ts +14 -0
  49. package/types/planrun.d.ts +13 -1
  50. package/types/providerrows.d.ts +229 -0
  51. package/types/run.d.ts +14 -1
  52. package/types/runlist.d.ts +66 -3
  53. package/types/source.d.ts +8 -3
  54. package/types/userrun.d.ts +54 -0
package/src/authorrun.js CHANGED
@@ -100,6 +100,9 @@
100
100
  // item 34 L17) — `run-interview.mjs` asks for it and writes it
101
101
  // in; a draft missing one, or naming one the provider factory
102
102
  // does not know, dies here loud, listing the known table.
103
+ import { keysForDoor } from './keysfile.js';
104
+ import { applyConfiguredKey, judgeRatesFor, keyNameFor, ratesFor, rowsForHome } from './providerrows.js';
105
+ import { ConfigError } from './config.js';
103
106
  import {
104
107
  readFileSync, writeFileSync, appendFileSync, mkdirSync, existsSync, renameSync, statSync,
105
108
  } from 'node:fs';
@@ -119,6 +122,7 @@ import { scanSecrets, redactSecrets } from './validate.js';
119
122
  import { detectLanguage } from './detectlang.js';
120
123
  import { closeJudges, GATE_AUDIT_FILE } from './kinds.js';
121
124
  import { resolveProvider, buildRunnerProviders, apiKeyProblem } from './providers.js';
125
+ import { commandFor } from './invoke.js';
122
126
  import { readSourceManifest, missingDependencies } from './source.js';
123
127
  import { tallyCalls } from './text.js';
124
128
  import {
@@ -141,6 +145,7 @@ class ExitSignal extends Error {
141
145
  /**
142
146
  * @typedef {object} Deps
143
147
  * @property {Record<string,string|undefined>} [env]
148
+ * @property {string} [keysHome] P4a — test seam: load this home's `.env` keys file even when `env` is injected.
144
149
  * @property {NodeJS.ReadStream} [stdin]
145
150
  * @property {NodeJS.WritableStream} [stdout]
146
151
  * @property {NodeJS.WritableStream} [stderr]
@@ -162,13 +167,16 @@ class ExitSignal extends Error {
162
167
  * @returns {Promise<number>} an exit code — never calls process.exit
163
168
  */
164
169
  export async function main(argv, deps = {}) {
165
- const env = deps.env ?? process.env;
170
+ // P4a item 1 — the keys file fills what the shell leaves unset (shell wins).
171
+ const keys = keysForDoor(deps);
172
+ const env = keys.env;
166
173
  const stdin = deps.stdin ?? process.stdin;
167
174
  const stdout = deps.stdout ?? process.stdout;
168
175
  const stderr = deps.stderr ?? process.stderr;
176
+ if (keys.warning && deps.env === undefined) stderr.write(`WARNING: ${keys.warning}\n`);
169
177
  const out = (/** @type {string} */ s = '') => { stdout.write(`${s}\n`); };
170
178
  const err = (/** @type {string} */ s) => { stderr.write(`${s}\n`); };
171
- const invokedAs = deps.invokedAs ?? 'node scripts/run-author.mjs';
179
+ const invokedAs = deps.invokedAs ?? commandFor('author', undefined);
172
180
  let exitCode = 0;
173
181
  try {
174
182
  /** the close precheck / seed read spawns real toolchains; the slowest stage is a
@@ -499,8 +507,12 @@ export async function main(argv, deps = {}) {
499
507
 
500
508
  // Secrets load from the environment; they never enter argv (a command line is
501
509
  // world-readable on /proc) and they are never printed.
502
- const AUTHOR_ENV_KEY = /** @type {NonNullable<typeof providerEntry>} */ (providerEntry).envKey;
503
- const apiKey = env[AUTHOR_ENV_KEY];
510
+ // P4a item 4 — the key variable the person picked in Settings (config.json) stands in for
511
+ // the provider's built-in one; none picked = the built-in. Names only.
512
+ const keyCfg = rowsForHome(keys.home);
513
+ const authorEnv = applyConfiguredKey(env, PROVIDER_NAME, baseUrl, keyCfg);
514
+ const AUTHOR_ENV_KEY = keyNameFor(PROVIDER_NAME, baseUrl, keyCfg).name;
515
+ const apiKey = authorEnv[/** @type {NonNullable<typeof providerEntry>} */ (providerEntry).envKey];
504
516
  if (!apiKey) { err(`${AUTHOR_ENV_KEY} not set (secrets load from the environment — never the tree, never argv)`); throw new ExitSignal(2); }
505
517
  // F181 — a key that carries a line break/control char/stray whitespace (a
506
518
  // two-line secret-store entry, e.g.) reads as "set" by the presence check
@@ -509,12 +521,22 @@ export async function main(argv, deps = {}) {
509
521
  // echoes the value or the reason's source.
510
522
  const AUTHOR_KEY_PROBLEM = apiKeyProblem(apiKey);
511
523
  if (AUTHOR_KEY_PROBLEM) { err(`${AUTHOR_ENV_KEY} ${AUTHOR_KEY_PROBLEM} — refusing rather than crashing mid-call (never trimmed or repaired; fix the value at its source)`); throw new ExitSignal(2); }
524
+ // The customer's own price on the author's key row (USD per 1M in config.json) — resolved once here at
525
+ // $0, before any provider is built, and handed to every model call this run makes (scout, declaration
526
+ // loop, confirm turn, calibration judge). A bad price refuses here; it never falls back to the guess.
527
+ /** @type {ReturnType<typeof ratesFor>} */
528
+ let authorPrice = null;
529
+ try { authorPrice = ratesFor(PROVIDER_NAME, baseUrl, keyCfg); } catch (e) {
530
+ if (!(e instanceof ConfigError)) throw e;
531
+ err(`${e.message} — refusing to start rather than guess a price. Nothing spent.`);
532
+ throw new ExitSignal(2);
533
+ }
512
534
  /** The judge's key follows the RESOLVED judge provider's own env var, with
513
535
  * `JUDGE_API_KEY` as the role-named override in front (PRD item 32.3) — the same
514
536
  * contract `scripts/run-u.mjs` keeps, so one story covers both surfaces. When the
515
537
  * judge IS the authoring provider this is the key already read above. */
516
538
  const judgeKeyFor = (/** @type {string} */ providerName) => (
517
- env.JUDGE_API_KEY ?? env[resolveProvider(providerName).envKey]
539
+ env.JUDGE_API_KEY ?? (providerName === PROVIDER_NAME ? authorEnv : applyConfiguredKey(env, providerName, undefined, keyCfg))[resolveProvider(providerName).envKey]
518
540
  );
519
541
 
520
542
  /** F6 — an unpriced call makes the TOTAL unknown, and unknown is reported as
@@ -789,7 +811,7 @@ export async function main(argv, deps = {}) {
789
811
  * bound to `CONFIRM_SYSTEM`, never the authoring `generate` above (that one
790
812
  * is bound to `AUTHOR_SYSTEM`). Same provider instance, a different system
791
813
  * prompt: reusing `generate` would run the wrong system prompt silently. */
792
- const confirmGenerate = makeLoopGenerate(provider, { system: CONFIRM_SYSTEM });
814
+ const confirmGenerate = makeLoopGenerate(provider, { system: CONFIRM_SYSTEM, rates: authorPrice?.rates ?? null });
793
815
 
794
816
  // ── PLAIN-FOLDER SOURCE: A NAMED $0 STOP, NO MODEL CALL (F191; PRD item 33 M3
795
817
  // piece 4, step S6, D5 amended 2026-09-21) ──────────────────────────────────
@@ -858,7 +880,8 @@ export async function main(argv, deps = {}) {
858
880
  // malformed field travels as `null` and `authorPrompt` simply states nothing.
859
881
  writeScope: Array.isArray(draft.writeScope) ? draft.writeScope : null,
860
882
  provider,
861
- generate: makeLoopGenerate(provider),
883
+ generate: makeLoopGenerate(provider, { rates: authorPrice?.rates ?? null }),
884
+ rates: authorPrice?.rates ?? null,
862
885
  // ONE number, both paid seams (the survey's and the declaration loop's) — the
863
886
  // advertised ceiling and the enforced ceiling are the same ceiling
864
887
  ceilingUsd: CEILING_USD,
@@ -1051,6 +1074,21 @@ export async function main(argv, deps = {}) {
1051
1074
  const judge = judges
1052
1075
  ? resolveJobJudge(spec, PROVIDER_NAME, resolveWorkerModel)
1053
1076
  : null;
1077
+ // the judge's price: the judge model's own row (`judgeRatesFor`) — on the author's endpoint when the
1078
+ // judge is the same provider, else the row for the judge's own provider. A bad one refuses here,
1079
+ // before the calibration gate spends anything — beside the judge-key check, the same door.
1080
+ /** @type {ReturnType<typeof ratesFor>} */
1081
+ let judgePrice = null;
1082
+ if (judge) {
1083
+ try {
1084
+ judgePrice = judgeRatesFor(judge, PROVIDER_NAME, baseUrl, authorPrice, keyCfg);
1085
+ } catch (e) {
1086
+ if (!(e instanceof ConfigError)) throw e;
1087
+ err(`${e.message} — refusing before the calibration gate spends anything`);
1088
+ archiveGateAudit();
1089
+ throw new ExitSignal(2);
1090
+ }
1091
+ }
1054
1092
  // F181 — the judge key is required exactly when `judges` is true (this
1055
1093
  // script has no presence check on it today; adding one is out of this
1056
1094
  // finding's scope). What this door DOES owe, the same as the worker
@@ -1120,7 +1158,7 @@ export async function main(argv, deps = {}) {
1120
1158
  // number would silently widen it.
1121
1159
  ceilingUsd: CEILING_USD,
1122
1160
  priorCalls: [...metered],
1123
- judgeLoop: judgeProvider ? (o) => defaultJudgeLoop({ provider: judgeProvider, system: o.system }) : null,
1161
+ judgeLoop: judgeProvider ? (o) => defaultJudgeLoop({ provider: judgeProvider, system: o.system, rates: judgePrice?.rates ?? null }) : null,
1124
1162
  // the seam's other half (PRD item 32.1): the graded set is STAMPED with
1125
1163
  // this, and the close later refuses to grade under any other identity.
1126
1164
  judgeModel: judge?.model ?? null,
@@ -1225,8 +1263,8 @@ export async function main(argv, deps = {}) {
1225
1263
  // <path>` and reads this SAME prepared copy's own source manifest for
1226
1264
  // the workdir and seed — so the command below is the whole of what a
1227
1265
  // person needs to run their own job, nothing left to hand off.
1228
- // `providerEntry.envKey` (never a hardcoded ANTHROPIC_API_KEY) is the
1229
- // same F187 rule scripts/run-u.mjs's own hint already follows.
1266
+ // `keyNameFor` (never a hardcoded ANTHROPIC_API_KEY, never the provider's built-in name
1267
+ // when the job's key row names another) is the same F187 rule run-u's own hint follows.
1230
1268
  // hamr's ruling 2026-09-28 (2nd addendum) — `totalCost.costUsd === null`
1231
1269
  // means this pipeline's own metered list carried at least one unpriced
1232
1270
  // call (`tallyCalls`'s `spendComplete: false`), so the drafting fold is
@@ -1239,7 +1277,7 @@ export async function main(argv, deps = {}) {
1239
1277
  + 'and run-u will enforce the remainder (Cap $ minus this) as ITS ceiling — pass --draft-spent-usd exactly as shown below, never a rounded or re-typed figure.');
1240
1278
  }
1241
1279
  out('\nTo run it (the same signature and gates as any other job — nothing here bypasses them):');
1242
- out(` ${providerEntry.envKey}=... node scripts/run-u.mjs --spec ${specFile} --approve ${hash}${draftSpentUsd > 0 ? ` --draft-spent-usd ${draftSpentUsd}${draftIncomplete ? ' --draft-spend-incomplete' : ''}` : ''}`);
1280
+ out(` ${keyNameFor(PROVIDER_NAME, baseUrl, keyCfg, spec.model).name}=... ${commandFor('run-u', deps.invokedAs)} --spec ${specFile} --approve ${hash}${draftSpentUsd > 0 ? ` --draft-spent-usd ${draftSpentUsd}${draftIncomplete ? ' --draft-spend-incomplete' : ''}` : ''}`);
1243
1281
  emit('author-end', { outcome: 'prepared', specHash: hash });
1244
1282
  }
1245
1283
  }
@@ -342,8 +342,8 @@ export async function defaultSurveyor({ workdir, granted, auditPath = join(workd
342
342
  }
343
343
 
344
344
  /** the default loop seam — one spelling of how this repo drives a model */
345
- const defaultLoop = (/** @type {any} */ { system, policy, onLlmResult, provider }) =>
346
- new Loop({ provider, system, policy, onLlmResult });
345
+ const defaultLoop = (/** @type {any} */ { system, policy, onLlmResult, provider, rates }) =>
346
+ new Loop({ provider, system, policy, onLlmResult, ...(rates ? { rates } : {}) });
347
347
 
348
348
  /**
349
349
  * Run the authoring scout.
@@ -376,7 +376,8 @@ const defaultLoop = (/** @type {any} */ { system, policy, onLlmResult, provider
376
376
  * rounds?: number, minBytes?: number, blobMax?: number, maxTokens?: number, ctx?: boolean,
377
377
  * attempts?: number, ceilingUsd?: number|null, callTimeoutMs?: number,
378
378
  * onCall?: (call: {label: string, costUsd: number|null, unpricedRounds: number}) => void,
379
- * createLoop?: (o: {system: string, policy: any, onLlmResult: any, provider: any}) => any,
379
+ * createLoop?: (o: {system: string, policy: any, onLlmResult: any, provider: any, rates?: {in: number, out: number}}) => any,
380
+ * rates?: {in: number, out: number}|null,
380
381
  * createSurveyor?: (o: {workdir: string, granted: readonly string[], ctx: boolean}) => Promise<any>}} o
381
382
  * @returns {Promise<Survey & {raw: string, raws: ReturnType<typeof scrubRaw>[],
382
383
  * calls: {label: string, costUsd: number|null, unpricedRounds: number}[],
@@ -398,6 +399,7 @@ export async function runAuthorScout({
398
399
  callTimeoutMs = AUTHOR_CALL_TIMEOUT_MS,
399
400
  onCall = () => {},
400
401
  createLoop = defaultLoop,
402
+ rates = null,
401
403
  createSurveyor = defaultSurveyor,
402
404
  }) {
403
405
  const callTimeout = Number.isFinite(Number(callTimeoutMs)) && Number(callTimeoutMs) > 0
@@ -406,6 +408,8 @@ export async function runAuthorScout({
406
408
  const granted = AUTHOR_SCOUT_VERBS;
407
409
  const system = SCOUT_SYSTEM + strategyFor([...granted]);
408
410
  const surveyor = await createSurveyor({ workdir, granted, ctx });
411
+ // the customer's own price for this provider's key row (USD per 1K, `ratesFor`); absent = the built-in guess
412
+ const priced = rates ? { rates } : {};
409
413
  /** @type {{label: string, costUsd: number|null, unpricedRounds: number}[]} */
410
414
  const calls = [];
411
415
  /** @type {ReturnType<typeof scrubRaw>[]} */
@@ -519,7 +523,7 @@ export async function runAuthorScout({
519
523
  }
520
524
  return surveyor.onLlmResult ? surveyor.onLlmResult(arg) : undefined;
521
525
  };
522
- loop = createLoop({ system, policy: surveyor.policy, onLlmResult: metered, provider });
526
+ loop = createLoop({ system, policy: surveyor.policy, onLlmResult: metered, provider, ...priced });
523
527
 
524
528
  const r = await settled(loop.run([{ role: 'user', content: scoutPrompt(workdir) }], surveyor.tools,
525
529
  { cacheMessages: true, maxTokens, timeoutMs: callTimeout }));
@@ -558,7 +562,7 @@ export async function runAuthorScout({
558
562
  const recoveryHalt = recoveryWanted ? capStop({ ceilingUsd, ...tallyCalls(calls) }) : null;
559
563
  if (recoveryHalt) budgetStop = recoveryHalt;
560
564
  if (recoveryWanted && !recoveryHalt) {
561
- const recovery = createLoop({ system, policy: surveyor.policy, onLlmResult: surveyor.onLlmResult, provider });
565
+ const recovery = createLoop({ system, policy: surveyor.policy, onLlmResult: surveyor.onLlmResult, provider, ...priced });
562
566
  // F147 (second instance; sibling fix: src/planrun.js `askFrom`, commit
563
567
  // 031ffe1): `r.msgs` is a prior `loop.run()`'s returned transcript,
564
568
  // which bare-agent already prepended `system` to (Loop.run, loop.js
@@ -591,7 +595,7 @@ export async function runAuthorScout({
591
595
  } else {
592
596
  // THE RE-ASK. Toolless by construction, over the survey's own
593
597
  // conversation, naming only what failed mechanically.
594
- const reask = createLoop({ system, policy: surveyor.policy, onLlmResult: surveyor.onLlmResult, provider });
598
+ const reask = createLoop({ system, policy: surveyor.policy, onLlmResult: surveyor.onLlmResult, provider, ...priced });
595
599
  // F147 (second instance; sibling fix: src/planrun.js `askFrom`, commit
596
600
  // 031ffe1): `lastMsgs` is a prior `loop.run()`'s returned transcript
597
601
  // (system already prepended by bare-agent); `reask` is built with the
@@ -0,0 +1,266 @@
1
+ // One runner (`feat/one-runner`) — `bareloop run <bundle> --repo <path>` is a thin DOOR to
2
+ // the same engine `bareloop run-u` drives (`src/userrun.js`), not a second runner. This
3
+ // module does only what is bundle-specific and $0: the bundle's own integrity/deps/
4
+ // envelope/blessing checks, the fresh git worktree (planned here, CREATED by the engine's
5
+ // `prepareTree` hook once its own refusals — keys, monthly limit — have passed), the
6
+ // tightened spec and its hash, and, after the engine returns, the bundle's history row,
7
+ // its first-green blessing and the merge hints. Providers, keys, judge, monthly limit,
8
+ // watchdog, readout and run list are the engine's — nothing here talks to a model.
9
+ //
10
+ // The signature the engine checks is `approve === jobSpecHash(spec)`. A bundle's human
11
+ // signature is its `bundleHash` (checked here, step 3); the executed spec is the tightened,
12
+ // `$BARELOOP_BUNDLE`-resolved one, so the door hands the engine the hash of exactly that
13
+ // spec — the equality stays a real check (the spec the engine sees is the spec hashed).
14
+ // `bundleHash` never changes when the runner tightens; `history.jsonl` records both hashes.
15
+
16
+ import { hostname } from 'node:os';
17
+ import { execFileSync } from 'node:child_process';
18
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
19
+ import { dirname, join, resolve } from 'node:path';
20
+
21
+ import {
22
+ readBundle, resolveBundleSpec, checkEnvelope, bless, verifyBlessing, appendHistory, checkBundleDeps,
23
+ } from './bundle.js';
24
+ import { jobSpecHash } from './job.js';
25
+ import { parseJsonl } from './replayio.js';
26
+ import { startRun, resumeRun } from './userrun.js';
27
+
28
+ /**
29
+ * `bareloop run <bundleDir> --repo <path> [--budget N] [--wall MIN] [--approve <bundleHash>]`
30
+ * `bareloop run <bundleDir> --resume <runid> [--repo <path>] [--budget N] [--wall MIN] [--approve <bundleHash>]`
31
+ * @param {string[]} args
32
+ * @param {{
33
+ * out: (s: string) => void, err: (s: string) => void, cwd: string,
34
+ * env: Record<string,string|undefined>, now: () => number, deps: any, keysHome?: string,
35
+ * parseFlags: (a: string[]) => { positional: string[], flags: Record<string, string|true> },
36
+ * printReds: (reds: import('./bundle.js').Red[], err: (s: string) => void) => void,
37
+ * }} ctx
38
+ * @returns {Promise<number>}
39
+ */
40
+ export async function bundleMain(args, {
41
+ out, err, cwd, env, now, deps, keysHome, parseFlags, printReds,
42
+ }) {
43
+ const { positional, flags } = parseFlags(args);
44
+ const bundleDirArg = positional[0];
45
+ const repoArg = typeof flags.repo === 'string' ? flags.repo : undefined;
46
+ const resumeArg = typeof flags.resume === 'string' ? flags.resume : undefined;
47
+ if (flags.resume === true || (!bundleDirArg) || (resumeArg === undefined && !repoArg)) {
48
+ err('usage: bareloop run <bundleDir> --repo <path> [--budget N] [--wall MIN] [--approve <bundleHash>]\n'
49
+ + ' bareloop run <bundleDir> --resume <runid> [--repo <path>] [--budget N] [--wall MIN] [--approve <bundleHash>]');
50
+ return 1;
51
+ }
52
+ const bundleDir = resolve(cwd, bundleDirArg);
53
+
54
+ // 1. readBundle — this must complete, clean, before anything else touches the bundle or
55
+ // the repo. Any red (incl. bundle-tampered) stops here.
56
+ const bundle = readBundle(bundleDir);
57
+ if (!bundle.ok) { printReds(bundle.reds, err); return 1; }
58
+
59
+ // 1b. F128 — the bundle's OWN node_modules must carry `bareloop` before anything else
60
+ // runs. Left unchecked, the close crashes deep inside runJob's close-first precheck with
61
+ // a bare ERR_MODULE_NOT_FOUND, reported as an unhelpful generic close-red with no cure.
62
+ const depsCheck = checkBundleDeps(bundleDir);
63
+ if (!depsCheck.ok) { printReds(depsCheck.reds, err); return 1; }
64
+
65
+ // 2. checkEnvelope — tighten-only, wall given in minutes -> ms.
66
+ /** @type {{ budgetUsd?: number, maxWallMs?: number }} */
67
+ const envelope = {};
68
+ if (flags.budget !== undefined) {
69
+ const n = Number(flags.budget);
70
+ if (!Number.isFinite(n)) { err(`--budget must be a number, got ${JSON.stringify(flags.budget)}`); return 1; }
71
+ envelope.budgetUsd = n;
72
+ }
73
+ if (flags.wall !== undefined) {
74
+ const n = Number(flags.wall);
75
+ if (!Number.isFinite(n)) { err(`--wall must be a number of minutes, got ${JSON.stringify(flags.wall)}`); return 1; }
76
+ envelope.maxWallMs = n * 60_000;
77
+ }
78
+ const envCheck = checkEnvelope(bundle.spec, envelope);
79
+ if (!envCheck.ok) { printReds(envCheck.reds, err); return 1; }
80
+
81
+ // 3. blessing — BEFORE any key is read (the engine reads the key last).
82
+ const { manifest } = bundle;
83
+ if (!bundle.blessing) {
84
+ // The runid that ACTUALLY minted this bundle's own signed spec — the bridge VERSION
85
+ // (base or shape fork; both ship into bridges/) whose recorded specHash equals the
86
+ // resolved bundle spec's jobSpecHash. NOT the bridge's first history row: a job can be
87
+ // re-exported/rebased many times, and only the version at THIS exact hash proves
88
+ // anything for THIS bundle.
89
+ const { approveHash: mintMatchHash } = resolveBundleSpec(bundle, bundleDir);
90
+ let mintRunid = null;
91
+ for (const b of bundle.bridges) {
92
+ const versions = Array.isArray(b.versions) ? b.versions : [];
93
+ const v = versions.find((/** @type {any} */ ver) => ver.specHash === mintMatchHash);
94
+ if (v) { mintRunid = v.runid; break; }
95
+ }
96
+ // the bundle's own README (the operator questions) belongs on this first-run screen:
97
+ // the engine, not this door, now refuses a missing key.
98
+ let readme = null;
99
+ try { readme = readFileSync(join(bundleDir, 'README.md'), 'utf8'); } catch { /* the hash alone */ }
100
+ if (readme) out(readme);
101
+ out('first run — this bundle has never been blessed on this machine.');
102
+ out(mintRunid ? `minting run: ${mintRunid} (spine not bundled in v1)` : 'no version at this hash');
103
+ out(`bundleHash: ${manifest.bundleHash}`);
104
+ if (flags.approve !== manifest.bundleHash) {
105
+ err(`--approve ${manifest.bundleHash} is required for the first run of an unblessed bundle`);
106
+ return 1;
107
+ }
108
+ } else {
109
+ const vb = verifyBlessing(bundle);
110
+ if (!vb.ok) { printReds(vb.reds, err); return 1; }
111
+ if (flags.approve) out('already blessed, --approve ignored');
112
+ }
113
+
114
+ // 4. the tree — refuse a non-repo, a repo with no commit, or a collision; ALWAYS fresh,
115
+ // never reused (a reused worktree reads last run's edits as "already-green" — the
116
+ // negative POC's exact finding). Only PLANNED here: the engine creates it (prepareTree)
117
+ // after its own $0 refusals, so a refusal leaves no worktree behind. A RESUME is the one
118
+ // exception: it re-enters the SAME worktree its halted run left (recorded in that run's
119
+ // `run.json`), and a gone tree is a stop, never a silent fresh start.
120
+ const runid = now().toString(36);
121
+ /** @type {string} */
122
+ let repo;
123
+ /** @type {string} */
124
+ let seed;
125
+ /** @type {string} */
126
+ let worktree;
127
+ /** @type {string|null} */
128
+ let deadSpine = null;
129
+ if (resumeArg !== undefined) {
130
+ if (!/^[a-z0-9]+$/.test(resumeArg)) { err(`--resume ${JSON.stringify(resumeArg)} is not a run id of this bundle`); return 1; }
131
+ const deadRunJson = join(bundleDir, 'runs', resumeArg, 'run.json');
132
+ /** @type {any} */
133
+ let rec;
134
+ try { rec = JSON.parse(readFileSync(deadRunJson, 'utf8')); } catch {
135
+ err(`--resume ${resumeArg}: no readable ${deadRunJson} — not a run of this bundle (or one that never started)`);
136
+ return 1;
137
+ }
138
+ if (typeof rec?.worktree !== 'string' || typeof rec?.seed !== 'string' || typeof rec?.repo !== 'string') {
139
+ err(`--resume ${resumeArg}: ${deadRunJson} does not record a worktree, seed and repo`);
140
+ return 1;
141
+ }
142
+ if (repoArg !== undefined && resolve(cwd, repoArg) !== rec.repo) {
143
+ err(`--repo ${repoArg} is not the repo run ${resumeArg} used (${rec.repo}) — a resume re-enters that run's own worktree`);
144
+ return 1;
145
+ }
146
+ if (!existsSync(rec.worktree)) {
147
+ err(`--resume ${resumeArg}: its worktree ${rec.worktree} is gone — a resume needs the tree the halted run left`);
148
+ return 1;
149
+ }
150
+ repo = rec.repo;
151
+ seed = rec.seed;
152
+ worktree = rec.worktree;
153
+ deadSpine = join(bundleDir, 'runs', resumeArg, 'spine.jsonl');
154
+ } else {
155
+ repo = resolve(cwd, /** @type {string} */ (repoArg));
156
+ try {
157
+ seed = execFileSync('git', ['-C', repo, 'rev-parse', 'HEAD'], { encoding: 'utf8' }).trim();
158
+ } catch (e) {
159
+ err(`--repo ${repoArg} must be a git repository with at least one commit: ${/** @type {Error} */ (e).message}`);
160
+ return 1;
161
+ }
162
+ worktree = join(repo, '.bareloop', 'wt', runid);
163
+ if (existsSync(worktree)) { err(`worktree already exists: ${worktree}`); return 1; }
164
+ }
165
+
166
+ // 5. resolve (in-memory $BARELOOP_BUNDLE substitution) + tighten. The envelope tightens
167
+ // the ACTUAL spec that runs (budgetUsd/maxWallMs have no separate override — the library
168
+ // reads them off the spec itself), so the hash is computed AFTER tightening: the door
169
+ // mints its own approval over the exact spec it hands the engine, never over the
170
+ // untightened one. The bundleHash a human approved never changes.
171
+ const runSpec = resolveBundleSpec(bundle, bundleDir).spec;
172
+ if (envelope.budgetUsd !== undefined) runSpec.budgetUsd = envelope.budgetUsd;
173
+ if (envelope.maxWallMs !== undefined) runSpec.maxWallMs = envelope.maxWallMs;
174
+ const approveHash = jobSpecHash(runSpec);
175
+
176
+ const runDir = join(bundleDir, 'runs', runid);
177
+ const spineFile = join(runDir, 'spine.jsonl');
178
+ // `run.json` is written by EVERY leg (a resume leg's points at the SAME worktree/seed): it is
179
+ // the bundle-local, always-present record of where the tree is — history.jsonl is written only
180
+ // at job-end (a killed run has none) and the run list is machine-global.
181
+ const prepareTree = () => {
182
+ if (deadSpine === null) {
183
+ mkdirSync(dirname(worktree), { recursive: true });
184
+ try {
185
+ execFileSync('git', ['-C', repo, 'worktree', 'add', '--detach', worktree, 'HEAD'], { encoding: 'utf8' });
186
+ } catch (e) {
187
+ throw new Error(`git worktree add failed: ${/** @type {Error} */ (e).message}`);
188
+ }
189
+ }
190
+ mkdirSync(runDir, { recursive: true });
191
+ writeFileSync(join(runDir, 'run.json'), `${JSON.stringify({
192
+ runid, worktree, seed, repo, at: new Date(now()).toISOString(), ...(resumeArg === undefined ? {} : { resumedFrom: resumeArg }),
193
+ }, null, 2)}\n`);
194
+ };
195
+
196
+ // 6. the engine. `deps` passes through exactly as `bareloop run-u` passes it, so every
197
+ // test seam (provider/providerFor/judgeProvider/runlistHome/keysHome) is the same one.
198
+ let engineCode;
199
+ try {
200
+ /** @type {import('./userrun.js').RunOpts} */
201
+ const opts = {
202
+ workdir: worktree,
203
+ seed,
204
+ approve: approveHash,
205
+ deps: { ...deps, env, out, err, ...(keysHome === undefined ? {} : { keysHome }) },
206
+ bundle: {
207
+ runid,
208
+ runDir,
209
+ // the bare re-invocation prefix; the leg's OWN tightened ceilings (only those the person passed) ride as
210
+ // DATA so the engine, which knows the halt kind and the spend, spells the printed line. Text only.
211
+ invoke: `bareloop run ${bundleDirArg} --repo ${repo}`,
212
+ tightened: {
213
+ ...(envelope.budgetUsd === undefined ? {} : { budgetUsd: envelope.budgetUsd }),
214
+ ...(flags.wall === undefined ? {} : { wallMin: Number(flags.wall) }),
215
+ },
216
+ signedBudgetUsd: bundle.spec.budgetUsd,
217
+ printApprove: manifest.bundleHash,
218
+ prepareTree,
219
+ },
220
+ };
221
+ engineCode = deadSpine === null ? await startRun(runSpec, opts) : await resumeRun(deadSpine, { ...opts, spec: runSpec });
222
+ } catch (e) {
223
+ err(`${/** @type {Error} */ (e).message}`);
224
+ return 1;
225
+ }
226
+
227
+ // 7. history + (on a first green) the blessing — off THIS run's own spine, tolerant of
228
+ // a torn line. No job-end (a $0 refusal, a crash) means no history row, as ever.
229
+ const events = existsSync(spineFile) ? parseJsonl(spineFile).records : [];
230
+ const je = events.findLast((/** @type {any} */ e) => e.type === 'job-end');
231
+ if (!je) return engineCode;
232
+ const outcome = je.outcome;
233
+ const spentUsd = je.spentUsd ?? null;
234
+ const spendComplete = je.spendComplete ?? null;
235
+ let branch = null;
236
+ try { branch = execFileSync('git', ['-C', worktree, 'rev-parse', '--abbrev-ref', 'HEAD'], { encoding: 'utf8' }).trim(); } catch { /* leave null — reported honestly below */ }
237
+ appendHistory(bundleDir, {
238
+ runid,
239
+ at: new Date(now()).toISOString(),
240
+ outcome,
241
+ spentUsd,
242
+ spendComplete,
243
+ budgetUsd: runSpec.budgetUsd,
244
+ maxWallMs: runSpec.maxWallMs ?? null,
245
+ worktree,
246
+ branch,
247
+ bundleHash: manifest.bundleHash,
248
+ approveHash,
249
+ ...(resumeArg === undefined ? {} : { resumedFrom: resumeArg }),
250
+ });
251
+ if (outcome === 'green' && !bundle.blessing) {
252
+ bless(bundleDir, {
253
+ bundleHash: manifest.bundleHash, blessedAt: new Date(now()).toISOString(), runid, outcome, host: hostname(),
254
+ });
255
+ }
256
+
257
+ // 8. the merge hints (the engine printed the outcome/spend readout).
258
+ out(`branch ${branch ?? 'UNKNOWN (could not read the worktree branch)'}`);
259
+ out(`worktree ${worktree}`);
260
+ if (branch) out(`merge git merge ${branch} (merge stays human — this CLI never merges)`);
261
+ out(`the worktree is kept until you remove it: git worktree remove ${worktree}`);
262
+ // exit 0 ONLY for a real green — every other outcome exits 1 so a caller scripting
263
+ // `bareloop run` off its exit code cannot mistake a red for a success. The engine's own
264
+ // 2 (refusal) / 3 (spine leak) are preserved.
265
+ return engineCode || (outcome === 'green' || outcome === 'already-green' ? 0 : 1);
266
+ }