bareloop 0.5.1 → 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/src/run.js CHANGED
@@ -1,83 +1,23 @@
1
- // The N2 runner — the shell's top layer. It owns everything the emergent parts
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
- // step sequencing, and the decision-ready stops. It composes the pieces below
5
- // it (drafting → validateConfig → per-step interpret loops) and interprets
6
- // nothing itself. Design record: docs/plans/2026-07-12-n2-headless-loop-design.md.
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 { spawnSync } from 'node:child_process';
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 {number} [opts.capRuns] shell-owned per-step iteration cap
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 every predicate step's interpreter. Defaults OFF
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' | 'escalated' | 'unapproved-spec' |
216
- * 'job-red' | 'smoke-red' | 'config-red' | 'pricing-red' | 'provider-red' |
217
- * 'cap-halt' | 'close-unsupported' | `step-red:<id>`
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, target, provider, nativeProvider, emit, capRuns = 3, shellCapUsd = 2, closeTimeoutMs, execCmd = defaultExec, layerRoot = false }) {
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
- ...(planShape ? { shape: 'plan', goal: job.goal } : { steps: job.steps.map((/** @type {any} */ s) => s.id) }),
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
- // ── Layer 2 dispatch: the plan flow (SCOUT → PLAN → EXECUTE → close) under
300
- // the SAME approval gate, smoke, and ledger — runJob stays the one entry
301
- // (N2 lock). runPlan emits every provider round as worker-round, so the
302
- // metered emit above accounts the plan flow natively (F12); the job-end
303
- // money contract is identical to the legacy path.
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, layerRoot,
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 — mirror the
316
- // legacy providerRed(): spendComplete false, never overclaim an exact total.
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
  }