bareloop 0.5.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/planrun.js CHANGED
@@ -14,18 +14,22 @@
14
14
  // (fs.deny on the gate audit / .smoke / .litectx, unchanged).
15
15
 
16
16
  import { createRequire } from 'node:module';
17
- import { readFileSync } from 'node:fs';
18
- import { join, resolve } from 'node:path';
19
- import { Gate, redact } from 'bareguard';
17
+ import { readFileSync, readdirSync } from 'node:fs';
18
+ import { createHash } from 'node:crypto';
19
+ import { join, resolve, relative } from 'node:path';
20
+ import { Gate } from 'bareguard';
20
21
  import { LiteCtx } from 'litectx';
21
- import { runClose, ralph, CLOSE_FAULTS } from './ralph.js';
22
- import { validatePlan } from './plan.js';
23
- import { WRITE_VERBS, EXIT_TYPES, MAX_EXITS_PER_STEP, MAX_PLAN_STEPS } from './plan.js';
22
+ import { runClose, runStages, ralph, CLOSE_FAULTS } from './ralph.js';
23
+ import { validatePlan, legalScopes, stageClose } from './plan.js';
24
+ import { WRITE_VERBS, EXIT_TYPES, MAX_EXITS_PER_STEP, MAX_PLAN_STEPS, MAX_SCOPE_MENU, STEP_MODELS } from './plan.js';
24
25
  import { snapshotScope, evalExits } from './exits.js';
25
- import { TOOL_MENU } from './job.js';
26
- import { TOOL_BY_VERB, CTX_TOOLS, createCtxTools, toolAction, PERSONA_TOOLS, RETRIEVAL_STRATEGY, EDIT_STRATEGY } from './interpret.js';
27
- import { globToPrefix, SECRET_PATTERNS } from './validate.js';
26
+ import { createRoot } from './root.js';
27
+ import { createStallWatch, STALL_MS, MAX_STALLS } from './stall.js';
28
+ import { TOOL_MENU, STORE_VERBS, checkMenu } from './job.js';
29
+ import { TOOL_BY_VERB, CTX_TOOLS, createCtxTools, toolAction, PERSONA_TOOLS, strategyFor } from './tools.js';
30
+ import { globToPrefix, redactSecrets, SECRET_PATTERNS } from './validate.js';
28
31
  import { extractArtifact } from './text.js';
32
+ import { createClock, isWallTimeout } from './clock.js';
29
33
 
30
34
  const require = createRequire(import.meta.url);
31
35
  const { Loop, wireGate, HaltError } = require('bare-agent');
@@ -48,6 +52,11 @@ const SCOUT_BLOB_MAX = 8000;
48
52
  const NATIVE_READ_CAP = 24 * 1024;
49
53
  /** native-only strategy: tell the worker WHY whole-file reads fail here and to navigate by symbol */
50
54
  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.';
55
+ /** F59 — below this a scout survey is treated as absent. Sized off the archive, not
56
+ * taste: the 15 truncated scouts produced 0/74/86 bytes; every real survey was
57
+ * 5991-8056. 200 sits between the two populations with an order of magnitude of
58
+ * daylight on each side, so it cannot be a tuned-to-one-case threshold. */
59
+ const SCOUT_MIN_BYTES = 200;
51
60
  /** feed-forward artifact bound per step (prompt ingredient, spine-bound) */
52
61
  const ARTIFACT_MAX = 2000;
53
62
  /** the wallet floor below which a replan is a stop, not an adaptation (review
@@ -55,9 +64,101 @@ const ARTIFACT_MAX = 2000;
55
64
  * just burns another draft and mislabels the money-cut as "exits still red"
56
65
  * (F45 class) — the honest terminal there is cap-halt. */
57
66
  const MONEY_MIN = 0.001;
67
+ /**
68
+ * A (PRD v1.27; design record §3.4, answered §6.2) — the replan trigger. A step
69
+ * that has consumed this share of the run's REMAINING money or time with its exits
70
+ * still red gets no further attempt; the planner re-allocates what is left instead.
71
+ *
72
+ * 50% is hamr's number (*"50% is fine"*), and it was **re-confirmed with its own
73
+ * inertness on the table**: F63 replayed 18 archived spines / 54 steps / 101 judged
74
+ * attempts and found it would have fired 0 times (near misses 0.35 · 0.35 · 0.40 ·
75
+ * 0.45). Shown that, hamr's call was *"keep 0.5"* (2026-07-26).
76
+ *
77
+ * So this is a GUARD against a step that eats the run, deliberately set above the
78
+ * observed population rather than tuned to it — lowering it to 0.35 because that is
79
+ * where those four points sit would be fitting the number to the data (the standing
80
+ * no-fit-to-pass rule), and threshold-setting is arbiter territory either way. It
81
+ * has not hardened by surviving unexamined; it survived being examined.
82
+ *
83
+ * This changes the replan TRIGGER only. The hard ceiling of ONE replan is
84
+ * unchanged — unlimited replanning launders thrash as adaptation (v1.12).
85
+ */
86
+ const VARIANCE_THRESHOLD = 0.5;
58
87
 
59
88
  /** @typedef {Error & {category?: string, lib?: string}} CategorizedError */
60
89
 
90
+ /**
91
+ * The materials block: what the run has to spend, as a BALANCE and nothing else.
92
+ *
93
+ * hamr's correction (design record addendum 2, verbatim): *"why would you tell it
94
+ * every round that you have x time per round? what if there is a heavier round?
95
+ * why don't you tell it same like cost, you have x left from cost/time instead of
96
+ * making it race against time?"*
97
+ *
98
+ * So: NO rate, NO per-round allowance, NO derived round count. F57 measured a 150x
99
+ * spread on the verification gaps (3.8s to 561s), so a per-round constant
100
+ * describes almost no real round — a heavy round would break the planner's
101
+ * arithmetic with nothing to tell it. A balance is self-correcting: after a heavy
102
+ * round the number is simply lower. A rate is also a stopwatch, and racing a clock
103
+ * is the rush-or-fake incentive the v1.12 §5 prompt contract exists to remove.
104
+ *
105
+ * At DRAFT the balance is the total, so this is scale only — the planner is NOT
106
+ * asked to allocate time, because sizing time at draft requires the rate that was
107
+ * just removed. A plan that does not fit is what the meter is for (addendum 2).
108
+ * @param {{ balanceUsd?: number|null, remainingMs?: number|null, progress?: string }} [m]
109
+ */
110
+ function materialsBlock(m) {
111
+ if (!m) return '';
112
+ const lines = [];
113
+ if (typeof m.balanceUsd === 'number') lines.push(`- money left for the whole run: $${m.balanceUsd.toFixed(2)}`);
114
+ if (typeof m.remainingMs === 'number') lines.push(`- time left for the whole run: ${Math.round(m.remainingMs / 60_000)} minutes`);
115
+ if (!lines.length) return '';
116
+ // The progress line only exists at REPLAN: it is the variance the meter read,
117
+ // and it is the adaptation channel (addendum 2). At draft there is no progress.
118
+ if (m.progress) lines.push(`- where the run got to: ${m.progress}`);
119
+ return `\nWhat this run has left to spend — plan within it:\n${lines.join('\n')}\nThese are totals for the run, not per-step allowances. Nothing here is a rate: a step's cost depends on what it does.\n`;
120
+ }
121
+
122
+ /**
123
+ * W-2 — the progress TREND, read off the two most recent close gaps.
124
+ *
125
+ * A run stopped by its wall hands the human a lever choice, and the two levers
126
+ * point opposite ways: more TIME only helps work that was still moving, and a goal
127
+ * the run cannot reach does not become reachable by being given longer. The
128
+ * cheapest honest discriminator is already in the fix loop's hand — consecutive
129
+ * close output. A byte-identical gap means the last attempt moved nothing the
130
+ * arbiter can see; a different one means the tree was still changing under it.
131
+ *
132
+ * Deliberately NOT a new instrument (no red counting, no red-set diffing): two
133
+ * strings the loop already holds, compared for equality. `unknown` is the honest
134
+ * reading when only one grade exists — never rounded up to "stalled", which would
135
+ * recommend rewriting a goal on no evidence at all (F6's rule, applied to a trend
136
+ * instead of a number).
137
+ * @param {string[]} gaps close gaps, oldest first
138
+ * @returns {{trend: 'stalled'|'moving'|'unknown', reading: string, lever: string}}
139
+ */
140
+ function gapTrend(gaps) {
141
+ if (gaps.length < 2) {
142
+ return {
143
+ trend: 'unknown',
144
+ reading: 'only one close grade exists, so there is nothing to compare',
145
+ lever: 'read the last close output before choosing between more time and a different goal',
146
+ };
147
+ }
148
+ const [prev, last] = gaps.slice(-2);
149
+ return prev === last
150
+ ? {
151
+ trend: 'stalled',
152
+ reading: 'the last two close outputs are byte-identical — the previous attempt moved nothing the close can see',
153
+ lever: 'more time alone is unlikely to help; revise the goal/spec first',
154
+ }
155
+ : {
156
+ trend: 'moving',
157
+ reading: 'the last two close outputs differ — the tree was still changing under the close when time ran out',
158
+ lever: 'the work was progressing; raising maxWallMs is the lever that fits',
159
+ };
160
+ }
161
+
61
162
  /**
62
163
  * The plan-drafting prompt: a schema DESCRIPTION built from the live validator
63
164
  * menus — never a copyable example (the drafter must author, not echo; the
@@ -65,10 +166,27 @@ const MONEY_MIN = 0.001;
65
166
  * arbiter territory the planner never sees.
66
167
  * @param {any} job @param {string} scoutBlob @param {any[]|null} reds
67
168
  * @param {number} maxStepRounds @param {string|null} failure replan context
169
+ * @param {string[]|undefined} scopes the offered `tree-changed` menu (`legalScopes`).
170
+ * Choose-don't-describe (§4): the agent picks a value, never authors a glob and
171
+ * guesses its shape. Omitted, it derives from the signed fence — the SAME
172
+ * fail-closed derivation `validatePlan` uses, so prompt and validator can never
173
+ * disagree about what was offered.
174
+ * @param {{ balanceUsd?: number|null, remainingMs?: number|null, progress?: string }} [materials]
175
+ * what the run has LEFT (T/A). Omitted → no materials block at all, which is the
176
+ * pre-T behaviour and keeps every existing caller byte-identical.
177
+ * @param {number} [capRuns] the shell's attempt cap — the ceiling `attempts` may only
178
+ * TIGHTEN. Interpolated the way every sibling bound is: `validatePlan` reds outside
179
+ * `1..capRuns`, and a prompt that says only "integer" spends a round teaching it.
180
+ * Defaults to validatePlan's own default so existing callers are unchanged.
68
181
  */
69
- function planPrompt(job, scoutBlob, reds, maxStepRounds, failure) {
182
+ export function planPrompt(job, scoutBlob, reds, maxStepRounds, failure, scopes, materials, capRuns = 3) {
183
+ const scopeMenu = Array.isArray(scopes) && scopes.length ? scopes : legalScopes(job.writeScope ?? []);
70
184
  const ceiling = Array.isArray(job.tools) ? job.tools : [...TOOL_MENU];
71
- const checkNames = (job.checks ?? []).map((/** @type {any} */ c) => c.name);
185
+ // W4: the menu comes off the STAGED close — the same one derivation `runPlan`
186
+ // executes and `validatePlan` accepts against. Reading `job.close` raw here left
187
+ // the legacy object form offering nothing while the runner preflighted a stage.
188
+ // (`?? []`: a close naming no command stages nothing, so it offers nothing)
189
+ const checkNames = checkMenu(stageClose(job.close) ?? []).map((m) => m.name);
72
190
  const doc = `DRAFT-PLAN
73
191
  You are planning how to accomplish a goal in a repository, as an ordered list of bounded
74
192
  steps (schema "plan-v1"). The plan is pure declarative JSON validated by a strict schema;
@@ -79,21 +197,40 @@ Shape: { "schema": "plan-v1", "steps": [ ... 1..${MAX_PLAN_STEPS} steps ... ] }
79
197
  strictly in array order. Each step (no other fields exist):
80
198
  - "id": kebab-case slug, unique
81
199
  - "action": the step's task, precise enough for a worker that sees ONLY this step
82
- - "tools": non-empty unique subset of ${JSON.stringify(ceiling)} (write/edit are the write-class verbs)
200
+ - "tools": non-empty unique subset of ${JSON.stringify(ceiling)} (write/edit change the tree; recall/get/impact/related/recent search and navigate the repository index; compress/peek read cheaply; stash/remember/forget park and record notes across steps)
83
201
  - "rounds": integer 1..${maxStepRounds} — the step's per-attempt tool-round bound
84
202
  - "target": the step's deliverable path (REQUIRED when tools include write/edit), inside ${JSON.stringify(job.writeScope)}
203
+ - "model" (optional): ${STEP_MODELS.join(' | ')} — a cheaper tier for mechanical steps; omit for the default
204
+ - "attempts" (optional): integer 1..${capRuns} — TIGHTEN this step's retry cap below the shell's; you may never raise it
205
+ - "scope" (optional): narrow this step's WRITE fence — copy one value from the offered scopes below.
206
+ A narrowed "scope" is the ONLY ground this step can write: its "target" must sit
207
+ inside it, and a "tree-changed" exit's scope must not be disjoint from it (one that
208
+ CONTAINS it is fine) — otherwise every write the step could make is denied, or the
209
+ exit can never pass, on every attempt.
85
210
  - "exit": 1..${MAX_EXITS_PER_STEP} form checks that ALL must pass (AND), each one of:
86
211
  {"type":"artifact-written","path":"...","pattern":"optional regex"}
87
- {"type":"tree-changed","scope":"a scope inside ${JSON.stringify(job.writeScope)}"}
212
+ {"type":"tree-changed","scope":"<copy one value from the offered scopes below>"}
88
213
  {"type":"json-valid","path":"..."}
89
- {"type":"check-passes","name":"one of ${JSON.stringify(checkNames)}"}
214
+ {"type":"check-passes","name":"<copy one name from the check menu below>"}
215
+ Check menu — a check-passes "name" is exactly one of ${JSON.stringify(checkNames)}; copy
216
+ it character for character. No other name exists, and you cannot add one.
90
217
  A check-passes on a write-granted step MUST be paired with a tree-changed exit
91
218
  (the repository starts green — a lone check would pass on the untouched tree).
219
+ A check-passes may ONLY appear on a step whose tools include write or edit: a
220
+ failing check is reported back to THAT step's worker for another attempt, so
221
+ the step must be able to act on the report. Do not plan a separate read-only
222
+ "verify" step — attach the check to the step that does the fixing; the final
223
+ verification after your plan is not yours to author.
92
224
  Reference checks by NAME only; you cannot author or modify one.
93
225
 
226
+ Offered "scope" values for tree-changed — copy ONE of these exactly, character for
227
+ character. No other value is accepted, and patterns of your own (like "src/*.js")
228
+ are not accepted:
229
+ ${scopeMenu.map((s) => ` ${JSON.stringify(s)}`).join('\n')}
230
+
94
231
  Goal:
95
232
  ${job.goal}
96
-
233
+ ${materialsBlock(materials)}
97
234
  Repository survey (from a read-only scout):
98
235
  ${scoutBlob || '(no scout notes)'}`;
99
236
  let p = doc;
@@ -103,7 +240,7 @@ ${scoutBlob || '(no scout notes)'}`;
103
240
  }
104
241
 
105
242
  /**
106
- * Execute a validateJob-GREEN plan-shape job (goal/verdictType/close/checks[]).
243
+ * Execute a validateJob-GREEN plan-shape job (goal/verdictType/close).
107
244
  * Called by runJob after the approval gate, validation, and the smoke — this
108
245
  * function owns the plan flow only; the caller owns the job-end record and the
109
246
  * one ledger (every provider round is emitted here as `worker-round`, which
@@ -124,16 +261,52 @@ ${scoutBlob || '(no scout notes)'}`;
124
261
  * @param {() => number} opts.remainingUsd the one wallet: what is left of the signed budget right now
125
262
  * @param {() => boolean} [opts.isUnpriced] has any round come back with a null cost? (F6) — the
126
263
  * plan flow bails IN-FLIGHT on the first unpriced round instead of burning the whole plan
264
+ * @param {(tier: string) => any} [opts.providerFor] P: per-step model-tier factory
265
+ * (tier ∈ STEP_MODELS → a provider). A plan naming a tier with no factory supplied is an
266
+ * interpreter-red STOP — never silently run the default tier as if the choice was honoured.
127
267
  * @param {number} [opts.capRuns] shell-owned per-step attempt cap
128
268
  * @param {number} [opts.closeTimeoutMs] close/check wall-clock cap (shell territory)
129
269
  * @param {number} [opts.maxStepRounds] the shell's per-step rounds ceiling (validatePlan's bound)
270
+ * @param {number} [opts.scoutRounds] the read-only survey's round bound (F59: the LAST round is
271
+ * reserved — a scout that spends every round on tools gets one toolless round to write its
272
+ * survey, because the bound halts it mid-tool-use and text is its only deliverable)
273
+ * @param {boolean} [opts.layerRoot=false] Layer R — the within-run ratchet (src/root.js),
274
+ * scoped PER STEP's ralph loop (each micro-wheel is the Layer-1 atom). Shell-assembled from
275
+ * the step's own books: per-attempt write-sets from the F32 workerWrites audit (teed for
276
+ * same-path rewrites, which the cumulative audit alone cannot see) and the red-set from the
277
+ * exit evaluator's own gap. Defaults OFF (decided 2026-07-21, F41): fixation is extinct on
278
+ * every current job, so ON has never won its A/B; `true` is the ON/experimental arm, and the
279
+ * first plan-flow job to emit `root-injected` runs the pre-registered ON-vs-OFF acceptance
280
+ * read (the Layer R default-flip, LAYERS.md ⚠). Excluded on native (clipipe): the native
281
+ * worker has no onToolResult seam, so the tee cannot settle and same-path rewrites are blind.
282
+ * @param {() => number} [opts.now] the wall clock's time source, injected. The real clock is the
283
+ * default; a caller supplies this to drive time deterministically (the same seam `createClock`
284
+ * already exposes — a run's terminal cannot otherwise be exercised without waiting out a cap
285
+ * whose floor is one close timeout).
130
286
  * @returns {Promise<string>} 'green' | 'already-green' | 'escalated' | 'plan-red' |
131
287
  * 'check-red' | 'close-red' | 'close-unsupported' | 'pricing-red' | 'cap-halt' |
132
- * 'provider-red' | 'interpreter-red' | `step-red:<id>`
288
+ * 'wall-halt' | 'provider-red' | 'interpreter-red' | 'step-stalled' | `step-red:<id>`
133
289
  */
134
- export async function runPlan(job, { workdir, provider, nativeProvider, emit, remainingUsd, isUnpriced = () => false, capRuns = 3, closeTimeoutMs, maxStepRounds = 40 }) {
290
+ export async function runPlan(job, { workdir, provider, nativeProvider, providerFor, emit, remainingUsd, isUnpriced = () => false, capRuns = 3, closeTimeoutMs, maxStepRounds = 40, layerRoot = false, scoutRounds = SCOUT_ROUNDS, now }) {
135
291
  workdir = resolve(workdir);
136
- const scrub = (/** @type {string} */ s) => redact(s, { patterns: SECRET_PATTERNS });
292
+ // ONE spelling of the redaction, housed next to the inventory it reads
293
+ // (src/validate.js) — the same helper the isolate verbs scrub the litectx store
294
+ // with. Two hand-spelled copies of "what a secret looks like" is exactly how
295
+ // detection and redaction drift apart.
296
+ const scrub = redactSecrets;
297
+ // The ctx verbs' spine channel, scrubbed at the WIRING. Every field a ctx-tool
298
+ // event carries is MODEL-CHOSEN text — a recall query, a stash id, a symbol, a
299
+ // path the worker spelled — and the spine is append-only: a log that captures a
300
+ // key captures it forever. Scrubbing the emitter (once, at construction) rather
301
+ // than the seventeen call sites is what makes the leak inexpressible: a new ctx
302
+ // verb cannot forget it. Same ONE inventory (SECRET_PATTERNS) the close output
303
+ // is scrubbed with — never a second spelling of what a secret looks like.
304
+ // Shallow by shape: the events are flat records of strings, string arrays
305
+ // (`paths`) and numbers, and numbers pass through untouched.
306
+ const emitCtx = (/** @type {string} */ type, /** @type {any} */ data) => emit(type, Object.fromEntries(
307
+ Object.entries(data ?? {}).map(([k, v]) => [k, typeof v === 'string' ? scrub(v)
308
+ : (Array.isArray(v) ? v.map((x) => (typeof x === 'string' ? scrub(x) : x)) : v)]),
309
+ ));
137
310
 
138
311
  // Which worker surface? `clipipe-subscription` drives tools NATIVELY (the CLI
139
312
  // owns the turn cycle, BA-16); every other provider runs the Loop. The close,
@@ -147,10 +320,17 @@ export async function runPlan(job, { workdir, provider, nativeProvider, emit, re
147
320
  // hard-class), and a gold close carries no `cmd`; running `close.cmd.trim()`
148
321
  // on it would TypeError out of runJob with NO job-end (the spine would dangle,
149
322
  // no spend recorded). Refuse a non-predicate close cleanly, before any tokens.
150
- if (job.close.type !== 'predicate') {
323
+ // A STAGED close (PRD v1.28) is an ordered list of command stages and is the
324
+ // go-forward shape; the object form survives only for the declared-but-locked
325
+ // verdict classes (gold/rubric/hitl), which name no command to run.
326
+ // W4: the staging itself lives in ONE place (`stageClose`), shared with
327
+ // `planPrompt` and `validatePlan` — a second copy here is exactly how the
328
+ // runner came to execute a stage neither of the other two could see.
329
+ const stagedClose = stageClose(job.close);
330
+ if (!stagedClose) {
151
331
  emit('escalation', {
152
332
  category: 'close-unsupported', decisionReady: true,
153
- 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).`,
333
+ decision: `The job's close is a ${job.close.type} close — the plan flow executes commands whose exit codes are truth (a staged close, or a single predicate).`,
154
334
  options: ['restate the close as a predicate', 'wait for the verdict-classes rung'],
155
335
  });
156
336
  return 'close-unsupported';
@@ -170,12 +350,100 @@ export async function runPlan(job, { workdir, provider, nativeProvider, emit, re
170
350
  return 'interpreter-red';
171
351
  }
172
352
 
173
- const closeArgv = job.close.cmd.trim().split(/\s+/);
174
- const closeOpts = { timeoutMs: closeTimeoutMs, cwd: workdir, expect: job.close.expect, judged: job.close.judged, gapKeep: job.close.gapKeep };
353
+ // T (PRD v1.27/v1.29) — the run's wall clock, STARTED HERE, before the close
354
+ // precheck. The precheck is real wall time and F62 measured close/check gaps as
355
+ // high as 328s on an archived run: a clock created after it would under-report
356
+ // elapsed by that much and hand the planner a balance the run does not have. Time
357
+ // the operator's own instruments too, or the budget is measured against a
358
+ // different run than the one that happened.
359
+ //
360
+ // `job.maxWallMs` is operator input with budgetUsd's status and has NO DEFAULT
361
+ // (hamr's ruling): absent means honestly time-unbounded, and the record says so
362
+ // rather than leaving a reader to infer it. Both numbers are reported — addendum 1
363
+ // measured that a deadline is only readable BETWEEN rounds, so enforcement is
364
+ // requested + the close's own worst case and quoting one number would be F6 in a
365
+ // time coat.
366
+ //
367
+ // W5 — that worst case is per STAGE. `runStages` hands each stage the full
368
+ // `closeTimeoutMs`, so a run whose deadline trips just before a 4-stage close can
369
+ // overshoot by four of them, and the clock is told the count rather than assuming
370
+ // one. `stagedClose` is the SAME staging the runner executes (never a second
371
+ // count), and it is non-null here by the close-unsupported guard above — a close
372
+ // that names no command escalated before this line, so there is no null to thread.
373
+ const clock = createClock({ maxWallMs: job.maxWallMs ?? null, closeStages: stagedClose.length, ...(now ? { now } : {}) });
374
+ const closeTimeoutForReport = closeTimeoutMs ?? 120_000;
375
+ emit('wall-clock', {
376
+ ...clock.report(closeTimeoutForReport),
377
+ meaning: clock.bounded
378
+ ? 'a between-round deadline; a close already in flight when it trips runs to completion'
379
+ : 'NO time cap was set — time-unbounded by explicit operator choice, never by a default',
380
+ });
381
+
382
+ /**
383
+ * F64 — ONE categoriser for a throw out of a provider call, so the seams cannot
384
+ * drift. A `HaltError` is the budget gate. The run's OWN deadline coming back as
385
+ * bare-agent's `TimeoutError` (`callTimeoutMs()` is derived from the wall) is a
386
+ * GOVERNANCE stop and rides out as `wall-halt` — filing it `provider-red` would
387
+ * discard the row as a casualty (never evidence, F45/F48) and hand the human a
388
+ * transport-debugging option list for a run that simply ran out of time. A
389
+ * thrower that named its own category keeps it (the standing typed-attribution
390
+ * rule); only an UNNAMED throw is classified here.
391
+ * @param {any} e
392
+ * @returns {{ err: CategorizedError, category: string }} the same throw, stamped,
393
+ * plus the category as a plain string (so a caller reading it never has to
394
+ * re-widen an optional field back to a value)
395
+ */
396
+ const categorize = (e) => {
397
+ const err = /** @type {CategorizedError} */ (e);
398
+ const category = e instanceof HaltError ? 'cap-halt'
399
+ : (err.category ?? (isWallTimeout(err, clock) ? 'wall-halt' : 'provider-red'));
400
+ err.category = category;
401
+ return { err, category };
402
+ };
403
+
404
+ /** the run-level TIME record, one shape wherever the wall stops the run.
405
+ * `cutMidCall` splits the two readings: `true` = the deadline landed INSIDE a
406
+ * provider call (F64, seen as its timeout), `false` = read between steps, the
407
+ * ordinary path. Both are the same stop; conflating them would hide which
408
+ * instrument saw it. */
409
+ const emitWallHalt = (/** @type {object} */ extra) => emit('wall-halt', {
410
+ ...clock.report(closeTimeoutForReport),
411
+ meaning: 'not under cap — not "can\'t"',
412
+ cutMidCall: false,
413
+ ...extra,
414
+ });
415
+ /** the wall's decision-ready levers, BYTE-IDENTICAL to ralph's own `wall-halt`
416
+ * entry (ralph.js DECISIONS). One category must hand a human ONE option list: which
417
+ * site read the clock is an implementation detail, and a shorter list here quietly
418
+ * withheld the second lever (revise the goal) from every stop this file escalates.
419
+ * Both levers are spec edits — a changed spec hash the runner refuses until it is
420
+ * re-approved — which is why each carries its own re-approval note, and why the
421
+ * caller's detail carries the progress trend that says which lever fits. */
422
+ const WALL_OPTIONS = [
423
+ 'raise maxWallMs and rerun (resume-to-cap; a spec edit, so the new hash needs re-approval)',
424
+ 'revise the goal/spec so the work fits the time (same re-approval)',
425
+ 'abandon the task',
426
+ ];
427
+
428
+ const closeOpts = { timeoutMs: closeTimeoutMs, cwd: workdir };
429
+ /** @type {string|undefined} the stage that rendered the LAST close verdict (Layer R's red-set source) */
430
+ let closeStage;
431
+ /** @type {any} the LAST close verdict itself (W-2). A wall stop KEEPS the grade
432
+ * the arbiter has already rendered rather than re-deriving one: past the deadline
433
+ * the run's answer is whatever the close last said, and nothing after the deadline
434
+ * is allowed to change it (hamr: *"keep the grade we already have and stop"*). */
435
+ let lastCloseVerdict;
436
+ /** the close, as ONE verdict: every stage in order, first red wins */
437
+ const judgeClose = async () => {
438
+ const v = await runStages(stagedClose, scrub, closeOpts);
439
+ closeStage = v.stage;
440
+ lastCloseVerdict = v;
441
+ return v;
442
+ };
175
443
 
176
444
  // ── 0a. close precheck (close-first, F17): already-green is a DISTINCT
177
445
  // record, zero tokens; a forbidden-zone verdict escalates before any spend
178
- const pre = runClose(closeArgv, scrub, closeOpts);
446
+ const pre = await judgeClose();
179
447
  emit('close-precheck', { ...pre });
180
448
  if (pre.verdict === 'satisfied') return 'already-green';
181
449
  const preFault = Object.hasOwn(CLOSE_FAULTS, pre.verdict) ? CLOSE_FAULTS[pre.verdict] : undefined;
@@ -184,16 +452,25 @@ export async function runPlan(job, { workdir, provider, nativeProvider, emit, re
184
452
  return 'close-red';
185
453
  }
186
454
 
187
- // ── 0b. checks preflight ($0, deterministic): every SIGNED check runs once
188
- // before tokens — an unrunnable check would fault mid-plan after real spend
189
- // (a frozen rule without a wired detector is prose; here the detector runs
190
- // first). Red and green are both fine: checks decide nothing.
191
- for (const c of job.checks ?? []) {
192
- const v = runClose(c.cmd.trim().split(/\s+/), scrub, { timeoutMs: closeTimeoutMs, cwd: workdir, expect: c.expect, judged: c.judged, gapKeep: c.gapKeep });
193
- emit('check-preflight', { name: c.name, verdict: v.verdict });
455
+ // ── 0b. check preflight ($0, deterministic): every OFFERABLE stage runs once
456
+ // before tokens — an unrunnable ruler would fault mid-plan after real spend (a
457
+ // frozen rule without a wired detector is prose; here the detector runs
458
+ // first). Red and green are both fine: checks decide nothing and mint nothing.
459
+ // The menu is DERIVED from the close (PRD v1.28) — nobody authored it, so the
460
+ // ruler and the real inspection cannot drift apart.
461
+ const menu = checkMenu(stagedClose);
462
+ emit('check-menu', {
463
+ offered: menu.map((m) => m.name),
464
+ ...(menu.length < stagedClose.length
465
+ ? { hidden: stagedClose.filter((s2) => s2.offer === false).map((s2) => s2.name), meaning: 'a stage that cannot stand alone as a ruler (a precondition) is not offered — a partial menu is acceptable, never a failure' }
466
+ : {}),
467
+ });
468
+ for (const m of menu) {
469
+ const v = await runStages(m.run, scrub, closeOpts);
470
+ emit('check-preflight', { name: m.name, verdict: v.verdict, ...(m.run.length > 1 ? { chain: m.run.map((s2) => s2.name) } : {}) });
194
471
  const f = Object.hasOwn(CLOSE_FAULTS, v.verdict) ? CLOSE_FAULTS[v.verdict] : undefined;
195
472
  if (f) {
196
- 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 ?? ''}` });
473
+ emit('escalation', { category: f.category, decisionReady: true, decision: `Close stage "${m.name}" rendered no judgment at preflight — every plan referencing it would fault mid-run. ${f.decision}`, options: f.options, detail: `${m.name}: ${v.detail ?? ''}` });
197
474
  return 'check-red';
198
475
  }
199
476
  }
@@ -202,16 +479,47 @@ export async function runPlan(job, { workdir, provider, nativeProvider, emit, re
202
479
  const ceiling = Array.isArray(job.tools) ? job.tools : [...TOOL_MENU];
203
480
  const fencePrefixes = job.writeScope.map((/** @type {string} */ g) => resolve(workdir, globToPrefix(g)));
204
481
  const auditPath = join(workdir, 'gate-audit.jsonl');
205
- const checksByName = new Map((job.checks ?? []).map((/** @type {any} */ c) => [c.name, c]));
482
+ const chainByName = new Map(menu.map((m) => [m.name, m.run]));
483
+ // Choose-don't-describe (§4): the offered `tree-changed` scopes, enumerated from
484
+ // the signed fence plus the directories that actually exist beneath it. ONE menu
485
+ // object feeds both the drafting prompt and the validator, so what was offered
486
+ // and what is accepted can never drift apart. Discovery is best-effort — a
487
+ // missing or unreadable fence directory degrades to the signed entries alone
488
+ // (which `snapshotScope` already handles as an empty snapshot), never a throw.
489
+ const discoveredDirs = job.writeScope.flatMap((/** @type {string} */ g) => {
490
+ const prefix = globToPrefix(g);
491
+ try {
492
+ return readdirSync(join(workdir, prefix), { recursive: true, withFileTypes: true })
493
+ .filter((d) => d.isDirectory())
494
+ .map((d) => `${prefix}/${relative(join(workdir, prefix), join(d.parentPath ?? d.path, d.name))}`);
495
+ } catch { return []; }
496
+ });
497
+ const scopeMenu = legalScopes(job.writeScope, discoveredDirs);
498
+ // No silent caps: MAX_SCOPE_MENU can drop deep directories, and a menu that
499
+ // reads as complete when it is not is the blind-instrument class. Report BOTH
500
+ // counts so a truncation is visible in the record rather than inferred.
501
+ const offerable = legalScopes(job.writeScope, discoveredDirs, Infinity).length;
502
+ emit('scope-menu', {
503
+ offered: scopeMenu,
504
+ ...(offerable > scopeMenu.length
505
+ ? { truncated: true, offerableCount: offerable, cap: MAX_SCOPE_MENU, meaning: 'deep directories were dropped from the menu; they remain reachable via a parent scope' }
506
+ : { truncated: false }),
507
+ });
206
508
 
207
509
  /** the check-passes seam evalExits delegates to: the FULL runClose machinery
208
510
  * per signed check; a forbidden-zone verdict rides out as `fault` by name so
209
511
  * the micro-loop escalates (or F32-routes a crash) instead of faking a gap */
210
512
  const runCheck = async (/** @type {string} */ name) => {
211
- const c = checksByName.get(name);
212
- if (!c) return { pass: false, fault: 'failed', gap: `no signed check named "${name}"` };
213
- const v = runClose(c.cmd.trim().split(/\s+/), scrub, { timeoutMs: closeTimeoutMs, cwd: workdir, expect: c.expect, judged: c.judged, gapKeep: c.gapKeep });
214
- emit('check-run', { name, verdict: v.verdict, ...(v.exitCode !== undefined ? { exitCode: v.exitCode } : {}) });
513
+ const chain = chainByName.get(name);
514
+ // UNREACHABLE through runPlan and deliberately kept: validatePlan reds
515
+ // `check-unknown` against `checkMenu(spec.close)` — the SAME derivation that
516
+ // built this map from the SAME close — so a name that got here was already
517
+ // proven to be on the menu. Defence in depth against a future second
518
+ // derivation, not a live path; the reachable guard is the validator's, and
519
+ // that one is mutation-covered.
520
+ if (!chain) return { pass: false, fault: 'failed', gap: `no offered close stage named "${name}"` };
521
+ const v = await runStages(chain, scrub, closeOpts);
522
+ emit('check-run', { name, verdict: v.verdict, ...(v.stage && v.stage !== name ? { stage: v.stage } : {}), ...(v.exitCode !== undefined ? { exitCode: v.exitCode } : {}) });
215
523
  if (v.verdict === 'satisfied') return { pass: true };
216
524
  if (v.verdict === 'needs_revision') return { pass: false, gap: v.gap };
217
525
  return { pass: false, fault: v.verdict, gap: v.detail };
@@ -228,18 +536,35 @@ export async function runPlan(job, { workdir, provider, nativeProvider, emit, re
228
536
  * denied, the wallet as its budget), granted tools only (the menu IS the
229
537
  * grant), per-attempt round bound via loop.stop() (F20), every round metered
230
538
  * with a phase label (F12).
231
- * @param {{granted: string[], phase: string, attemptRounds: number, attempts: number, writable: boolean}} o
539
+ * @param {{granted: string[], phase: string, attemptRounds: number, attempts: number, writable: boolean, root?: ReturnType<typeof createRoot>|null, fence?: string[]|null, workerProvider?: any}} o
540
+ * `root` (Layer R): when present, the worker's write-class actions are teed
541
+ * for the within-run ratchet — staged before the gate decides, discarded on
542
+ * deny/halt, settled to landed-or-not after execution (the two axes, F43/F7).
543
+ * Wired ONLY on the Loop path: the native session exposes no onToolResult seam.
232
544
  */
233
- async function mkWorker({ granted, phase, attemptRounds, attempts, writable }) {
545
+ async function mkWorker({ granted, phase, attemptRounds, attempts, writable, root = null, fence = null, workerProvider = null }) {
234
546
  const gate = new Gate({
235
547
  fs: {
236
- writeScope: writable ? fencePrefixes : [],
548
+ // P: a step's `scope` narrows the fence — the per-step prefixes are always a
549
+ // SUBSET of the signed fence (validator: scope ∈ the same menu tree-changed
550
+ // uses), so this can only tighten, never widen
551
+ writeScope: writable ? (fence ?? fencePrefixes) : [],
237
552
  readScope: [workdir],
238
553
  deny: [auditPath, join(workdir, '.smoke'), join(workdir, '.litectx')],
239
554
  },
240
555
  budget: { maxCostUsd: Math.max(remainingUsd(), 0.0001) },
241
556
  limits: { maxTurns: attemptRounds * (attempts + 1) },
242
557
  audit: { path: auditPath },
558
+ // The gate audit is the THIRD persistent, append-only channel (beside the
559
+ // spine and the ctx-verb events) and it lives IN THE TREE, so it gets the
560
+ // SAME ONE inventory. bareguard's default-on backstop covers only
561
+ // apiKey/authorization/`Bearer …`/`sk-…` — measured: a `ghp_` token landed
562
+ // in cleartext through every model-authored identifier the action carries
563
+ // (an isolate verb's `id`, a path the worker spelled), and masked under
564
+ // these patterns. Content is already unreachable here (`toolAction` reduces
565
+ // a write to `{bytes}`), but the identifiers are raw model text, and a log
566
+ // that captures a key captures it forever.
567
+ secrets: { patterns: SECRET_PATTERNS },
243
568
  humanChannel: async () => ({ decision: 'terminate' }),
244
569
  });
245
570
  await gate.init();
@@ -259,7 +584,69 @@ export async function runPlan(job, { workdir, provider, nativeProvider, emit, re
259
584
  return [...paths];
260
585
  } catch { return []; }
261
586
  };
262
- const { policy, onLlmResult } = wireGate(gate, { actionTranslator: (/** @type {string} */ n, /** @type {any} */ a) => toolAction(n, a, workdir) });
587
+ // Layer R tee (design record 2026-07-19; created per-step by executeStep).
588
+ // The translator STAGES write/edit content and snapshots the pre-write hash
589
+ // BEFORE the gate decides (Finding 6); the policy wrapper DISCARDS on a deny
590
+ // or a halt (the tool never runs); onToolOutcome SETTLES landed-vs-not after
591
+ // execution (Finding 7 — the gate's allow is intent, the file is outcome).
592
+ // All no-ops when root is null: every scout/drafter and every native worker.
593
+ const fileHash = (/** @type {string} */ p) => {
594
+ try { return createHash('sha256').update(readFileSync(p)).digest('hex'); } catch { return null; }
595
+ };
596
+ /** @type {{path: string, content: string, type: string, before: string|null}|null} the write-class action awaiting its OUTCOME */
597
+ let pendingProbe = null;
598
+ const teeingTranslator = (/** @type {string} */ n, /** @type {any} */ a) => {
599
+ // classify by action TYPE, never a name list (the workerWrites filter uses
600
+ // the same write|edit test — a third enumeration would let a future
601
+ // write-class verb bypass the tee, the blind-instrument class)
602
+ const act = toolAction(n, a, workdir);
603
+ if (root && (act.type === 'write' || act.type === 'edit')) {
604
+ const path = /** @type {string} */ (act.path);
605
+ const content = String((act.type === 'edit' ? a?.newText : a?.content) ?? '');
606
+ root.stageWrite(path, content); // content read off RAW args, never onto the action (the audit stays bytes-only)
607
+ pendingProbe = { path, content, type: act.type, before: fileHash(path) };
608
+ }
609
+ return act;
610
+ };
611
+ const { policy: gatePolicy, onLlmResult } = wireGate(gate, { actionTranslator: teeingTranslator });
612
+ // Settle the stage on the verdict. A DENY/HALT means the tool never runs, so
613
+ // the outcome seam below never fires for it — discard here (an allow is NOT
614
+ // settled here: the bytes are not written yet; settling on the verdict is the
615
+ // exact Finding 7 defect). The catch RE-THROWS so cap-halt routing still reads
616
+ // the throw. Non-write actions stage nothing, so both settlements are no-ops.
617
+ const policy = root
618
+ ? async (/** @type {string} */ n, /** @type {any} */ a, /** @type {any} */ c) => {
619
+ try {
620
+ const verdict = await gatePolicy(n, a, c);
621
+ if (verdict !== true) { root.discardWrite(); pendingProbe = null; }
622
+ return verdict;
623
+ } catch (e) { root.discardWrite(); pendingProbe = null; throw e; }
624
+ }
625
+ : gatePolicy;
626
+ /**
627
+ * Finding 7 — settle the staged write on what the tool ACTUALLY did. Fires
628
+ * after every tool.execute (bare-agent loop.js), success or error, and tool
629
+ * calls run strictly sequentially, so exactly one probe is ever in flight. A
630
+ * probe that never reaches here (a halt out of a tool body) leaves the stage
631
+ * unsettled — correct: nothing landed, and the attempt boundary drops it.
632
+ * @param {{result?: any}} [info] the tool's return value
633
+ */
634
+ const onToolOutcome = async (info) => {
635
+ if (!root || !pendingProbe) return;
636
+ const { path: p, content, type, before } = pendingProbe;
637
+ pendingProbe = null;
638
+ let after = null;
639
+ try { after = readFileSync(p, 'utf8'); } catch { /* absent → after stays null */ }
640
+ let landed = after !== null && createHash('sha256').update(after, 'utf8').digest('hex') !== before;
641
+ if (!landed && after !== null) {
642
+ // no-byte-change: a write always writes (identical bytes ⇒ content IS the
643
+ // file, exact-equal never substring); a no-op edit is an idempotent apply
644
+ // OR a missed anchor — the tool RESULT is the only honest signal (F2)
645
+ if (type === 'write') landed = after === content;
646
+ else landed = typeof info?.result === 'string' && info.result.startsWith('edited ');
647
+ }
648
+ root.settleWrite(landed);
649
+ };
263
650
  /** @type {number|string|undefined} */
264
651
  let roundIteration;
265
652
  let roundsThisAttempt = 0;
@@ -285,11 +672,22 @@ export async function runPlan(job, { workdir, provider, nativeProvider, emit, re
285
672
  }
286
673
  }
287
674
  const ctx = [...CTX_TOOLS].some((t) => grantedNames.has(t))
288
- ? createCtxTools(lc, workdir, emit).filter((t) => grantedNames.has(t.name))
675
+ ? createCtxTools(lc, workdir, emitCtx).filter((t) => grantedNames.has(t.name))
289
676
  : [];
290
- if (ctx.length) await lc.index();
677
+ // LC-3 (litectx 0.31.0): cooperative yield — index() is async but its work is sync CPU,
678
+ // and the default pass holds THIS event loop (the fuse's timers, the wall clock, the lag
679
+ // sampler all live here) for its full duration: measured 4.4s at 4.5% liveness on a
680
+ // 155-file force pass. With yield:true the worst single block measured 189–252ms (n=4),
681
+ // below anything a second/minute-scale timer can perceive. Store output is byte-identical.
682
+ if (ctx.length) await lc.index({ yield: true });
291
683
  const toolDefs = [...shell, ...ctx];
292
- const system = PERSONA_TOOLS + (granted.includes('edit') ? EDIT_STRATEGY : '') + (ctx.length ? RETRIEVAL_STRATEGY : '')
684
+ // F19 per VERB (P, corrected 2026-07-31): a strategy SENTENCE rides only with
685
+ // the verb it names. Gating per COMPONENT was close but over-reached — a
686
+ // worker granted only `impact` must not be steered to tools it lacks, and the
687
+ // isolate/retrieval paragraphs named other components' verbs outright. The
688
+ // assembly lives in tools.js beside the prose it composes, and is asserted
689
+ // byte-identical to the full-component paragraphs on a full grant.
690
+ const system = PERSONA_TOOLS + strategyFor(granted)
293
691
  + (native && grantedNames.has(TOOL_BY_VERB.read) ? NATIVE_READ_STRATEGY : '');
294
692
  /** @param {any} u @returns {{inputTokens: number, outputTokens: number, cacheReadTokens: number, cacheCreationTokens: number}} */
295
693
  const usageOf = (u) => ({ inputTokens: u?.inputTokens ?? 0, outputTokens: u?.outputTokens ?? 0, cacheReadTokens: u?.cacheReadTokens ?? 0, cacheCreationTokens: u?.cacheCreationTokens ?? 0 });
@@ -323,14 +721,16 @@ export async function runPlan(job, { workdir, provider, nativeProvider, emit, re
323
721
  const provider2 = /** @type {any} */ (nativeProvider)({ policy, onTurn: nativeMetered, maxTurns: attemptRounds, hasTools: true });
324
722
  const loop = new Loop({ provider: provider2, system }); // no Loop policy / no cacheMessages: the CLI owns the transcript
325
723
  /** @param {string} prompt @param {typeof toolDefs} [defs] */
724
+ /** F59: unavailable on native — a toolless native session fires no onTurn and
725
+ * reports NO cost (F48/BA-16 live-verified), so a recovery round there would be
726
+ * unmetered spend. The caller treats a null return as "no recovery possible". */
727
+ const askFrom = async () => null;
326
728
  const ask = async (prompt, defs = toolDefs) => {
327
729
  let r;
328
730
  try {
329
731
  r = await loop.run([{ role: 'user', content: prompt }], defs, { maxTokens: 32000 });
330
732
  } catch (e) {
331
- const err = /** @type {CategorizedError} */ (e);
332
- err.category = e instanceof HaltError ? 'cap-halt' : (err.category ?? 'provider-red');
333
- throw err;
733
+ throw categorize(e).err;
334
734
  }
335
735
  // a maxTurns session is a BOUNDED attempt, not an escalation — the same
336
736
  // role loop.stop() plays on the Loop path: judge the partial work and
@@ -352,7 +752,7 @@ export async function runPlan(job, { workdir, provider, nativeProvider, emit, re
352
752
  }
353
753
  return r;
354
754
  };
355
- return { ask, workerWrites, setIteration: (/** @type {number|string} */ i) => { roundIteration = i; roundsThisAttempt = 0; }, wasBounded: () => attemptBounded };
755
+ return { ask, askFrom, workerWrites, setIteration: (/** @type {number|string} */ i) => { roundIteration = i; roundsThisAttempt = 0; }, wasBounded: () => attemptBounded };
356
756
  }
357
757
 
358
758
  // ── LOOP path: the injected provider (anthropic-api and every other
@@ -364,35 +764,123 @@ export async function runPlan(job, { workdir, provider, nativeProvider, emit, re
364
764
  // never invisible (F6/F44). The gate policy is wired but idle (no tools).
365
765
  const loopProvider = native
366
766
  ? /** @type {any} */ (nativeProvider)({ policy, maxTurns: attemptRounds * (attempts + 1), hasTools: false })
367
- : provider;
368
- /** @param {{costUsd?: number|null, pricing?: string|null, usage?: any, kind?: string}} arg */
369
- const metered = async (arg) => {
370
- emit('worker-round', {
371
- phase, iteration: roundIteration, kind: arg?.kind ?? 'turn',
372
- costUsd: arg?.costUsd ?? null, pricing: arg?.pricing ?? null,
373
- tokens: (arg?.usage?.inputTokens ?? 0) + (arg?.usage?.outputTokens ?? 0),
374
- usage: usageOf(arg?.usage),
375
- });
376
- if ((arg?.kind ?? 'turn') === 'turn') {
377
- roundsThisAttempt += 1;
378
- if (roundsThisAttempt >= attemptRounds) {
379
- attemptBounded = roundIteration;
380
- emit('attempt-bounded', { phase, iteration: roundIteration, rounds: roundsThisAttempt, cap: attemptRounds });
381
- loop.stop();
767
+ : (workerProvider ?? provider);
768
+ // F66 — the stall watchdog. bare-agent's `timeoutMs` bounds socket INACTIVITY,
769
+ // not call duration (provider-http.js `req.setTimeout` resets on every byte),
770
+ // so a connection that stays alive while producing nothing is invisible to it:
771
+ // U run ms3197n8 hung 274 minutes inside ONE call and died ECONNRESET with the
772
+ // bound correctly passed. The heartbeat here is a completed ROUND, which is the
773
+ // one signal that cannot be faked by a live socket. One watch PER WORKER, so
774
+ // the ceiling counts stalls across the step rather than resetting each call.
775
+ // L4 — the watch is handed the run's WALL. Self-heal is what a run does with
776
+ // time left: past the deadline a reissue is spend the run already declined to
777
+ // authorize, and this is the one path no other clock read covers (a stall
778
+ // completes no round and returns from no step, which are the only two places
779
+ // `clock.expired()` is otherwise consulted). Same clock object as every other
780
+ // reader — never a second time source.
781
+ const stallWatch = createStallWatch({
782
+ onStall: (n) => emit('stall', { phase, iteration: roundIteration, stall: n, stallMs: STALL_MS, maxStalls: MAX_STALLS }),
783
+ expired: () => clock.expired(),
784
+ });
785
+ /**
786
+ * ONE Loop PER ISSUED CALL, and every callback on it scoped to that call's
787
+ * generation. After a stall there are TWO calls alive: the abandoned one, whose
788
+ * socket keeps streaming rounds (ms3197n8's lived 274min past the point we moved
789
+ * on), and the reissue. Sharing one Loop between them shares `_stopped`, so a
790
+ * corpse's round bound would cut the LIVE call mid-flight; sharing one callback
791
+ * left the corpse beating the watch that was timing its replacement, which meant
792
+ * the replacement could hang forever without tripping — the fuse feeding itself
793
+ * from the thing it abandoned. A Loop is a plain object over the shared provider,
794
+ * holds no cross-run state (`run()` takes the transcript per call), so one per
795
+ * issue costs nothing.
796
+ *
797
+ * RESIDUAL, documented not fixed: both generations still execute tools through
798
+ * the SAME Gate (`policy`) and the same audit — deliberately, because the audit
799
+ * is what `workerWrites` reads and a per-generation gate would split the run's
800
+ * own books. Concurrent tool calls across a stall therefore interleave on one
801
+ * fence. The fence itself is decision-per-call and order-independent, so the
802
+ * denial semantics hold; what is not modelled is two calls writing the same path
803
+ * in the same window. Latent today (Layer R off, and a stall is rare).
804
+ * @param {number} gen the generation `stallWatch` handed this call
805
+ */
806
+ const newLoop = (gen) => {
807
+ /** @type {any} */
808
+ let self = null;
809
+ /** @param {{costUsd?: number|null, pricing?: string|null, usage?: any, kind?: string}} arg */
810
+ const metered = async (arg) => {
811
+ // The meter fires for EVERY generation, live or abandoned. An orphaned round
812
+ // was really billed, and spend that no instrument sees is the one thing this
813
+ // repo never ships (F6/F44 — unknown is reported as unknown, never as zero;
814
+ // stall.js says it plainly: a reissue can pay twice, and the wallet is what
815
+ // caps that).
816
+ emit('worker-round', {
817
+ phase, iteration: roundIteration, kind: arg?.kind ?? 'turn',
818
+ costUsd: arg?.costUsd ?? null, pricing: arg?.pricing ?? null,
819
+ tokens: (arg?.usage?.inputTokens ?? 0) + (arg?.usage?.outputTokens ?? 0),
820
+ usage: usageOf(arg?.usage),
821
+ });
822
+ // Everything BELOW here belongs to the live call only. A superseded call must
823
+ // not beat the watch (that is the whole watchdog), must not spend the step's
824
+ // round bound (stall.js: "the step's round bound is untouched" by a reissue —
825
+ // the wallet is what bounds a reissue's spend), and must not announce a bound
826
+ // it cannot own.
827
+ if (!stallWatch.isCurrent(gen)) return onLlmResult(arg);
828
+ // F66 — the heartbeat is ANY result coming back from the model layer, not
829
+ // only a `turn`. The question the watchdog asks is "is the model still
830
+ // answering", and a non-turn result answers it just as well; gating the beat
831
+ // on `kind` would arm a false stall on any round shape we don't enumerate
832
+ // here, which is the blind-instrument class in miniature.
833
+ stallWatch.beat(gen);
834
+ if ((arg?.kind ?? 'turn') === 'turn') {
835
+ roundsThisAttempt += 1;
836
+
837
+ // The wall deadline rides the SAME seam as the round bound, because it is
838
+ // the only seam that exists: loop.stop() is read at the round boundary and
839
+ // cannot cut an in-flight call (F61/C1, measured — fired at 500ms, returned
840
+ // at 4,018ms). Stopping here judges the partial work and feeds the gap
841
+ // forward exactly as a round-bounded attempt does — this seam never decides a
842
+ // terminal. Three sites do, each reading the clock with nothing in flight: the
843
+ // step loop after a step returns (between steps), the head of a step's attempt
844
+ // (a step that would BEGIN past the deadline is never funded), and the head of
845
+ // a close-fix iteration (W-2 — the run stops on the verdict already minted).
846
+ if (clock.expired()) {
847
+ attemptBounded = roundIteration;
848
+ emit('wall-bounded', { phase, iteration: roundIteration, ...clock.report(closeTimeoutForReport) });
849
+ self.stop();
850
+ } else if (roundsThisAttempt >= attemptRounds) {
851
+ attemptBounded = roundIteration;
852
+ emit('attempt-bounded', { phase, iteration: roundIteration, rounds: roundsThisAttempt, cap: attemptRounds });
853
+ self.stop();
854
+ }
382
855
  }
383
- }
384
- return onLlmResult(arg);
856
+ return onLlmResult(arg);
857
+ };
858
+ self = new Loop({ provider: loopProvider, system, policy, onLlmResult: metered, onToolResult: onToolOutcome });
859
+ return self;
385
860
  };
386
- const loop = new Loop({ provider: loopProvider, system, policy, onLlmResult: metered });
387
861
  /** @param {string} prompt @param {typeof toolDefs} [defs] */
862
+ /**
863
+ * F59 — continue an EXISTING conversation for exactly one TOOLLESS round.
864
+ * `defs` is `[]`, so text is the only output the model can produce: the summary
865
+ * round is guaranteed by CONSTRUCTION, never requested in prose (F19/F37 — a
866
+ * pacing mandate in the persona was violated 6/6). Meters like any other round
867
+ * (F12); the bound has already fired, so this is a deliberate +1 paid once.
868
+ * @param {any[]} msgs the prior conversation (loop.run's `msgs`)
869
+ * @param {string} prompt
870
+ */
871
+ const askFrom = async (msgs, prompt) => {
872
+ try {
873
+ return await stallWatch.watch((gen) => newLoop(gen).run([...msgs, { role: 'user', content: prompt }], [], { cacheMessages: true, maxTokens: 32000, timeoutMs: clock.callTimeoutMs() }));
874
+ } catch (e) {
875
+ throw categorize(e).err;
876
+ }
877
+ };
388
878
  const ask = async (prompt, defs = toolDefs) => {
389
879
  let r;
390
880
  try {
391
- r = await loop.run([{ role: 'user', content: prompt }], defs, { cacheMessages: true, maxTokens: 32000 });
881
+ r = await stallWatch.watch((gen) => newLoop(gen).run([{ role: 'user', content: prompt }], defs, { cacheMessages: true, maxTokens: 32000, timeoutMs: clock.callTimeoutMs() }));
392
882
  } catch (e) {
393
- const err = /** @type {CategorizedError} */ (e);
394
- err.category = e instanceof HaltError ? 'cap-halt' : (err.category ?? 'provider-red');
395
- throw err;
883
+ throw categorize(e).err;
396
884
  }
397
885
  // same error-return taxonomy as interpret's ask (one map, same doctrine):
398
886
  // halt → cap-halt, denial streak → gate-red, API truncation → provider-red
@@ -407,29 +895,47 @@ export async function runPlan(job, { workdir, provider, nativeProvider, emit, re
407
895
  }
408
896
  return r;
409
897
  };
410
- return { ask, workerWrites, setIteration: (/** @type {number|string} */ i) => { roundIteration = i; roundsThisAttempt = 0; }, wasBounded: () => attemptBounded };
898
+ return { ask, askFrom, workerWrites, setIteration: (/** @type {number|string} */ i) => { roundIteration = i; roundsThisAttempt = 0; }, wasBounded: () => attemptBounded };
411
899
  }
412
900
 
413
- /** relay a throw from OUTSIDE ralph (scout/plan drafting) as its honest category */
901
+ /** relay a throw from OUTSIDE ralph (scout/plan drafting/fix) as its honest
902
+ * category. Three terminals, never collapsed: the budget gate, the wall clock
903
+ * (F64 — the scout and the drafter run under the same derived call timeout the
904
+ * worker does, so this path needs the same split or it is the blinder route),
905
+ * and transport. */
414
906
  const relay = (/** @type {any} */ e, /** @type {string} */ phase) => {
415
- const category = e instanceof HaltError ? 'cap-halt' : (typeof e?.category === 'string' ? e.category : 'provider-red');
416
- if (category === 'cap-halt') emit('cap-halt', { category, meaning: 'not under cap — not "can\'t"', detail: String(e?.message ?? e) });
907
+ const { category } = categorize(e);
908
+ const detail = String(e?.message ?? e);
909
+ if (category === 'cap-halt') emit('cap-halt', { category, meaning: 'not under cap — not "can\'t"', detail });
910
+ if (category === 'wall-halt') emitWallHalt({ cutMidCall: true, phase });
911
+ const DECIDE = {
912
+ 'cap-halt': [`The budget gate tripped during ${phase} — the wallet cannot fund the plan flow.`, ['raise the job budget and rerun', 'abandon the run']],
913
+ 'wall-halt': [`The run reached its wall-clock cap during ${phase}. Time ran out, not capability.`, WALL_OPTIONS],
914
+ // A stall OUTSIDE a step (scout, drafting, fix) has no step to replan, so it
915
+ // surfaces — but it is named, never laundered into provider-red. The socket
916
+ // did not fail here; the model stopped answering and reissuing did not help,
917
+ // and those are different diagnoses with different remedies (F45/F48: a
918
+ // casualty is never evidence, and a governance stop is never a casualty).
919
+ 'step-stalled': [`The model stopped producing rounds during ${phase} and reissuing the call did not recover it.`, ['retry the run', 'check provider status', 'abandon the run']],
920
+ };
921
+ const [decision, options] = (Object.hasOwn(DECIDE, category) ? DECIDE[category] : undefined)
922
+ ?? [`The ${phase} call failed (${category}) — no result exists.`, ['retry the run', 'fix the provider binding', 'abandon the run']];
417
923
  emit('escalation', {
418
- category, decisionReady: true, phase,
419
- 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.`,
420
- options: category === 'cap-halt' ? ['raise the job budget and rerun', 'abandon the run'] : ['retry the run', 'fix the provider binding', 'abandon the run'],
421
- detail: String(e?.message ?? e),
924
+ category, decisionReady: true, phase, decision, options, detail,
422
925
  ...(typeof e?.lib === 'string' ? { lib: e.lib } : {}),
423
926
  });
424
- return category === 'cap-halt' ? 'cap-halt' : 'provider-red';
927
+ // `step-stalled` rides out as ITSELF, not as provider-red. Naming the
928
+ // escalation while returning a casualty label would launder a governance stop
929
+ // into transport noise at exactly the layer the readout reads.
930
+ return category === 'cap-halt' || category === 'wall-halt' || category === 'step-stalled' ? category : 'provider-red';
425
931
  };
426
932
 
427
933
  // ── 1. SCOUT — read-only by construction: the write-class verbs are simply
428
934
  // not in its menu (the menu is the grant), and its gate fences zero paths
429
935
  let scoutBlob = '';
430
- emit('scout-start', { rounds: SCOUT_ROUNDS });
936
+ emit('scout-start', { rounds: scoutRounds });
431
937
  try {
432
- const scout = await mkWorker({ granted: ceiling.filter((v) => !WRITE_VERBS.includes(v)), phase: 'scout', attemptRounds: SCOUT_ROUNDS, attempts: 1, writable: false });
938
+ const scout = await mkWorker({ granted: ceiling.filter((v) => !WRITE_VERBS.includes(v) && !STORE_VERBS.includes(v)), phase: 'scout', attemptRounds: scoutRounds, attempts: 1, writable: false });
433
939
  scout.setIteration(1);
434
940
  const r = await scout.ask([
435
941
  '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.',
@@ -438,10 +944,37 @@ export async function runPlan(job, { workdir, provider, nativeProvider, emit, re
438
944
  pre.gap && `The job's verification is currently failing. Its output on the tree as it stands:\n${pre.gap}`,
439
945
  ].filter(Boolean).join('\n\n'));
440
946
  scoutBlob = scrub(r.text ?? '').slice(0, SCOUT_BLOB_MAX);
947
+ // F59 — RESERVE the summary round. The round bound is enforced from the
948
+ // metering callback (loop.stop), so a scout still calling tools on its last
949
+ // round is halted mid-tool-use and `text` is empty: it spends the whole
950
+ // allowance exploring and never writes the survey that is its ONLY
951
+ // deliverable. Measured on 15 of 18 archived runs, which then drafted from
952
+ // `(no scout notes)` while still paying ~12% of the run (F55) for the walk.
953
+ // The recovery is one TOOLLESS round on the SAME conversation (`r.msgs`
954
+ // carries the exploration): with no tools offered, text is the only possible
955
+ // output — a mechanical guarantee, never a prose plea to summarise (F19/F37:
956
+ // a pacing mandate in the persona was violated 6/6).
957
+ // The trigger is BOUNDED-and-short, never short alone: a terse survey that
958
+ // finished on its own was not cut off and has nothing to recover. The archive
959
+ // separates cleanly on exactly this pair — every truncated scout was bounded
960
+ // with 0-86 bytes, and the two that finished naturally wrote 5991/8056.
961
+ if (scout.wasBounded() && Buffer.byteLength(scoutBlob) < SCOUT_MIN_BYTES && Array.isArray(r.msgs) && r.msgs.length) {
962
+ emit('scout-truncated', { bytes: Buffer.byteLength(scoutBlob) });
963
+ const s2 = await scout.askFrom(r.msgs, 'You are out of exploration turns. Write your survey NOW, from what you have already read: the relevant layout, the key files and symbols, and your best hypothesis about what the work requires. Text only.');
964
+ const recovered = scrub(s2?.text ?? '').slice(0, SCOUT_BLOB_MAX);
965
+ if (Buffer.byteLength(recovered) > Buffer.byteLength(scoutBlob)) scoutBlob = recovered;
966
+ }
441
967
  } catch (e) {
442
968
  return relay(e, 'scout');
443
969
  }
444
970
  emit('scout-result', { bytes: Buffer.byteLength(scoutBlob) });
971
+ // F59 — the LOUD half. `scout-result {bytes: 0}` was emitted faithfully 18 times
972
+ // out of 18 and read by nobody: an instrument that works but has no consumer is a
973
+ // log line, not evidence. A survey that is still empty after its reserved round is
974
+ // now a NAMED condition. It is never a halt — F59's own evidence is that 3 of 5
975
+ // archived greens had an empty scout, so failing the run here would be a worse
976
+ // error than the one being fixed.
977
+ if (Buffer.byteLength(scoutBlob) < SCOUT_MIN_BYTES) emit('scout-empty', { bytes: Buffer.byteLength(scoutBlob), rounds: scoutRounds });
445
978
  // F6 in-flight: an unpriced round means the cap cannot govern spend it cannot
446
979
  // see — halt at the boundary rather than run the whole plan blind (the caller
447
980
  // emits pricing-red; runPlan just stops burning tokens). Legacy halts per-step.
@@ -457,20 +990,41 @@ export async function runPlan(job, { workdir, provider, nativeProvider, emit, re
457
990
  * total run spend can never exceed the signed budget (advertised == enforced,
458
991
  * the hard line) — a drafter built once at full budget would let the replan
459
992
  * draft spend against money the steps had already consumed.
460
- * @param {string} phase @param {string|null} failure */
461
- const obtainPlan = async (phase, failure) => {
993
+ * @param {string} phase @param {string|null} failure
994
+ * @param {string} [progress] the meter's read, replan only (A's adaptation channel) */
995
+ const obtainPlan = async (phase, failure, progress) => {
462
996
  const drafter = await mkWorker({ granted: [], phase: 'plan', attemptRounds: 2, attempts: 3, writable: false });
997
+ // The BALANCE, read live at each draft — never a snapshot and never a rate
998
+ // (addendum 2). At draft it is the whole budget (scale); at replan it is what
999
+ // is actually left, which is the adaptation channel.
1000
+ const materials = { balanceUsd: remainingUsd(), remainingMs: clock.bounded ? clock.remainingMs() : null, ...(progress ? { progress } : {}) };
1001
+ emit('materials', { phase, ...materials });
463
1002
  const draftPlan = async (/** @type {any[]|null} */ reds) => {
464
1003
  drafter.setIteration(reds ? 'redraft' : 'draft');
465
- const r = await drafter.ask(planPrompt(job, scoutBlob, reds, maxStepRounds, failure), []);
1004
+ // capRuns is the SAME number validatePlan bounds `attempts` against below —
1005
+ // one source, so the prompt and the validator cannot drift apart
1006
+ const r = await drafter.ask(planPrompt(job, scoutBlob, reds, maxStepRounds, failure, scopeMenu, materials, capRuns), []);
466
1007
  return extractArtifact(r.text).code ?? '';
467
1008
  };
1009
+ // Scrubbed HERE, once, before any consumer reads them — the judge() precedent
1010
+ // and the same V8-quoting class it was written for: `parse-error`'s detail is
1011
+ // JSON.parse's own message, and V8 quotes a window of the SOURCE inside it (a
1012
+ // body of 20 chars or fewer is quoted whole). That source is the DRAFT, which
1013
+ // the model wrote, so a red can carry model bytes onto `plan-validate`, onto
1014
+ // `plan-red`, and back into the redraft prompt — and the spine is append-only
1015
+ // forever (the hard line). The boundary is the right place and not the three
1016
+ // emit sites: scrubbing here makes the leak inexpressible, scrubbing each
1017
+ // consumer makes it something the next consumer has to remember.
1018
+ const validate = (/** @type {string} */ t) => {
1019
+ const v = validatePlan(t, { job, maxStepRounds, scopes: scopeMenu, capRuns });
1020
+ return { ...v, reds: v.reds.map((r) => (typeof r.detail === 'string' ? { ...r, detail: scrub(r.detail) } : r)) };
1021
+ };
468
1022
  let text = await draftPlan(null);
469
- let pv = validatePlan(text, { job, maxStepRounds });
1023
+ let pv = validate(text);
470
1024
  emit('plan-validate', { ok: pv.ok, reds: pv.reds, phase: `${phase}-1` });
471
1025
  if (!pv.ok) {
472
1026
  text = await draftPlan(pv.reds);
473
- pv = validatePlan(text, { job, maxStepRounds });
1027
+ pv = validate(text);
474
1028
  emit('plan-validate', { ok: pv.ok, reds: pv.reds, phase: `${phase}-2` });
475
1029
  }
476
1030
  return pv;
@@ -497,6 +1051,13 @@ export async function runPlan(job, { workdir, provider, nativeProvider, emit, re
497
1051
  const stepOutcomes = [];
498
1052
  let replanned = false;
499
1053
  const planExecuted = () => emit('plan-executed', { steps: stepOutcomes, replanned });
1054
+ /** @type {object|null} W-2 at the STEP site — set when the WALL refused to open a
1055
+ * step's attempt. It carries that stop's own record fields, and its presence is what
1056
+ * lets the step loop's terminal tell this reading apart from F64's mid-call one
1057
+ * without consulting the clock a second time (two clock reads for one event is how
1058
+ * the two instruments come to disagree). Exactly the fix loop's `wallStop`, one
1059
+ * layer up. */
1060
+ let stepWallStop = null;
500
1061
 
501
1062
  /** @param {any} step */
502
1063
  const executeStep = async (step) => {
@@ -507,13 +1068,127 @@ export async function runPlan(job, { workdir, provider, nativeProvider, emit, re
507
1068
  for (const e of step.exit) {
508
1069
  if (e.type === 'tree-changed') for (const [k, v] of await snapshotScope(workdir, e.scope)) snapshot.set(k, v);
509
1070
  }
510
- const w = await mkWorker({ granted: step.tools, phase: `step:${step.id}`, attemptRounds: step.rounds, attempts: capRuns, writable: true });
1071
+ // Layer R — one root per step's ralph loop (each micro-wheel is the Layer-1
1072
+ // atom, LAYERS.md). The red-set is the exit evaluator's OWN gap: fixation
1073
+ // here means "the worker rewrote the same file(s) AND the exit evaluator's
1074
+ // complaint is byte-identical" — so the whole normalized gap is the
1075
+ // comparable set (gapKeep `\S` = every non-blank line). That is more robust
1076
+ // than reusing a check's `^`-anchored gapKeep, which the exit wrapper
1077
+ // (`check "x" red: …`) would break. writesInformative is true (tool mode,
1078
+ // always) but never fires on write-overlap ALONE — a plan step rewrites its
1079
+ // one target every attempt, so only a KNOWN-unmoved red-set can distinguish
1080
+ // repetition from progress (Finding 3). Native is excluded (no onToolResult
1081
+ // seam → the tee cannot settle → same-path rewrites are blind).
1082
+ const root = layerRoot && !native
1083
+ ? createRoot({ gapKeep: '\\S', redact: scrub, writesInformative: true })
1084
+ : null;
1085
+ // P: the three widened step fields, each wired or refused — a silently-ignored
1086
+ // optional field is the F50 blind-instrument class. `attempts` tightens the
1087
+ // shell cap (validator already bounds it); `scope` narrows the fence to a
1088
+ // menu value (a subset by construction); `model` swaps the worker's provider
1089
+ // via the runner's factory — absent factory it must STOP, not silently run
1090
+ // the default tier as if the choice had been honoured.
1091
+ const stepCap = step.attempts !== undefined ? Math.min(step.attempts, capRuns) : capRuns;
1092
+ if (step.model !== undefined && !providerFor) {
1093
+ const err = /** @type {any} */ (new Error(`step "${step.id}" selects model tier "${step.model}" but the runner supplied no providerFor factory — wiring gap, not a plan defect`));
1094
+ err.category = 'interpreter-red';
1095
+ throw err;
1096
+ }
1097
+ const w = await mkWorker({
1098
+ granted: step.tools, phase: `step:${step.id}`, attemptRounds: step.rounds, attempts: stepCap, writable: true, root,
1099
+ fence: step.scope !== undefined ? [resolve(workdir, globToPrefix(step.scope))] : null,
1100
+ workerProvider: step.model !== undefined && providerFor ? providerFor(step.model) : null,
1101
+ });
511
1102
  let lastText = '';
512
1103
  let iterationNow = 0;
1104
+ // The meter's baseline: what the RUN had left when this step began. Shares are
1105
+ // computed against this, never against the original budget — a late step is
1106
+ // measured against what actually remains for it, which is what "eating the run"
1107
+ // means. Read once, here, so the denominator cannot drift mid-step.
1108
+ const stepStartUsd = remainingUsd();
1109
+ const stepStartMs = clock.bounded ? clock.remainingMs() : Infinity;
513
1110
  /** @param {number} iteration @param {string} [gap] */
514
1111
  const middle = async (iteration, gap) => {
1112
+ // A — the variance check, at the HEAD of an attempt so no work is discarded
1113
+ // (attempts 1..n-1 accrued the spend; their writes are already on disk). Never
1114
+ // fires on attempt 1: the share is 0 there by construction. Throwing with a
1115
+ // category is the seam ralph already owns; it routes the category and the step
1116
+ // loop reads it as a REPLAN, not a stop.
1117
+ if (iteration > 1) {
1118
+ const moneyShare = stepStartUsd > 0 ? (stepStartUsd - remainingUsd()) / stepStartUsd : 0;
1119
+ const timeShare = clock.bounded && stepStartMs > 0 ? (stepStartMs - clock.remainingMs()) / stepStartMs : 0;
1120
+ if (moneyShare >= VARIANCE_THRESHOLD || timeShare >= VARIANCE_THRESHOLD) {
1121
+ emit('variance', {
1122
+ step: step.id, iteration, threshold: VARIANCE_THRESHOLD,
1123
+ moneyShare: Number(moneyShare.toFixed(3)),
1124
+ timeShare: clock.bounded ? Number(timeShare.toFixed(3)) : null,
1125
+ axis: moneyShare >= VARIANCE_THRESHOLD ? 'money' : 'time',
1126
+ });
1127
+ const err = /** @type {CategorizedError} */ (new Error(
1128
+ `step "${step.id}" consumed ${Math.round(Math.max(moneyShare, timeShare) * 100)}% of the run's remaining `
1129
+ + `${moneyShare >= VARIANCE_THRESHOLD ? 'money' : 'time'} across ${iteration - 1} attempt(s) with its exits still red`));
1130
+ err.category = 'step-variance';
1131
+ throw err;
1132
+ }
1133
+ }
1134
+ // T/W-2 at the STEP site, and it sits BELOW the meter deliberately. The meter is
1135
+ // the instrument for a step that was ALREADY RUNNING when the deadline passed:
1136
+ // that step reads a time share of 1 and routes to the ONE replan exactly as it
1137
+ // always has. The case it is structurally blind to is a step that STARTS past the
1138
+ // deadline — its share is measured against what the run had left when it began,
1139
+ // which is zero, so the share is zero forever and the step burns every attempt it
1140
+ // is allowed. (Measured on the real flow: three attempts, three judged exits, all
1141
+ // past the deadline.) The two are exhaustive and do not overlap: past the deadline
1142
+ // a step that began with time left reads a share of exactly 1, so the meter always
1143
+ // fires first for it — which is why this check only ever reaches attempt 1, and why
1144
+ // the routing of a step that was already running is untouched.
1145
+ //
1146
+ // Every one of those attempts is worthless by construction: the metered clock
1147
+ // check stops the worker on its FIRST round, so it writes nothing, and the judge
1148
+ // still runs the step's exits — themselves close stages, at the full close timeout
1149
+ // each — to re-mint the red already on the record. hamr's ruling, the same one the
1150
+ // close-fix loop applies: when time is up, keep the grade we already have and
1151
+ // stop. Resume-to-cap extended from money to time — the stop IS the checkpoint.
1152
+ //
1153
+ // The placement IS the mechanism, and the in-flight case falls out of it with
1154
+ // nothing added: a deadline landing DURING an attempt still lets that attempt and
1155
+ // its judge run to completion, because the clock is not consulted again until the
1156
+ // next attempt is about to start. Grading real work always completes — a wall that
1157
+ // kills grading leaves the run unreadable after the money is spent (F45).
1158
+ if (clock.expired()) {
1159
+ stepWallStop = {
1160
+ cutMidCall: false,
1161
+ phase: `step:${step.id}`,
1162
+ stepsDone: idx,
1163
+ stepsPlanned: plan.steps.length,
1164
+ attemptsUsed: iteration - 1,
1165
+ // BOTH, honestly named. `capRuns` means the RUN's attempt cap on every
1166
+ // wall-halt record — the fix site writes exactly that — so this site must
1167
+ // not quietly put the step's TIGHTENED cap under the same key: two records
1168
+ // of one type disagreeing about what a number counts, with nothing on the
1169
+ // record saying which, is the two-instruments class. The tightening is real
1170
+ // information (it is what bounded THIS step) and keeps its own name.
1171
+ capRuns,
1172
+ stepAttemptCap: stepCap,
1173
+ };
1174
+ // No trend here, and none invented: `gapTrend` reads consecutive CLOSE output,
1175
+ // and a step that never ran has no grade of its own to compare. The lever the
1176
+ // human needs is on the run-level record either way.
1177
+ const err = /** @type {CategorizedError} */ (new Error(
1178
+ `the wall-clock cap passed before step "${step.id}" attempt ${iteration} could start — `
1179
+ + `the step was never funded to run. ${idx} of ${plan.steps.length} step(s) completed before it. `
1180
+ + 'Nothing after the deadline is allowed to change the verdict the run already has.'));
1181
+ err.category = 'wall-halt';
1182
+ throw err;
1183
+ }
515
1184
  w.setIteration(iteration);
516
1185
  iterationNow = iteration;
1186
+ // Layer R observe: finalize the prior attempt from the books (workerWrites
1187
+ // audit + teed same-path rewrites), run the fixation detector, and inject
1188
+ // its escalating note into THIS attempt's prompt (null = inert). The event
1189
+ // carries counts and paths only — never content (the spine is forever).
1190
+ const rootInj = root ? root.observe({ iteration, gap, writes: w.workerWrites() }) : null;
1191
+ if (rootInj) emit('root-injected', { step: step.id, ...rootInj.event });
517
1192
  const r = await w.ask([
518
1193
  step.action,
519
1194
  `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.`,
@@ -522,11 +1197,26 @@ export async function runPlan(job, { workdir, provider, nativeProvider, emit, re
522
1197
  gap && `Previous attempt failed this step's checks:\n${gap}`,
523
1198
  w.wasBounded() === iteration - 1
524
1199
  && `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.`,
1200
+ rootInj && rootInj.note,
525
1201
  ].filter(Boolean).join('\n\n'));
526
1202
  lastText = scrub(r.text ?? '').slice(0, ARTIFACT_MAX);
527
1203
  };
528
1204
  const judge = async () => {
529
- const { pass, results } = await evalExits(step.exit, { dir: workdir, snapshot, runCheck });
1205
+ const { pass, results: raw } = await evalExits(step.exit, { dir: workdir, snapshot, runCheck });
1206
+ // Scrubbed HERE, once, before the results are read by anyone. An exit detail is
1207
+ // supposed to be names and counts, but `json-valid` embeds `JSON.parse`'s own
1208
+ // message and V8 quotes a window of the SOURCE inside it (a body of 20 chars or
1209
+ // fewer is quoted whole) — so the detail can carry FILE BYTES the worker chose,
1210
+ // and the spine is append-only forever (the hard line). ONE inventory
1211
+ // (SECRET_PATTERNS), the same `scrub` the close output and the ctx-verb channel
1212
+ // already ride through — never a second spelling of what a secret looks like.
1213
+ // The boundary is the right place and not the three call sites below it: these
1214
+ // same details become the `exit-eval` record, a fault's escalation `detail`
1215
+ // (via ralph's close-verdict + CLOSE_FAULTS emissions), and the worker gap
1216
+ // (which ralph also puts on the spine as close-verdict.gap). Scrubbing the
1217
+ // results makes the leak inexpressible; scrubbing each consumer makes it
1218
+ // something the next consumer has to remember.
1219
+ const results = raw.map((r) => (typeof r.detail === 'string' ? { ...r, detail: scrub(r.detail) } : r));
530
1220
  emit('exit-eval', { step: step.id, iteration: iterationNow, results });
531
1221
  // an instrument fault rides out by its runClose verdict NAME: ralph
532
1222
  // escalates it through CLOSE_FAULTS (or F32-routes a crash after writes)
@@ -536,7 +1226,7 @@ export async function runPlan(job, { workdir, provider, nativeProvider, emit, re
536
1226
  // AND-only: the gap names EVERY failing wall (mechanical genre, F38)
537
1227
  return { verdict: 'needs_revision', gap: results.filter((r) => !r.pass).map((r) => r.detail).join('\n') };
538
1228
  };
539
- const outcome = await ralph({ middle, judge, capRuns, emit: emitL, workerWrites: w.workerWrites });
1229
+ const outcome = await ralph({ middle, judge, capRuns: stepCap, emit: emitL, workerWrites: w.workerWrites });
540
1230
  return { outcome, artifact: lastText };
541
1231
  };
542
1232
 
@@ -576,6 +1266,23 @@ export async function runPlan(job, { workdir, provider, nativeProvider, emit, re
576
1266
  if (res.outcome === 'green') {
577
1267
  artifacts.push({ id: step.id, text: res.artifact });
578
1268
  idx += 1;
1269
+ // T's terminal: a run that cannot fit its time budget STOPS, and the stop is
1270
+ // the result (build-ladder discipline, v1.27 — unchanged). Checked after a
1271
+ // GREEN step too, not just a red one: the alternative silently starts a step
1272
+ // it cannot fund, which is the same class as the money cap binding mid-attempt
1273
+ // (F45). Resume-to-cap applies exactly as it does to money — the stop IS the
1274
+ // checkpoint, so the completed steps are not wasted.
1275
+ if (clock.expired() && idx < plan.steps.length) {
1276
+ emitWallHalt({ stepsDone: idx, stepsPlanned: plan.steps.length });
1277
+ emit('escalation', {
1278
+ category: 'wall-halt', decisionReady: true, phase: `step:${step.id}`,
1279
+ decision: `The run reached its wall-clock cap after ${idx} of ${plan.steps.length} steps. Time ran out, not capability.`,
1280
+ options: WALL_OPTIONS,
1281
+ detail: `requested ${clock.requestedMs}ms, elapsed ${clock.elapsedMs()}ms`,
1282
+ });
1283
+ planExecuted();
1284
+ return 'wall-halt';
1285
+ }
579
1286
  continue;
580
1287
  }
581
1288
  // ONE replan, and only for EXHAUSTION with FUNDS LEFT (review #5): ralph
@@ -587,16 +1294,50 @@ export async function runPlan(job, { workdir, provider, nativeProvider, emit, re
587
1294
  // exhaustion leaves money on the table. An instrument/governance stop that
588
1295
  // is not cap-halt never replans either. (An unpriced step already returned
589
1296
  // pricing-red at the step-end guard above, so isUnpriced() is false here.)
1297
+ //
1298
+ // A (PRD v1.27) adds the SECOND trigger: `step-variance` — the meter stopped a
1299
+ // step that had eaten a declared share of the run with its exits unmoved. That
1300
+ // is the case exhaustion-only could never reach (F56: a replan has fired zero
1301
+ // times in the programme), and it is a genuine re-allocation rather than a stop.
1302
+ // Both triggers still need FUNDS LEFT, and both are still bounded by the ONE
1303
+ // replan ceiling — this changes the trigger, never the ceiling (v1.12).
590
1304
  const cat = lastEscalation?.category;
591
- if (!replanned && cat === 'cap-halt' && remainingUsd() > MONEY_MIN) {
1305
+ // F66 adds the THIRD trigger: `step-stalled` — the stall watchdog reissued the
1306
+ // model call MAX_STALLS times and never got a round back. hamr's ruling is that
1307
+ // a stall must self-heal rather than end the run ("killing and coming back is
1308
+ // not an option"), so the watchdog reissues silently; only once reissuing has
1309
+ // demonstrably stopped working does the run adapt instead — by replanning, not
1310
+ // by stopping. Same ONE-replan ceiling and same FUNDS-LEFT condition as the
1311
+ // other two: this changes the trigger, never the ceiling (v1.12).
1312
+ const replanTrigger = cat === 'cap-halt' ? 'step exhausted its attempts with exits still red'
1313
+ : cat === 'step-variance' ? 'the meter stopped a step that was consuming the run with its exits unmoved'
1314
+ : cat === 'step-stalled' ? 'the model stopped producing rounds and reissuing the call did not recover it'
1315
+ : null;
1316
+ if (!replanned && replanTrigger && remainingUsd() > MONEY_MIN) {
592
1317
  replanned = true;
593
- emit('replan', { step: step.id, reason: 'step exhausted its attempts with exits still red' });
594
- const failure = `Step "${step.id}" (${step.action}) ran ${capRuns} attempts and its exits were still red. `
1318
+ emit('replan', { step: step.id, reason: replanTrigger, trigger: cat });
1319
+ // The brief names the trigger HONESTLY, one branch per trigger: it is the
1320
+ // only channel the redrafting planner adapts to, so a wrong diagnosis here
1321
+ // is handed to the one component whose whole job is to respond to it. A
1322
+ // stall in particular never judged its exits at all — the exhaustion
1323
+ // sentence would invent a red the run never saw (F28's rule, replan side).
1324
+ const why = cat === 'step-variance'
1325
+ ? 'It was stopped by the run\'s meter for consuming too large a share of what was left.'
1326
+ : cat === 'step-stalled'
1327
+ ? 'It stalled: the model stopped producing rounds and reissuing the call did not recover it, so its exits were never judged.'
1328
+ : `It ran ${step.attempts !== undefined ? Math.min(step.attempts, capRuns) : capRuns} attempts and its exits were still red.`;
1329
+ const failure = `Step "${step.id}" (${step.action}) did not reach its exits. `
1330
+ + `${why}\n`
595
1331
  + `Last exit state:\n${lastEscalation?.detail ?? '(none)'}\n`
596
1332
  + `Steps completed so far: ${artifacts.map((a) => a.id).join(', ') || 'none'}.`;
1333
+ // The progress line IS the adaptation channel (addendum 2): the balance rides
1334
+ // in via obtainPlan's live read, and this says where the run got to. Both are
1335
+ // the planner re-allocating what remains across what is left — never a rate.
1336
+ const progress = `step ${idx + 1} of ${plan.steps.length} ("${step.id}") did not finish; `
1337
+ + `${artifacts.length} step(s) completed before it`;
597
1338
  let pv;
598
1339
  try {
599
- pv = await obtainPlan('replan', failure);
1340
+ pv = await obtainPlan('replan', failure, progress);
600
1341
  } catch (e) {
601
1342
  planExecuted();
602
1343
  return relay(e, 'replan');
@@ -615,6 +1356,22 @@ export async function runPlan(job, { workdir, provider, nativeProvider, emit, re
615
1356
  idx = 0;
616
1357
  continue;
617
1358
  }
1359
+ // F64 — the wall stopped this attempt from INSIDE a provider call (the derived
1360
+ // call timeout is the deadline in the provider's own coin). ralph already
1361
+ // emitted the escalation under this category, so the outcome and the record
1362
+ // agree (F11); what is still missing is the run-level TIME record, which the
1363
+ // between-steps site emits and this path must too. Never a replan trigger and
1364
+ // never a casualty: time ran out, and the stop IS the checkpoint.
1365
+ // W-2 adds the step loop's SECOND wall reading: the deadline seen at the head of
1366
+ // an attempt, with no call in flight. `stepWallStop` is that reading's own record
1367
+ // (attempts used, steps done); its absence means the deadline landed inside a call
1368
+ // instead, which is F64's cutMidCall stop — and run.js keys the job-end money floor
1369
+ // on exactly that field, so conflating the two would report an unknown as exact.
1370
+ if (cat === 'wall-halt') {
1371
+ emitWallHalt(stepWallStop ?? { cutMidCall: true, phase: `step:${step.id}`, stepsDone: idx, stepsPlanned: plan.steps.length });
1372
+ planExecuted();
1373
+ return 'wall-halt';
1374
+ }
618
1375
  // A money-gate halt (wallet drained) is an honest cap-halt terminal, never a
619
1376
  // step-red: the exits never ran because the money ran out, not because the
620
1377
  // work failed. Attempt-exhaustion WITH funds after the one replan is spent
@@ -623,6 +1380,16 @@ export async function runPlan(job, { workdir, provider, nativeProvider, emit, re
623
1380
  planExecuted();
624
1381
  return remainingUsd() <= MONEY_MIN ? 'cap-halt' : `step-red:${step.id}`;
625
1382
  }
1383
+ // A second `step-variance` after the one replan is spent is a STOP, and the stop
1384
+ // is the result — but it rides out as `step-red:<id>`, NOT as its own top-level
1385
+ // outcome. The category is a replan TRIGGER, never a run verdict: leaking it
1386
+ // upward would mint an outcome run.js and the ledger's class table do not know,
1387
+ // and an unmapped category is counted as a library bug (ledger.js) when this is a
1388
+ // planning story. The escalation ralph already emitted carries the real detail.
1389
+ if (cat === 'step-variance') {
1390
+ planExecuted();
1391
+ return `step-red:${step.id}`;
1392
+ }
626
1393
  // Any OTHER terminal escalation category is NOT a capability failure: a
627
1394
  // provider-red is a transport CASUALTY, a gate-red/interpreter-red/close
628
1395
  // fault is an instrument stop. Each rides out under its OWN name (F11: the
@@ -636,7 +1403,7 @@ export async function runPlan(job, { workdir, provider, nativeProvider, emit, re
636
1403
  // ── 4. THE CLOSE — the operator's signed command, the only truth. Red →
637
1404
  // the gap feeds ONE bounded fix loop judged by the REAL close (v1.12 §4);
638
1405
  // still red → the escalation ralph already emitted stands.
639
- const post = runClose(closeArgv, scrub, closeOpts);
1406
+ const post = await judgeClose();
640
1407
  emit('outer-close', { ...post });
641
1408
  if (post.verdict === 'satisfied') {
642
1409
  planExecuted();
@@ -650,28 +1417,141 @@ export async function runPlan(job, { workdir, provider, nativeProvider, emit, re
650
1417
  }
651
1418
  emit('fix-loop', { gapBytes: Buffer.byteLength(post.gap ?? '') });
652
1419
  let fixOutcome;
1420
+ /** @type {object|null} W-2 — set when the WALL stopped the fix loop BETWEEN
1421
+ * iterations. It carries that stop's own record fields, and its presence is what
1422
+ * lets the post-loop branch tell this reading apart from F64's mid-call one
1423
+ * without consulting the clock a second time (two clock reads for one event is
1424
+ * how the two instruments come to disagree). */
1425
+ let wallStop = null;
653
1426
  try {
654
- const w = await mkWorker({ granted: ceiling, phase: 'fix', attemptRounds: maxStepRounds, attempts: capRuns, writable: true });
1427
+ // Layer R for the close-fix loop — the plan flow's single ralph loop judged
1428
+ // by the REAL close (the plan flow's single ralph loop, and the
1429
+ // likeliest place fixation manifests: the fix worker has the full menu and
1430
+ // is judged by a command, not a form-only exit). The red-set comes from the
1431
+ // stage that ACTUALLY rendered the verdict this attempt, not from one pattern
1432
+ // fixed for the run: a staged close has N gapKeeps and the wall the worker hit
1433
+ // varies attempt to attempt (the gap here is the raw close output, so the
1434
+ // `^`-anchored pattern matches, unlike the exec steps' exit-eval gap). Same
1435
+ // native exclusion (no onToolResult seam ⇒ the tee cannot settle).
1436
+ const fixRoot = layerRoot && !native
1437
+ ? createRoot({ redact: scrub, writesInformative: true })
1438
+ : null;
1439
+ const w = await mkWorker({ granted: ceiling, phase: 'fix', attemptRounds: maxStepRounds, attempts: capRuns, writable: true, root: fixRoot });
1440
+ // W-2's only input, and it is already in this loop's hand: the close gaps seen
1441
+ // so far, oldest first. `post.gap` is the grade the loop OPENED on; every later
1442
+ // entry is what ralph carried back from the close that judged the previous
1443
+ // attempt (or, after a worker-crash, F32's routed gap — still the feedback that
1444
+ // attempt actually received). Nothing is instrumented for this.
1445
+ const gaps = [post.gap ?? ''];
655
1446
  /** @param {number} iteration @param {string} [gap] */
656
1447
  const middle = async (iteration, gap) => {
1448
+ if (gap !== undefined) gaps.push(gap);
1449
+ // T/W-2 — the wall, read before a NEW fix attempt is started, and never
1450
+ // around the close itself. hamr's ruling: *"when time is up, keep the grade
1451
+ // we already have and stop … run tests (free) and when done if original time
1452
+ // is past due, pause and ask user to increase time or adjust prompt"*.
1453
+ //
1454
+ // Past the deadline this iteration is guaranteed to be worthless: the metered
1455
+ // round check stops the fix worker on its FIRST round, so it writes nothing,
1456
+ // and the iteration then re-runs the FULL staged close over an unchanged tree
1457
+ // (four stages × closeTimeoutMs = 60 minutes on the shipped spec) only to mint
1458
+ // the verdict already on the record — capRuns times over. So the run stops
1459
+ // here, ON that verdict. Resume-to-cap extended from money to time: the stop
1460
+ // IS the checkpoint, and the human tops up (v1.12).
1461
+ //
1462
+ // The placement IS the mechanism, and the in-flight case falls out of it with
1463
+ // nothing added: a deadline that lands DURING an attempt still lets that
1464
+ // attempt's close run to completion, because the clock is not consulted again
1465
+ // until the next iteration is about to start. The close is never bounded — a
1466
+ // wall that kills grading leaves the run unreadable after the money is spent
1467
+ // (the F45 class), so the one thing this must never do is stop a close.
1468
+ if (clock.expired()) {
1469
+ const t = gapTrend(gaps);
1470
+ wallStop = {
1471
+ cutMidCall: false,
1472
+ phase: 'fix',
1473
+ iterationsUsed: iteration - 1,
1474
+ capRuns,
1475
+ verdict: lastCloseVerdict?.verdict,
1476
+ ...(lastCloseVerdict?.stage ? { stage: lastCloseVerdict.stage } : {}),
1477
+ trend: t.trend,
1478
+ };
1479
+ // The detail carries what the human needs to pick a lever and nothing else:
1480
+ // the verdict that stands, how much of the loop was actually spent, and the
1481
+ // trend that says which of the two spec edits fits. It never names a culprit
1482
+ // file (F28) — that is the worker's job, not the escalation's.
1483
+ const err = /** @type {CategorizedError} */ (new Error(
1484
+ `the wall-clock cap passed before fix attempt ${iteration} could start. `
1485
+ + `The verdict stands as the last close rendered it: ${lastCloseVerdict?.verdict ?? 'unknown'}`
1486
+ + `${lastCloseVerdict?.stage ? ` at stage "${lastCloseVerdict.stage}"` : ''}, `
1487
+ + `after ${iteration - 1} of ${capRuns} fix iteration(s). `
1488
+ + `Progress trend: ${t.trend} — ${t.reading}; ${t.lever}.`));
1489
+ err.category = 'wall-halt';
1490
+ throw err;
1491
+ }
657
1492
  w.setIteration(iteration);
1493
+ // the red-set's source travels WITH the gap: `closeStage` is the stage the
1494
+ // judge stopped at for the attempt this gap came from, and its own gapKeep
1495
+ // designates that stage's failures
1496
+ const rootInj = fixRoot
1497
+ ? fixRoot.observe({
1498
+ iteration, gap, writes: w.workerWrites(),
1499
+ redStage: closeStage,
1500
+ redKeep: stagedClose.find((s2) => s2.name === closeStage)?.gapKeep,
1501
+ })
1502
+ : null;
1503
+ if (rootInj) emit('root-injected', { phase: 'fix', ...rootInj.event });
658
1504
  await w.ask([
659
1505
  'The job\'s final verification is failing. Fix the repository so it passes.',
660
1506
  `Repository root (absolute): ${workdir}\nEvery path you pass to a tool MUST be absolute and inside this root.`,
661
1507
  artifacts.length > 0 && `Working context (read-only) — the plan's steps produced:\n${artifacts.map((a) => `[${a.id}] ${a.text}`).join('\n\n')}`,
662
1508
  !gap && post.gap && `The verification's output on the tree as it stands (not an attempt of yours):\n${post.gap}`,
663
1509
  gap && `Previous attempt failed the verification:\n${gap}`,
1510
+ rootInj && rootInj.note,
664
1511
  ].filter(Boolean).join('\n\n'));
665
1512
  };
666
1513
  fixOutcome = await ralph({
667
- middle, close: closeArgv, capRuns, emit: emitL, redact: scrub,
668
- closeTimeoutMs, cwd: workdir, expect: job.close.expect, judged: job.close.judged, gapKeep: job.close.gapKeep,
669
- workerWrites: w.workerWrites,
1514
+ // the staged close rides in as the JUDGE seam (one verdict, every stage in
1515
+ // order) — ralph's single-command path stays exactly what it was
1516
+ middle, judge: async () => judgeClose(), capRuns, emit: emitL, redact: scrub,
1517
+ closeTimeoutMs, cwd: workdir, workerWrites: w.workerWrites,
670
1518
  });
671
1519
  } catch (e) {
672
1520
  planExecuted();
673
1521
  return relay(e, 'fix');
674
1522
  }
675
1523
  planExecuted();
1524
+ if (fixOutcome !== 'green' && lastEscalation?.category === 'wall-halt') {
1525
+ // F64 in the close-fix loop: the same governance stop, and it must not ride out
1526
+ // as a bare `escalated` either — that reads as "the fix failed" when the fix was
1527
+ // never given the time to run.
1528
+ // W-2 adds the loop's SECOND wall reading: the deadline seen between iterations,
1529
+ // where a real grade already exists and is kept. `wallStop` is that reading's own
1530
+ // record (verdict, iterations used, trend); its absence means the deadline landed
1531
+ // inside a call instead, which is F64's cutMidCall stop.
1532
+ emitWallHalt(wallStop ?? { cutMidCall: true, phase: 'fix' });
1533
+ return 'wall-halt';
1534
+ }
1535
+ if (fixOutcome !== 'green' && lastEscalation?.category === 'cap-halt') {
1536
+ // ralph spells BOTH of its terminals cap-halt — attempt-exhaustion AND a
1537
+ // money-gate halt thrown mid-attempt — so the category alone cannot tell them
1538
+ // apart, and the wallet is the only instrument that can. The step loop already
1539
+ // splits this exact pair on the same reading; the fix loop must too, because
1540
+ // the fix worker's gate is built with the wallet at its MOST drained (every
1541
+ // step's spend is behind it), which is precisely where a money cut masquerades
1542
+ // as "the fix failed". F45: a money cut is never a capability read — it rides
1543
+ // out as cap-halt, the resume-to-cap checkpoint (the stop IS the checkpoint).
1544
+ // Attempts spent with money still on the table stays the designed terminal
1545
+ // ("close still red") and keeps riding out as `escalated` below.
1546
+ // (MED-4, fixed with hamr's explicit go, 2026-07-30.)
1547
+ if (remainingUsd() <= MONEY_MIN) return 'cap-halt';
1548
+ } else if (fixOutcome !== 'green' && typeof lastEscalation?.category === 'string') {
1549
+ // The step loop's category restoration (F11), mirrored: ralph returns the flat
1550
+ // 'escalated' on a middle throw while its escalation carries the real name — a
1551
+ // provider-red here is a transport CASUALTY, and run.js keys the F44
1552
+ // spendComplete:false floor on the OUTCOME, so a laundered label would report
1553
+ // an exact-looking total for a call that never billed back.
1554
+ return lastEscalation.category;
1555
+ }
676
1556
  return fixOutcome === 'green' ? 'green' : 'escalated';
677
1557
  }