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/CHANGELOG.md +79 -0
- package/README.md +4 -1
- package/bareloop.context.md +150 -77
- package/package.json +1 -1
- package/src/authorflow.js +5 -3
- package/src/authorjob.js +3 -1
- package/src/authorrun.js +36 -8
- package/src/authorscout.js +10 -6
- package/src/bundlerun.js +266 -0
- package/src/cli.js +24 -352
- package/src/interviewrun.js +3 -2
- package/src/invoke.js +22 -0
- package/src/judged.js +5 -3
- package/src/monthly.js +92 -22
- package/src/panel/authorsession.js +24 -4
- package/src/planrun.js +7 -3
- package/src/providerrows.js +85 -1
- package/src/run.js +7 -2
- package/src/runlist.js +79 -7
- package/src/source.js +8 -3
- package/src/u-watchdog.mjs +296 -0
- package/src/userrun.js +186 -64
- package/types/authorflow.d.ts +8 -2
- package/types/authorjob.d.ts +6 -1
- package/types/authorscout.d.ts +11 -2
- package/types/bundlerun.d.ts +26 -0
- package/types/invoke.d.ts +8 -0
- package/types/judged.d.ts +5 -1
- package/types/monthly.d.ts +43 -8
- package/types/planrun.d.ts +13 -1
- package/types/providerrows.d.ts +65 -0
- package/types/run.d.ts +14 -1
- package/types/runlist.d.ts +65 -3
- package/types/source.d.ts +8 -3
- package/types/userrun.d.ts +49 -0
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 ?? '
|
|
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
|
-
// `
|
|
1239
|
-
// same F187 rule
|
|
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(` ${
|
|
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
|
}
|
package/src/authorscout.js
CHANGED
|
@@ -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
|
package/src/bundlerun.js
ADDED
|
@@ -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
|
+
}
|