bareloop 0.31.0 → 0.33.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +151 -0
- package/README.md +4 -1
- package/bareloop.context.md +232 -85
- package/bin/bareloop.mjs +0 -1
- package/package.json +1 -1
- package/src/authorflow.js +5 -3
- package/src/authorjob.js +3 -1
- package/src/authorrun.js +49 -11
- package/src/authorscout.js +10 -6
- package/src/bundlerun.js +266 -0
- package/src/cli.js +35 -357
- package/src/config.js +135 -0
- package/src/interviewrun.js +13 -4
- package/src/invoke.js +22 -0
- package/src/judged.js +5 -3
- package/src/keysfile.js +156 -0
- package/src/ledger.js +26 -0
- package/src/monthly.js +422 -0
- package/src/panel/authorroutes.js +61 -16
- package/src/panel/authorsession.js +46 -28
- package/src/panel/index.html +287 -13
- package/src/panel/server.js +58 -51
- package/src/panel/settingsroutes.js +230 -0
- package/src/planrun.js +7 -3
- package/src/providerrows.js +301 -0
- package/src/providers.js +3 -1
- package/src/run.js +7 -2
- package/src/runlist.js +92 -7
- package/src/source.js +8 -3
- package/src/u-watchdog.mjs +296 -0
- package/src/userrun.js +230 -54
- package/types/authorflow.d.ts +8 -2
- package/types/authorjob.d.ts +6 -1
- package/types/authorrun.d.ts +5 -0
- package/types/authorscout.d.ts +11 -2
- package/types/bundlerun.d.ts +26 -0
- package/types/cli.d.ts +2 -1
- package/types/config.d.ts +48 -0
- package/types/interviewrun.d.ts +5 -0
- package/types/invoke.d.ts +8 -0
- package/types/judged.d.ts +5 -1
- package/types/keysfile.d.ts +90 -0
- package/types/ledger.d.ts +14 -0
- package/types/monthly.d.ts +227 -0
- package/types/panel/authorroutes.d.ts +4 -2
- package/types/panel/authorsession.d.ts +7 -15
- package/types/panel/server.d.ts +10 -3
- package/types/panel/settingsroutes.d.ts +14 -0
- package/types/planrun.d.ts +13 -1
- package/types/providerrows.d.ts +229 -0
- package/types/run.d.ts +14 -1
- package/types/runlist.d.ts +66 -3
- package/types/source.d.ts +8 -3
- package/types/userrun.d.ts +54 -0
package/src/authorrun.js
CHANGED
|
@@ -100,6 +100,9 @@
|
|
|
100
100
|
// item 34 L17) — `run-interview.mjs` asks for it and writes it
|
|
101
101
|
// in; a draft missing one, or naming one the provider factory
|
|
102
102
|
// does not know, dies here loud, listing the known table.
|
|
103
|
+
import { keysForDoor } from './keysfile.js';
|
|
104
|
+
import { applyConfiguredKey, judgeRatesFor, keyNameFor, ratesFor, rowsForHome } from './providerrows.js';
|
|
105
|
+
import { ConfigError } from './config.js';
|
|
103
106
|
import {
|
|
104
107
|
readFileSync, writeFileSync, appendFileSync, mkdirSync, existsSync, renameSync, statSync,
|
|
105
108
|
} from 'node:fs';
|
|
@@ -119,6 +122,7 @@ import { scanSecrets, redactSecrets } from './validate.js';
|
|
|
119
122
|
import { detectLanguage } from './detectlang.js';
|
|
120
123
|
import { closeJudges, GATE_AUDIT_FILE } from './kinds.js';
|
|
121
124
|
import { resolveProvider, buildRunnerProviders, apiKeyProblem } from './providers.js';
|
|
125
|
+
import { commandFor } from './invoke.js';
|
|
122
126
|
import { readSourceManifest, missingDependencies } from './source.js';
|
|
123
127
|
import { tallyCalls } from './text.js';
|
|
124
128
|
import {
|
|
@@ -141,6 +145,7 @@ class ExitSignal extends Error {
|
|
|
141
145
|
/**
|
|
142
146
|
* @typedef {object} Deps
|
|
143
147
|
* @property {Record<string,string|undefined>} [env]
|
|
148
|
+
* @property {string} [keysHome] P4a — test seam: load this home's `.env` keys file even when `env` is injected.
|
|
144
149
|
* @property {NodeJS.ReadStream} [stdin]
|
|
145
150
|
* @property {NodeJS.WritableStream} [stdout]
|
|
146
151
|
* @property {NodeJS.WritableStream} [stderr]
|
|
@@ -162,13 +167,16 @@ class ExitSignal extends Error {
|
|
|
162
167
|
* @returns {Promise<number>} an exit code — never calls process.exit
|
|
163
168
|
*/
|
|
164
169
|
export async function main(argv, deps = {}) {
|
|
165
|
-
|
|
170
|
+
// P4a item 1 — the keys file fills what the shell leaves unset (shell wins).
|
|
171
|
+
const keys = keysForDoor(deps);
|
|
172
|
+
const env = keys.env;
|
|
166
173
|
const stdin = deps.stdin ?? process.stdin;
|
|
167
174
|
const stdout = deps.stdout ?? process.stdout;
|
|
168
175
|
const stderr = deps.stderr ?? process.stderr;
|
|
176
|
+
if (keys.warning && deps.env === undefined) stderr.write(`WARNING: ${keys.warning}\n`);
|
|
169
177
|
const out = (/** @type {string} */ s = '') => { stdout.write(`${s}\n`); };
|
|
170
178
|
const err = (/** @type {string} */ s) => { stderr.write(`${s}\n`); };
|
|
171
|
-
const invokedAs = deps.invokedAs ?? '
|
|
179
|
+
const invokedAs = deps.invokedAs ?? commandFor('author', undefined);
|
|
172
180
|
let exitCode = 0;
|
|
173
181
|
try {
|
|
174
182
|
/** the close precheck / seed read spawns real toolchains; the slowest stage is a
|
|
@@ -499,8 +507,12 @@ export async function main(argv, deps = {}) {
|
|
|
499
507
|
|
|
500
508
|
// Secrets load from the environment; they never enter argv (a command line is
|
|
501
509
|
// world-readable on /proc) and they are never printed.
|
|
502
|
-
|
|
503
|
-
|
|
510
|
+
// P4a item 4 — the key variable the person picked in Settings (config.json) stands in for
|
|
511
|
+
// the provider's built-in one; none picked = the built-in. Names only.
|
|
512
|
+
const keyCfg = rowsForHome(keys.home);
|
|
513
|
+
const authorEnv = applyConfiguredKey(env, PROVIDER_NAME, baseUrl, keyCfg);
|
|
514
|
+
const AUTHOR_ENV_KEY = keyNameFor(PROVIDER_NAME, baseUrl, keyCfg).name;
|
|
515
|
+
const apiKey = authorEnv[/** @type {NonNullable<typeof providerEntry>} */ (providerEntry).envKey];
|
|
504
516
|
if (!apiKey) { err(`${AUTHOR_ENV_KEY} not set (secrets load from the environment — never the tree, never argv)`); throw new ExitSignal(2); }
|
|
505
517
|
// F181 — a key that carries a line break/control char/stray whitespace (a
|
|
506
518
|
// two-line secret-store entry, e.g.) reads as "set" by the presence check
|
|
@@ -509,12 +521,22 @@ export async function main(argv, deps = {}) {
|
|
|
509
521
|
// echoes the value or the reason's source.
|
|
510
522
|
const AUTHOR_KEY_PROBLEM = apiKeyProblem(apiKey);
|
|
511
523
|
if (AUTHOR_KEY_PROBLEM) { err(`${AUTHOR_ENV_KEY} ${AUTHOR_KEY_PROBLEM} — refusing rather than crashing mid-call (never trimmed or repaired; fix the value at its source)`); throw new ExitSignal(2); }
|
|
524
|
+
// The customer's own price on the author's key row (USD per 1M in config.json) — resolved once here at
|
|
525
|
+
// $0, before any provider is built, and handed to every model call this run makes (scout, declaration
|
|
526
|
+
// loop, confirm turn, calibration judge). A bad price refuses here; it never falls back to the guess.
|
|
527
|
+
/** @type {ReturnType<typeof ratesFor>} */
|
|
528
|
+
let authorPrice = null;
|
|
529
|
+
try { authorPrice = ratesFor(PROVIDER_NAME, baseUrl, keyCfg); } catch (e) {
|
|
530
|
+
if (!(e instanceof ConfigError)) throw e;
|
|
531
|
+
err(`${e.message} — refusing to start rather than guess a price. Nothing spent.`);
|
|
532
|
+
throw new ExitSignal(2);
|
|
533
|
+
}
|
|
512
534
|
/** The judge's key follows the RESOLVED judge provider's own env var, with
|
|
513
535
|
* `JUDGE_API_KEY` as the role-named override in front (PRD item 32.3) — the same
|
|
514
536
|
* contract `scripts/run-u.mjs` keeps, so one story covers both surfaces. When the
|
|
515
537
|
* judge IS the authoring provider this is the key already read above. */
|
|
516
538
|
const judgeKeyFor = (/** @type {string} */ providerName) => (
|
|
517
|
-
env.JUDGE_API_KEY ?? env[resolveProvider(providerName).envKey]
|
|
539
|
+
env.JUDGE_API_KEY ?? (providerName === PROVIDER_NAME ? authorEnv : applyConfiguredKey(env, providerName, undefined, keyCfg))[resolveProvider(providerName).envKey]
|
|
518
540
|
);
|
|
519
541
|
|
|
520
542
|
/** F6 — an unpriced call makes the TOTAL unknown, and unknown is reported as
|
|
@@ -789,7 +811,7 @@ export async function main(argv, deps = {}) {
|
|
|
789
811
|
* bound to `CONFIRM_SYSTEM`, never the authoring `generate` above (that one
|
|
790
812
|
* is bound to `AUTHOR_SYSTEM`). Same provider instance, a different system
|
|
791
813
|
* prompt: reusing `generate` would run the wrong system prompt silently. */
|
|
792
|
-
const confirmGenerate = makeLoopGenerate(provider, { system: CONFIRM_SYSTEM });
|
|
814
|
+
const confirmGenerate = makeLoopGenerate(provider, { system: CONFIRM_SYSTEM, rates: authorPrice?.rates ?? null });
|
|
793
815
|
|
|
794
816
|
// ── PLAIN-FOLDER SOURCE: A NAMED $0 STOP, NO MODEL CALL (F191; PRD item 33 M3
|
|
795
817
|
// piece 4, step S6, D5 amended 2026-09-21) ──────────────────────────────────
|
|
@@ -858,7 +880,8 @@ export async function main(argv, deps = {}) {
|
|
|
858
880
|
// malformed field travels as `null` and `authorPrompt` simply states nothing.
|
|
859
881
|
writeScope: Array.isArray(draft.writeScope) ? draft.writeScope : null,
|
|
860
882
|
provider,
|
|
861
|
-
generate: makeLoopGenerate(provider),
|
|
883
|
+
generate: makeLoopGenerate(provider, { rates: authorPrice?.rates ?? null }),
|
|
884
|
+
rates: authorPrice?.rates ?? null,
|
|
862
885
|
// ONE number, both paid seams (the survey's and the declaration loop's) — the
|
|
863
886
|
// advertised ceiling and the enforced ceiling are the same ceiling
|
|
864
887
|
ceilingUsd: CEILING_USD,
|
|
@@ -1051,6 +1074,21 @@ export async function main(argv, deps = {}) {
|
|
|
1051
1074
|
const judge = judges
|
|
1052
1075
|
? resolveJobJudge(spec, PROVIDER_NAME, resolveWorkerModel)
|
|
1053
1076
|
: null;
|
|
1077
|
+
// the judge's price: the judge model's own row (`judgeRatesFor`) — on the author's endpoint when the
|
|
1078
|
+
// judge is the same provider, else the row for the judge's own provider. A bad one refuses here,
|
|
1079
|
+
// before the calibration gate spends anything — beside the judge-key check, the same door.
|
|
1080
|
+
/** @type {ReturnType<typeof ratesFor>} */
|
|
1081
|
+
let judgePrice = null;
|
|
1082
|
+
if (judge) {
|
|
1083
|
+
try {
|
|
1084
|
+
judgePrice = judgeRatesFor(judge, PROVIDER_NAME, baseUrl, authorPrice, keyCfg);
|
|
1085
|
+
} catch (e) {
|
|
1086
|
+
if (!(e instanceof ConfigError)) throw e;
|
|
1087
|
+
err(`${e.message} — refusing before the calibration gate spends anything`);
|
|
1088
|
+
archiveGateAudit();
|
|
1089
|
+
throw new ExitSignal(2);
|
|
1090
|
+
}
|
|
1091
|
+
}
|
|
1054
1092
|
// F181 — the judge key is required exactly when `judges` is true (this
|
|
1055
1093
|
// script has no presence check on it today; adding one is out of this
|
|
1056
1094
|
// finding's scope). What this door DOES owe, the same as the worker
|
|
@@ -1120,7 +1158,7 @@ export async function main(argv, deps = {}) {
|
|
|
1120
1158
|
// number would silently widen it.
|
|
1121
1159
|
ceilingUsd: CEILING_USD,
|
|
1122
1160
|
priorCalls: [...metered],
|
|
1123
|
-
judgeLoop: judgeProvider ? (o) => defaultJudgeLoop({ provider: judgeProvider, system: o.system }) : null,
|
|
1161
|
+
judgeLoop: judgeProvider ? (o) => defaultJudgeLoop({ provider: judgeProvider, system: o.system, rates: judgePrice?.rates ?? null }) : null,
|
|
1124
1162
|
// the seam's other half (PRD item 32.1): the graded set is STAMPED with
|
|
1125
1163
|
// this, and the close later refuses to grade under any other identity.
|
|
1126
1164
|
judgeModel: judge?.model ?? null,
|
|
@@ -1225,8 +1263,8 @@ export async function main(argv, deps = {}) {
|
|
|
1225
1263
|
// <path>` and reads this SAME prepared copy's own source manifest for
|
|
1226
1264
|
// the workdir and seed — so the command below is the whole of what a
|
|
1227
1265
|
// person needs to run their own job, nothing left to hand off.
|
|
1228
|
-
// `
|
|
1229
|
-
// 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.
|
|
1230
1268
|
// hamr's ruling 2026-09-28 (2nd addendum) — `totalCost.costUsd === null`
|
|
1231
1269
|
// means this pipeline's own metered list carried at least one unpriced
|
|
1232
1270
|
// call (`tallyCalls`'s `spendComplete: false`), so the drafting fold is
|
|
@@ -1239,7 +1277,7 @@ export async function main(argv, deps = {}) {
|
|
|
1239
1277
|
+ 'and run-u will enforce the remainder (Cap $ minus this) as ITS ceiling — pass --draft-spent-usd exactly as shown below, never a rounded or re-typed figure.');
|
|
1240
1278
|
}
|
|
1241
1279
|
out('\nTo run it (the same signature and gates as any other job — nothing here bypasses them):');
|
|
1242
|
-
out(` ${
|
|
1280
|
+
out(` ${keyNameFor(PROVIDER_NAME, baseUrl, keyCfg, spec.model).name}=... ${commandFor('run-u', deps.invokedAs)} --spec ${specFile} --approve ${hash}${draftSpentUsd > 0 ? ` --draft-spent-usd ${draftSpentUsd}${draftIncomplete ? ' --draft-spend-incomplete' : ''}` : ''}`);
|
|
1243
1281
|
emit('author-end', { outcome: 'prepared', specHash: hash });
|
|
1244
1282
|
}
|
|
1245
1283
|
}
|
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
|
+
}
|