bareloop 0.4.0 → 0.5.1

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/planrun.js ADDED
@@ -0,0 +1,788 @@
1
+ // Layer 2 executor — the plan-v1 run, end to end (PRD v1.12; design record
2
+ // 2026-07-21). SCOUT (read-only, hard-bounded) → PLAN (the decompose call,
3
+ // gated by validatePlan before tokens burn) → EXECUTE (per-step micro-loops:
4
+ // the same ralph(), judge = the exit evaluator — the F46 mechanism) → ONE
5
+ // replan → the operator's close, the only truth. The checks referenced by
6
+ // `check-passes` run under the FULL runClose machinery (forbidden zone,
7
+ // judged floor, redaction, gapKeep); they decide nothing and mint nothing —
8
+ // a check result is a progress gate and a gap source, nothing more.
9
+ //
10
+ // Prompt contract (v1.12 §5), held here: a step worker sees its action, the
11
+ // absolute repo root, its target, prior steps' artifacts labeled by id, its
12
+ // gap, and a cut-off notice. It NEVER sees the budget, the close command, a
13
+ // check's command, the validator, other steps' grants, or the arbiter's books
14
+ // (fs.deny on the gate audit / .smoke / .litectx, unchanged).
15
+
16
+ import { createRequire } from 'node:module';
17
+ import { readFileSync } from 'node:fs';
18
+ import { createHash } from 'node:crypto';
19
+ import { join, resolve } from 'node:path';
20
+ import { Gate, redact } from 'bareguard';
21
+ import { LiteCtx } from 'litectx';
22
+ import { runClose, ralph, CLOSE_FAULTS } from './ralph.js';
23
+ import { validatePlan } from './plan.js';
24
+ import { WRITE_VERBS, EXIT_TYPES, MAX_EXITS_PER_STEP, MAX_PLAN_STEPS } from './plan.js';
25
+ import { snapshotScope, evalExits } from './exits.js';
26
+ import { createRoot } from './root.js';
27
+ import { TOOL_MENU } from './job.js';
28
+ import { TOOL_BY_VERB, CTX_TOOLS, createCtxTools, toolAction, PERSONA_TOOLS, RETRIEVAL_STRATEGY, EDIT_STRATEGY } from './interpret.js';
29
+ import { globToPrefix, SECRET_PATTERNS } from './validate.js';
30
+ import { extractArtifact } from './text.js';
31
+
32
+ const require = createRequire(import.meta.url);
33
+ const { Loop, wireGate, HaltError } = require('bare-agent');
34
+ const { createShellTools } = require('bare-agent/tools');
35
+
36
+ /** the scout's hard round bound — read-only survey, never a worker (v1.12 §1) */
37
+ const SCOUT_ROUNDS = 8;
38
+ /** blob bound: the scout's output is a PROMPT ingredient for every later call */
39
+ const SCOUT_BLOB_MAX = 8000;
40
+ /**
41
+ * NATIVE-ONLY read cap (F48/BA-16). On the clipipe-subscription surface the claude CLI
42
+ * TRUNCATES a large tool result before the model ever sees it (~40-50KB / ~line 550,
43
+ * measured), spilling the remainder to a `~/.claude/.../tool-results/` file the fence denies
44
+ * AND wrapping it in a "read this in chunks" notice the model correctly distrusts as prompt
45
+ * injection — so a whole-file `shell_read` of a large file blinds the worker (0-write stall,
46
+ * F48). We bound OUR read result below the CLI cap and hand back a TRUSTED notice steering to
47
+ * `ctx_get` (ranged retrieval survives the cap: one function per fetch). API path is untouched
48
+ * — there the full result rides straight into context, so no cap and RETRIEVAL_STRATEGY alone.
49
+ */
50
+ const NATIVE_READ_CAP = 24 * 1024;
51
+ /** native-only strategy: tell the worker WHY whole-file reads fail here and to navigate by symbol */
52
+ const NATIVE_READ_STRATEGY = '\nINTERFACE LIMIT: on this surface a whole-file read of a large file is TRUNCATED before you see it — a shell_read of a file over ~24KB returns only its start followed by a truncation notice. This is NOT the whole file. To read a function IN FULL, always use ctx_recall(<symbol>) then ctx_get(<pointer>) — that returns the entire function no matter how large the file is. To locate a line, use shell_grep(<pattern>). Never try to understand a file over ~400 lines by reading it whole; recall its symbols instead.';
53
+ /** feed-forward artifact bound per step (prompt ingredient, spine-bound) */
54
+ const ARTIFACT_MAX = 2000;
55
+ /** the wallet floor below which a replan is a stop, not an adaptation (review
56
+ * #5): a money-gate halt drains the wallet to ~0, so replanning against dust
57
+ * just burns another draft and mislabels the money-cut as "exits still red"
58
+ * (F45 class) — the honest terminal there is cap-halt. */
59
+ const MONEY_MIN = 0.001;
60
+
61
+ /** @typedef {Error & {category?: string, lib?: string}} CategorizedError */
62
+
63
+ /**
64
+ * The plan-drafting prompt: a schema DESCRIPTION built from the live validator
65
+ * menus — never a copyable example (the drafter must author, not echo; the
66
+ * run.js draftPrompt precedent). Check NAMES only: a check's command is
67
+ * arbiter territory the planner never sees.
68
+ * @param {any} job @param {string} scoutBlob @param {any[]|null} reds
69
+ * @param {number} maxStepRounds @param {string|null} failure replan context
70
+ */
71
+ function planPrompt(job, scoutBlob, reds, maxStepRounds, failure) {
72
+ const ceiling = Array.isArray(job.tools) ? job.tools : [...TOOL_MENU];
73
+ const checkNames = (job.checks ?? []).map((/** @type {any} */ c) => c.name);
74
+ const doc = `DRAFT-PLAN
75
+ You are planning how to accomplish a goal in a repository, as an ordered list of bounded
76
+ steps (schema "plan-v1"). The plan is pure declarative JSON validated by a strict schema;
77
+ ANY unknown field, wrong enum value, or out-of-bounds number is rejected. Output ONLY the
78
+ JSON object, no fences, no commentary.
79
+
80
+ Shape: { "schema": "plan-v1", "steps": [ ... 1..${MAX_PLAN_STEPS} steps ... ] } — steps run
81
+ strictly in array order. Each step (no other fields exist):
82
+ - "id": kebab-case slug, unique
83
+ - "action": the step's task, precise enough for a worker that sees ONLY this step
84
+ - "tools": non-empty unique subset of ${JSON.stringify(ceiling)} (write/edit are the write-class verbs)
85
+ - "rounds": integer 1..${maxStepRounds} — the step's per-attempt tool-round bound
86
+ - "target": the step's deliverable path (REQUIRED when tools include write/edit), inside ${JSON.stringify(job.writeScope)}
87
+ - "exit": 1..${MAX_EXITS_PER_STEP} form checks that ALL must pass (AND), each one of:
88
+ {"type":"artifact-written","path":"...","pattern":"optional regex"}
89
+ {"type":"tree-changed","scope":"a scope inside ${JSON.stringify(job.writeScope)}"}
90
+ {"type":"json-valid","path":"..."}
91
+ {"type":"check-passes","name":"one of ${JSON.stringify(checkNames)}"}
92
+ A check-passes on a write-granted step MUST be paired with a tree-changed exit
93
+ (the repository starts green — a lone check would pass on the untouched tree).
94
+ Reference checks by NAME only; you cannot author or modify one.
95
+
96
+ Goal:
97
+ ${job.goal}
98
+
99
+ Repository survey (from a read-only scout):
100
+ ${scoutBlob || '(no scout notes)'}`;
101
+ let p = doc;
102
+ if (failure) p += `\n\nWhat happened when the previous plan ran:\n${failure}\nPlan differently — a repeat of the same steps will fail the same way.`;
103
+ if (reds) p += `\n\nYour previous plan was REJECTED with these reds (code:path):\n${JSON.stringify(reds)}\nFix every red. Output ONLY the corrected JSON object.`;
104
+ return p;
105
+ }
106
+
107
+ /**
108
+ * Execute a validateJob-GREEN plan-shape job (goal/verdictType/close/checks[]).
109
+ * Called by runJob after the approval gate, validation, and the smoke — this
110
+ * function owns the plan flow only; the caller owns the job-end record and the
111
+ * one ledger (every provider round is emitted here as `worker-round`, which
112
+ * the caller's metered emit accounts — F12).
113
+ *
114
+ * @param {any} job the validated plan-shape spec
115
+ * @param {object} opts
116
+ * @param {string} opts.workdir the run directory (the fence's root)
117
+ * @param {any} opts.provider shell-owned LLM binding (the Loop path — `anthropic-api`)
118
+ * @param {(o: {policy: Function, onTurn?: Function, maxTurns: number, hasTools: boolean}) => any} [opts.nativeProvider]
119
+ * NATIVE clipipe factory (BA-16): required when `job.provider === 'clipipe-subscription'`.
120
+ * The runner builds a FRESH provider per worker and picks the mode by `hasTools`:
121
+ * `true` → native tool mode (`toolProtocol:'claude-mcp'`, wire `policy`+`onTurn`+`maxTurns`);
122
+ * `false` → the drafter has no tools, so a native session would report NO cost — return a
123
+ * metered claude-json TEXT provider (`--output-format json`, `parse:'claude-json'`) instead,
124
+ * so its spend is never invisible. The Loop path (`anthropic-api`) never touches this.
125
+ * @param {(type: string, data?: object) => object} opts.emit spine emitter (the caller's METERED emit)
126
+ * @param {() => number} opts.remainingUsd the one wallet: what is left of the signed budget right now
127
+ * @param {() => boolean} [opts.isUnpriced] has any round come back with a null cost? (F6) — the
128
+ * plan flow bails IN-FLIGHT on the first unpriced round instead of burning the whole plan
129
+ * @param {number} [opts.capRuns] shell-owned per-step attempt cap
130
+ * @param {number} [opts.closeTimeoutMs] close/check wall-clock cap (shell territory)
131
+ * @param {number} [opts.maxStepRounds] the shell's per-step rounds ceiling (validatePlan's bound)
132
+ * @param {boolean} [opts.layerRoot=false] Layer R — the within-run ratchet (src/root.js),
133
+ * scoped PER STEP's ralph loop (each micro-wheel is the Layer-1 atom). Shell-assembled from
134
+ * the step's own books: per-attempt write-sets from the F32 workerWrites audit (teed for
135
+ * same-path rewrites, which the cumulative audit alone cannot see) and the red-set from the
136
+ * exit evaluator's own gap. Defaults OFF (decided 2026-07-21, F41): fixation is extinct on
137
+ * every current job, so ON has never won its A/B; `true` is the ON/experimental arm, and the
138
+ * first plan-flow job to emit `root-injected` runs the pre-registered ON-vs-OFF acceptance
139
+ * read (the Layer R default-flip, LAYERS.md ⚠). Excluded on native (clipipe): the native
140
+ * worker has no onToolResult seam, so the tee cannot settle and same-path rewrites are blind.
141
+ * @returns {Promise<string>} 'green' | 'already-green' | 'escalated' | 'plan-red' |
142
+ * 'check-red' | 'close-red' | 'close-unsupported' | 'pricing-red' | 'cap-halt' |
143
+ * 'provider-red' | 'interpreter-red' | `step-red:<id>`
144
+ */
145
+ export async function runPlan(job, { workdir, provider, nativeProvider, emit, remainingUsd, isUnpriced = () => false, capRuns = 3, closeTimeoutMs, maxStepRounds = 40, layerRoot = false }) {
146
+ workdir = resolve(workdir);
147
+ const scrub = (/** @type {string} */ s) => redact(s, { patterns: SECRET_PATTERNS });
148
+
149
+ // Which worker surface? `clipipe-subscription` drives tools NATIVELY (the CLI
150
+ // owns the turn cycle, BA-16); every other provider runs the Loop. The close,
151
+ // the checks, and the exit evaluator are provider-independent (commands and
152
+ // form checks) — ONLY the worker differs, so the whole plan flow is shared.
153
+ const native = job.provider === 'clipipe-subscription';
154
+
155
+ // ── close-unsupported (F17 guard, mirrored from the legacy path at run.js):
156
+ // the plan flow executes a PREDICATE close only — a command whose exit code is
157
+ // truth. validateJob admits a GOLD close under verdictType green (gold is
158
+ // hard-class), and a gold close carries no `cmd`; running `close.cmd.trim()`
159
+ // on it would TypeError out of runJob with NO job-end (the spine would dangle,
160
+ // no spend recorded). Refuse a non-predicate close cleanly, before any tokens.
161
+ if (job.close.type !== 'predicate') {
162
+ emit('escalation', {
163
+ category: 'close-unsupported', decisionReady: true,
164
+ decision: `The job's close is a ${job.close.type} close — the plan flow executes a predicate close only (a command whose exit code is truth).`,
165
+ options: ['restate the close as a predicate', 'wait for the verdict-classes rung'],
166
+ });
167
+ return 'close-unsupported';
168
+ }
169
+
170
+ // ── native wiring: a clipipe-subscription job needs the native provider
171
+ // FACTORY (native governance is constructor-time + per-worker, so the runner
172
+ // cannot reuse one injected instance). A missing factory is an adopter wiring
173
+ // gap, never a silent fall-back to the Loop path (that would run a
174
+ // subscription job on the metered API — the wrong bill on the wrong surface).
175
+ if (native && typeof nativeProvider !== 'function') {
176
+ emit('escalation', {
177
+ category: 'interpreter-red', decisionReady: true,
178
+ decision: 'This job declares provider clipipe-subscription (native tool mode), but no native provider factory was wired into the runner.',
179
+ options: ['wire a native CLIPipeProvider factory (opts.nativeProvider)', 'change the job provider to a Loop-driven one'],
180
+ });
181
+ return 'interpreter-red';
182
+ }
183
+
184
+ const closeArgv = job.close.cmd.trim().split(/\s+/);
185
+ const closeOpts = { timeoutMs: closeTimeoutMs, cwd: workdir, expect: job.close.expect, judged: job.close.judged, gapKeep: job.close.gapKeep };
186
+
187
+ // ── 0a. close precheck (close-first, F17): already-green is a DISTINCT
188
+ // record, zero tokens; a forbidden-zone verdict escalates before any spend
189
+ const pre = runClose(closeArgv, scrub, closeOpts);
190
+ emit('close-precheck', { ...pre });
191
+ if (pre.verdict === 'satisfied') return 'already-green';
192
+ const preFault = Object.hasOwn(CLOSE_FAULTS, pre.verdict) ? CLOSE_FAULTS[pre.verdict] : undefined;
193
+ if (preFault) {
194
+ emit('escalation', { category: preFault.category, decisionReady: true, decision: preFault.decision, options: preFault.options, detail: pre.detail });
195
+ return 'close-red';
196
+ }
197
+
198
+ // ── 0b. checks preflight ($0, deterministic): every SIGNED check runs once
199
+ // before tokens — an unrunnable check would fault mid-plan after real spend
200
+ // (a frozen rule without a wired detector is prose; here the detector runs
201
+ // first). Red and green are both fine: checks decide nothing.
202
+ for (const c of job.checks ?? []) {
203
+ const v = runClose(c.cmd.trim().split(/\s+/), scrub, { timeoutMs: closeTimeoutMs, cwd: workdir, expect: c.expect, judged: c.judged, gapKeep: c.gapKeep });
204
+ emit('check-preflight', { name: c.name, verdict: v.verdict });
205
+ const f = Object.hasOwn(CLOSE_FAULTS, v.verdict) ? CLOSE_FAULTS[v.verdict] : undefined;
206
+ if (f) {
207
+ emit('escalation', { category: f.category, decisionReady: true, decision: `Signed check "${c.name}" rendered no judgment at preflight — every plan referencing it would fault mid-run. ${f.decision}`, options: f.options, detail: `${c.name}: ${v.detail ?? ''}` });
208
+ return 'check-red';
209
+ }
210
+ }
211
+
212
+ const lc = new LiteCtx({ root: workdir });
213
+ const ceiling = Array.isArray(job.tools) ? job.tools : [...TOOL_MENU];
214
+ const fencePrefixes = job.writeScope.map((/** @type {string} */ g) => resolve(workdir, globToPrefix(g)));
215
+ const auditPath = join(workdir, 'gate-audit.jsonl');
216
+ const checksByName = new Map((job.checks ?? []).map((/** @type {any} */ c) => [c.name, c]));
217
+
218
+ /** the check-passes seam evalExits delegates to: the FULL runClose machinery
219
+ * per signed check; a forbidden-zone verdict rides out as `fault` by name so
220
+ * the micro-loop escalates (or F32-routes a crash) instead of faking a gap */
221
+ const runCheck = async (/** @type {string} */ name) => {
222
+ const c = checksByName.get(name);
223
+ if (!c) return { pass: false, fault: 'failed', gap: `no signed check named "${name}"` };
224
+ const v = runClose(c.cmd.trim().split(/\s+/), scrub, { timeoutMs: closeTimeoutMs, cwd: workdir, expect: c.expect, judged: c.judged, gapKeep: c.gapKeep });
225
+ emit('check-run', { name, verdict: v.verdict, ...(v.exitCode !== undefined ? { exitCode: v.exitCode } : {}) });
226
+ if (v.verdict === 'satisfied') return { pass: true };
227
+ if (v.verdict === 'needs_revision') return { pass: false, gap: v.gap };
228
+ return { pass: false, fault: v.verdict, gap: v.detail };
229
+ };
230
+
231
+ /** the last escalation ralph emitted — the replan trigger reads its category
232
+ * (only exhaustion replans; an instrument stop stays a stop) */
233
+ let lastEscalation = /** @type {any} */ (null);
234
+ /** @type {(type: string, data?: object) => object} */
235
+ const emitL = (type, data) => { if (type === 'escalation') lastEscalation = data; return emit(type, data); };
236
+
237
+ /**
238
+ * Assemble one bounded worker: fresh Gate (the fence, the arbiter's books
239
+ * denied, the wallet as its budget), granted tools only (the menu IS the
240
+ * grant), per-attempt round bound via loop.stop() (F20), every round metered
241
+ * with a phase label (F12).
242
+ * @param {{granted: string[], phase: string, attemptRounds: number, attempts: number, writable: boolean, root?: ReturnType<typeof createRoot>|null}} o
243
+ * `root` (Layer R): when present, the worker's write-class actions are teed
244
+ * for the within-run ratchet — staged before the gate decides, discarded on
245
+ * deny/halt, settled to landed-or-not after execution (the two axes, F43/F7).
246
+ * Wired ONLY on the Loop path: the native session exposes no onToolResult seam.
247
+ */
248
+ async function mkWorker({ granted, phase, attemptRounds, attempts, writable, root = null }) {
249
+ const gate = new Gate({
250
+ fs: {
251
+ writeScope: writable ? fencePrefixes : [],
252
+ readScope: [workdir],
253
+ deny: [auditPath, join(workdir, '.smoke'), join(workdir, '.litectx')],
254
+ },
255
+ budget: { maxCostUsd: Math.max(remainingUsd(), 0.0001) },
256
+ limits: { maxTurns: attemptRounds * (attempts + 1) },
257
+ audit: { path: auditPath },
258
+ humanChannel: async () => ({ decision: 'terminate' }),
259
+ });
260
+ await gate.init();
261
+ // F32's instrument, run_id-scoped, write AND edit, allow-decision only —
262
+ // the same audit read as interpret's (never git status, F45)
263
+ const workerWrites = () => {
264
+ try {
265
+ const paths = new Set();
266
+ for (const line of readFileSync(auditPath, 'utf8').split('\n')) {
267
+ if (!line) continue;
268
+ let rec;
269
+ try { rec = JSON.parse(line); } catch { continue; }
270
+ if (rec.run_id === gate.runId && rec.phase === 'gate' && rec.decision === 'allow'
271
+ && (rec.action?.type === 'write' || rec.action?.type === 'edit')
272
+ && typeof rec.action.path === 'string') paths.add(rec.action.path);
273
+ }
274
+ return [...paths];
275
+ } catch { return []; }
276
+ };
277
+ // Layer R tee (design record 2026-07-19; created per-step by executeStep).
278
+ // The translator STAGES write/edit content and snapshots the pre-write hash
279
+ // BEFORE the gate decides (Finding 6); the policy wrapper DISCARDS on a deny
280
+ // or a halt (the tool never runs); onToolOutcome SETTLES landed-vs-not after
281
+ // execution (Finding 7 — the gate's allow is intent, the file is outcome).
282
+ // All no-ops when root is null: every scout/drafter and every native worker.
283
+ const fileHash = (/** @type {string} */ p) => {
284
+ try { return createHash('sha256').update(readFileSync(p)).digest('hex'); } catch { return null; }
285
+ };
286
+ /** @type {{path: string, content: string, type: string, before: string|null}|null} the write-class action awaiting its OUTCOME */
287
+ let pendingProbe = null;
288
+ const teeingTranslator = (/** @type {string} */ n, /** @type {any} */ a) => {
289
+ // classify by action TYPE, never a name list (the workerWrites filter uses
290
+ // the same write|edit test — a third enumeration would let a future
291
+ // write-class verb bypass the tee, the blind-instrument class)
292
+ const act = toolAction(n, a, workdir);
293
+ if (root && (act.type === 'write' || act.type === 'edit')) {
294
+ const path = /** @type {string} */ (act.path);
295
+ const content = String((act.type === 'edit' ? a?.newText : a?.content) ?? '');
296
+ root.stageWrite(path, content); // content read off RAW args, never onto the action (the audit stays bytes-only)
297
+ pendingProbe = { path, content, type: act.type, before: fileHash(path) };
298
+ }
299
+ return act;
300
+ };
301
+ const { policy: gatePolicy, onLlmResult } = wireGate(gate, { actionTranslator: teeingTranslator });
302
+ // Settle the stage on the verdict. A DENY/HALT means the tool never runs, so
303
+ // the outcome seam below never fires for it — discard here (an allow is NOT
304
+ // settled here: the bytes are not written yet; settling on the verdict is the
305
+ // exact Finding 7 defect). The catch RE-THROWS so cap-halt routing still reads
306
+ // the throw. Non-write actions stage nothing, so both settlements are no-ops.
307
+ const policy = root
308
+ ? async (/** @type {string} */ n, /** @type {any} */ a, /** @type {any} */ c) => {
309
+ try {
310
+ const verdict = await gatePolicy(n, a, c);
311
+ if (verdict !== true) { root.discardWrite(); pendingProbe = null; }
312
+ return verdict;
313
+ } catch (e) { root.discardWrite(); pendingProbe = null; throw e; }
314
+ }
315
+ : gatePolicy;
316
+ /**
317
+ * Finding 7 — settle the staged write on what the tool ACTUALLY did. Fires
318
+ * after every tool.execute (bare-agent loop.js), success or error, and tool
319
+ * calls run strictly sequentially, so exactly one probe is ever in flight. A
320
+ * probe that never reaches here (a halt out of a tool body) leaves the stage
321
+ * unsettled — correct: nothing landed, and the attempt boundary drops it.
322
+ * @param {{result?: any}} [info] the tool's return value
323
+ */
324
+ const onToolOutcome = async (info) => {
325
+ if (!root || !pendingProbe) return;
326
+ const { path: p, content, type, before } = pendingProbe;
327
+ pendingProbe = null;
328
+ let after = null;
329
+ try { after = readFileSync(p, 'utf8'); } catch { /* absent → after stays null */ }
330
+ let landed = after !== null && createHash('sha256').update(after, 'utf8').digest('hex') !== before;
331
+ if (!landed && after !== null) {
332
+ // no-byte-change: a write always writes (identical bytes ⇒ content IS the
333
+ // file, exact-equal never substring); a no-op edit is an idempotent apply
334
+ // OR a missed anchor — the tool RESULT is the only honest signal (F2)
335
+ if (type === 'write') landed = after === content;
336
+ else landed = typeof info?.result === 'string' && info.result.startsWith('edited ');
337
+ }
338
+ root.settleWrite(landed);
339
+ };
340
+ /** @type {number|string|undefined} */
341
+ let roundIteration;
342
+ let roundsThisAttempt = 0;
343
+ /** @type {number|string|undefined} */
344
+ let attemptBounded;
345
+ const grantedNames = new Set(granted.map((v) => /** @type {Record<string, string>} */ (TOOL_BY_VERB)[v]));
346
+ const shell = createShellTools().tools.filter((/** @type {{name: string}} */ t) => grantedNames.has(t.name));
347
+ // F48: on native, bound shell_read below the CLI's tool-result display cap and hand back a
348
+ // TRUSTED truncation notice steering to ctx_get — the CLI's own truncation blinds the worker
349
+ // (spilled + injection-flagged). Fresh tool objects per mkWorker call, so mutating is per-worker.
350
+ if (native) {
351
+ const rd = shell.find((/** @type {{name: string}} */ t) => t.name === TOOL_BY_VERB.read);
352
+ if (rd) {
353
+ const inner = rd.execute;
354
+ rd.execute = async (/** @type {any} */ args) => {
355
+ const r = await inner(args);
356
+ if (typeof r === 'string' && Buffer.byteLength(r, 'utf8') > NATIVE_READ_CAP) {
357
+ const head = Buffer.from(r, 'utf8').subarray(0, NATIVE_READ_CAP).toString('utf8');
358
+ return head + `\n\n[bareloop: file truncated at ${NATIVE_READ_CAP} bytes — this interface will not display more of a single read. To read a specific function IN FULL use ctx_recall(<symbol>) then ctx_get(<pointer>); to find a line use shell_grep(<pattern>).]`;
359
+ }
360
+ return r;
361
+ };
362
+ }
363
+ }
364
+ const ctx = [...CTX_TOOLS].some((t) => grantedNames.has(t))
365
+ ? createCtxTools(lc, workdir, emit).filter((t) => grantedNames.has(t.name))
366
+ : [];
367
+ if (ctx.length) await lc.index();
368
+ const toolDefs = [...shell, ...ctx];
369
+ const system = PERSONA_TOOLS + (granted.includes('edit') ? EDIT_STRATEGY : '') + (ctx.length ? RETRIEVAL_STRATEGY : '')
370
+ + (native && grantedNames.has(TOOL_BY_VERB.read) ? NATIVE_READ_STRATEGY : '');
371
+ /** @param {any} u @returns {{inputTokens: number, outputTokens: number, cacheReadTokens: number, cacheCreationTokens: number}} */
372
+ const usageOf = (u) => ({ inputTokens: u?.inputTokens ?? 0, outputTokens: u?.outputTokens ?? 0, cacheReadTokens: u?.cacheReadTokens ?? 0, cacheCreationTokens: u?.cacheCreationTokens ?? 0 });
373
+
374
+ if (native && toolDefs.length > 0) {
375
+ // ── NATIVE clipipe TOOL session (BA-16): the CLI owns the turn cycle, so
376
+ // the arbiter clips onto the PROVIDER — policy (the SAME wireGate fence,
377
+ // proven to deny out-of-scope), onTurn (metering), maxTurns (the
378
+ // per-session round bound). Money is null per turn and AUTHORITATIVE at
379
+ // session close (the round-level F12 figure the CLI does not expose; the
380
+ // session total is honest — the per-session reconciliation). Only workers
381
+ // WITH tools take this path: a native session with NO tools fires no
382
+ // onTurn and reports no cost (live-verified), so the toolless drafter runs
383
+ // the metered claude-json TEXT path below instead (never unmetered spend).
384
+ /** @param {{costUsd?: number|null, pricing?: string|null, usage?: any, kind?: string}} arg */
385
+ const nativeMetered = async (arg) => {
386
+ const session = (arg?.kind ?? 'turn') === 'session';
387
+ // per-turn events are ATTRIBUTION ONLY (`worker-turn`, never accounted —
388
+ // a native turn's cost is null BY DESIGN and F6 must not read it as
389
+ // unpriced); the session-close event carries the authoritative cost and
390
+ // IS the one accounted `worker-round` the ledger sums (F12 at the surface
391
+ // the CLI actually meters).
392
+ emit(session ? 'worker-round' : 'worker-turn', {
393
+ phase, iteration: roundIteration, kind: arg?.kind ?? 'turn',
394
+ costUsd: session ? (arg?.costUsd ?? null) : null, pricing: arg?.pricing ?? null,
395
+ tokens: (arg?.usage?.inputTokens ?? 0) + (arg?.usage?.outputTokens ?? 0),
396
+ usage: usageOf(arg?.usage),
397
+ });
398
+ return onLlmResult(arg);
399
+ };
400
+ const provider2 = /** @type {any} */ (nativeProvider)({ policy, onTurn: nativeMetered, maxTurns: attemptRounds, hasTools: true });
401
+ const loop = new Loop({ provider: provider2, system }); // no Loop policy / no cacheMessages: the CLI owns the transcript
402
+ /** @param {string} prompt @param {typeof toolDefs} [defs] */
403
+ const ask = async (prompt, defs = toolDefs) => {
404
+ let r;
405
+ try {
406
+ r = await loop.run([{ role: 'user', content: prompt }], defs, { maxTokens: 32000 });
407
+ } catch (e) {
408
+ const err = /** @type {CategorizedError} */ (e);
409
+ err.category = e instanceof HaltError ? 'cap-halt' : (err.category ?? 'provider-red');
410
+ throw err;
411
+ }
412
+ // a maxTurns session is a BOUNDED attempt, not an escalation — the same
413
+ // role loop.stop() plays on the Loop path: judge the partial work and
414
+ // feed the gap forward (the CLI preserves lastText, BA-5).
415
+ if (r.error === 'max_turns') {
416
+ attemptBounded = roundIteration;
417
+ emit('attempt-bounded', { phase, iteration: roundIteration, cap: attemptRounds, native: true });
418
+ return r;
419
+ }
420
+ if (r.error) {
421
+ const err = /** @type {CategorizedError} */ (new Error(`native session: ${r.error}`));
422
+ // halt → cap-halt, denial streak → gate-red; a bridge/session terminal
423
+ // (bridge-failed, session_timeout, session:*) is provider-owned transport
424
+ err.category = r.error.startsWith('halt:') ? 'cap-halt'
425
+ : r.error.startsWith('denied:') ? 'gate-red'
426
+ : 'provider-red';
427
+ err.lib = 'bare-agent';
428
+ throw err;
429
+ }
430
+ return r;
431
+ };
432
+ return { ask, workerWrites, setIteration: (/** @type {number|string} */ i) => { roundIteration = i; roundsThisAttempt = 0; }, wasBounded: () => attemptBounded };
433
+ }
434
+
435
+ // ── LOOP path: the injected provider (anthropic-api and every other
436
+ // Loop-driven binding), OR — for a native worker with NO tools (the plan
437
+ // drafter) — a claude-json structured-output CLIPipe from the factory. Native
438
+ // tool mode cannot meter a toolless session (no onTurn, no cost); the
439
+ // claude-json TEXT path reports a real per-call cost that the Loop's
440
+ // onLlmResult meters exactly like an API round, so the drafter's spend is
441
+ // never invisible (F6/F44). The gate policy is wired but idle (no tools).
442
+ const loopProvider = native
443
+ ? /** @type {any} */ (nativeProvider)({ policy, maxTurns: attemptRounds * (attempts + 1), hasTools: false })
444
+ : provider;
445
+ /** @param {{costUsd?: number|null, pricing?: string|null, usage?: any, kind?: string}} arg */
446
+ const metered = async (arg) => {
447
+ emit('worker-round', {
448
+ phase, iteration: roundIteration, kind: arg?.kind ?? 'turn',
449
+ costUsd: arg?.costUsd ?? null, pricing: arg?.pricing ?? null,
450
+ tokens: (arg?.usage?.inputTokens ?? 0) + (arg?.usage?.outputTokens ?? 0),
451
+ usage: usageOf(arg?.usage),
452
+ });
453
+ if ((arg?.kind ?? 'turn') === 'turn') {
454
+ roundsThisAttempt += 1;
455
+ if (roundsThisAttempt >= attemptRounds) {
456
+ attemptBounded = roundIteration;
457
+ emit('attempt-bounded', { phase, iteration: roundIteration, rounds: roundsThisAttempt, cap: attemptRounds });
458
+ loop.stop();
459
+ }
460
+ }
461
+ return onLlmResult(arg);
462
+ };
463
+ const loop = new Loop({ provider: loopProvider, system, policy, onLlmResult: metered, onToolResult: onToolOutcome });
464
+ /** @param {string} prompt @param {typeof toolDefs} [defs] */
465
+ const ask = async (prompt, defs = toolDefs) => {
466
+ let r;
467
+ try {
468
+ r = await loop.run([{ role: 'user', content: prompt }], defs, { cacheMessages: true, maxTokens: 32000 });
469
+ } catch (e) {
470
+ const err = /** @type {CategorizedError} */ (e);
471
+ err.category = e instanceof HaltError ? 'cap-halt' : (err.category ?? 'provider-red');
472
+ throw err;
473
+ }
474
+ // same error-return taxonomy as interpret's ask (one map, same doctrine):
475
+ // halt → cap-halt, denial streak → gate-red, API truncation → provider-red
476
+ if (r.error) {
477
+ const err = /** @type {CategorizedError} */ (new Error(`worker loop: ${r.error}`));
478
+ err.category = r.error.startsWith('halt:') ? 'cap-halt'
479
+ : r.error.startsWith('denied:') ? 'gate-red'
480
+ : r.error.startsWith('truncated:') ? 'provider-red'
481
+ : 'interpreter-red';
482
+ err.lib = 'bare-agent';
483
+ throw err;
484
+ }
485
+ return r;
486
+ };
487
+ return { ask, workerWrites, setIteration: (/** @type {number|string} */ i) => { roundIteration = i; roundsThisAttempt = 0; }, wasBounded: () => attemptBounded };
488
+ }
489
+
490
+ /** relay a throw from OUTSIDE ralph (scout/plan drafting) as its honest category */
491
+ const relay = (/** @type {any} */ e, /** @type {string} */ phase) => {
492
+ const category = e instanceof HaltError ? 'cap-halt' : (typeof e?.category === 'string' ? e.category : 'provider-red');
493
+ if (category === 'cap-halt') emit('cap-halt', { category, meaning: 'not under cap — not "can\'t"', detail: String(e?.message ?? e) });
494
+ emit('escalation', {
495
+ category, decisionReady: true, phase,
496
+ decision: category === 'cap-halt' ? `The budget gate tripped during ${phase} — the wallet cannot fund the plan flow.` : `The ${phase} call failed (${category}) — no result exists.`,
497
+ options: category === 'cap-halt' ? ['raise the job budget and rerun', 'abandon the run'] : ['retry the run', 'fix the provider binding', 'abandon the run'],
498
+ detail: String(e?.message ?? e),
499
+ ...(typeof e?.lib === 'string' ? { lib: e.lib } : {}),
500
+ });
501
+ return category === 'cap-halt' ? 'cap-halt' : 'provider-red';
502
+ };
503
+
504
+ // ── 1. SCOUT — read-only by construction: the write-class verbs are simply
505
+ // not in its menu (the menu is the grant), and its gate fences zero paths
506
+ let scoutBlob = '';
507
+ emit('scout-start', { rounds: SCOUT_ROUNDS });
508
+ try {
509
+ const scout = await mkWorker({ granted: ceiling.filter((v) => !WRITE_VERBS.includes(v)), phase: 'scout', attemptRounds: SCOUT_ROUNDS, attempts: 1, writable: false });
510
+ scout.setIteration(1);
511
+ const r = await scout.ask([
512
+ 'Survey this repository READ-ONLY for the goal below. Report: the relevant layout, the key files and symbols, and your best hypothesis about what the work requires. Be concise — your notes brief a planner that cannot see the repository.',
513
+ `Repository root (absolute): ${workdir}\nEvery path you pass to a tool MUST be absolute and inside this root.`,
514
+ `Goal:\n${job.goal}`,
515
+ pre.gap && `The job's verification is currently failing. Its output on the tree as it stands:\n${pre.gap}`,
516
+ ].filter(Boolean).join('\n\n'));
517
+ scoutBlob = scrub(r.text ?? '').slice(0, SCOUT_BLOB_MAX);
518
+ } catch (e) {
519
+ return relay(e, 'scout');
520
+ }
521
+ emit('scout-result', { bytes: Buffer.byteLength(scoutBlob) });
522
+ // F6 in-flight: an unpriced round means the cap cannot govern spend it cannot
523
+ // see — halt at the boundary rather than run the whole plan blind (the caller
524
+ // emits pricing-red; runPlan just stops burning tokens). Legacy halts per-step.
525
+ if (isUnpriced()) return 'pricing-red';
526
+
527
+ // ── 2. PLAN — the decompose call; the planner NEVER sees the repo (no tools,
528
+ // scout blob only — what keeps the plan a plan and not a second worker).
529
+ // One shot + one redraft with the reds fed back (the drafting precedent).
530
+ /** draft + validate with one redraft; emits plan-validate per phase. The
531
+ * drafter is built FRESH per call (review #4): its Gate budget snapshots the
532
+ * CURRENT wallet, never a stale pre-execute allocation. A replan draft after
533
+ * the steps have spent is therefore bounded by what is ACTUALLY left, so the
534
+ * total run spend can never exceed the signed budget (advertised == enforced,
535
+ * the hard line) — a drafter built once at full budget would let the replan
536
+ * draft spend against money the steps had already consumed.
537
+ * @param {string} phase @param {string|null} failure */
538
+ const obtainPlan = async (phase, failure) => {
539
+ const drafter = await mkWorker({ granted: [], phase: 'plan', attemptRounds: 2, attempts: 3, writable: false });
540
+ const draftPlan = async (/** @type {any[]|null} */ reds) => {
541
+ drafter.setIteration(reds ? 'redraft' : 'draft');
542
+ const r = await drafter.ask(planPrompt(job, scoutBlob, reds, maxStepRounds, failure), []);
543
+ return extractArtifact(r.text).code ?? '';
544
+ };
545
+ let text = await draftPlan(null);
546
+ let pv = validatePlan(text, { job, maxStepRounds });
547
+ emit('plan-validate', { ok: pv.ok, reds: pv.reds, phase: `${phase}-1` });
548
+ if (!pv.ok) {
549
+ text = await draftPlan(pv.reds);
550
+ pv = validatePlan(text, { job, maxStepRounds });
551
+ emit('plan-validate', { ok: pv.ok, reds: pv.reds, phase: `${phase}-2` });
552
+ }
553
+ return pv;
554
+ };
555
+ let plan;
556
+ try {
557
+ const pv = await obtainPlan('draft', null);
558
+ if (!pv.ok) {
559
+ for (const r of pv.reds) emit('plan-red', r);
560
+ return 'plan-red';
561
+ }
562
+ plan = /** @type {any} */ (pv.plan);
563
+ emit('plan-accepted', { plan });
564
+ } catch (e) {
565
+ return relay(e, 'plan');
566
+ }
567
+ if (isUnpriced()) return 'pricing-red'; // F6: the plan drafting round came back unpriced — halt before steps
568
+
569
+ // ── 3. EXECUTE — strictly sequential micro-loops; judge = the exit
570
+ // evaluator through ralph's shell-owned seam; artifacts feed forward (F21)
571
+ /** @type {{id: string, text: string}[]} */
572
+ const artifacts = [];
573
+ /** @type {{id: string, outcome: string}[]} */
574
+ const stepOutcomes = [];
575
+ let replanned = false;
576
+ const planExecuted = () => emit('plan-executed', { steps: stepOutcomes, replanned });
577
+
578
+ /** @param {any} step */
579
+ const executeStep = async (step) => {
580
+ emit('step-start', { step: step.id, rounds: step.rounds, tools: step.tools });
581
+ // the before-side of every tree-changed exit, taken at STEP start: the
582
+ // step's cumulative work is what the exit judges (outcome, never intent)
583
+ const snapshot = new Map();
584
+ for (const e of step.exit) {
585
+ if (e.type === 'tree-changed') for (const [k, v] of await snapshotScope(workdir, e.scope)) snapshot.set(k, v);
586
+ }
587
+ // Layer R — one root per step's ralph loop (each micro-wheel is the Layer-1
588
+ // atom, LAYERS.md). The red-set is the exit evaluator's OWN gap: fixation
589
+ // here means "the worker rewrote the same file(s) AND the exit evaluator's
590
+ // complaint is byte-identical" — so the whole normalized gap is the
591
+ // comparable set (gapKeep `\S` = every non-blank line). That is more robust
592
+ // than reusing a check's `^`-anchored gapKeep, which the exit wrapper
593
+ // (`check "x" red: …`) would break. writesInformative is true (tool mode,
594
+ // always) but never fires on write-overlap ALONE — a plan step rewrites its
595
+ // one target every attempt, so only a KNOWN-unmoved red-set can distinguish
596
+ // repetition from progress (Finding 3). Native is excluded (no onToolResult
597
+ // seam → the tee cannot settle → same-path rewrites are blind).
598
+ const root = layerRoot && !native
599
+ ? createRoot({ gapKeep: '\\S', redact: scrub, writesInformative: true })
600
+ : null;
601
+ const w = await mkWorker({ granted: step.tools, phase: `step:${step.id}`, attemptRounds: step.rounds, attempts: capRuns, writable: true, root });
602
+ let lastText = '';
603
+ let iterationNow = 0;
604
+ /** @param {number} iteration @param {string} [gap] */
605
+ const middle = async (iteration, gap) => {
606
+ w.setIteration(iteration);
607
+ iterationNow = iteration;
608
+ // Layer R observe: finalize the prior attempt from the books (workerWrites
609
+ // audit + teed same-path rewrites), run the fixation detector, and inject
610
+ // its escalating note into THIS attempt's prompt (null = inert). The event
611
+ // carries counts and paths only — never content (the spine is forever).
612
+ const rootInj = root ? root.observe({ iteration, gap, writes: w.workerWrites() }) : null;
613
+ if (rootInj) emit('root-injected', { step: step.id, ...rootInj.event });
614
+ const r = await w.ask([
615
+ step.action,
616
+ `Repository root (absolute): ${workdir}\nEvery path you pass to a tool MUST be absolute and inside this root — a relative path resolves against a different directory and will be denied by the gate.`,
617
+ step.target && `Write your deliverable to: ${resolve(workdir, step.target)}`,
618
+ artifacts.length > 0 && `Working context (read-only) — prior steps' results:\n${artifacts.map((a) => `[${a.id}] ${a.text}`).join('\n\n')}`,
619
+ gap && `Previous attempt failed this step's checks:\n${gap}`,
620
+ w.wasBounded() === iteration - 1
621
+ && `Your previous attempt was CUT OFF after ${step.rounds} tool rounds. Reading is bounded; writing is not. Form a hypothesis EARLY and make the change.`,
622
+ rootInj && rootInj.note,
623
+ ].filter(Boolean).join('\n\n'));
624
+ lastText = scrub(r.text ?? '').slice(0, ARTIFACT_MAX);
625
+ };
626
+ const judge = async () => {
627
+ const { pass, results } = await evalExits(step.exit, { dir: workdir, snapshot, runCheck });
628
+ emit('exit-eval', { step: step.id, iteration: iterationNow, results });
629
+ // an instrument fault rides out by its runClose verdict NAME: ralph
630
+ // escalates it through CLOSE_FAULTS (or F32-routes a crash after writes)
631
+ const faulty = results.find((r) => r.fault);
632
+ if (faulty) return { verdict: /** @type {string} */ (faulty.fault), detail: faulty.detail };
633
+ if (pass) return { verdict: 'satisfied' };
634
+ // AND-only: the gap names EVERY failing wall (mechanical genre, F38)
635
+ return { verdict: 'needs_revision', gap: results.filter((r) => !r.pass).map((r) => r.detail).join('\n') };
636
+ };
637
+ const outcome = await ralph({ middle, judge, capRuns, emit: emitL, workerWrites: w.workerWrites });
638
+ return { outcome, artifact: lastText };
639
+ };
640
+
641
+ let idx = 0;
642
+ while (idx < plan.steps.length) {
643
+ const step = plan.steps[idx];
644
+ let res;
645
+ try {
646
+ res = await executeStep(step);
647
+ } catch (e) {
648
+ // Reachable ONLY by mkWorker/gate/index SETUP faults: ralph catches middle
649
+ // throws and returns 'escalated', so a provider throw never reaches here.
650
+ // An uncategorized setup fault is interpreter-red (broken infra), never
651
+ // relay's provider-red default — and the RECORDED outcome must match the
652
+ // escalation the human reads (review #6, F11 misfiling: a spine that says
653
+ // interpreter-red while the escalation says provider-red is two
654
+ // instruments disagreeing about the same event).
655
+ const err = /** @type {CategorizedError} */ (e);
656
+ const category = err instanceof HaltError ? 'cap-halt' : (typeof err.category === 'string' ? err.category : 'interpreter-red');
657
+ stepOutcomes.push({ id: step.id, outcome: category });
658
+ if (category === 'cap-halt') emit('cap-halt', { category, meaning: 'not under cap — not "can\'t"', detail: String(err?.message ?? err) });
659
+ emit('escalation', {
660
+ category, decisionReady: true, phase: `step:${step.id}`,
661
+ decision: category === 'cap-halt'
662
+ ? `The budget gate tripped while building step "${step.id}" — the wallet cannot fund the plan flow.`
663
+ : `Step "${step.id}" could not be set up (${category}) — the worker, gate, or index failed before the step ran.`,
664
+ options: category === 'cap-halt' ? ['raise the job budget and rerun', 'abandon the run'] : ['fix the interpreter/environment', 'retry the run', 'abandon the run'],
665
+ detail: String(err?.message ?? err),
666
+ ...(typeof err?.lib === 'string' ? { lib: err.lib } : {}),
667
+ });
668
+ planExecuted();
669
+ return category;
670
+ }
671
+ stepOutcomes.push({ id: step.id, outcome: res.outcome });
672
+ emit('step-end', { step: step.id, outcome: res.outcome });
673
+ if (isUnpriced()) { planExecuted(); return 'pricing-red'; } // F6: a step round came back unpriced — halt before the next
674
+ if (res.outcome === 'green') {
675
+ artifacts.push({ id: step.id, text: res.artifact });
676
+ idx += 1;
677
+ continue;
678
+ }
679
+ // ONE replan, and only for EXHAUSTION with FUNDS LEFT (review #5): ralph
680
+ // emits cap-halt for BOTH attempt-exhaustion AND a money-gate halt
681
+ // mid-attempt — but a drained wallet is a stop, not an adaptation. A
682
+ // money-gate halt necessarily drained the wallet (the worker's cap WAS the
683
+ // whole remaining wallet), so replanning against it burns another draft and
684
+ // mislabels the money-cut as "exits still red" (F45 class); attempt-
685
+ // exhaustion leaves money on the table. An instrument/governance stop that
686
+ // is not cap-halt never replans either. (An unpriced step already returned
687
+ // pricing-red at the step-end guard above, so isUnpriced() is false here.)
688
+ const cat = lastEscalation?.category;
689
+ if (!replanned && cat === 'cap-halt' && remainingUsd() > MONEY_MIN) {
690
+ replanned = true;
691
+ emit('replan', { step: step.id, reason: 'step exhausted its attempts with exits still red' });
692
+ const failure = `Step "${step.id}" (${step.action}) ran ${capRuns} attempts and its exits were still red. `
693
+ + `Last exit state:\n${lastEscalation?.detail ?? '(none)'}\n`
694
+ + `Steps completed so far: ${artifacts.map((a) => a.id).join(', ') || 'none'}.`;
695
+ let pv;
696
+ try {
697
+ pv = await obtainPlan('replan', failure);
698
+ } catch (e) {
699
+ planExecuted();
700
+ return relay(e, 'replan');
701
+ }
702
+ if (!pv.ok) {
703
+ for (const r of pv.reds) emit('plan-red', r);
704
+ planExecuted();
705
+ return 'plan-red';
706
+ }
707
+ plan = /** @type {any} */ (pv.plan);
708
+ emit('plan-accepted', { plan, phase: 'replan' });
709
+ // the new plan's steps are NOT the old plan's steps — its abandoned greens
710
+ // must not ride forward as this plan's "prior steps' results" (stale, and
711
+ // it names steps the current plan does not contain)
712
+ artifacts.length = 0;
713
+ idx = 0;
714
+ continue;
715
+ }
716
+ // A money-gate halt (wallet drained) is an honest cap-halt terminal, never a
717
+ // step-red: the exits never ran because the money ran out, not because the
718
+ // work failed. Attempt-exhaustion WITH funds after the one replan is spent
719
+ // stays a step-red (the stop is a result).
720
+ if (cat === 'cap-halt') {
721
+ planExecuted();
722
+ return remainingUsd() <= MONEY_MIN ? 'cap-halt' : `step-red:${step.id}`;
723
+ }
724
+ // Any OTHER terminal escalation category is NOT a capability failure: a
725
+ // provider-red is a transport CASUALTY, a gate-red/interpreter-red/close
726
+ // fault is an instrument stop. Each rides out under its OWN name (F11: the
727
+ // returned outcome and the emitted escalation must agree) so run.js labels
728
+ // it honestly — a provider-red carries the F44 spendComplete:false floor,
729
+ // never laundered into step-red tier data. Mirrors the setup-fault catch.
730
+ planExecuted();
731
+ return typeof cat === 'string' ? cat : `step-red:${step.id}`;
732
+ }
733
+
734
+ // ── 4. THE CLOSE — the operator's signed command, the only truth. Red →
735
+ // the gap feeds ONE bounded fix loop judged by the REAL close (v1.12 §4);
736
+ // still red → the escalation ralph already emitted stands.
737
+ const post = runClose(closeArgv, scrub, closeOpts);
738
+ emit('outer-close', { ...post });
739
+ if (post.verdict === 'satisfied') {
740
+ planExecuted();
741
+ return 'green';
742
+ }
743
+ const postFault = Object.hasOwn(CLOSE_FAULTS, post.verdict) ? CLOSE_FAULTS[post.verdict] : undefined;
744
+ if (postFault) {
745
+ emit('escalation', { category: postFault.category, decisionReady: true, decision: postFault.decision, options: postFault.options, detail: post.detail });
746
+ planExecuted();
747
+ return 'close-red';
748
+ }
749
+ emit('fix-loop', { gapBytes: Buffer.byteLength(post.gap ?? '') });
750
+ let fixOutcome;
751
+ try {
752
+ // Layer R for the close-fix loop — the plan flow's single ralph loop judged
753
+ // by the REAL close (the closest analog to interpret.js's loop, and the
754
+ // likeliest place fixation manifests: the fix worker has the full menu and
755
+ // is judged by a command, not a form-only exit). The red-set is the CLOSE's
756
+ // own gapKeep (the gap here is the raw close output, unwrapped — so the
757
+ // `^`-anchored pattern matches, unlike the exec steps' exit-eval gap). Same
758
+ // native exclusion (no onToolResult seam ⇒ the tee cannot settle).
759
+ const fixRoot = layerRoot && !native
760
+ ? createRoot({ gapKeep: job.close.gapKeep, redact: scrub, writesInformative: true })
761
+ : null;
762
+ const w = await mkWorker({ granted: ceiling, phase: 'fix', attemptRounds: maxStepRounds, attempts: capRuns, writable: true, root: fixRoot });
763
+ /** @param {number} iteration @param {string} [gap] */
764
+ const middle = async (iteration, gap) => {
765
+ w.setIteration(iteration);
766
+ const rootInj = fixRoot ? fixRoot.observe({ iteration, gap, writes: w.workerWrites() }) : null;
767
+ if (rootInj) emit('root-injected', { phase: 'fix', ...rootInj.event });
768
+ await w.ask([
769
+ 'The job\'s final verification is failing. Fix the repository so it passes.',
770
+ `Repository root (absolute): ${workdir}\nEvery path you pass to a tool MUST be absolute and inside this root.`,
771
+ artifacts.length > 0 && `Working context (read-only) — the plan's steps produced:\n${artifacts.map((a) => `[${a.id}] ${a.text}`).join('\n\n')}`,
772
+ !gap && post.gap && `The verification's output on the tree as it stands (not an attempt of yours):\n${post.gap}`,
773
+ gap && `Previous attempt failed the verification:\n${gap}`,
774
+ rootInj && rootInj.note,
775
+ ].filter(Boolean).join('\n\n'));
776
+ };
777
+ fixOutcome = await ralph({
778
+ middle, close: closeArgv, capRuns, emit: emitL, redact: scrub,
779
+ closeTimeoutMs, cwd: workdir, expect: job.close.expect, judged: job.close.judged, gapKeep: job.close.gapKeep,
780
+ workerWrites: w.workerWrites,
781
+ });
782
+ } catch (e) {
783
+ planExecuted();
784
+ return relay(e, 'fix');
785
+ }
786
+ planExecuted();
787
+ return fixOutcome === 'green' ? 'green' : 'escalated';
788
+ }