bareloop 0.20.0 → 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 +67 -0
- package/README.md +14 -0
- package/bareloop.context.md +215 -0
- 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/reuse.js +1 -1
- package/types/bundle.d.ts +199 -0
- package/types/cli.d.ts +19 -0
- package/types/index.d.ts +3 -1
- package/types/reuse.d.ts +14 -0
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/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
|
|