bareloop 0.32.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.
package/src/authorrun.js CHANGED
@@ -101,7 +101,8 @@
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
103
  import { keysForDoor } from './keysfile.js';
104
- import { applyConfiguredKey, keyNameFor, rowsForHome } from './providerrows.js';
104
+ import { applyConfiguredKey, judgeRatesFor, keyNameFor, ratesFor, rowsForHome } from './providerrows.js';
105
+ import { ConfigError } from './config.js';
105
106
  import {
106
107
  readFileSync, writeFileSync, appendFileSync, mkdirSync, existsSync, renameSync, statSync,
107
108
  } from 'node:fs';
@@ -121,6 +122,7 @@ import { scanSecrets, redactSecrets } from './validate.js';
121
122
  import { detectLanguage } from './detectlang.js';
122
123
  import { closeJudges, GATE_AUDIT_FILE } from './kinds.js';
123
124
  import { resolveProvider, buildRunnerProviders, apiKeyProblem } from './providers.js';
125
+ import { commandFor } from './invoke.js';
124
126
  import { readSourceManifest, missingDependencies } from './source.js';
125
127
  import { tallyCalls } from './text.js';
126
128
  import {
@@ -174,7 +176,7 @@ export async function main(argv, deps = {}) {
174
176
  if (keys.warning && deps.env === undefined) stderr.write(`WARNING: ${keys.warning}\n`);
175
177
  const out = (/** @type {string} */ s = '') => { stdout.write(`${s}\n`); };
176
178
  const err = (/** @type {string} */ s) => { stderr.write(`${s}\n`); };
177
- const invokedAs = deps.invokedAs ?? 'node scripts/run-author.mjs';
179
+ const invokedAs = deps.invokedAs ?? commandFor('author', undefined);
178
180
  let exitCode = 0;
179
181
  try {
180
182
  /** the close precheck / seed read spawns real toolchains; the slowest stage is a
@@ -519,6 +521,16 @@ export async function main(argv, deps = {}) {
519
521
  // echoes the value or the reason's source.
520
522
  const AUTHOR_KEY_PROBLEM = apiKeyProblem(apiKey);
521
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
+ }
522
534
  /** The judge's key follows the RESOLVED judge provider's own env var, with
523
535
  * `JUDGE_API_KEY` as the role-named override in front (PRD item 32.3) — the same
524
536
  * contract `scripts/run-u.mjs` keeps, so one story covers both surfaces. When the
@@ -799,7 +811,7 @@ export async function main(argv, deps = {}) {
799
811
  * bound to `CONFIRM_SYSTEM`, never the authoring `generate` above (that one
800
812
  * is bound to `AUTHOR_SYSTEM`). Same provider instance, a different system
801
813
  * prompt: reusing `generate` would run the wrong system prompt silently. */
802
- const confirmGenerate = makeLoopGenerate(provider, { system: CONFIRM_SYSTEM });
814
+ const confirmGenerate = makeLoopGenerate(provider, { system: CONFIRM_SYSTEM, rates: authorPrice?.rates ?? null });
803
815
 
804
816
  // ── PLAIN-FOLDER SOURCE: A NAMED $0 STOP, NO MODEL CALL (F191; PRD item 33 M3
805
817
  // piece 4, step S6, D5 amended 2026-09-21) ──────────────────────────────────
@@ -868,7 +880,8 @@ export async function main(argv, deps = {}) {
868
880
  // malformed field travels as `null` and `authorPrompt` simply states nothing.
869
881
  writeScope: Array.isArray(draft.writeScope) ? draft.writeScope : null,
870
882
  provider,
871
- generate: makeLoopGenerate(provider),
883
+ generate: makeLoopGenerate(provider, { rates: authorPrice?.rates ?? null }),
884
+ rates: authorPrice?.rates ?? null,
872
885
  // ONE number, both paid seams (the survey's and the declaration loop's) — the
873
886
  // advertised ceiling and the enforced ceiling are the same ceiling
874
887
  ceilingUsd: CEILING_USD,
@@ -1061,6 +1074,21 @@ export async function main(argv, deps = {}) {
1061
1074
  const judge = judges
1062
1075
  ? resolveJobJudge(spec, PROVIDER_NAME, resolveWorkerModel)
1063
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
+ }
1064
1092
  // F181 — the judge key is required exactly when `judges` is true (this
1065
1093
  // script has no presence check on it today; adding one is out of this
1066
1094
  // finding's scope). What this door DOES owe, the same as the worker
@@ -1130,7 +1158,7 @@ export async function main(argv, deps = {}) {
1130
1158
  // number would silently widen it.
1131
1159
  ceilingUsd: CEILING_USD,
1132
1160
  priorCalls: [...metered],
1133
- 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,
1134
1162
  // the seam's other half (PRD item 32.1): the graded set is STAMPED with
1135
1163
  // this, and the close later refuses to grade under any other identity.
1136
1164
  judgeModel: judge?.model ?? null,
@@ -1235,8 +1263,8 @@ export async function main(argv, deps = {}) {
1235
1263
  // <path>` and reads this SAME prepared copy's own source manifest for
1236
1264
  // the workdir and seed — so the command below is the whole of what a
1237
1265
  // person needs to run their own job, nothing left to hand off.
1238
- // `providerEntry.envKey` (never a hardcoded ANTHROPIC_API_KEY) is the
1239
- // 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.
1240
1268
  // hamr's ruling 2026-09-28 (2nd addendum) — `totalCost.costUsd === null`
1241
1269
  // means this pipeline's own metered list carried at least one unpriced
1242
1270
  // call (`tallyCalls`'s `spendComplete: false`), so the drafting fold is
@@ -1249,7 +1277,7 @@ export async function main(argv, deps = {}) {
1249
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.');
1250
1278
  }
1251
1279
  out('\nTo run it (the same signature and gates as any other job — nothing here bypasses them):');
1252
- 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' : ''}` : ''}`);
1253
1281
  emit('author-end', { outcome: 'prepared', specHash: hash });
1254
1282
  }
1255
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
+ }