bareloop 0.19.1 → 0.21.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +90 -0
- package/README.md +14 -0
- package/bareloop.context.md +230 -1
- package/bin/bareloop.mjs +24 -0
- package/package.json +6 -2
- package/src/bundle.js +719 -0
- package/src/cli.js +460 -0
- package/src/index.js +17 -1
- package/src/planrun.js +26 -1
- package/src/reuse.js +1 -1
- package/src/run.js +12 -2
- package/types/bundle.d.ts +199 -0
- package/types/cli.d.ts +19 -0
- package/types/index.d.ts +3 -1
- package/types/planrun.d.ts +13 -1
- package/types/reuse.d.ts +14 -0
- package/types/run.d.ts +8 -1
package/src/cli.js
ADDED
|
@@ -0,0 +1,460 @@
|
|
|
1
|
+
// Export M2 (`docs/product/EXPORT-BUILD.md`) — the `bareloop` CLI. All logic
|
|
2
|
+
// lives HERE, testable at $0 with an in-process `main(argv, deps)`; `bin/
|
|
3
|
+
// bareloop.mjs` is a ~10-line adapter that supplies the real `deps` and turns
|
|
4
|
+
// the returned exit code into `process.exitCode`.
|
|
5
|
+
//
|
|
6
|
+
// `deps.provider` (and `providerFor`/`judgeProvider`) is the ONE test seam: a
|
|
7
|
+
// caller that supplies it skips the real-key check entirely (a scripted
|
|
8
|
+
// provider IS the run, same as `tests/planrun.test.js`'s pattern). A caller
|
|
9
|
+
// that does NOT supply one goes through the real path M1's frozen spec names:
|
|
10
|
+
// a key from `ANTHROPIC_API_KEY` only, absent means print the operator
|
|
11
|
+
// questions and spend nothing (`bareloop run`, step 3).
|
|
12
|
+
//
|
|
13
|
+
// Money/arbiter rules this module must not bend: `--budget`/`--wall` only
|
|
14
|
+
// TIGHTEN a bundle's signed ceilings (`checkEnvelope`, M1); a bundle's
|
|
15
|
+
// `bundleHash` (the human-facing signature) never changes when the runner
|
|
16
|
+
// tightens — the SPEC that actually executes does, and this module mints a
|
|
17
|
+
// FRESH `approvals` record over that tightened, `$BARELOOP_BUNDLE`-resolved
|
|
18
|
+
// spec so `checkApproval` inside `runJob` always sees the spec it is about to
|
|
19
|
+
// run, never the unresolved bundleHash. `history.jsonl` records both hashes
|
|
20
|
+
// side by side (POC fact 2) so the pairing can never drift silently.
|
|
21
|
+
|
|
22
|
+
import { createRequire } from 'node:module';
|
|
23
|
+
import { createInterface } from 'node:readline/promises';
|
|
24
|
+
import { hostname } from 'node:os';
|
|
25
|
+
import { execFileSync } from 'node:child_process';
|
|
26
|
+
import {
|
|
27
|
+
existsSync, mkdirSync, readFileSync, renameSync,
|
|
28
|
+
} from 'node:fs';
|
|
29
|
+
import {
|
|
30
|
+
dirname, isAbsolute, join, resolve,
|
|
31
|
+
} from 'node:path';
|
|
32
|
+
|
|
33
|
+
import {
|
|
34
|
+
exportBundle, readBundle, resolveBundleSpec, checkEnvelope, bless, verifyBlessing, appendHistory, checkBundleDeps,
|
|
35
|
+
runJob, makeSpine, loadRegistry, listingRow, jobSpecHash, resolveWorkerModel, JUDGE_MODEL,
|
|
36
|
+
} from './index.js';
|
|
37
|
+
|
|
38
|
+
const require = createRequire(import.meta.url);
|
|
39
|
+
|
|
40
|
+
// The runner's own tier menu, the same spelling `scripts/run-u.mjs` uses (not
|
|
41
|
+
// exported from `src/` — the tier->model mapping is runner territory, not the
|
|
42
|
+
// library's). A `bareloop run` has no `--model` flag (not in the frozen
|
|
43
|
+
// spec), so `flagModel` is always undefined and only the spec's own `model`
|
|
44
|
+
// (if signed) or this default is ever in play.
|
|
45
|
+
const DEFAULT_TIER_MODELS = { sonnet: 'claude-sonnet-5', haiku: 'claude-haiku-4-5-20251001' };
|
|
46
|
+
|
|
47
|
+
/** @param {unknown} v @returns {v is Record<string, any>} */
|
|
48
|
+
const isObj = (v) => typeof v === 'object' && v !== null;
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Build the real providers from an API key, exactly the way `scripts/run-
|
|
52
|
+
* u.mjs` does (provider/providerFor/judgeProvider from `bare-agent/
|
|
53
|
+
* providers`'s `AnthropicProvider`). `AnthropicProvider` itself lives in
|
|
54
|
+
* `bare-agent` (the one production dependency already budgeted for provider
|
|
55
|
+
* calls); this function only wires it up the same way the u-runner does —
|
|
56
|
+
* it is not arbiter logic (no budget/verdict/merge decision lives here).
|
|
57
|
+
* @param {string} apiKey
|
|
58
|
+
* @param {any} spec the (already `$BARELOOP_BUNDLE`-resolved) bundle spec
|
|
59
|
+
*/
|
|
60
|
+
function buildProviders(apiKey, spec) {
|
|
61
|
+
const { AnthropicProvider } = require('bare-agent/providers');
|
|
62
|
+
const modelResolution = resolveWorkerModel({
|
|
63
|
+
specModel: isObj(spec) && typeof spec.model === 'string' ? spec.model : undefined,
|
|
64
|
+
flagModel: undefined,
|
|
65
|
+
defaultModel: DEFAULT_TIER_MODELS.sonnet,
|
|
66
|
+
});
|
|
67
|
+
const MODEL = modelResolution.model;
|
|
68
|
+
const provider = new AnthropicProvider({ apiKey, model: MODEL });
|
|
69
|
+
const TIER_MODELS = modelResolution.source === 'spec' ? { ...DEFAULT_TIER_MODELS, sonnet: MODEL } : DEFAULT_TIER_MODELS;
|
|
70
|
+
/** @type {Record<string, any>} */
|
|
71
|
+
const tierCache = {};
|
|
72
|
+
const providerFor = (/** @type {string} */ tier) => (tierCache[tier] ??= (TIER_MODELS[/** @type {keyof typeof TIER_MODELS} */ (tier)] === MODEL ? provider : new AnthropicProvider({ apiKey, model: TIER_MODELS[/** @type {keyof typeof TIER_MODELS} */ (tier)] })));
|
|
73
|
+
const judgeProvider = new AnthropicProvider({ apiKey, model: JUDGE_MODEL });
|
|
74
|
+
return { provider, providerFor, judgeProvider };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** @param {string[]} args @returns {{ positional: string[], flags: Record<string, string|true> }} */
|
|
78
|
+
function parseFlags(args) {
|
|
79
|
+
/** @type {string[]} */
|
|
80
|
+
const positional = [];
|
|
81
|
+
/** @type {Record<string, string|true>} */
|
|
82
|
+
const flags = {};
|
|
83
|
+
for (let i = 0; i < args.length; i++) {
|
|
84
|
+
const a = args[i];
|
|
85
|
+
if (a.startsWith('--')) {
|
|
86
|
+
const key = a.slice(2);
|
|
87
|
+
const next = args[i + 1];
|
|
88
|
+
if (next !== undefined && !next.startsWith('--')) { flags[key] = next; i++; } else flags[key] = true;
|
|
89
|
+
} else positional.push(a);
|
|
90
|
+
}
|
|
91
|
+
return { positional, flags };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** @returns {string} this package's own version, for the manifest's `bareloopVersion` */
|
|
95
|
+
function readPackageVersion() {
|
|
96
|
+
const pkgUrl = new URL('../package.json', import.meta.url);
|
|
97
|
+
return JSON.parse(readFileSync(pkgUrl, 'utf8')).version;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Build the `closeScripts` map `exportBundle` expects (path -> source) and a
|
|
102
|
+
* copy of the spec whose `close[].cmd` paths are ABSOLUTE — M1's
|
|
103
|
+
* `exportBundle` only relocates `node <absolute path>.mjs …`. A relative path
|
|
104
|
+
* in a hand-authored spec is resolved against the spec FILE's own directory
|
|
105
|
+
* (the same "the spec's own location is the reference point" the u-runner
|
|
106
|
+
* uses when it resolves `jobs/<name>.json` off `import.meta.url`), not the
|
|
107
|
+
* process cwd.
|
|
108
|
+
* @param {any} spec @param {string} specFile absolute path to the spec file
|
|
109
|
+
*/
|
|
110
|
+
function loadCloseScripts(spec, specFile) {
|
|
111
|
+
const specDir = dirname(specFile);
|
|
112
|
+
const stages = Array.isArray(spec.close) ? spec.close : [];
|
|
113
|
+
const resolvedClose = stages.map((/** @type {any} */ stage) => {
|
|
114
|
+
if (!isObj(stage) || typeof stage.cmd !== 'string') return stage;
|
|
115
|
+
const parts = stage.cmd.trim().split(/\s+/);
|
|
116
|
+
if (parts[0] !== 'node' || parts.length < 2) return stage;
|
|
117
|
+
const scriptPath = parts[1];
|
|
118
|
+
const abs = isAbsolute(scriptPath) ? scriptPath : resolve(specDir, scriptPath);
|
|
119
|
+
return { ...stage, cmd: ['node', abs, ...parts.slice(2)].join(' ') };
|
|
120
|
+
});
|
|
121
|
+
/** @type {Record<string, string>} */
|
|
122
|
+
const closeScripts = {};
|
|
123
|
+
for (const stage of resolvedClose) {
|
|
124
|
+
if (!isObj(stage) || typeof stage.cmd !== 'string') continue;
|
|
125
|
+
const parts = stage.cmd.trim().split(/\s+/);
|
|
126
|
+
if (parts[0] === 'node' && parts[1] && isAbsolute(parts[1]) && existsSync(parts[1])) {
|
|
127
|
+
closeScripts[parts[1]] = readFileSync(parts[1], 'utf8');
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
return { resolvedSpec: { ...spec, close: resolvedClose }, closeScripts };
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** @param {import('./bundle.js').Red[]} reds @param {(s: string) => void} err */
|
|
134
|
+
function printReds(reds, err) {
|
|
135
|
+
for (const r of reds) err(`${r.code} at ${r.path}: ${r.detail ?? ''}`.trim());
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* `bareloop export <jobs/x.json> --registry <dir> --out <dir>`
|
|
140
|
+
* @param {string[]} args @param {{ out: (s: string) => void, err: (s: string) => void, cwd: string }} ctx
|
|
141
|
+
*/
|
|
142
|
+
function doExport(args, { out, err, cwd }) {
|
|
143
|
+
const { positional, flags } = parseFlags(args);
|
|
144
|
+
const specPathArg = positional[0];
|
|
145
|
+
const registryDir = typeof flags.registry === 'string' ? flags.registry : undefined;
|
|
146
|
+
const outDirArg = typeof flags.out === 'string' ? flags.out : undefined;
|
|
147
|
+
if (!specPathArg || !registryDir || !outDirArg) {
|
|
148
|
+
err('usage: bareloop export <jobs/x.json> --registry <dir> --out <dir>');
|
|
149
|
+
return 1;
|
|
150
|
+
}
|
|
151
|
+
const specFile = resolve(cwd, specPathArg);
|
|
152
|
+
let specText;
|
|
153
|
+
try { specText = readFileSync(specFile, 'utf8'); } catch (e) {
|
|
154
|
+
err(`cannot read job spec at ${specFile}: ${/** @type {Error} */ (e).message}`);
|
|
155
|
+
return 1;
|
|
156
|
+
}
|
|
157
|
+
let spec;
|
|
158
|
+
try { spec = JSON.parse(specText); } catch (e) {
|
|
159
|
+
err(`job spec at ${specFile} is not valid JSON: ${/** @type {Error} */ (e).message}`);
|
|
160
|
+
return 1;
|
|
161
|
+
}
|
|
162
|
+
const { resolvedSpec, closeScripts } = loadCloseScripts(spec, specFile);
|
|
163
|
+
const bareloopVersion = readPackageVersion();
|
|
164
|
+
const r = exportBundle({
|
|
165
|
+
spec: resolvedSpec, closeScripts, registryDir: resolve(cwd, registryDir), outDir: resolve(cwd, outDirArg), bareloopVersion,
|
|
166
|
+
});
|
|
167
|
+
if (!r.ok) { printReds(r.reds, err); return 1; }
|
|
168
|
+
out(`bundleHash: ${r.bundleHash}`);
|
|
169
|
+
out('files:');
|
|
170
|
+
for (const f of Object.keys(r.manifest.files).sort()) out(` ${f}`);
|
|
171
|
+
out("this bundle is unblessed until its first run greens on the importer's machine.");
|
|
172
|
+
return 0;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* `bareloop run <bundleDir> --repo <path> [--budget N] [--wall MIN] [--approve <bundleHash>]`
|
|
177
|
+
* — the exact five-step order the frozen spec names; no step may be reordered.
|
|
178
|
+
* @param {string[]} args
|
|
179
|
+
* @param {{ out: (s: string) => void, err: (s: string) => void, cwd: string, env: Record<string,string|undefined>, now: () => number, deps: any }} ctx
|
|
180
|
+
*/
|
|
181
|
+
async function doRun(args, { out, err, cwd, env, now, deps }) {
|
|
182
|
+
const { positional, flags } = parseFlags(args);
|
|
183
|
+
const bundleDirArg = positional[0];
|
|
184
|
+
const repoArg = typeof flags.repo === 'string' ? flags.repo : undefined;
|
|
185
|
+
if (!bundleDirArg || !repoArg) {
|
|
186
|
+
err('usage: bareloop run <bundleDir> --repo <path> [--budget N] [--wall MIN] [--approve <bundleHash>]');
|
|
187
|
+
return 1;
|
|
188
|
+
}
|
|
189
|
+
const bundleDir = resolve(cwd, bundleDirArg);
|
|
190
|
+
const repo = resolve(cwd, repoArg);
|
|
191
|
+
|
|
192
|
+
// 1. readBundle — N4: this must complete, clean, before anything else
|
|
193
|
+
// touches the bundle or the repo. Any red (incl. bundle-tampered) stops here.
|
|
194
|
+
const bundle = readBundle(bundleDir);
|
|
195
|
+
if (!bundle.ok) { printReds(bundle.reds, err); return 1; }
|
|
196
|
+
|
|
197
|
+
// 1b. F128 — the bundle's OWN node_modules must carry `bareloop` before
|
|
198
|
+
// anything else runs. Left unchecked, the close crashes deep inside
|
|
199
|
+
// runJob's close-first precheck with a bare ERR_MODULE_NOT_FOUND, reported
|
|
200
|
+
// as an unhelpful generic close-red with no cure line.
|
|
201
|
+
const depsCheck = checkBundleDeps(bundleDir);
|
|
202
|
+
if (!depsCheck.ok) { printReds(depsCheck.reds, err); return 1; }
|
|
203
|
+
|
|
204
|
+
// 2. checkEnvelope — tighten-only, wall given in minutes -> ms.
|
|
205
|
+
/** @type {{ budgetUsd?: number, maxWallMs?: number }} */
|
|
206
|
+
const envelope = {};
|
|
207
|
+
if (flags.budget !== undefined) {
|
|
208
|
+
const n = Number(flags.budget);
|
|
209
|
+
if (!Number.isFinite(n)) { err(`--budget must be a number, got ${JSON.stringify(flags.budget)}`); return 1; }
|
|
210
|
+
envelope.budgetUsd = n;
|
|
211
|
+
}
|
|
212
|
+
if (flags.wall !== undefined) {
|
|
213
|
+
const n = Number(flags.wall);
|
|
214
|
+
if (!Number.isFinite(n)) { err(`--wall must be a number of minutes, got ${JSON.stringify(flags.wall)}`); return 1; }
|
|
215
|
+
envelope.maxWallMs = n * 60_000;
|
|
216
|
+
}
|
|
217
|
+
const envCheck = checkEnvelope(bundle.spec, envelope);
|
|
218
|
+
if (!envCheck.ok) { printReds(envCheck.reds, err); return 1; }
|
|
219
|
+
|
|
220
|
+
// 3. the key — ANTHROPIC_API_KEY only, UNLESS a caller already handed in a
|
|
221
|
+
// provider (the test seam). Absent and no injected provider: print the
|
|
222
|
+
// operator questions (the bundle's own README) and the hash, spend nothing.
|
|
223
|
+
let { provider, providerFor, judgeProvider } = deps;
|
|
224
|
+
if (!provider) {
|
|
225
|
+
const apiKey = env.ANTHROPIC_API_KEY;
|
|
226
|
+
if (!apiKey) {
|
|
227
|
+
let readme = null;
|
|
228
|
+
try { readme = readFileSync(join(bundleDir, 'README.md'), 'utf8'); } catch { /* fall through to the hash alone */ }
|
|
229
|
+
if (readme) out(readme);
|
|
230
|
+
out(`bundleHash: ${bundle.manifest.bundleHash}`);
|
|
231
|
+
return 0;
|
|
232
|
+
}
|
|
233
|
+
({ provider, providerFor, judgeProvider } = buildProviders(apiKey, bundle.spec));
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// 4. blessing.
|
|
237
|
+
const { manifest } = bundle;
|
|
238
|
+
if (!bundle.blessing) {
|
|
239
|
+
// The runid that ACTUALLY minted this bundle's own signed spec — the
|
|
240
|
+
// bridge VERSION (base or shape fork; both ship into bridges/) whose
|
|
241
|
+
// recorded specHash equals the resolved bundle spec's jobSpecHash. NOT
|
|
242
|
+
// the bridge's first history row: a job can be re-exported/rebased many
|
|
243
|
+
// times, and only the version at THIS exact hash proves anything for
|
|
244
|
+
// THIS bundle.
|
|
245
|
+
const { approveHash: mintMatchHash } = resolveBundleSpec(bundle, bundleDir);
|
|
246
|
+
let mintRunid = null;
|
|
247
|
+
for (const b of bundle.bridges) {
|
|
248
|
+
const versions = Array.isArray(b.versions) ? b.versions : [];
|
|
249
|
+
const v = versions.find((/** @type {any} */ ver) => ver.specHash === mintMatchHash);
|
|
250
|
+
if (v) { mintRunid = v.runid; break; }
|
|
251
|
+
}
|
|
252
|
+
out('first run — this bundle has never been blessed on this machine.');
|
|
253
|
+
out(mintRunid ? `minting run: ${mintRunid} (spine not bundled in v1)` : 'no version at this hash');
|
|
254
|
+
out(`bundleHash: ${manifest.bundleHash}`);
|
|
255
|
+
if (flags.approve !== manifest.bundleHash) {
|
|
256
|
+
err(`--approve ${manifest.bundleHash} is required for the first run of an unblessed bundle`);
|
|
257
|
+
return 1;
|
|
258
|
+
}
|
|
259
|
+
} else {
|
|
260
|
+
const vb = verifyBlessing(bundle);
|
|
261
|
+
if (!vb.ok) { printReds(vb.reds, err); return 1; }
|
|
262
|
+
if (flags.approve) out('already blessed, --approve ignored');
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// 5. the worktree — refuse a non-repo, a repo with no commit, or a collision;
|
|
266
|
+
// ALWAYS fresh, never reused (a reused worktree reads last run's edits as
|
|
267
|
+
// "already-green" — the negative POC's exact finding).
|
|
268
|
+
try {
|
|
269
|
+
execFileSync('git', ['-C', repo, 'rev-parse', 'HEAD'], { encoding: 'utf8' });
|
|
270
|
+
} catch (e) {
|
|
271
|
+
err(`--repo ${repoArg} must be a git repository with at least one commit: ${/** @type {Error} */ (e).message}`);
|
|
272
|
+
return 1;
|
|
273
|
+
}
|
|
274
|
+
const runid = now().toString(36);
|
|
275
|
+
const worktree = join(repo, '.bareloop', 'wt', runid);
|
|
276
|
+
if (existsSync(worktree)) { err(`worktree already exists: ${worktree}`); return 1; }
|
|
277
|
+
mkdirSync(dirname(worktree), { recursive: true });
|
|
278
|
+
try {
|
|
279
|
+
execFileSync('git', ['-C', repo, 'worktree', 'add', '--detach', worktree, 'HEAD'], { encoding: 'utf8' });
|
|
280
|
+
} catch (e) {
|
|
281
|
+
err(`git worktree add failed: ${/** @type {Error} */ (e).message}`);
|
|
282
|
+
return 1;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
// 6. resolve (in-memory $BARELOOP_BUNDLE substitution) + tighten + run. The
|
|
286
|
+
// envelope tightens the ACTUAL spec that runs (budgetUsd/maxWallMs have no
|
|
287
|
+
// separate runJob override — the library reads them off the spec itself),
|
|
288
|
+
// so `approveHash` is recomputed AFTER tightening: the runner mints its own
|
|
289
|
+
// fresh approval over the exact spec it is about to hand to runJob, never
|
|
290
|
+
// over the untightened one `resolveBundleSpec` first returned. The
|
|
291
|
+
// bundleHash a human approved never changes; only the executed spec's own
|
|
292
|
+
// hash does, and that hash is minted here, not signed by a person.
|
|
293
|
+
const resolved = resolveBundleSpec(bundle, bundleDir);
|
|
294
|
+
const runSpec = resolved.spec;
|
|
295
|
+
if (envelope.budgetUsd !== undefined) runSpec.budgetUsd = envelope.budgetUsd;
|
|
296
|
+
if (envelope.maxWallMs !== undefined) runSpec.maxWallMs = envelope.maxWallMs;
|
|
297
|
+
const approveHash = jobSpecHash(runSpec);
|
|
298
|
+
|
|
299
|
+
const runsDir = join(bundleDir, 'runs', runid);
|
|
300
|
+
mkdirSync(runsDir, { recursive: true });
|
|
301
|
+
const spineFile = join(runsDir, 'spine.jsonl');
|
|
302
|
+
const emit = makeSpine(spineFile);
|
|
303
|
+
|
|
304
|
+
let outcome;
|
|
305
|
+
try {
|
|
306
|
+
outcome = await runJob(runSpec, {
|
|
307
|
+
approvals: [{ specHash: approveHash, signer: 'bundle', ts: new Date(now()).toISOString() }],
|
|
308
|
+
workdir: worktree,
|
|
309
|
+
provider,
|
|
310
|
+
providerFor,
|
|
311
|
+
judgeProvider,
|
|
312
|
+
emit,
|
|
313
|
+
shellCapUsd: runSpec.budgetUsd,
|
|
314
|
+
readShim: 'cap',
|
|
315
|
+
scout: true,
|
|
316
|
+
});
|
|
317
|
+
} catch (e) {
|
|
318
|
+
err(`runJob crashed: ${/** @type {Error} */ (e).message}`);
|
|
319
|
+
return 1;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
// the gate-audit relocation (POC fact 3, run-u.mjs:1293's move)
|
|
323
|
+
const auditSrc = join(worktree, 'gate-audit.jsonl');
|
|
324
|
+
if (existsSync(auditSrc)) renameSync(auditSrc, join(runsDir, 'gate-audit.jsonl'));
|
|
325
|
+
|
|
326
|
+
// the job-end record off THIS run's own spine — never fabricate a 0 for an
|
|
327
|
+
// unknown spend (F6/F12's class).
|
|
328
|
+
const events = readFileSync(spineFile, 'utf8').trimEnd().split('\n').filter(Boolean).map((l) => JSON.parse(l));
|
|
329
|
+
const je = events.findLast((/** @type {any} */ e) => e.type === 'job-end');
|
|
330
|
+
const spentUsd = je?.spentUsd ?? null;
|
|
331
|
+
const spendComplete = je?.spendComplete ?? null;
|
|
332
|
+
|
|
333
|
+
let branch = null;
|
|
334
|
+
try { branch = execFileSync('git', ['-C', worktree, 'rev-parse', '--abbrev-ref', 'HEAD'], { encoding: 'utf8' }).trim(); } catch { /* leave null — reported honestly below */ }
|
|
335
|
+
|
|
336
|
+
// 7. history + (on a first green) the blessing.
|
|
337
|
+
appendHistory(bundleDir, {
|
|
338
|
+
runid,
|
|
339
|
+
at: new Date(now()).toISOString(),
|
|
340
|
+
outcome,
|
|
341
|
+
spentUsd,
|
|
342
|
+
spendComplete,
|
|
343
|
+
budgetUsd: runSpec.budgetUsd,
|
|
344
|
+
maxWallMs: runSpec.maxWallMs ?? null,
|
|
345
|
+
worktree,
|
|
346
|
+
branch,
|
|
347
|
+
bundleHash: manifest.bundleHash,
|
|
348
|
+
approveHash,
|
|
349
|
+
});
|
|
350
|
+
if (outcome === 'green' && !bundle.blessing) {
|
|
351
|
+
bless(bundleDir, {
|
|
352
|
+
bundleHash: manifest.bundleHash, blessedAt: new Date(now()).toISOString(), runid, outcome, host: hostname(),
|
|
353
|
+
});
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
// 8. the tail.
|
|
357
|
+
out(`outcome ${outcome}`);
|
|
358
|
+
out(`spent ${spentUsd == null ? 'UNKNOWN' : `${spendComplete === false ? '≥' : ''}$${spentUsd.toFixed(4)}`} of $${runSpec.budgetUsd}`);
|
|
359
|
+
out(`branch ${branch ?? 'UNKNOWN (could not read the worktree branch)'}`);
|
|
360
|
+
out(`worktree ${worktree}`);
|
|
361
|
+
if (branch) out(`merge git merge ${branch} (merge stays human — this CLI never merges)`);
|
|
362
|
+
out(`the worktree is kept until you remove it: git worktree remove ${worktree}`);
|
|
363
|
+
// exit 0 ONLY for a real green — every other outcome (close-red, plan-red,
|
|
364
|
+
// escalated, cap/wall halts, worker-crash, provider-red, …) exits 1 so a
|
|
365
|
+
// caller scripting `bareloop run` off its exit code cannot mistake a red
|
|
366
|
+
// for a success. The tail print above is unchanged either way.
|
|
367
|
+
return outcome === 'green' || outcome === 'already-green' ? 0 : 1;
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
/**
|
|
371
|
+
* `bareloop history <bundleDir>`
|
|
372
|
+
* @param {string[]} args @param {{ out: (s: string) => void, err: (s: string) => void, cwd: string }} ctx
|
|
373
|
+
*/
|
|
374
|
+
function doHistory(args, { out, err, cwd }) {
|
|
375
|
+
const bundleDirArg = args[0];
|
|
376
|
+
if (!bundleDirArg) { err('usage: bareloop history <bundleDir>'); return 1; }
|
|
377
|
+
const dir = resolve(cwd, bundleDirArg);
|
|
378
|
+
const historyFile = join(dir, 'history.jsonl');
|
|
379
|
+
if (existsSync(historyFile)) {
|
|
380
|
+
out('history:');
|
|
381
|
+
for (const line of readFileSync(historyFile, 'utf8').split('\n')) {
|
|
382
|
+
if (line.trim()) out(` ${line.trim()}`);
|
|
383
|
+
}
|
|
384
|
+
} else out('history: (no runs yet)');
|
|
385
|
+
const reg = loadRegistry(join(dir, 'bridges'));
|
|
386
|
+
if (reg.bridges.length > 0) {
|
|
387
|
+
out('bridges:');
|
|
388
|
+
for (const b of reg.bridges) out(` ${JSON.stringify(listingRow(b))}`);
|
|
389
|
+
}
|
|
390
|
+
return 0;
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
/**
|
|
394
|
+
* The bare `bareloop` menu: `1 export 2 run 3 history q quit`, then the
|
|
395
|
+
* SAME code path as the sub-command, asking its args line by line.
|
|
396
|
+
* @param {any} deps @param {{ out: (s: string) => void, err: (s: string) => void, cwd: string, env: any, now: () => number }} ctx
|
|
397
|
+
*/
|
|
398
|
+
async function runMenu(deps, ctx) {
|
|
399
|
+
const stdin = deps.stdin;
|
|
400
|
+
const stdout = deps.stdout;
|
|
401
|
+
const rl = createInterface({ input: stdin, output: stdout });
|
|
402
|
+
try {
|
|
403
|
+
ctx.out('1 export 2 run 3 history q quit');
|
|
404
|
+
const choice = (await rl.question('> ')).trim().toLowerCase();
|
|
405
|
+
if (choice === '' || choice === 'q' || choice === 'quit') return 0;
|
|
406
|
+
if (choice === '1') {
|
|
407
|
+
const specPath = (await rl.question('job spec path: ')).trim();
|
|
408
|
+
const registry = (await rl.question('registry dir: ')).trim();
|
|
409
|
+
const outDir = (await rl.question('out dir: ')).trim();
|
|
410
|
+
return doExport([specPath, '--registry', registry, '--out', outDir], ctx);
|
|
411
|
+
}
|
|
412
|
+
if (choice === '2') {
|
|
413
|
+
const bundleDir = (await rl.question('bundle dir: ')).trim();
|
|
414
|
+
const repo = (await rl.question('repo path: ')).trim();
|
|
415
|
+
const budget = (await rl.question('budget (blank to skip): ')).trim();
|
|
416
|
+
const wall = (await rl.question('wall minutes (blank to skip): ')).trim();
|
|
417
|
+
const approve = (await rl.question('approve hash (blank to skip): ')).trim();
|
|
418
|
+
const args = [bundleDir, '--repo', repo];
|
|
419
|
+
if (budget) args.push('--budget', budget);
|
|
420
|
+
if (wall) args.push('--wall', wall);
|
|
421
|
+
if (approve) args.push('--approve', approve);
|
|
422
|
+
return await doRun(args, { ...ctx, deps });
|
|
423
|
+
}
|
|
424
|
+
if (choice === '3') {
|
|
425
|
+
const bundleDir = (await rl.question('bundle dir: ')).trim();
|
|
426
|
+
return doHistory([bundleDir], ctx);
|
|
427
|
+
}
|
|
428
|
+
ctx.err(`unknown choice ${JSON.stringify(choice)} — one of: 1, 2, 3, q`);
|
|
429
|
+
return 1;
|
|
430
|
+
} finally {
|
|
431
|
+
rl.close();
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
/**
|
|
436
|
+
* The one entry point. Never calls `process.exit` — always returns an exit
|
|
437
|
+
* code, so `bin/bareloop.mjs` can set `process.exitCode` and let node flush
|
|
438
|
+
* queued stdout on its own (F-something: `process.exit()` can discard it).
|
|
439
|
+
* @param {string[]} argv
|
|
440
|
+
* @param {{ env?: Record<string,string|undefined>, stdout?: any, stderr?: any, cwd?: string, provider?: any, providerFor?: any, judgeProvider?: any, now?: () => number, stdin?: any }} deps
|
|
441
|
+
* @returns {Promise<number>}
|
|
442
|
+
*/
|
|
443
|
+
export async function main(argv, deps = {}) {
|
|
444
|
+
const env = deps.env ?? process.env;
|
|
445
|
+
const stdout = deps.stdout ?? process.stdout;
|
|
446
|
+
const stderr = deps.stderr ?? process.stderr;
|
|
447
|
+
const cwd = deps.cwd ?? process.cwd();
|
|
448
|
+
const now = deps.now ?? (() => Date.now());
|
|
449
|
+
const out = (/** @type {string} */ s) => stdout.write(`${s}\n`);
|
|
450
|
+
const err = (/** @type {string} */ s) => stderr.write(`${s}\n`);
|
|
451
|
+
const ctx = { out, err, cwd, env, now, deps };
|
|
452
|
+
|
|
453
|
+
const [cmd, ...rest] = argv;
|
|
454
|
+
if (!cmd) return runMenu({ ...deps, stdin: deps.stdin ?? process.stdin, stdout }, ctx);
|
|
455
|
+
if (cmd === 'export') return doExport(rest, ctx);
|
|
456
|
+
if (cmd === 'run') return doRun(rest, ctx);
|
|
457
|
+
if (cmd === 'history') return doHistory(rest, ctx);
|
|
458
|
+
err(`unknown command ${JSON.stringify(cmd)} — one of: export, run, history`);
|
|
459
|
+
return 1;
|
|
460
|
+
}
|
package/src/index.js
CHANGED
|
@@ -81,7 +81,7 @@ export { renderListing, selectionPrompt } from './selection.js';
|
|
|
81
81
|
// at a review door, and a door with no row to act on can only describe a held learning
|
|
82
82
|
// credit it has no way to release. STORAGE ONLY — nothing here selects, promotes or reuses
|
|
83
83
|
// a bridge; those stay parked on `layer-3-reuse`.
|
|
84
|
-
export { validateEnvelope, resolveTrySpec, resolveReuse, reuseSpecHash, selectBridge, runReuse, REUSE_GRADED_RED, readResume, resumeTreeGate, CHECKPOINT_OUTCOMES, PAUSE_TTL_MS, checkpointAgeGate, applyDoorDecision, writeRunGreenRow } from './reuse.js';
|
|
84
|
+
export { validateEnvelope, resolveTrySpec, resolveReuse, reuseSpecHash, selectBridge, runReuse, REUSE_GRADED_RED, readResume, resumeTreeGate, CHECKPOINT_OUTCOMES, PAUSE_TTL_MS, checkpointAgeGate, applyDoorDecision, writeRunGreenRow, shapeForkName } from './reuse.js';
|
|
85
85
|
// BA-21 pricing provenance rides out with the ledger because it is the same job: reading
|
|
86
86
|
// a run's own spend record honestly. `rateProvenance`/`spendProvenance` answer "how much of
|
|
87
87
|
// this was priced by a rate nobody vouched for" — REPORTING ONLY, no halt, no refusal.
|
|
@@ -252,3 +252,19 @@ export {
|
|
|
252
252
|
// whether a changed file is one. The ONE place this list lives; exported so
|
|
253
253
|
// bareloop.context.md can point an adopter here instead of re-deriving it.
|
|
254
254
|
export { PROMPT_REGISTERS, isPromptFile } from './promptregisters.js';
|
|
255
|
+
// Export M1 (`docs/product/EXPORT-BUILD.md`) — the bundle a `bareloop run`
|
|
256
|
+
// (M2) drives from a clean consumer directory: a signed spec (close paths
|
|
257
|
+
// rewritten to `$BARELOOP_BUNDLE/close/<script>`), the close scripts it
|
|
258
|
+
// needs, and the job's whole registry history. `bundle.js` reads this
|
|
259
|
+
// module's own export list at call time (never a hand-kept copy) to judge
|
|
260
|
+
// whether a close script's imports are legal — a cycle by design, safe
|
|
261
|
+
// because the binding is read only inside function bodies.
|
|
262
|
+
export {
|
|
263
|
+
exportBundle, bundleHash, readBundle, resolveBundleSpec, checkEnvelope, bless, verifyBlessing, appendHistory, checkBundleDeps,
|
|
264
|
+
} from './bundle.js';
|
|
265
|
+
// Export M2 (`docs/product/EXPORT-BUILD.md`) — the CLI's one entry point,
|
|
266
|
+
// exported as `cliMain` (never `main`, a name generic enough to collide with
|
|
267
|
+
// an adopter's own). `bin/bareloop.mjs` is the only other caller; this export
|
|
268
|
+
// exists so an adopter driving `bareloop run` from their own script can do so
|
|
269
|
+
// without spawning `bin/bareloop.mjs` as a child process.
|
|
270
|
+
export { main as cliMain } from './cli.js';
|
package/src/planrun.js
CHANGED
|
@@ -642,6 +642,17 @@ ${scoutBlob || '(no scout notes)'}`;
|
|
|
642
642
|
* it.
|
|
643
643
|
* @param {number} [opts.closeTimeoutMs] close/check wall-clock cap (shell territory)
|
|
644
644
|
* @param {number} [opts.maxStepRounds] the shell's per-step rounds ceiling (validatePlan's bound)
|
|
645
|
+
* @param {boolean} [opts.scout=true] operator-only switch (docs/product/SCOUT-CONTRAST.md):
|
|
646
|
+
* `false` on a FRESH run (no `resumeSeed`) skips the survey entirely — `scoutBlob`
|
|
647
|
+
* stays `''`, no `scout-start`/`scout-result`/`scout-empty` is emitted, and a single
|
|
648
|
+
* `scout-skipped {reason:'operator-off'}` records it (a RECORD, never silence — the
|
|
649
|
+
* resume skip's own rule). The planner then drafts from `(no scout notes)`
|
|
650
|
+
* (line ~587) exactly as it does today when a scout returns an empty blob — OFF is
|
|
651
|
+
* byte-identical to today's run everywhere but the survey. Precedence: when
|
|
652
|
+
* `resumeSeed` is set the existing resume skip fires first and wins its own reason
|
|
653
|
+
* (`resumed`); `operator-off` is only ever emitted on a fresh run. A non-boolean
|
|
654
|
+
* THROWS a TypeError at entry (the `readShimArm` guard class) — a knob that
|
|
655
|
+
* silently coerced would mislabel a bench row.
|
|
645
656
|
* @param {number} [opts.scoutRounds] the read-only survey's round bound (F59: the LAST round is
|
|
646
657
|
* reserved — a scout that spends every round on tools gets one toolless round to write its
|
|
647
658
|
* survey, because the bound halts it mid-tool-use and text is its only deliverable)
|
|
@@ -805,7 +816,7 @@ ${scoutBlob || '(no scout notes)'}`;
|
|
|
805
816
|
* 'branch-red' | 'cap-halt' | 'wall-halt' | 'provider-red' | 'interpreter-red' |
|
|
806
817
|
* 'step-stalled' | 'hitl-pause' | 'hitl-decision-red' | `step-red:<id>`
|
|
807
818
|
*/
|
|
808
|
-
export async function runPlan(job, { workdir, provider, nativeProvider, providerFor, judgeProvider = null, emit, remainingUsd, isUnpriced = () => false, spendComplete = () => true, capRuns = 3, strikeLimit = STRIKE_LIMIT, closeTimeoutMs, maxStepRounds = 40, layerRoot = false, readShim = false, scoutRounds = SCOUT_ROUNDS, bridge = null, now, priorWallMs = 0, resumeSeed = null, resumeGrades = [], resumeReplans = null, resumeBranch = null, humanRuling = null, heldRuling = null, priorSpentUsd = 0, reviewDoor = null, doorRerun = null }) {
|
|
819
|
+
export async function runPlan(job, { workdir, provider, nativeProvider, providerFor, judgeProvider = null, emit, remainingUsd, isUnpriced = () => false, spendComplete = () => true, capRuns = 3, strikeLimit = STRIKE_LIMIT, closeTimeoutMs, maxStepRounds = 40, layerRoot = false, readShim = false, scout = true, scoutRounds = SCOUT_ROUNDS, bridge = null, now, priorWallMs = 0, resumeSeed = null, resumeGrades = [], resumeReplans = null, resumeBranch = null, humanRuling = null, heldRuling = null, priorSpentUsd = 0, reviewDoor = null, doorRerun = null }) {
|
|
809
820
|
// MEMORY-CACHE: what the read shim (src/readshim.js) saved THIS run, summed across
|
|
810
821
|
// every mkWorker's own shim instance (scout, drafter, each step's worker, the fix
|
|
811
822
|
// worker) — one accumulator closed over by all of them, because the shim's ledger
|
|
@@ -822,6 +833,11 @@ export async function runPlan(job, { workdir, provider, nativeProvider, provider
|
|
|
822
833
|
// would be wrong and would look fine). Only the guard runs here; the flag itself
|
|
823
834
|
// is still threaded onward as written.
|
|
824
835
|
readShimArm(readShim);
|
|
836
|
+
// The scout switch's guard, same class and same door: a mis-typed value
|
|
837
|
+
// coerced by truthiness would run the scout under an "off" label (or skip it
|
|
838
|
+
// under an "on" one) and no reading of the results afterwards could recover
|
|
839
|
+
// which arm actually ran (docs/product/SCOUT-CONTRAST.md).
|
|
840
|
+
if (typeof scout !== 'boolean') throw new TypeError(`scout: expected boolean, got ${JSON.stringify(scout)}`);
|
|
825
841
|
// G1's OTHER half, refused at the same $0 door. `validatePlan` reds `read-blind`
|
|
826
842
|
// when a step grants `read` without the retrieval pair — but a step cannot grant
|
|
827
843
|
// what the SIGNED CEILING does not offer, so against a spec whose `tools` lack
|
|
@@ -2456,6 +2472,15 @@ export async function runPlan(job, { workdir, provider, nativeProvider, provider
|
|
|
2456
2472
|
reason: 'resumed', phase: resumeSeed.phase ?? null,
|
|
2457
2473
|
meaning: 'the killed run already paid for this survey; its plan is reloaded, not re-drafted. A replan after this point drafts from an empty survey plus the failure brief.',
|
|
2458
2474
|
});
|
|
2475
|
+
} else if (scout === false) {
|
|
2476
|
+
// The operator-off arm (docs/product/SCOUT-CONTRAST.md): a fresh run whose
|
|
2477
|
+
// operator asked for no survey. Precedence already settled above — the
|
|
2478
|
+
// resume branch fires first and wins its own reason, so this arm is only
|
|
2479
|
+
// ever reached when there is nothing to resume from.
|
|
2480
|
+
emit('scout-skipped', {
|
|
2481
|
+
reason: 'operator-off',
|
|
2482
|
+
meaning: 'the operator ran with --scout off; the planner drafts from "(no scout notes)"; no survey was paid for.',
|
|
2483
|
+
});
|
|
2459
2484
|
} else {
|
|
2460
2485
|
emit('scout-start', { rounds: scoutRounds });
|
|
2461
2486
|
try {
|
package/src/reuse.js
CHANGED
|
@@ -167,7 +167,7 @@ function sameCloseShape(entry, stageNames) {
|
|
|
167
167
|
* to prevent.
|
|
168
168
|
* @param {string} name @param {string[]} stageNames @returns {string}
|
|
169
169
|
*/
|
|
170
|
-
function shapeForkName(name, stageNames) {
|
|
170
|
+
export function shapeForkName(name, stageNames) {
|
|
171
171
|
return `${name}-${createHash('sha256').update(stageNames.join('\n')).digest('hex').slice(0, 8)}`;
|
|
172
172
|
}
|
|
173
173
|
|
package/src/run.js
CHANGED
|
@@ -171,6 +171,12 @@ async function primitiveSmoke(workdir) {
|
|
|
171
171
|
* frozen A0 baseline arm has to be exactly today, so a guard firing under a disabled
|
|
172
172
|
* shim would make the baseline a treatment arm. The default-flip belongs to a
|
|
173
173
|
* paid contrast nobody has approved yet (the `layerRoot` precedent, F41).
|
|
174
|
+
* @param {boolean} [opts.scout=true] operator-only switch (docs/product/SCOUT-CONTRAST.md),
|
|
175
|
+
* forwarded verbatim to the plan flow: `false` on a fresh run (no `resumeSeed`) skips the
|
|
176
|
+
* read-only survey and records `scout-skipped {reason:'operator-off'}` instead — the
|
|
177
|
+
* planner drafts from `(no scout notes)`. Runner territory exactly like `readShim`: the
|
|
178
|
+
* spec names no scout, so the signed hash is unaffected. A non-boolean THROWS at this
|
|
179
|
+
* entry, before the ledger, before the approval gate (the `readShim` guard class).
|
|
174
180
|
* @param {boolean} [opts.layerRoot=false] Layer R (within-run ratchet) — shell
|
|
175
181
|
* territory, threaded to the plan flow. Defaults OFF
|
|
176
182
|
* (decided 2026-07-21): fixation is extinct on every current job (F41), so
|
|
@@ -202,7 +208,7 @@ async function primitiveSmoke(workdir) {
|
|
|
202
208
|
* 'interpreter-red' | 'cap-halt' | 'wall-halt' | 'step-stalled' |
|
|
203
209
|
* 'hitl-pause' | 'hitl-decision-red' | `step-red:<id>`
|
|
204
210
|
*/
|
|
205
|
-
export async function runJob(rawSpec, { approvals, workdir, provider, nativeProvider, providerFor, judgeProvider = null, emit, capRuns = 3, strikeLimit, shellCapUsd = 2, closeTimeoutMs, layerRoot = false, readShim = false, bridge = null, priorSpentUsd = 0, priorSpendComplete = true, priorWallMs = 0, resumeSeed = null, resumeGrades = [], resumeReplans = null, resumeBranch = null, humanRuling = null, heldRuling = null, reviewDoor = null, doorRerun = null }) {
|
|
211
|
+
export async function runJob(rawSpec, { approvals, workdir, provider, nativeProvider, providerFor, judgeProvider = null, emit, capRuns = 3, strikeLimit, shellCapUsd = 2, closeTimeoutMs, layerRoot = false, readShim = false, scout = true, bridge = null, priorSpentUsd = 0, priorSpendComplete = true, priorWallMs = 0, resumeSeed = null, resumeGrades = [], resumeReplans = null, resumeBranch = null, humanRuling = null, heldRuling = null, reviewDoor = null, doorRerun = null }) {
|
|
206
212
|
// THE READ SHIM's ARM, resolved at the door — the FIRST thing this entry does,
|
|
207
213
|
// before the ledger, before the approval gate, before a byte of the spec is read.
|
|
208
214
|
// An unrecognised spelling throws here at zero cost instead of being coerced by
|
|
@@ -211,6 +217,10 @@ export async function runJob(rawSpec, { approvals, workdir, provider, nativeProv
|
|
|
211
217
|
// blind-instrument failure this programme keeps logging. Only the guard runs
|
|
212
218
|
// here; the flag itself is threaded onward to the plan flow as written.
|
|
213
219
|
readShimArm(readShim);
|
|
220
|
+
// The scout switch's guard, same class and same door (docs/product/SCOUT-CONTRAST.md):
|
|
221
|
+
// a mis-coerced value would run under the wrong label with no way to recover which arm
|
|
222
|
+
// actually fired from the results afterwards.
|
|
223
|
+
if (typeof scout !== 'boolean') throw new TypeError(`scout: expected boolean, got ${JSON.stringify(scout)}`);
|
|
214
224
|
// 0. the ledger's counters, declared FIRST so that every job-end — including
|
|
215
225
|
// the pre-token reds below — can state a real figure. An omitted `spentUsd` is
|
|
216
226
|
// not a zero: a consumer reads `undefined` and either crashes or launders it
|
|
@@ -405,7 +415,7 @@ export async function runJob(rawSpec, { approvals, workdir, provider, nativeProv
|
|
|
405
415
|
// accounts it natively (F12) and the job-end money contract is unchanged.
|
|
406
416
|
{
|
|
407
417
|
const outcome = await runPlan(job, {
|
|
408
|
-
workdir, provider, nativeProvider, providerFor, judgeProvider, emit: meter, capRuns, ...(strikeLimit !== undefined ? { strikeLimit } : {}), closeTimeoutMs, layerRoot, readShim, bridge, priorWallMs: chainWallMs, resumeSeed, resumeGrades, resumeReplans, resumeBranch, humanRuling, heldRuling, reviewDoor, doorRerun, priorSpentUsd: chainFoldUsd,
|
|
418
|
+
workdir, provider, nativeProvider, providerFor, judgeProvider, emit: meter, capRuns, ...(strikeLimit !== undefined ? { strikeLimit } : {}), closeTimeoutMs, layerRoot, readShim, scout, bridge, priorWallMs: chainWallMs, resumeSeed, resumeGrades, resumeReplans, resumeBranch, humanRuling, heldRuling, reviewDoor, doorRerun, priorSpentUsd: chainFoldUsd,
|
|
409
419
|
remainingUsd: () => Math.min(shellCapUsd, job.budgetUsd - spentUsd),
|
|
410
420
|
isUnpriced: () => unpriced, // F6: let the plan flow bail in-flight, not just after it returns
|
|
411
421
|
spendComplete, // …and let its money-halt readout say whether the remaining it quotes is exact
|