bareloop 0.5.0 → 0.6.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 +746 -1
- package/README.md +13 -3
- package/bareloop.context.md +311 -234
- package/package.json +3 -3
- package/src/clock.js +181 -0
- package/src/exits.js +8 -2
- package/src/index.js +23 -17
- package/src/job.js +231 -108
- package/src/ledger.js +28 -3
- package/src/plan.js +356 -20
- package/src/planrun.js +978 -98
- package/src/ralph.js +226 -7
- package/src/root.js +32 -12
- package/src/run.js +61 -390
- package/src/stall.js +252 -0
- package/src/tools.js +611 -0
- package/src/validate.js +35 -250
- package/types/clock.d.ts +109 -0
- package/types/index.d.ts +4 -6
- package/types/job.d.ts +83 -8
- package/types/ledger.d.ts +3 -1
- package/types/plan.d.ts +107 -7
- package/types/planrun.d.ts +51 -3
- package/types/ralph.d.ts +62 -4
- package/types/root.d.ts +10 -2
- package/types/run.d.ts +9 -17
- package/types/stall.d.ts +80 -0
- package/types/tools.d.ts +262 -0
- package/types/validate.d.ts +21 -38
- package/src/extract.js +0 -103
- package/src/interpret.js +0 -887
- package/types/extract.d.ts +0 -25
- package/types/interpret.d.ts +0 -201
package/src/run.js
CHANGED
|
@@ -1,83 +1,23 @@
|
|
|
1
|
-
// The
|
|
1
|
+
// The runner — the shell's top layer. It owns everything the emergent parts
|
|
2
2
|
// must never touch: the approval gate (human-signs-always), the primitive
|
|
3
3
|
// smoke, the ONE budget ledger (cap-not-estimate; unpriced is never free, F6),
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
4
|
+
// and the decision-ready stops. It composes the plan flow below it and
|
|
5
|
+
// interprets nothing itself.
|
|
6
|
+
// Design record: docs/plans/2026-07-12-n2-headless-loop-design.md.
|
|
7
|
+
//
|
|
8
|
+
// The legacy operator-authored `steps[]` path was DELETED 2026-07-26 (PRD
|
|
9
|
+
// v1.32): the drafting call, config-v1, the per-step interpret loops and the
|
|
10
|
+
// hitl draft-PR step went with it. runJob is now approval → smoke → ledger →
|
|
11
|
+
// runPlan, and `hitl`/`soft-green` return as a Layer 3 decision, rebuilt in the
|
|
12
|
+
// plan shape rather than ported.
|
|
7
13
|
|
|
8
|
-
import { createRequire } from 'node:module';
|
|
9
14
|
import { join } from 'node:path';
|
|
10
|
-
import {
|
|
11
|
-
import { LiteCtx, COMPRESS_LEVELS, KINDS } from 'litectx';
|
|
12
|
-
import { redact } from 'bareguard';
|
|
15
|
+
import { LiteCtx } from 'litectx';
|
|
13
16
|
import { validateJob, jobSpecHash, checkApproval } from './job.js';
|
|
14
|
-
import { validateConfig, LOOP_SHAPES, SLOTS, VERBS, globToPrefix, SECRET_PATTERNS, REMEMBER_KINDS } from './validate.js';
|
|
15
|
-
import { interpret } from './interpret.js';
|
|
16
17
|
import { runPlan } from './planrun.js';
|
|
17
|
-
import { runClose, CLOSE_FAULTS } from './ralph.js';
|
|
18
|
-
import { extractArtifact, priceOf } from './text.js';
|
|
19
|
-
|
|
20
|
-
const require = createRequire(import.meta.url);
|
|
21
|
-
const { Loop } = require('bare-agent');
|
|
22
18
|
|
|
23
19
|
/** @typedef {{code: string, path: string, detail?: string}} Red */
|
|
24
20
|
|
|
25
|
-
/**
|
|
26
|
-
* The shell's own drafting allowance, carved out of the job budget (F9).
|
|
27
|
-
* Drafting is a paid shell call that happens BEFORE the config it produces is
|
|
28
|
-
* validated — so a ceiling advertised as the whole job budget is invalidated by
|
|
29
|
-
* the very call that answers it, and a rational drafter (one that claims the
|
|
30
|
-
* ceiling it is given) deadlocks every run. The shell therefore reserves this
|
|
31
|
-
* slice for itself and advertises `budget − reserve` as the ceiling the config
|
|
32
|
-
* may claim. It is a CAP, not an estimate: overspend it and the run stops on
|
|
33
|
-
* the budget story it is, never on a config-red blaming the drafter.
|
|
34
|
-
*/
|
|
35
|
-
const DRAFT_RESERVE_FRAC = 0.05;
|
|
36
|
-
/** truncate (never round up) — an advertised ceiling must be one the shell can honour */
|
|
37
|
-
const floor4 = (/** @type {number} */ n) => Math.floor(n * 10_000) / 10_000;
|
|
38
|
-
|
|
39
|
-
// The drafting prompt: a schema DESCRIPTION built from the live validator
|
|
40
|
-
// menus — never a copyable example config (the drafter must author, not echo;
|
|
41
|
-
// the N2 drafting probe proved a frontier model greens on this, F6). The
|
|
42
|
-
// DRAFT-CONFIG sentinel is fixed shell vocabulary, not content.
|
|
43
|
-
// REMEMBER_KINDS comes from the validator: the prompt advertises the SAME menu
|
|
44
|
-
// the validator enforces — one source, no drift.
|
|
45
|
-
/** @param {any} job @param {Red[]|null} reds @param {number} ceiling the budget the config may actually claim (F9) */
|
|
46
|
-
function draftPrompt(job, reds, ceiling) {
|
|
47
|
-
const doc = `DRAFT-CONFIG
|
|
48
|
-
You are drafting a workflow config (schema "v1") for the job spec below. The config is
|
|
49
|
-
pure declarative JSON validated by a strict schema; ANY unknown field, wrong enum value,
|
|
50
|
-
or out-of-bounds number is rejected. Output ONLY the JSON object, no fences, no commentary.
|
|
51
|
-
|
|
52
|
-
Required top-level fields (no others exist):
|
|
53
|
-
- "schema": must be exactly "v1"
|
|
54
|
-
- "loop": { "shape": one of ${JSON.stringify([...LOOP_SHAPES])}, "maxIterations": optional integer 1..8 }
|
|
55
|
-
- "memory": { "store": must be "litectx",
|
|
56
|
-
"recall": optional { "k": integer 1..20, "kinds": optional non-empty subset of ${JSON.stringify([...KINDS])} },
|
|
57
|
-
"compressLevel": optional, one of ${JSON.stringify([...COMPRESS_LEVELS])} }
|
|
58
|
-
- "gate": { "budgetUsd": number, 0 < n <= the ceiling stated below, "writeScope": array of path-prefix globs like "src/**" }
|
|
59
|
-
- "escalation": { "mode": must be "decision-ready" }
|
|
60
|
-
- "hooks": optional; keys from ${JSON.stringify([...SLOTS])}, each an array of ops.
|
|
61
|
-
Each op is { "op": one of ${JSON.stringify([...VERBS])}, ...params }. STRICT slot legality:
|
|
62
|
-
"recall" (optional "k", "kinds") and "compress" (optional "level") ONLY in "before-attempt";
|
|
63
|
-
"stash" (no params) ONLY in "after-red"; "remember" (optional "kind", one of
|
|
64
|
-
${JSON.stringify(REMEMBER_KINDS)}) ONLY in "on-green".
|
|
65
|
-
|
|
66
|
-
Hard constraints from the job spec (violations are rejected):
|
|
67
|
-
- Every writeScope entry must fit INSIDE the job's writeScope (${JSON.stringify(job.writeScope)}).
|
|
68
|
-
No "..", no absolute paths, no mid-path wildcards.
|
|
69
|
-
- gate.budgetUsd must be <= ${ceiling}. (This is ONE STEP's share of the job's
|
|
70
|
-
$${job.budgetUsd} budget, minus the shell's own drafting allowance: this config is drafted
|
|
71
|
-
once and reused for every step, so the number must still fit after earlier steps have
|
|
72
|
-
spent. Claiming exactly ${ceiling} is legal and validates.)
|
|
73
|
-
- The config CANNOT contain close commands, provider choice, retry caps, or minting claims.
|
|
74
|
-
|
|
75
|
-
Job spec:
|
|
76
|
-
${JSON.stringify(job, null, 2)}`;
|
|
77
|
-
return reds
|
|
78
|
-
? `${doc}\n\nYour previous draft was REJECTED with these reds (code:path):\n${JSON.stringify(reds)}\nFix every red. Output ONLY the corrected JSON object.`
|
|
79
|
-
: doc;
|
|
80
|
-
}
|
|
81
21
|
|
|
82
22
|
/**
|
|
83
23
|
* Known-answer smoke on the primitive the run is about to trust (PRD v1.5 §4,
|
|
@@ -99,71 +39,6 @@ async function primitiveSmoke(workdir) {
|
|
|
99
39
|
}
|
|
100
40
|
}
|
|
101
41
|
|
|
102
|
-
/** @typedef {(argv: string[], opts?: {cwd?: string}) => {status: number|null, stdout: string, stderr: string}} ExecCmd */
|
|
103
|
-
|
|
104
|
-
/** default process runner for the hitl PR mechanics — real spawnSync, no shell
|
|
105
|
-
* @type {ExecCmd} */
|
|
106
|
-
const defaultExec = (argv, { cwd } = {}) => {
|
|
107
|
-
const r = spawnSync(argv[0], argv.slice(1), { cwd, encoding: 'utf8', timeout: 60_000 });
|
|
108
|
-
if (r.error) return { status: null, stdout: '', stderr: String(r.error) };
|
|
109
|
-
return { status: r.status, stdout: r.stdout ?? '', stderr: r.stderr ?? '' };
|
|
110
|
-
};
|
|
111
|
-
|
|
112
|
-
/**
|
|
113
|
-
* The hitl middle's work (2b): branch → stage the job fence ONLY → commit →
|
|
114
|
-
* push → draft PR. Deterministic runner code, never model tools (the model
|
|
115
|
-
* never sees a git or gh surface — design addendum 2026-07-12b). Staging is
|
|
116
|
-
* fence-scoped so spines, audit logs, and anything else living in the workdir
|
|
117
|
-
* never enter the PR. Any failure is an honest red carrying the failed argv;
|
|
118
|
-
* the caller still escalates — a broken PR path must never swallow the
|
|
119
|
-
* escalation (the pain channel, law #7).
|
|
120
|
-
* The checkout is handed back exactly as it was found: the starting branch is
|
|
121
|
-
* read BEFORE anything moves and restored on EVERY path, success or failure
|
|
122
|
-
* (found by the real job #1 run, 2026-07-13 — the workdir was left sitting on
|
|
123
|
-
* the bareloop branch, so the next cadenced run would branch off yesterday's
|
|
124
|
-
* unmerged branch and judge its close against that state). A restore that
|
|
125
|
-
* itself fails is loud (`strandedOn`), never silent.
|
|
126
|
-
*
|
|
127
|
-
* @param {{workdir: string, branch: string, title: string, body: string, addPaths: string[], exec: ExecCmd}} o
|
|
128
|
-
* @returns {Promise<{url: string|null, red: {argv: string, detail: string}|null, strandedOn: string|null}>}
|
|
129
|
-
*/
|
|
130
|
-
async function openDraftPr({ workdir, branch, title, body, addPaths, exec }) {
|
|
131
|
-
// Subprocess output is scrubbed AT CAPTURE with the ONE shape inventory —
|
|
132
|
-
// the same doctrine as the close path (interpret wires SECRET_PATTERNS into
|
|
133
|
-
// ralph): git/gh error text can echo a credentialed remote URL, and the
|
|
134
|
-
// detail lands on the append-only spine (pr-red + the escalation's pr.error).
|
|
135
|
-
const scrub = (/** @type {string} */ s) => redact(s, { patterns: SECRET_PATTERNS });
|
|
136
|
-
const head = exec(['git', 'rev-parse', '--abbrev-ref', 'HEAD'], { cwd: workdir });
|
|
137
|
-
const startBranch = head.status === 0 ? head.stdout.trim() : '';
|
|
138
|
-
/** put the checkout back where it started; report the branch it is stranded on if that fails */
|
|
139
|
-
const restore = () => {
|
|
140
|
-
if (!startBranch) return null; // never knew where we started — nothing honest to restore to
|
|
141
|
-
const r = exec(['git', 'checkout', startBranch], { cwd: workdir });
|
|
142
|
-
return r.status === 0 ? null : branch;
|
|
143
|
-
};
|
|
144
|
-
/** @param {{url?: string|null, red?: {argv: string, detail: string}|null}} o */
|
|
145
|
-
const done = ({ url = null, red = null }) => ({ url, red, strandedOn: restore() });
|
|
146
|
-
|
|
147
|
-
const steps = [
|
|
148
|
-
['git', 'checkout', '-b', branch],
|
|
149
|
-
['git', 'add', '--', ...addPaths],
|
|
150
|
-
['git', 'commit', '-m', title],
|
|
151
|
-
['git', 'push', '-u', 'origin', branch],
|
|
152
|
-
['gh', 'pr', 'create', '--draft', '--title', title, '--body', body],
|
|
153
|
-
];
|
|
154
|
-
for (const argv of steps) {
|
|
155
|
-
const r = exec(argv, { cwd: workdir });
|
|
156
|
-
if (r.status !== 0) {
|
|
157
|
-
return done({ red: { argv: argv.join(' '), detail: `${argv.slice(0, 2).join(' ')} failed: ${scrub((r.stderr || r.stdout || `exit ${r.status}`)).trim().slice(0, 500)}` } });
|
|
158
|
-
}
|
|
159
|
-
if (argv[0] === 'gh') {
|
|
160
|
-
const url = (r.stdout.match(/https?:\/\/\S+/) ?? [null])[0];
|
|
161
|
-
// a "successful" gh with no link is not a PR the human can act on
|
|
162
|
-
return url ? done({ url }) : done({ red: { argv: argv.join(' '), detail: 'no PR URL in gh output' } });
|
|
163
|
-
}
|
|
164
|
-
}
|
|
165
|
-
return done({ red: { argv: 'gh pr create', detail: 'PR step never ran' } }); // unreachable while gh is last; belt for edits
|
|
166
|
-
}
|
|
167
42
|
|
|
168
43
|
/**
|
|
169
44
|
* Run an approved job end to end: approval gate → primitive smoke → sequential
|
|
@@ -194,35 +69,52 @@ async function openDraftPr({ workdir, branch, title, body, addPaths, exec }) {
|
|
|
194
69
|
* @param {object} opts
|
|
195
70
|
* @param {unknown} opts.approvals `{ specHash, signer, ts }` records (from OUTSIDE the spec)
|
|
196
71
|
* @param {string} opts.workdir the run directory (the fence's root)
|
|
197
|
-
* @param {string} [opts.target] the step artifact path (text-mode steps; unused by tool-mode steps)
|
|
198
72
|
* @param {any} opts.provider shell-owned LLM binding (never the config's; the Loop path)
|
|
199
73
|
* @param {(o: {policy: Function, onTurn?: Function, maxTurns: number, hasTools: boolean}) => any} [opts.nativeProvider]
|
|
200
74
|
* native clipipe provider factory (BA-16), required for a `clipipe-subscription` plan-shape job —
|
|
201
75
|
* threaded to runPlan, which builds one per worker (native tool mode when `hasTools`, else a
|
|
202
76
|
* metered claude-json text provider for the toolless drafter); ignored on every Loop-driven provider
|
|
203
77
|
* @param {(type: string, data?: object) => object} opts.emit spine emitter
|
|
204
|
-
* @param {
|
|
78
|
+
* @param {(tier: string) => any} [opts.providerFor] P: per-step model-tier provider factory (forwarded to the plan flow)
|
|
79
|
+
* @param {number} [opts.capRuns] shell-owned per-step attempt cap
|
|
205
80
|
* @param {number} [opts.shellCapUsd] the shell's hard USD ceiling
|
|
206
81
|
* @param {number} [opts.closeTimeoutMs] close wall-clock cap (shell territory)
|
|
207
|
-
* @param {ExecCmd} [opts.execCmd] process runner for the hitl PR mechanics
|
|
208
82
|
* @param {boolean} [opts.layerRoot=false] Layer R (within-run ratchet) — shell
|
|
209
|
-
* territory, threaded to
|
|
83
|
+
* territory, threaded to the plan flow. Defaults OFF
|
|
210
84
|
* (decided 2026-07-21): fixation is extinct on every current job (F41), so
|
|
211
85
|
* ON has never won its own A/B — the acceptance read defers to Layer 2 (or
|
|
212
86
|
* a manufactured-fixation probe). `true` is the ON/experimental arm; the
|
|
213
87
|
* default is the OFF arm. (Shell-owned seam, same doctrine as the provider
|
|
214
88
|
* binding.)
|
|
215
|
-
* @returns {Promise<string>} outcome: 'green' | '
|
|
216
|
-
* '
|
|
217
|
-
* '
|
|
89
|
+
* @returns {Promise<string>} outcome: 'green' | 'already-green' | 'escalated' |
|
|
90
|
+
* 'unapproved-spec' | 'job-red' | 'smoke-red' | 'plan-red' | 'check-red' |
|
|
91
|
+
* 'close-red' | 'close-unsupported' | 'pricing-red' | 'provider-red' |
|
|
92
|
+
* 'interpreter-red' | 'cap-halt' | 'wall-halt' | 'step-stalled' | `step-red:<id>`
|
|
218
93
|
*/
|
|
219
|
-
export async function runJob(rawSpec, { approvals, workdir,
|
|
94
|
+
export async function runJob(rawSpec, { approvals, workdir, provider, nativeProvider, providerFor, emit, capRuns = 3, shellCapUsd = 2, closeTimeoutMs, layerRoot = false }) {
|
|
220
95
|
// 0. the ledger's counters, declared FIRST so that every job-end — including
|
|
221
96
|
// the pre-token reds below — can state a real figure. An omitted `spentUsd` is
|
|
222
97
|
// not a zero: a consumer reads `undefined` and either crashes or launders it
|
|
223
98
|
// into $0 (F12's class, at the terminal record instead of mid-attempt).
|
|
224
99
|
let spentUsd = 0;
|
|
225
100
|
let unpriced = false;
|
|
101
|
+
// W1: did this run ABSORB a stall? The terminal `step-stalled` is the rare case;
|
|
102
|
+
// the common one self-heals (src/stall.js abandons the hung call and reissues),
|
|
103
|
+
// and an abandoned call may already have been billed — a reissue can pay twice
|
|
104
|
+
// and the provider never told us. So a run that stalls and then ends green (or
|
|
105
|
+
// escalated, or at the cap) also holds a FLOOR, not a total.
|
|
106
|
+
let stalled = false;
|
|
107
|
+
// F64: did the wall cut this run from INSIDE a provider call? A `wall-halt` is two
|
|
108
|
+
// stops wearing one name. Read between rounds (or between attempts, W-2) it is the
|
|
109
|
+
// cleanest terminal there is — nothing was in flight, every round the sum counted
|
|
110
|
+
// came back priced, and the figure is exact. But the deadline can also land inside a
|
|
111
|
+
// call and come back as the provider's own timeout, and THAT call returned no usage
|
|
112
|
+
// at all: it may already have been billed and will never say so. Same unknown as the
|
|
113
|
+
// transport floor and the self-healed stall, so the same honest answer.
|
|
114
|
+
// The discriminator is the wall-halt record's own `cutMidCall` field, never the
|
|
115
|
+
// outcome — keying on the outcome would floor the exact stop too, which is the same
|
|
116
|
+
// dishonesty pointing the other way.
|
|
117
|
+
let cutMidCall = false;
|
|
226
118
|
// The money on the terminal record, and whether the money is EXACT. `spentUsd`
|
|
227
119
|
// is the accumulated sum of PRICED rounds ONLY — never an estimate derived
|
|
228
120
|
// from tokens or averages (cap-not-estimate). When any round came back
|
|
@@ -230,7 +122,7 @@ export async function runJob(rawSpec, { approvals, workdir, target, provider, na
|
|
|
230
122
|
// says so machine-readably instead of dressing a floor up as exact. Emitted on
|
|
231
123
|
// EVERY job-end (true when everything was priced) so no consumer ever has to
|
|
232
124
|
// branch on field presence.
|
|
233
|
-
const spend = () => ({ spentUsd, spendComplete: !unpriced });
|
|
125
|
+
const spend = () => ({ spentUsd, spendComplete: !unpriced && !stalled && !cutMidCall });
|
|
234
126
|
// 1. human-signs-always — before ANY provider call (N1 decision #1)
|
|
235
127
|
if (!checkApproval(rawSpec, approvals)) {
|
|
236
128
|
emit('job-end', { outcome: 'unapproved-spec', detail: 'no approval record matches this exact spec version', ...spend() });
|
|
@@ -243,22 +135,9 @@ export async function runJob(rawSpec, { approvals, workdir, target, provider, na
|
|
|
243
135
|
return 'job-red';
|
|
244
136
|
}
|
|
245
137
|
const job = /** @type {any} */ (jv.job);
|
|
246
|
-
// Layer 2: the two exclusive shapes (decision 6). A plan-shape spec (goal/
|
|
247
|
-
// verdictType/close/checks[]) has no steps[] and its steps are tool-mode by
|
|
248
|
-
// construction — the text-mode target rule below is a legacy-steps rule only.
|
|
249
|
-
const planShape = job.goal !== undefined;
|
|
250
|
-
// Reds-before-tokens: a text-mode predicate step writes ONE artifact — with
|
|
251
|
-
// no target it would burn a draft + a worker call and then crash inside the
|
|
252
|
-
// middle as a misfiled interpreter-red. The spec is fine; the CALL is not.
|
|
253
|
-
if (!planShape && (typeof target !== 'string' || !target)
|
|
254
|
-
&& job.steps.some((/** @type {any} */ s) => s.close.type === 'predicate' && (s.mode ?? 'text') === 'text')) {
|
|
255
|
-
emit('job-red', { code: 'missing-required', path: 'opts.target', detail: 'text-mode steps write ONE artifact — pass opts.target (reds-before-tokens)' });
|
|
256
|
-
emit('job-end', { outcome: 'job-red', ...spend() });
|
|
257
|
-
return 'job-red';
|
|
258
|
-
}
|
|
259
138
|
emit('job-start', {
|
|
260
139
|
job: job.job, specHash: jobSpecHash(job), budgetUsd: job.budgetUsd,
|
|
261
|
-
|
|
140
|
+
shape: 'plan', goal: job.goal,
|
|
262
141
|
});
|
|
263
142
|
|
|
264
143
|
// 2. known-answer smoke before tokens (A3: silent degradation throws nothing)
|
|
@@ -288,6 +167,15 @@ export async function runJob(rawSpec, { approvals, workdir, target, provider, na
|
|
|
288
167
|
// unknown, never $0 — F6): per-round metering means a partially-unpriced run
|
|
289
168
|
// is caught natively, round by round, with no separate unpricedRounds tally.
|
|
290
169
|
if (type === 'worker-round') account(/** @type {any} */ (data)?.costUsd);
|
|
170
|
+
// W1: a `stall` is the plan flow announcing it ABANDONED a call and reissued
|
|
171
|
+
// it. The abandoned call's spend is unknowable from here (billed or not,
|
|
172
|
+
// indistinguishable), so from this point the priced sum is a floor no matter
|
|
173
|
+
// how the run ends — the flag is one-way and read by `spend()`.
|
|
174
|
+
if (type === 'stall') stalled = true;
|
|
175
|
+
// F64: the plan flow's own TIME record says which of the two wall stops this was.
|
|
176
|
+
// One-way, and read by `spend()` — the run ends on this record either way, but the
|
|
177
|
+
// between-rounds stop must keep its exact figure rather than inheriting a floor.
|
|
178
|
+
if (type === 'wall-halt' && /** @type {any} */ (data)?.cutMidCall === true) cutMidCall = true;
|
|
291
179
|
return emit(type, data);
|
|
292
180
|
};
|
|
293
181
|
const pricingRed = () => {
|
|
@@ -296,249 +184,32 @@ export async function runJob(rawSpec, { approvals, workdir, target, provider, na
|
|
|
296
184
|
return 'pricing-red';
|
|
297
185
|
};
|
|
298
186
|
|
|
299
|
-
// ──
|
|
300
|
-
// the
|
|
301
|
-
//
|
|
302
|
-
//
|
|
303
|
-
|
|
304
|
-
if (planShape) {
|
|
187
|
+
// ── the plan flow (SCOUT → PLAN → EXECUTE → close) under the approval gate,
|
|
188
|
+
// the smoke and the ledger above — runJob stays the one entry (N2 lock).
|
|
189
|
+
// runPlan emits every provider round as worker-round, so the metered emit
|
|
190
|
+
// accounts it natively (F12) and the job-end money contract is unchanged.
|
|
191
|
+
{
|
|
305
192
|
const outcome = await runPlan(job, {
|
|
306
|
-
workdir, provider, nativeProvider, emit: meter, capRuns, closeTimeoutMs,
|
|
193
|
+
workdir, provider, nativeProvider, providerFor, emit: meter, capRuns, closeTimeoutMs, layerRoot,
|
|
307
194
|
remainingUsd: () => Math.min(shellCapUsd, job.budgetUsd - spentUsd),
|
|
308
195
|
isUnpriced: () => unpriced, // F6: let the plan flow bail in-flight, not just after it returns
|
|
309
196
|
});
|
|
310
197
|
if (unpriced) return pricingRed();
|
|
311
198
|
if (outcome.startsWith('step-red:')) {
|
|
312
199
|
emit('job-end', { outcome: 'step-red', step: outcome.slice('step-red:'.length), ...spend() });
|
|
313
|
-
} else if (outcome === 'provider-red') {
|
|
200
|
+
} else if (outcome === 'provider-red' || outcome === 'step-stalled') {
|
|
314
201
|
// F44: a transport-throw provider-red never returned a usage figure for the
|
|
315
|
-
// failed call, so the priced sum is a FLOOR, not the total —
|
|
316
|
-
//
|
|
202
|
+
// failed call, so the priced sum is a FLOOR, not the total — spendComplete
|
|
203
|
+
// false, never an exact-looking total.
|
|
204
|
+
// F66: `step-stalled` carries the same unknown. Every abandoned call MAY
|
|
205
|
+
// already have been billed by the provider before it went quiet — that is
|
|
206
|
+
// the known cost of self-heal — and an unbilled reissue is indistinguishable
|
|
207
|
+
// from a billed one from here. Reporting the priced sum as exact would be F6
|
|
208
|
+
// in a self-heal coat.
|
|
317
209
|
emit('job-end', { outcome, ...spend(), spendComplete: false });
|
|
318
210
|
} else {
|
|
319
211
|
emit('job-end', { outcome, ...spend() });
|
|
320
212
|
}
|
|
321
213
|
return outcome;
|
|
322
214
|
}
|
|
323
|
-
|
|
324
|
-
// 4. sealed drafting: one shot + one redraft, reds fed back, PRICED path
|
|
325
|
-
// (through Loop, the same accounting the worker uses — never around it, F6)
|
|
326
|
-
const drafter = new Loop({ provider, system: 'You draft workflow configs as pure JSON. Output only the JSON object.' });
|
|
327
|
-
/** @type {string|null} a transport throw from the drafting call — spend UNKNOWN, terminal */
|
|
328
|
-
let transportRed = null;
|
|
329
|
-
/** @type {string|null} a drafting round the API TRUNCATED (BA-6, bare-agent 0.27.0) — provider-red like
|
|
330
|
-
* a throw, but the round WAS metered so spend is known; must never launder into a config-red (F25). */
|
|
331
|
-
let draftTruncated = null;
|
|
332
|
-
/** @param {Red[]|null} reds */
|
|
333
|
-
const draft = async (reds) => {
|
|
334
|
-
let r;
|
|
335
|
-
try {
|
|
336
|
-
r = await drafter.run([{ role: 'user', content: draftPrompt(job, reds, ceilingNow()) }]);
|
|
337
|
-
} catch (e) {
|
|
338
|
-
// Loop defaults throwOnError: true — a misconfigured binding or a bare
|
|
339
|
-
// transient 500 REJECTS here. The spend is UNKNOWN (F6), and the spine
|
|
340
|
-
// must still terminate: never an unhandled rejection out of the runner.
|
|
341
|
-
transportRed = String(/** @type {Error} */ (e)?.message ?? e);
|
|
342
|
-
emit('draft-result', { costUsd: null, redraft: reds !== null, error: transportRed });
|
|
343
|
-
return null;
|
|
344
|
-
}
|
|
345
|
-
const { costUsd, unpricedRounds } = priceOf(r); // the ONE honest-null cost read (F6)
|
|
346
|
-
account(costUsd);
|
|
347
|
-
if (unpricedRounds > 0) unpriced = true;
|
|
348
|
-
emit('draft-result', { costUsd, redraft: reds !== null, error: r.error ?? null });
|
|
349
|
-
// BA-6: the API cut the drafting round off mid-JSON. That is provider-red (the API's
|
|
350
|
-
// fault, not the drafter's), so it must NOT be laundered into a config-red blaming the
|
|
351
|
-
// drafter (F25's class) and must NOT consume the redraft — retrying a truncation blindly
|
|
352
|
-
// just burns budget; the human decides. Metered above, so unlike transportRed spend is known.
|
|
353
|
-
if (typeof r.error === 'string' && r.error.startsWith('truncated:')) { draftTruncated = r.error; return null; }
|
|
354
|
-
return r.error ? null : (extractArtifact(r.text).code ?? '');
|
|
355
|
-
};
|
|
356
|
-
const providerRed = () => {
|
|
357
|
-
emit('escalation', { category: 'provider-red', decisionReady: true, decision: 'The provider path threw before a result existed — spend for the failed call is unknown (F6), and no drafting verdict exists.', options: ['fix the provider binding', 'retry the run', 'abandon the run'], detail: transportRed, spentUsd });
|
|
358
|
-
// A transport THROW never returned a usage figure, so the floor is NOT the total
|
|
359
|
-
// (F6): spendComplete must be false — spend()'s `!unpriced` only knows about priced
|
|
360
|
-
// rounds that came back unpriced, not a call that never returned at all. Leaving it
|
|
361
|
-
// true would have the job-end contradict the escalation's own "spend … is unknown".
|
|
362
|
-
emit('job-end', { outcome: 'provider-red', ...spend(), spendComplete: false });
|
|
363
|
-
return 'provider-red';
|
|
364
|
-
};
|
|
365
|
-
const capNow = () => Math.min(shellCapUsd, job.budgetUsd - spentUsd);
|
|
366
|
-
// The ceiling the CONFIG may claim (F9): the job budget minus the shell's own
|
|
367
|
-
// drafting allowance, and never above what is actually left. Advertised in the
|
|
368
|
-
// prompt and enforced by the validator — ONE number, so a drafter that claims
|
|
369
|
-
// the ceiling it was given always validates. (Before: the prompt advertised the
|
|
370
|
-
// whole job budget while the validator enforced budget − drafting-spend, a bound
|
|
371
|
-
// the drafter was never told and could not satisfy.)
|
|
372
|
-
// ...and a PER-STEP share of it, not the whole pot. The config is drafted ONCE
|
|
373
|
-
// and re-validated at every step against what is LEFT, so a ceiling sized to the
|
|
374
|
-
// whole budget is stale the moment step 1 spends: step 2 then reds `bounds` on an
|
|
375
|
-
// unchanged config with plenty of money still in the pot (review 2026-07-18).
|
|
376
|
-
// Dividing by the steps that will actually run a worker makes the number the
|
|
377
|
-
// drafter is handed one that still fits after its predecessors have spent —
|
|
378
|
-
// sum(shares) = budget − reserve, so every step can claim its share. This is a
|
|
379
|
-
// DRAFTING change, not an enforcement one: cap-not-estimate is untouched, and a
|
|
380
|
-
// step that needs more than its share still cap-halts cleanly with a resume
|
|
381
|
-
// point. Single-step jobs (every shipped job today) are arithmetically unchanged.
|
|
382
|
-
const loopSteps = Math.max(1, job.steps.filter((s) => s.close.type === 'predicate').length);
|
|
383
|
-
const workerCeiling = floor4(job.budgetUsd * (1 - DRAFT_RESERVE_FRAC) / loopSteps);
|
|
384
|
-
const ceilingNow = () => Math.min(workerCeiling, capNow());
|
|
385
|
-
const cOpts = () => ({ shellCapUsd: ceilingNow(), jobWriteScope: job.writeScope });
|
|
386
|
-
// Drafting is DEFERRED to the first step that needs a worker (resume model,
|
|
387
|
-
// design addendum 2026-07-13): a step whose close already greens skips for
|
|
388
|
-
// zero tokens, so a clean rerun may need no config at all — drafting one no
|
|
389
|
-
// interpret will ever read is spend for nothing (F6's spirit). Decision #3
|
|
390
|
-
// holds: when a config IS needed, it is drafted fresh THIS run, never inherited.
|
|
391
|
-
/** @type {any} the validated config, drafted on first need */
|
|
392
|
-
let config = null;
|
|
393
|
-
/** @returns {Promise<string|null>} a terminal job outcome, or null with `config` set */
|
|
394
|
-
const ensureConfig = async () => {
|
|
395
|
-
if (config) return null;
|
|
396
|
-
let text = await draft(null);
|
|
397
|
-
let cv = validateConfig(text ?? '', cOpts());
|
|
398
|
-
emit('config-validate', { ok: cv.ok, reds: cv.reds, phase: 'draft-1' });
|
|
399
|
-
// the redraft only fires when it can still help: not over a transport red,
|
|
400
|
-
// not over unpriced spend, and never over an already-blown budget (a paid
|
|
401
|
-
// call against a negative cap can only red again — budget story, not drafter)
|
|
402
|
-
if (!cv.ok && !unpriced && !transportRed && !draftTruncated && capNow() > 0) {
|
|
403
|
-
text = await draft(cv.reds);
|
|
404
|
-
cv = validateConfig(text ?? '', cOpts());
|
|
405
|
-
emit('config-validate', { ok: cv.ok, reds: cv.reds, phase: 'draft-2' });
|
|
406
|
-
}
|
|
407
|
-
if (transportRed) return providerRed();
|
|
408
|
-
if (draftTruncated) {
|
|
409
|
-
// A truncated draft is provider-red, NOT config-red — the API cut it off; the drafter is
|
|
410
|
-
// not on trial. Mirrors the worker path (interpret.js) and the human's options: the round
|
|
411
|
-
// was metered, so this is a fault with a known cost, not a spend-unknown transport throw.
|
|
412
|
-
emit('escalation', { category: 'provider-red', decisionReady: true, decision: 'The drafting round was truncated at the output cap (BA-6) — the API cut the config off mid-generation, so no valid config exists. Not the drafter\'s competence (never a config-red), and not silently retried.', options: ['raise the drafting cap and rerun', 'retry the run', 'abandon the run'], detail: draftTruncated, spentUsd });
|
|
413
|
-
emit('job-end', { outcome: 'provider-red', ...spend() });
|
|
414
|
-
return 'provider-red';
|
|
415
|
-
}
|
|
416
|
-
if (unpriced) return pricingRed();
|
|
417
|
-
if (!cv.ok) {
|
|
418
|
-
if (capNow() <= 0) {
|
|
419
|
-
// drafting consumed the whole job budget before a valid config existed —
|
|
420
|
-
// the honest stop is a cap story, never a config-red blaming the drafter
|
|
421
|
-
emit('cap-halt', { category: 'cap-halt', meaning: 'not under cap — not "can\'t"', spentUsd, budgetUsd: job.budgetUsd });
|
|
422
|
-
emit('escalation', { category: 'cap-halt', decisionReady: true, decision: `Drafting spend ($${spentUsd.toFixed(4)}) consumed the job budget ($${job.budgetUsd}) before a valid config existed.`, options: ['raise the job budget and rerun', 'abandon the run'], spentUsd });
|
|
423
|
-
emit('job-end', { outcome: 'cap-halt', ...spend() });
|
|
424
|
-
return 'cap-halt';
|
|
425
|
-
}
|
|
426
|
-
for (const r of cv.reds) emit('config-red', r);
|
|
427
|
-
emit('job-end', { outcome: 'config-red', ...spend() });
|
|
428
|
-
return 'config-red';
|
|
429
|
-
}
|
|
430
|
-
config = cv.config;
|
|
431
|
-
return null;
|
|
432
|
-
};
|
|
433
|
-
|
|
434
|
-
// 5. sequential per-step loops under the one ledger; a step-red stops the
|
|
435
|
-
// job and the stop is a result (ladder discipline applied to steps)
|
|
436
|
-
for (const step of job.steps) {
|
|
437
|
-
if (step.close.type === 'hitl') {
|
|
438
|
-
// Nothing changed in the fence → nothing for a human to review (found by
|
|
439
|
-
// the real job #1 run, 2026-07-13): a cadenced run whose steps all skip
|
|
440
|
-
// already-green would otherwise branch/add/commit, and `git commit`
|
|
441
|
-
// correctly fails ("nothing added to commit") — a broken-PR escalation
|
|
442
|
-
// every single day. A hitl close is a human decision point; with no
|
|
443
|
-
// changes there IS no decision. Only an AFFIRMATIVE clean answer skips:
|
|
444
|
-
// a failed check (not a repo, broken git) falls through to the PR path
|
|
445
|
-
// and reds honestly there — an unknown fence state is never a green.
|
|
446
|
-
const fence = job.writeScope.map((/** @type {string} */ g) => globToPrefix(g));
|
|
447
|
-
const st = execCmd(['git', 'status', '--porcelain', '--', ...fence], { cwd: workdir });
|
|
448
|
-
if (st.status === 0 && st.stdout.trim() === '') {
|
|
449
|
-
emit('pr-skipped', { step: step.id, reason: 'no changes in the job fence — nothing for a human to review' });
|
|
450
|
-
emit('step-end', { step: step.id, outcome: 'already-green', spentUsd });
|
|
451
|
-
continue;
|
|
452
|
-
}
|
|
453
|
-
// the hitl middle's work (2b): deterministic branch/commit/draft-PR —
|
|
454
|
-
// model tools never touch git; a PR failure is red + escalation, never a
|
|
455
|
-
// swallowed escalation
|
|
456
|
-
const branch = `bareloop/${job.job}-${Date.now().toString(36)}`;
|
|
457
|
-
const title = `bareloop draft: ${job.job}`;
|
|
458
|
-
const pr = await openDraftPr({
|
|
459
|
-
workdir, branch, title,
|
|
460
|
-
body: `${job.description}\n\nstep: ${step.id}\nprompt: ${step.close.prompt}\nspent: $${spentUsd.toFixed(4)} of $${job.budgetUsd}\n\nDraft by bareloop — merge stays human, forever.`,
|
|
461
|
-
addPaths: job.writeScope.map((/** @type {string} */ g) => globToPrefix(g)),
|
|
462
|
-
exec: execCmd,
|
|
463
|
-
});
|
|
464
|
-
if (pr.red) emit('pr-red', { step: step.id, ...pr.red });
|
|
465
|
-
else emit('pr-opened', { step: step.id, branch, url: pr.url });
|
|
466
|
-
// a checkout left on a bareloop branch poisons the NEXT cadenced run's
|
|
467
|
-
// baseline — loud, and never allowed to un-open a real PR
|
|
468
|
-
if (pr.strandedOn) emit('workdir-red', { step: step.id, branch: pr.strandedOn, detail: 'the run could not return the checkout to its starting branch — the next run would build on this one' });
|
|
469
|
-
emit('escalation', { category: 'hitl-close', decisionReady: true, step: step.id, prompt: step.close.prompt, spentUsd, decision: step.close.prompt, options: ['approve', 'reject'], pr: { url: pr.url, branch, error: pr.red?.detail ?? null } });
|
|
470
|
-
emit('job-end', { outcome: 'escalated', step: step.id, ...spend() });
|
|
471
|
-
return 'escalated'; // by design: the human acts outside the run, forever
|
|
472
|
-
}
|
|
473
|
-
if (step.close.type !== 'predicate') {
|
|
474
|
-
// honest refusal: gold/rubric EXECUTION lands with the verdict classes
|
|
475
|
-
// (N4); a fake verdict here would poison every contrast downstream
|
|
476
|
-
emit('escalation', { category: 'close-unsupported', decisionReady: true, step: step.id, decision: `Step "${step.id}" has a ${step.close.type} close — N2 executes predicate and hitl closes only.`, options: ['restate the close as a predicate', 'wait for the verdict-classes rung'] });
|
|
477
|
-
emit('job-end', { outcome: 'close-unsupported', step: step.id, ...spend() });
|
|
478
|
-
return 'close-unsupported';
|
|
479
|
-
}
|
|
480
|
-
emit('step-start', { step: step.id, remainingUsd: job.budgetUsd - spentUsd, spentUsd, mode: step.mode ?? 'text' });
|
|
481
|
-
// Close-first skip (resume model): the workdir + the close ARE the
|
|
482
|
-
// checkpoint. Run the step's own arbiter BEFORE any tokens — already-green
|
|
483
|
-
// means the step's work is done, and the step skips as a DISTINCT record
|
|
484
|
-
// (already-green, never plain green: nothing was done, so it mints no
|
|
485
|
-
// learning credit and runs no on-green retention). Same instrument as
|
|
486
|
-
// ralph's verdict (runClose), same at-capture scrub as every close output.
|
|
487
|
-
// trim before splitting: validateJob reds whitespace-padded cmds, but an
|
|
488
|
-
// empty argv[0] would THROW at spawn — belt for any path around the validator
|
|
489
|
-
const closeArgv = step.close.cmd.trim().split(/\s+/);
|
|
490
|
-
const closeOpts = { timeoutMs: closeTimeoutMs, cwd: workdir, expect: step.close.expect, judged: step.close.judged, gapKeep: step.close.gapKeep };
|
|
491
|
-
const pre = runClose(closeArgv, (/** @type {string} */ s) => redact(s, { patterns: SECRET_PATTERNS }), closeOpts);
|
|
492
|
-
emit('close-precheck', { step: step.id, ...pre });
|
|
493
|
-
if (pre.verdict === 'satisfied') {
|
|
494
|
-
emit('step-end', { step: step.id, outcome: 'already-green', spentUsd });
|
|
495
|
-
continue;
|
|
496
|
-
}
|
|
497
|
-
// reds-before-tokens: the forbidden zone is checked BEFORE the draft and the
|
|
498
|
-
// worker call. A close that cannot run, never finished, died by signal, or
|
|
499
|
-
// judged nothing rendered NO VERDICT — escalate by its own name (the SAME
|
|
500
|
-
// map ralph uses; two maps would be two instruments), never spend on it.
|
|
501
|
-
const fault = Object.hasOwn(CLOSE_FAULTS, pre.verdict) ? CLOSE_FAULTS[pre.verdict] : undefined;
|
|
502
|
-
if (fault) {
|
|
503
|
-
emit('escalation', { category: fault.category, decisionReady: true, step: step.id, decision: fault.decision, options: fault.options, detail: pre.detail, spentUsd });
|
|
504
|
-
emit('job-end', { outcome: 'step-red', step: step.id, cause: fault.category, ...spend() });
|
|
505
|
-
return `step-red:${step.id}`;
|
|
506
|
-
}
|
|
507
|
-
// The precheck's gap goes to the worker as what it IS: the close's output on
|
|
508
|
-
// the tree as it stands (F13). It is never framed as an attempt — no attempt
|
|
509
|
-
// has happened — so the contrast evidence stays clean. Withholding it (the
|
|
510
|
-
// original call) left the worker unable to see the failure it was hired to
|
|
511
|
-
// fix: `run` is a locked verb, so it cannot execute the close itself.
|
|
512
|
-
const halted = await ensureConfig();
|
|
513
|
-
if (halted) return halted;
|
|
514
|
-
let outcome;
|
|
515
|
-
try {
|
|
516
|
-
outcome = await interpret(config, {
|
|
517
|
-
task: `${job.description} — step: ${step.id}`,
|
|
518
|
-
target, close: closeArgv, workdir, capRuns,
|
|
519
|
-
emit: meter, provider, ...cOpts(), closeTimeoutMs, closeState: pre.gap,
|
|
520
|
-
// the SPEC's grant, threaded verbatim (2b decision #2) — the drafted
|
|
521
|
-
// config cannot express mode or tools, so there is nothing to merge.
|
|
522
|
-
// expect/judged/gapKeep ride the same rail for the same reason: they are
|
|
523
|
-
// arbiter territory (PRD v1.11; F28 for gapKeep).
|
|
524
|
-
mode: step.mode ?? 'text', tools: step.tools,
|
|
525
|
-
closeExpect: step.close.expect, closeJudged: step.close.judged, closeGapKeep: step.close.gapKeep,
|
|
526
|
-
layerRoot,
|
|
527
|
-
});
|
|
528
|
-
} catch (e) {
|
|
529
|
-
// ralph belts throws INSIDE the loop; this belts the interpreter's own
|
|
530
|
-
// setup (gate.init, store ctor) — the spine must terminate, never dangle
|
|
531
|
-
emit('escalation', { category: 'interpreter-red', decisionReady: true, step: step.id, decision: 'The interpreter broke outside the loop — no harness verdict is trustworthy until it is fixed.', options: ['fix the interpreter/run directory', 'abandon the run'], detail: String(/** @type {Error} */ (e)?.message ?? e), spentUsd });
|
|
532
|
-
emit('job-end', { outcome: 'step-red', step: step.id, cause: 'interpreter-red', ...spend() });
|
|
533
|
-
return `step-red:${step.id}`;
|
|
534
|
-
}
|
|
535
|
-
emit('step-end', { step: step.id, outcome, spentUsd });
|
|
536
|
-
if (unpriced) return pricingRed();
|
|
537
|
-
if (outcome !== 'green') {
|
|
538
|
-
emit('job-end', { outcome: 'step-red', step: step.id, cause: outcome, ...spend() });
|
|
539
|
-
return `step-red:${step.id}`;
|
|
540
|
-
}
|
|
541
|
-
}
|
|
542
|
-
emit('job-end', { outcome: 'green', ...spend() });
|
|
543
|
-
return 'green';
|
|
544
215
|
}
|