bareloop 0.2.0 → 0.4.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 ADDED
@@ -0,0 +1,507 @@
1
+ // The N2 runner — the shell's top layer. It owns everything the emergent parts
2
+ // must never touch: the approval gate (human-signs-always), the primitive
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.
7
+
8
+ import { createRequire } from 'node:module';
9
+ 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';
13
+ 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
+ import { runClose, CLOSE_FAULTS } from './ralph.js';
17
+ import { extractArtifact, priceOf } from './text.js';
18
+
19
+ const require = createRequire(import.meta.url);
20
+ const { Loop } = require('bare-agent');
21
+
22
+ /** @typedef {{code: string, path: string, detail?: string}} Red */
23
+
24
+ /**
25
+ * The shell's own drafting allowance, carved out of the job budget (F9).
26
+ * Drafting is a paid shell call that happens BEFORE the config it produces is
27
+ * validated — so a ceiling advertised as the whole job budget is invalidated by
28
+ * the very call that answers it, and a rational drafter (one that claims the
29
+ * ceiling it is given) deadlocks every run. The shell therefore reserves this
30
+ * slice for itself and advertises `budget − reserve` as the ceiling the config
31
+ * may claim. It is a CAP, not an estimate: overspend it and the run stops on
32
+ * the budget story it is, never on a config-red blaming the drafter.
33
+ */
34
+ const DRAFT_RESERVE_FRAC = 0.05;
35
+ /** truncate (never round up) — an advertised ceiling must be one the shell can honour */
36
+ const floor4 = (/** @type {number} */ n) => Math.floor(n * 10_000) / 10_000;
37
+
38
+ // The drafting prompt: a schema DESCRIPTION built from the live validator
39
+ // menus — never a copyable example config (the drafter must author, not echo;
40
+ // the N2 drafting probe proved a frontier model greens on this, F6). The
41
+ // DRAFT-CONFIG sentinel is fixed shell vocabulary, not content.
42
+ // REMEMBER_KINDS comes from the validator: the prompt advertises the SAME menu
43
+ // the validator enforces — one source, no drift.
44
+ /** @param {any} job @param {Red[]|null} reds @param {number} ceiling the budget the config may actually claim (F9) */
45
+ function draftPrompt(job, reds, ceiling) {
46
+ const doc = `DRAFT-CONFIG
47
+ You are drafting a workflow config (schema "v1") for the job spec below. The config is
48
+ pure declarative JSON validated by a strict schema; ANY unknown field, wrong enum value,
49
+ or out-of-bounds number is rejected. Output ONLY the JSON object, no fences, no commentary.
50
+
51
+ Required top-level fields (no others exist):
52
+ - "schema": must be exactly "v1"
53
+ - "loop": { "shape": one of ${JSON.stringify([...LOOP_SHAPES])}, "maxIterations": optional integer 1..8 }
54
+ - "memory": { "store": must be "litectx",
55
+ "recall": optional { "k": integer 1..20, "kinds": optional non-empty subset of ${JSON.stringify([...KINDS])} },
56
+ "compressLevel": optional, one of ${JSON.stringify([...COMPRESS_LEVELS])} }
57
+ - "gate": { "budgetUsd": number, 0 < n <= the ceiling stated below, "writeScope": array of path-prefix globs like "src/**" }
58
+ - "escalation": { "mode": must be "decision-ready" }
59
+ - "hooks": optional; keys from ${JSON.stringify([...SLOTS])}, each an array of ops.
60
+ Each op is { "op": one of ${JSON.stringify([...VERBS])}, ...params }. STRICT slot legality:
61
+ "recall" (optional "k", "kinds") and "compress" (optional "level") ONLY in "before-attempt";
62
+ "stash" (no params) ONLY in "after-red"; "remember" (optional "kind", one of
63
+ ${JSON.stringify(REMEMBER_KINDS)}) ONLY in "on-green".
64
+
65
+ Hard constraints from the job spec (violations are rejected):
66
+ - Every writeScope entry must fit INSIDE the job's writeScope (${JSON.stringify(job.writeScope)}).
67
+ No "..", no absolute paths, no mid-path wildcards.
68
+ - gate.budgetUsd must be <= ${ceiling}. (This is ONE STEP's share of the job's
69
+ $${job.budgetUsd} budget, minus the shell's own drafting allowance: this config is drafted
70
+ once and reused for every step, so the number must still fit after earlier steps have
71
+ spent. Claiming exactly ${ceiling} is legal and validates.)
72
+ - The config CANNOT contain close commands, provider choice, retry caps, or minting claims.
73
+
74
+ Job spec:
75
+ ${JSON.stringify(job, null, 2)}`;
76
+ return reds
77
+ ? `${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.`
78
+ : doc;
79
+ }
80
+
81
+ /**
82
+ * Known-answer smoke on the primitive the run is about to trust (PRD v1.5 §4,
83
+ * adaptlearn A3): a silently-degraded store throws nothing — only a
84
+ * remember→recall round-trip with a KNOWN answer can red it, and it must run
85
+ * BEFORE tokens. Scratch namespace under the workdir so the real lineage store
86
+ * is never polluted with probe facts.
87
+ * @param {string} workdir
88
+ */
89
+ async function primitiveSmoke(workdir) {
90
+ try {
91
+ const lc = new LiteCtx({ root: join(workdir, '.smoke') });
92
+ await lc.remember('smoke-known-answer', 'bareloop primitive smoke: the answer is 42', { kind: 'fact' });
93
+ const hits = await lc.recall('bareloop primitive smoke known answer', { kind: 'fact', n: 5, body: true });
94
+ const ok = hits.some((h) => String(h.body ?? '').includes('the answer is 42'));
95
+ return { ok, primitive: 'litectx', detail: ok ? `round-trip returned the known answer (${hits.length} hit(s))` : 'remember→recall round-trip lost the known answer' };
96
+ } catch (e) {
97
+ return { ok: false, primitive: 'litectx', detail: String(/** @type {Error} */ (e)?.message || e) };
98
+ }
99
+ }
100
+
101
+ /** @typedef {(argv: string[], opts?: {cwd?: string}) => {status: number|null, stdout: string, stderr: string}} ExecCmd */
102
+
103
+ /** default process runner for the hitl PR mechanics — real spawnSync, no shell
104
+ * @type {ExecCmd} */
105
+ const defaultExec = (argv, { cwd } = {}) => {
106
+ const r = spawnSync(argv[0], argv.slice(1), { cwd, encoding: 'utf8', timeout: 60_000 });
107
+ if (r.error) return { status: null, stdout: '', stderr: String(r.error) };
108
+ return { status: r.status, stdout: r.stdout ?? '', stderr: r.stderr ?? '' };
109
+ };
110
+
111
+ /**
112
+ * The hitl middle's work (2b): branch → stage the job fence ONLY → commit →
113
+ * push → draft PR. Deterministic runner code, never model tools (the model
114
+ * never sees a git or gh surface — design addendum 2026-07-12b). Staging is
115
+ * fence-scoped so spines, audit logs, and anything else living in the workdir
116
+ * never enter the PR. Any failure is an honest red carrying the failed argv;
117
+ * the caller still escalates — a broken PR path must never swallow the
118
+ * escalation (the pain channel, law #7).
119
+ * The checkout is handed back exactly as it was found: the starting branch is
120
+ * read BEFORE anything moves and restored on EVERY path, success or failure
121
+ * (found by the real job #1 run, 2026-07-13 — the workdir was left sitting on
122
+ * the bareloop branch, so the next cadenced run would branch off yesterday's
123
+ * unmerged branch and judge its close against that state). A restore that
124
+ * itself fails is loud (`strandedOn`), never silent.
125
+ *
126
+ * @param {{workdir: string, branch: string, title: string, body: string, addPaths: string[], exec: ExecCmd}} o
127
+ * @returns {Promise<{url: string|null, red: {argv: string, detail: string}|null, strandedOn: string|null}>}
128
+ */
129
+ async function openDraftPr({ workdir, branch, title, body, addPaths, exec }) {
130
+ // Subprocess output is scrubbed AT CAPTURE with the ONE shape inventory —
131
+ // the same doctrine as the close path (interpret wires SECRET_PATTERNS into
132
+ // ralph): git/gh error text can echo a credentialed remote URL, and the
133
+ // detail lands on the append-only spine (pr-red + the escalation's pr.error).
134
+ const scrub = (/** @type {string} */ s) => redact(s, { patterns: SECRET_PATTERNS });
135
+ const head = exec(['git', 'rev-parse', '--abbrev-ref', 'HEAD'], { cwd: workdir });
136
+ const startBranch = head.status === 0 ? head.stdout.trim() : '';
137
+ /** put the checkout back where it started; report the branch it is stranded on if that fails */
138
+ const restore = () => {
139
+ if (!startBranch) return null; // never knew where we started — nothing honest to restore to
140
+ const r = exec(['git', 'checkout', startBranch], { cwd: workdir });
141
+ return r.status === 0 ? null : branch;
142
+ };
143
+ /** @param {{url?: string|null, red?: {argv: string, detail: string}|null}} o */
144
+ const done = ({ url = null, red = null }) => ({ url, red, strandedOn: restore() });
145
+
146
+ const steps = [
147
+ ['git', 'checkout', '-b', branch],
148
+ ['git', 'add', '--', ...addPaths],
149
+ ['git', 'commit', '-m', title],
150
+ ['git', 'push', '-u', 'origin', branch],
151
+ ['gh', 'pr', 'create', '--draft', '--title', title, '--body', body],
152
+ ];
153
+ for (const argv of steps) {
154
+ const r = exec(argv, { cwd: workdir });
155
+ if (r.status !== 0) {
156
+ 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)}` } });
157
+ }
158
+ if (argv[0] === 'gh') {
159
+ const url = (r.stdout.match(/https?:\/\/\S+/) ?? [null])[0];
160
+ // a "successful" gh with no link is not a PR the human can act on
161
+ return url ? done({ url }) : done({ red: { argv: argv.join(' '), detail: 'no PR URL in gh output' } });
162
+ }
163
+ }
164
+ return done({ red: { argv: 'gh pr create', detail: 'PR step never ran' } }); // unreachable while gh is last; belt for edits
165
+ }
166
+
167
+ /**
168
+ * Run an approved job end to end: approval gate → primitive smoke → sequential
169
+ * per-step interpret loops under the one ledger → the hitl step opens the draft
170
+ * PR (deterministic git, never model tools) and becomes the decision-ready
171
+ * escalation. Every predicate step runs its close FIRST (close-first skip,
172
+ * resume model): already-green skips the step for zero tokens as a distinct
173
+ * spine record, so a stopped run reruns from where it died — the workdir plus
174
+ * the closes are the checkpoint. The sealed config draft (one shot + one
175
+ * redraft) is deferred to the first step that actually needs a worker; a clean
176
+ * rerun pays zero provider calls.
177
+ *
178
+ * N2 bounds (honest, documented): text-mode steps write a single `target`
179
+ * artifact; tool-mode steps (2b) work multi-file through the gated shell tools,
180
+ * granted per step by the SPEC (mode/tools — the drafted config cannot express
181
+ * either); `gold`/`rubric` closes refuse `close-unsupported` (execution lands
182
+ * with the verdict classes, N4); the pricing-red halt lands at the step
183
+ * boundary — the Gate still caps within a step, but an unpriced result cannot
184
+ * be summed, so the run stops rather than counting it $0 (F6).
185
+ *
186
+ * EVERY terminal `job-end` states the money: `spentUsd` (the sum of PRICED
187
+ * rounds only — never an estimate; a real 0 on the pre-token reds) plus
188
+ * `spendComplete`, false when any round came back unpriced and the figure is
189
+ * therefore a FLOOR, not the total. Both are always present, so a consumer
190
+ * never branches on field presence nor launders a blank into $0.
191
+ *
192
+ * @param {object|string} rawSpec the job spec (job-v1), text or parsed
193
+ * @param {object} opts
194
+ * @param {unknown} opts.approvals `{ specHash, signer, ts }` records (from OUTSIDE the spec)
195
+ * @param {string} opts.workdir the run directory (the fence's root)
196
+ * @param {string} [opts.target] the step artifact path (text-mode steps; unused by tool-mode steps)
197
+ * @param {any} opts.provider shell-owned LLM binding (never the config's)
198
+ * @param {(type: string, data?: object) => object} opts.emit spine emitter
199
+ * @param {number} [opts.capRuns] shell-owned per-step iteration cap
200
+ * @param {number} [opts.shellCapUsd] the shell's hard USD ceiling
201
+ * @param {number} [opts.closeTimeoutMs] close wall-clock cap (shell territory)
202
+ * @param {ExecCmd} [opts.execCmd] process runner for the hitl PR mechanics
203
+ * @param {boolean} [opts.layerRoot=false] Layer R (within-run ratchet) — shell
204
+ * territory, threaded to every predicate step's interpreter. Defaults OFF
205
+ * (decided 2026-07-21): fixation is extinct on every current job (F41), so
206
+ * ON has never won its own A/B — the acceptance read defers to Layer 2 (or
207
+ * a manufactured-fixation probe). `true` is the ON/experimental arm; the
208
+ * default is the OFF arm. (Shell-owned seam, same doctrine as the provider
209
+ * binding.)
210
+ * @returns {Promise<string>} outcome: 'green' | 'escalated' | 'unapproved-spec' |
211
+ * 'job-red' | 'smoke-red' | 'config-red' | 'pricing-red' | 'provider-red' |
212
+ * 'cap-halt' | 'close-unsupported' | `step-red:<id>`
213
+ */
214
+ export async function runJob(rawSpec, { approvals, workdir, target, provider, emit, capRuns = 3, shellCapUsd = 2, closeTimeoutMs, execCmd = defaultExec, layerRoot = false }) {
215
+ // 0. the ledger's counters, declared FIRST so that every job-end — including
216
+ // the pre-token reds below — can state a real figure. An omitted `spentUsd` is
217
+ // not a zero: a consumer reads `undefined` and either crashes or launders it
218
+ // into $0 (F12's class, at the terminal record instead of mid-attempt).
219
+ let spentUsd = 0;
220
+ let unpriced = false;
221
+ // The money on the terminal record, and whether the money is EXACT. `spentUsd`
222
+ // is the accumulated sum of PRICED rounds ONLY — never an estimate derived
223
+ // from tokens or averages (cap-not-estimate). When any round came back
224
+ // unpriced (F6) that sum is a FLOOR, not the total, and `spendComplete: false`
225
+ // says so machine-readably instead of dressing a floor up as exact. Emitted on
226
+ // EVERY job-end (true when everything was priced) so no consumer ever has to
227
+ // branch on field presence.
228
+ const spend = () => ({ spentUsd, spendComplete: !unpriced });
229
+ // 1. human-signs-always — before ANY provider call (N1 decision #1)
230
+ if (!checkApproval(rawSpec, approvals)) {
231
+ emit('job-end', { outcome: 'unapproved-spec', detail: 'no approval record matches this exact spec version', ...spend() });
232
+ return 'unapproved-spec';
233
+ }
234
+ const jv = validateJob(rawSpec, { shellCapUsd });
235
+ if (!jv.ok) {
236
+ for (const r of jv.reds) emit('job-red', r);
237
+ emit('job-end', { outcome: 'job-red', ...spend() });
238
+ return 'job-red';
239
+ }
240
+ const job = /** @type {any} */ (jv.job);
241
+ // Reds-before-tokens: a text-mode predicate step writes ONE artifact — with
242
+ // no target it would burn a draft + a worker call and then crash inside the
243
+ // middle as a misfiled interpreter-red. The spec is fine; the CALL is not.
244
+ if ((typeof target !== 'string' || !target)
245
+ && job.steps.some((/** @type {any} */ s) => s.close.type === 'predicate' && (s.mode ?? 'text') === 'text')) {
246
+ emit('job-red', { code: 'missing-required', path: 'opts.target', detail: 'text-mode steps write ONE artifact — pass opts.target (reds-before-tokens)' });
247
+ emit('job-end', { outcome: 'job-red', ...spend() });
248
+ return 'job-red';
249
+ }
250
+ emit('job-start', { job: job.job, specHash: jobSpecHash(job), budgetUsd: job.budgetUsd, steps: job.steps.map((/** @type {any} */ s) => s.id) });
251
+
252
+ // 2. known-answer smoke before tokens (A3: silent degradation throws nothing)
253
+ const smoke = await primitiveSmoke(workdir);
254
+ emit('primitive-smoke', smoke);
255
+ if (!smoke.ok) {
256
+ emit('escalation', { category: 'smoke-red', decisionReady: true, decision: `The ${smoke.primitive} primitive failed its known-answer check — no run verdict is trustworthy on a degraded primitive.`, options: ['fix the primitive/store', 'abandon the run'], detail: smoke.detail });
257
+ emit('job-end', { outcome: 'smoke-red', ...spend() });
258
+ return 'smoke-red';
259
+ }
260
+
261
+ // 3. the ONE ledger. Unpriced is never free (F6): a null cost can't be
262
+ // summed, so it flags a stop instead of accumulating $0.
263
+ /** @param {number|null|undefined} c */
264
+ const account = (c) => { if (typeof c === 'number' && Number.isFinite(c)) spentUsd += c; else unpriced = true; };
265
+ /** @type {(type: string, data?: object) => object} */
266
+ const meter = (type, data) => {
267
+ // The ledger counts ROUNDS, not attempts (F12). `worker-round` fires as each
268
+ // round is bought — including the round that trips the gate — so an attempt
269
+ // that HALTS mid-flight (loop.run never returns, no worker-result, no
270
+ // worker-plan) still lands its real spend here. Accounting the attempt-level
271
+ // events instead made the real run report $0.0048 of a $1.4375 spend.
272
+ // Round-level is also the ONLY level that cannot double-count: worker-result
273
+ // and worker-plan are attempt TOTALS of these same rounds — they stay on the
274
+ // spine for display and are deliberately NOT accounted.
275
+ // `account` reds the run on ANY unpriced round (a null cost is the honest
276
+ // unknown, never $0 — F6): per-round metering means a partially-unpriced run
277
+ // is caught natively, round by round, with no separate unpricedRounds tally.
278
+ if (type === 'worker-round') account(/** @type {any} */ (data)?.costUsd);
279
+ return emit(type, data);
280
+ };
281
+ const pricingRed = () => {
282
+ emit('escalation', { category: 'pricing-red', decisionReady: true, decision: 'A provider result carried no priced cost — the hard cap cannot govern spend it cannot see (unpriced is never free, F6).', options: ['bind a priced provider/model', 'abandon the run'], spentUsd });
283
+ emit('job-end', { outcome: 'pricing-red', ...spend() });
284
+ return 'pricing-red';
285
+ };
286
+
287
+ // 4. sealed drafting: one shot + one redraft, reds fed back, PRICED path
288
+ // (through Loop, the same accounting the worker uses — never around it, F6)
289
+ const drafter = new Loop({ provider, system: 'You draft workflow configs as pure JSON. Output only the JSON object.' });
290
+ /** @type {string|null} a transport throw from the drafting call — spend UNKNOWN, terminal */
291
+ let transportRed = null;
292
+ /** @type {string|null} a drafting round the API TRUNCATED (BA-6, bare-agent 0.27.0) — provider-red like
293
+ * a throw, but the round WAS metered so spend is known; must never launder into a config-red (F25). */
294
+ let draftTruncated = null;
295
+ /** @param {Red[]|null} reds */
296
+ const draft = async (reds) => {
297
+ let r;
298
+ try {
299
+ r = await drafter.run([{ role: 'user', content: draftPrompt(job, reds, ceilingNow()) }]);
300
+ } catch (e) {
301
+ // Loop defaults throwOnError: true — a misconfigured binding or a bare
302
+ // transient 500 REJECTS here. The spend is UNKNOWN (F6), and the spine
303
+ // must still terminate: never an unhandled rejection out of the runner.
304
+ transportRed = String(/** @type {Error} */ (e)?.message ?? e);
305
+ emit('draft-result', { costUsd: null, redraft: reds !== null, error: transportRed });
306
+ return null;
307
+ }
308
+ const { costUsd, unpricedRounds } = priceOf(r); // the ONE honest-null cost read (F6)
309
+ account(costUsd);
310
+ if (unpricedRounds > 0) unpriced = true;
311
+ emit('draft-result', { costUsd, redraft: reds !== null, error: r.error ?? null });
312
+ // BA-6: the API cut the drafting round off mid-JSON. That is provider-red (the API's
313
+ // fault, not the drafter's), so it must NOT be laundered into a config-red blaming the
314
+ // drafter (F25's class) and must NOT consume the redraft — retrying a truncation blindly
315
+ // just burns budget; the human decides. Metered above, so unlike transportRed spend is known.
316
+ if (typeof r.error === 'string' && r.error.startsWith('truncated:')) { draftTruncated = r.error; return null; }
317
+ return r.error ? null : (extractArtifact(r.text).code ?? '');
318
+ };
319
+ const providerRed = () => {
320
+ 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 });
321
+ // A transport THROW never returned a usage figure, so the floor is NOT the total
322
+ // (F6): spendComplete must be false — spend()'s `!unpriced` only knows about priced
323
+ // rounds that came back unpriced, not a call that never returned at all. Leaving it
324
+ // true would have the job-end contradict the escalation's own "spend … is unknown".
325
+ emit('job-end', { outcome: 'provider-red', ...spend(), spendComplete: false });
326
+ return 'provider-red';
327
+ };
328
+ const capNow = () => Math.min(shellCapUsd, job.budgetUsd - spentUsd);
329
+ // The ceiling the CONFIG may claim (F9): the job budget minus the shell's own
330
+ // drafting allowance, and never above what is actually left. Advertised in the
331
+ // prompt and enforced by the validator — ONE number, so a drafter that claims
332
+ // the ceiling it was given always validates. (Before: the prompt advertised the
333
+ // whole job budget while the validator enforced budget − drafting-spend, a bound
334
+ // the drafter was never told and could not satisfy.)
335
+ // ...and a PER-STEP share of it, not the whole pot. The config is drafted ONCE
336
+ // and re-validated at every step against what is LEFT, so a ceiling sized to the
337
+ // whole budget is stale the moment step 1 spends: step 2 then reds `bounds` on an
338
+ // unchanged config with plenty of money still in the pot (review 2026-07-18).
339
+ // Dividing by the steps that will actually run a worker makes the number the
340
+ // drafter is handed one that still fits after its predecessors have spent —
341
+ // sum(shares) = budget − reserve, so every step can claim its share. This is a
342
+ // DRAFTING change, not an enforcement one: cap-not-estimate is untouched, and a
343
+ // step that needs more than its share still cap-halts cleanly with a resume
344
+ // point. Single-step jobs (every shipped job today) are arithmetically unchanged.
345
+ const loopSteps = Math.max(1, job.steps.filter((s) => s.close.type === 'predicate').length);
346
+ const workerCeiling = floor4(job.budgetUsd * (1 - DRAFT_RESERVE_FRAC) / loopSteps);
347
+ const ceilingNow = () => Math.min(workerCeiling, capNow());
348
+ const cOpts = () => ({ shellCapUsd: ceilingNow(), jobWriteScope: job.writeScope });
349
+ // Drafting is DEFERRED to the first step that needs a worker (resume model,
350
+ // design addendum 2026-07-13): a step whose close already greens skips for
351
+ // zero tokens, so a clean rerun may need no config at all — drafting one no
352
+ // interpret will ever read is spend for nothing (F6's spirit). Decision #3
353
+ // holds: when a config IS needed, it is drafted fresh THIS run, never inherited.
354
+ /** @type {any} the validated config, drafted on first need */
355
+ let config = null;
356
+ /** @returns {Promise<string|null>} a terminal job outcome, or null with `config` set */
357
+ const ensureConfig = async () => {
358
+ if (config) return null;
359
+ let text = await draft(null);
360
+ let cv = validateConfig(text ?? '', cOpts());
361
+ emit('config-validate', { ok: cv.ok, reds: cv.reds, phase: 'draft-1' });
362
+ // the redraft only fires when it can still help: not over a transport red,
363
+ // not over unpriced spend, and never over an already-blown budget (a paid
364
+ // call against a negative cap can only red again — budget story, not drafter)
365
+ if (!cv.ok && !unpriced && !transportRed && !draftTruncated && capNow() > 0) {
366
+ text = await draft(cv.reds);
367
+ cv = validateConfig(text ?? '', cOpts());
368
+ emit('config-validate', { ok: cv.ok, reds: cv.reds, phase: 'draft-2' });
369
+ }
370
+ if (transportRed) return providerRed();
371
+ if (draftTruncated) {
372
+ // A truncated draft is provider-red, NOT config-red — the API cut it off; the drafter is
373
+ // not on trial. Mirrors the worker path (interpret.js) and the human's options: the round
374
+ // was metered, so this is a fault with a known cost, not a spend-unknown transport throw.
375
+ 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 });
376
+ emit('job-end', { outcome: 'provider-red', ...spend() });
377
+ return 'provider-red';
378
+ }
379
+ if (unpriced) return pricingRed();
380
+ if (!cv.ok) {
381
+ if (capNow() <= 0) {
382
+ // drafting consumed the whole job budget before a valid config existed —
383
+ // the honest stop is a cap story, never a config-red blaming the drafter
384
+ emit('cap-halt', { category: 'cap-halt', meaning: 'not under cap — not "can\'t"', spentUsd, budgetUsd: job.budgetUsd });
385
+ 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 });
386
+ emit('job-end', { outcome: 'cap-halt', ...spend() });
387
+ return 'cap-halt';
388
+ }
389
+ for (const r of cv.reds) emit('config-red', r);
390
+ emit('job-end', { outcome: 'config-red', ...spend() });
391
+ return 'config-red';
392
+ }
393
+ config = cv.config;
394
+ return null;
395
+ };
396
+
397
+ // 5. sequential per-step loops under the one ledger; a step-red stops the
398
+ // job and the stop is a result (ladder discipline applied to steps)
399
+ for (const step of job.steps) {
400
+ if (step.close.type === 'hitl') {
401
+ // Nothing changed in the fence → nothing for a human to review (found by
402
+ // the real job #1 run, 2026-07-13): a cadenced run whose steps all skip
403
+ // already-green would otherwise branch/add/commit, and `git commit`
404
+ // correctly fails ("nothing added to commit") — a broken-PR escalation
405
+ // every single day. A hitl close is a human decision point; with no
406
+ // changes there IS no decision. Only an AFFIRMATIVE clean answer skips:
407
+ // a failed check (not a repo, broken git) falls through to the PR path
408
+ // and reds honestly there — an unknown fence state is never a green.
409
+ const fence = job.writeScope.map((/** @type {string} */ g) => globToPrefix(g));
410
+ const st = execCmd(['git', 'status', '--porcelain', '--', ...fence], { cwd: workdir });
411
+ if (st.status === 0 && st.stdout.trim() === '') {
412
+ emit('pr-skipped', { step: step.id, reason: 'no changes in the job fence — nothing for a human to review' });
413
+ emit('step-end', { step: step.id, outcome: 'already-green', spentUsd });
414
+ continue;
415
+ }
416
+ // the hitl middle's work (2b): deterministic branch/commit/draft-PR —
417
+ // model tools never touch git; a PR failure is red + escalation, never a
418
+ // swallowed escalation
419
+ const branch = `bareloop/${job.job}-${Date.now().toString(36)}`;
420
+ const title = `bareloop draft: ${job.job}`;
421
+ const pr = await openDraftPr({
422
+ workdir, branch, title,
423
+ 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.`,
424
+ addPaths: job.writeScope.map((/** @type {string} */ g) => globToPrefix(g)),
425
+ exec: execCmd,
426
+ });
427
+ if (pr.red) emit('pr-red', { step: step.id, ...pr.red });
428
+ else emit('pr-opened', { step: step.id, branch, url: pr.url });
429
+ // a checkout left on a bareloop branch poisons the NEXT cadenced run's
430
+ // baseline — loud, and never allowed to un-open a real PR
431
+ 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' });
432
+ 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 } });
433
+ emit('job-end', { outcome: 'escalated', step: step.id, ...spend() });
434
+ return 'escalated'; // by design: the human acts outside the run, forever
435
+ }
436
+ if (step.close.type !== 'predicate') {
437
+ // honest refusal: gold/rubric EXECUTION lands with the verdict classes
438
+ // (N4); a fake verdict here would poison every contrast downstream
439
+ 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'] });
440
+ emit('job-end', { outcome: 'close-unsupported', step: step.id, ...spend() });
441
+ return 'close-unsupported';
442
+ }
443
+ emit('step-start', { step: step.id, remainingUsd: job.budgetUsd - spentUsd, spentUsd, mode: step.mode ?? 'text' });
444
+ // Close-first skip (resume model): the workdir + the close ARE the
445
+ // checkpoint. Run the step's own arbiter BEFORE any tokens — already-green
446
+ // means the step's work is done, and the step skips as a DISTINCT record
447
+ // (already-green, never plain green: nothing was done, so it mints no
448
+ // learning credit and runs no on-green retention). Same instrument as
449
+ // ralph's verdict (runClose), same at-capture scrub as every close output.
450
+ // trim before splitting: validateJob reds whitespace-padded cmds, but an
451
+ // empty argv[0] would THROW at spawn — belt for any path around the validator
452
+ const closeArgv = step.close.cmd.trim().split(/\s+/);
453
+ const closeOpts = { timeoutMs: closeTimeoutMs, cwd: workdir, expect: step.close.expect, judged: step.close.judged, gapKeep: step.close.gapKeep };
454
+ const pre = runClose(closeArgv, (/** @type {string} */ s) => redact(s, { patterns: SECRET_PATTERNS }), closeOpts);
455
+ emit('close-precheck', { step: step.id, ...pre });
456
+ if (pre.verdict === 'satisfied') {
457
+ emit('step-end', { step: step.id, outcome: 'already-green', spentUsd });
458
+ continue;
459
+ }
460
+ // reds-before-tokens: the forbidden zone is checked BEFORE the draft and the
461
+ // worker call. A close that cannot run, never finished, died by signal, or
462
+ // judged nothing rendered NO VERDICT — escalate by its own name (the SAME
463
+ // map ralph uses; two maps would be two instruments), never spend on it.
464
+ const fault = Object.hasOwn(CLOSE_FAULTS, pre.verdict) ? CLOSE_FAULTS[pre.verdict] : undefined;
465
+ if (fault) {
466
+ emit('escalation', { category: fault.category, decisionReady: true, step: step.id, decision: fault.decision, options: fault.options, detail: pre.detail, spentUsd });
467
+ emit('job-end', { outcome: 'step-red', step: step.id, cause: fault.category, ...spend() });
468
+ return `step-red:${step.id}`;
469
+ }
470
+ // The precheck's gap goes to the worker as what it IS: the close's output on
471
+ // the tree as it stands (F13). It is never framed as an attempt — no attempt
472
+ // has happened — so the contrast evidence stays clean. Withholding it (the
473
+ // original call) left the worker unable to see the failure it was hired to
474
+ // fix: `run` is a locked verb, so it cannot execute the close itself.
475
+ const halted = await ensureConfig();
476
+ if (halted) return halted;
477
+ let outcome;
478
+ try {
479
+ outcome = await interpret(config, {
480
+ task: `${job.description} — step: ${step.id}`,
481
+ target, close: closeArgv, workdir, capRuns,
482
+ emit: meter, provider, ...cOpts(), closeTimeoutMs, closeState: pre.gap,
483
+ // the SPEC's grant, threaded verbatim (2b decision #2) — the drafted
484
+ // config cannot express mode or tools, so there is nothing to merge.
485
+ // expect/judged/gapKeep ride the same rail for the same reason: they are
486
+ // arbiter territory (PRD v1.11; F28 for gapKeep).
487
+ mode: step.mode ?? 'text', tools: step.tools,
488
+ closeExpect: step.close.expect, closeJudged: step.close.judged, closeGapKeep: step.close.gapKeep,
489
+ layerRoot,
490
+ });
491
+ } catch (e) {
492
+ // ralph belts throws INSIDE the loop; this belts the interpreter's own
493
+ // setup (gate.init, store ctor) — the spine must terminate, never dangle
494
+ 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 });
495
+ emit('job-end', { outcome: 'step-red', step: step.id, cause: 'interpreter-red', ...spend() });
496
+ return `step-red:${step.id}`;
497
+ }
498
+ emit('step-end', { step: step.id, outcome, spentUsd });
499
+ if (unpriced) return pricingRed();
500
+ if (outcome !== 'green') {
501
+ emit('job-end', { outcome: 'step-red', step: step.id, cause: outcome, ...spend() });
502
+ return `step-red:${step.id}`;
503
+ }
504
+ }
505
+ emit('job-end', { outcome: 'green', ...spend() });
506
+ return 'green';
507
+ }
package/src/text.js CHANGED
@@ -1,9 +1,66 @@
1
- // Shared text helpers. stripFences is deliberately minimal at N0 (reference
2
- // parity): it strips ONE leading and ONE trailing markdown fence. The
3
- // fence-robust upgrade (prose-wrapped, mid-text fences — adaptlearn F21's
4
- // instrument caveat, filed in FINDINGS F2) lands here at N2, in one place:
5
- // interpret's artifact path and extract's rules path must never disagree
6
- // about what the same model output parses to.
1
+ // Shared model-output helpers. extractArtifact is the ONE parser for every
2
+ // model-output extraction (interpret's artifact, extract's rules, the
3
+ // runner's drafted config) — two parsers that disagree about the same output
4
+ // are two instruments (F2/F21, the adaptlearn instrument caveat). It replaced
5
+ // N0's stripFences when the fence-robust upgrade landed at N2. priceOf is the
6
+ // ONE spelling of the honest-null cost read for the same reason (F6).
7
7
 
8
- /** @param {string} t */
9
- export const stripFences = (t) => t.trim().replace(/^```[a-z]*\n?/i, '').replace(/\n?```\s*$/, '');
8
+ // A fence counts as the artifact's WRAPPER only when it opens within this many
9
+ // lines of the response — the chatty-preamble shape ("Here's the fix:\n```…").
10
+ // A fence buried deeper inside an unfenced reply is the artifact's OWN content
11
+ // (a doc generator's example block): extracting it truncated a whole module to
12
+ // its 2-line fragment with red:null (review 2026-07-13, confirmed by execution).
13
+ // The trade-off is documented and pinned: past the window, prose + fence is
14
+ // treated as the artifact verbatim — with the no-fences persona, long-preamble
15
+ // replies are the rare shape; truncated artifacts corrupted the close signal.
16
+ const FENCE_PREAMBLE_LINES = 5;
17
+
18
+ /**
19
+ * Fence-robust artifact extraction (F2 port requirements #1/#2):
20
+ * - a fenced block opening within the first {@link FENCE_PREAMBLE_LINES} lines
21
+ * is the artifact — prose-wrapped and preamble fences extract clean (the F21
22
+ * instrument caveat: prose written to the target corrupted the close signal);
23
+ * - a fence opening deeper is the artifact's own content — the trimmed whole
24
+ * text is the artifact (fence-heavy artifacts belong in tool mode, where the
25
+ * worker writes files directly and nothing is parsed);
26
+ * - no fence → the trimmed whole text, with N0's unclosed-leading-fence strip
27
+ * as the fallback (reference parity);
28
+ * - nothing extractable → `code: null` plus a named reason: artifact-red
29
+ * material for the caller's OWN axis, never a silent empty write.
30
+ * Never throws.
31
+ * @param {string|null|undefined} t
32
+ * @returns {{ code: string|null, red: string|null }}
33
+ */
34
+ export function extractArtifact(t) {
35
+ const text = String(t ?? '');
36
+ const m = text.match(/```[^\n`]*\n?([\s\S]*?)```/);
37
+ if (m) {
38
+ if (text.slice(0, /** @type {number} */ (m.index)).split('\n').length - 1 < FENCE_PREAMBLE_LINES) {
39
+ const code = m[1].trim();
40
+ return code ? { code, red: null } : { code: null, red: 'empty fenced block' };
41
+ }
42
+ // deep fence = the artifact's own content: verbatim, NO fence strips — the
43
+ // N0 fallback below would eat a trailing example fence off a real artifact
44
+ return { code: text.trim(), red: null };
45
+ }
46
+ const bare = text.trim().replace(/^```[a-z]*\n?/i, '').replace(/\n?```\s*$/, '').trim();
47
+ return bare ? { code: bare, red: null } : { code: null, red: 'empty response' };
48
+ }
49
+
50
+ /**
51
+ * The ONE spelling of the F6 honest-null cost read on a bare-agent result.
52
+ * `metrics.costUsd` is the honest null when NOTHING priced — never fall
53
+ * through to `cost` when metrics exist: `cost` sums priced rounds only, so an
54
+ * all-unpriced run reports cost 0 and `?? cost` would launder the explicit
55
+ * unknown into a silent $0 (the F6 class — four shipped launderings were this
56
+ * exact fallback chain hand-spelled). `unpricedRounds` makes a PARTIALLY
57
+ * unpriced run visible (costUsd finite but an under-count).
58
+ * @param {any} r a Loop.run() result (or null)
59
+ * @returns {{ costUsd: number|null, unpricedRounds: number }}
60
+ */
61
+ export function priceOf(r) {
62
+ return {
63
+ costUsd: r?.metrics ? r.metrics.costUsd : (r?.cost ?? null),
64
+ unpricedRounds: r?.metrics?.unpricedRounds ?? 0,
65
+ };
66
+ }