bareloop 0.5.1 → 0.7.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,20 +14,25 @@
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';
17
+ import { readFileSync, readdirSync } from 'node:fs';
18
18
  import { createHash } from 'node:crypto';
19
- import { join, resolve } from 'node:path';
20
- import { Gate, redact } from 'bareguard';
19
+ import { join, resolve, relative } from 'node:path';
20
+ import { Gate } from 'bareguard';
21
21
  import { LiteCtx } from 'litectx';
22
- import { runClose, ralph, CLOSE_FAULTS } from './ralph.js';
23
- import { validatePlan } from './plan.js';
24
- import { WRITE_VERBS, EXIT_TYPES, MAX_EXITS_PER_STEP, MAX_PLAN_STEPS } from './plan.js';
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';
25
25
  import { snapshotScope, evalExits } from './exits.js';
26
26
  import { createRoot } from './root.js';
27
- import { TOOL_MENU } from './job.js';
28
- import { TOOL_BY_VERB, CTX_TOOLS, createCtxTools, toolAction, PERSONA_TOOLS, RETRIEVAL_STRATEGY, EDIT_STRATEGY } from './interpret.js';
29
- import { globToPrefix, SECRET_PATTERNS } from './validate.js';
27
+ import { createStallWatch, STALL_MS, MAX_STALLS } from './stall.js';
28
+ import { createLadder, STRIKE_LIMIT } from './ladder.js';
29
+ import { createTrend, FIX_STRIKE_LIMIT } from './trend.js';
30
+ import { TOOL_MENU, STORE_VERBS, checkMenu } from './job.js';
31
+ import { TOOL_BY_VERB, CTX_TOOLS, createCtxTools, toolAction, PERSONA_TOOLS, strategyFor } from './tools.js';
32
+ import { globToPrefix, redactSecrets, SECRET_PATTERNS } from './validate.js';
33
+ import { validateBridge, loadGate } from './bridges.js';
30
34
  import { extractArtifact } from './text.js';
35
+ import { createClock, isWallTimeout } from './clock.js';
31
36
 
32
37
  const require = createRequire(import.meta.url);
33
38
  const { Loop, wireGate, HaltError } = require('bare-agent');
@@ -50,6 +55,11 @@ const SCOUT_BLOB_MAX = 8000;
50
55
  const NATIVE_READ_CAP = 24 * 1024;
51
56
  /** native-only strategy: tell the worker WHY whole-file reads fail here and to navigate by symbol */
52
57
  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.';
58
+ /** F59 — below this a scout survey is treated as absent. Sized off the archive, not
59
+ * taste: the 15 truncated scouts produced 0/74/86 bytes; every real survey was
60
+ * 5991-8056. 200 sits between the two populations with an order of magnitude of
61
+ * daylight on each side, so it cannot be a tuned-to-one-case threshold. */
62
+ const SCOUT_MIN_BYTES = 200;
53
63
  /** feed-forward artifact bound per step (prompt ingredient, spine-bound) */
54
64
  const ARTIFACT_MAX = 2000;
55
65
  /** the wallet floor below which a replan is a stop, not an adaptation (review
@@ -57,9 +67,132 @@ const ARTIFACT_MAX = 2000;
57
67
  * just burns another draft and mislabels the money-cut as "exits still red"
58
68
  * (F45 class) — the honest terminal there is cap-halt. */
59
69
  const MONEY_MIN = 0.001;
70
+ /**
71
+ * A (PRD v1.27; design record §3.4, answered §6.2) — the replan trigger. A step
72
+ * that has consumed this share of the run's REMAINING money or time with its exits
73
+ * still red gets no further attempt; the planner re-allocates what is left instead.
74
+ *
75
+ * 50% is hamr's number (*"50% is fine"*), and it was **re-confirmed with its own
76
+ * inertness on the table**: F63 replayed 18 archived spines / 54 steps / 101 judged
77
+ * attempts and found it would have fired 0 times (near misses 0.35 · 0.35 · 0.40 ·
78
+ * 0.45). Shown that, hamr's call was *"keep 0.5"* (2026-07-26).
79
+ *
80
+ * So this is a GUARD against a step that eats the run, deliberately set above the
81
+ * observed population rather than tuned to it — lowering it to 0.35 because that is
82
+ * where those four points sit would be fitting the number to the data (the standing
83
+ * no-fit-to-pass rule), and threshold-setting is arbiter territory either way. It
84
+ * has not hardened by surviving unexamined; it survived being examined.
85
+ *
86
+ * This changes the replan TRIGGER only. The hard ceiling of ONE replan is
87
+ * unchanged — unlimited replanning launders thrash as adaptation (v1.12).
88
+ */
89
+ const VARIANCE_THRESHOLD = 0.5;
60
90
 
61
91
  /** @typedef {Error & {category?: string, lib?: string}} CategorizedError */
62
92
 
93
+ /**
94
+ * The materials block: what the run has to spend, as a BALANCE and nothing else.
95
+ *
96
+ * hamr's correction (design record addendum 2, verbatim): *"why would you tell it
97
+ * every round that you have x time per round? what if there is a heavier round?
98
+ * why don't you tell it same like cost, you have x left from cost/time instead of
99
+ * making it race against time?"*
100
+ *
101
+ * So: NO rate, NO per-round allowance, NO derived round count. F57 measured a 150x
102
+ * spread on the verification gaps (3.8s to 561s), so a per-round constant
103
+ * describes almost no real round — a heavy round would break the planner's
104
+ * arithmetic with nothing to tell it. A balance is self-correcting: after a heavy
105
+ * round the number is simply lower. A rate is also a stopwatch, and racing a clock
106
+ * is the rush-or-fake incentive the v1.12 §5 prompt contract exists to remove.
107
+ *
108
+ * At DRAFT the balance is the total, so this is scale only — the planner is NOT
109
+ * asked to allocate time, because sizing time at draft requires the rate that was
110
+ * just removed. A plan that does not fit is what the meter is for (addendum 2).
111
+ * @param {{ balanceUsd?: number|null, remainingMs?: number|null, progress?: string }} [m]
112
+ */
113
+ function materialsBlock(m) {
114
+ if (!m) return '';
115
+ const lines = [];
116
+ if (typeof m.balanceUsd === 'number') lines.push(`- money left for the whole run: $${m.balanceUsd.toFixed(2)}`);
117
+ if (typeof m.remainingMs === 'number') lines.push(`- time left for the whole run: ${Math.round(m.remainingMs / 60_000)} minutes`);
118
+ if (!lines.length) return '';
119
+ // The progress line only exists at REPLAN: it is the variance the meter read,
120
+ // and it is the adaptation channel (addendum 2). At draft there is no progress.
121
+ if (m.progress) lines.push(`- where the run got to: ${m.progress}`);
122
+ 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`;
123
+ }
124
+
125
+ /**
126
+ * Layer 3 (D4) — the MECHANICAL START. A loaded bridge is handed to the drafter as a
127
+ * STARTING DRAFT, not as a contract: it is appended to the ordinary drafting prompt and
128
+ * the result passes the ORDINARY validator. No second, looser path exists for an
129
+ * inherited plan.
130
+ *
131
+ * The framing is the pre-probe's arm C (`scripts/reuse-preprobe.mjs` ARM_FRAME.C),
132
+ * carried through the execution probe (`scripts/reuse-exec-probe.mjs` C_BLOCK) that
133
+ * greened this job shape end to end. It is reproduced VERBATIM because that exact text
134
+ * is what the draft-tier read and the execution kill-gate were measured against —
135
+ * reworded, this would be a different arm than the one that passed.
136
+ *
137
+ * ONE token is not verbatim, and deliberately: the probe's job had FOUR close stages and
138
+ * said so. A prompt that tells a two-stage job it shares "the same four close stages" is
139
+ * a falsehood handed to the planner, so the count is read off the job's own staged close
140
+ * (which the load gate has already proven equal to the bridge's). The small numbers are
141
+ * spelled, so on the four-stage shape the probe validated this renders byte-identical to
142
+ * the text it validated.
143
+ * @param {number} n how many stages both closes have
144
+ * @param {object} plan the bridge version's plan, as executed
145
+ */
146
+ function startingDraftBlock(n, plan) {
147
+ const count = ['no', 'one', 'two', 'three', 'four', 'five', 'six', 'seven', 'eight', 'nine', 'ten'][n] ?? String(n);
148
+ return `
149
+
150
+ YOUR STARTING DRAFT — begin from the plan below and tweak it.
151
+ This plan greened a same-shape job (the same ${count} close stages) on a DIFFERENT repository. Take it
152
+ as your starting draft rather than starting from a blank page: keep what carries over, change what
153
+ this repository needs. Everything you submit must be legal for THIS job as described above — its
154
+ paths, scopes, verbs, bounds and check names. Output the finished plan as the single JSON object
155
+ required above, and nothing else.
156
+
157
+ ${JSON.stringify(plan, null, 2)}`;
158
+ }
159
+
160
+ /**
161
+ * Files named in an exit gap that the step's worker never wrote — the mechanical
162
+ * fact behind the prose-prohibition trap (u-msdpuaej, 2026-08-03): a drafter wrote
163
+ * "do not modify any file outside X" into a step whose exit check judges the whole
164
+ * goal, the worker obeyed, and the replanner — handed the gap AND the stall note —
165
+ * rebuilt the same shape, because inferring "the exit reds on files this step never
166
+ * touched" is exactly the semantic leap that fails (F38: mechanical gaps convert,
167
+ * semantic ones stall; 3/3 across the mailbox class). This computes that fact from
168
+ * the two books that already exist: path-like tokens in the gap text (extension
169
+ * required — a bare `step/two` is not a file claim) suffix-matched against the F32
170
+ * write audit's absolute paths. Advisory-only and fail-safe: extraction that finds
171
+ * nothing yields no line, never a guess.
172
+ *
173
+ * @param {string | undefined} gap the step's last exit gap text
174
+ * @param {string[] | undefined} writes the step's cumulative write-audit paths
175
+ * @returns {string[]} gap-named files with no matching write, in gap order
176
+ */
177
+ export function gapFilesNeverWritten(gap, writes) {
178
+ if (typeof gap !== 'string' || gap === '') return [];
179
+ const tokens = gap.match(/[\w.-]+(?:\/[\w.-]+)+/g) ?? [];
180
+ const seen = new Set();
181
+ const out = [];
182
+ for (const raw of tokens) {
183
+ const p = raw.replace(/^\.\//, '');
184
+ if (!/\.[A-Za-z0-9]+$/.test(p.split('/').at(-1) ?? '')) continue;
185
+ if (seen.has(p)) continue;
186
+ seen.add(p);
187
+ const written = (writes ?? []).some((w) => {
188
+ const ws = String(w);
189
+ return ws === p || ws.endsWith(`/${p}`) || p.endsWith(`/${ws}`);
190
+ });
191
+ if (!written) out.push(p);
192
+ }
193
+ return out;
194
+ }
195
+
63
196
  /**
64
197
  * The plan-drafting prompt: a schema DESCRIPTION built from the live validator
65
198
  * menus — never a copyable example (the drafter must author, not echo; the
@@ -67,10 +200,50 @@ const MONEY_MIN = 0.001;
67
200
  * arbiter territory the planner never sees.
68
201
  * @param {any} job @param {string} scoutBlob @param {any[]|null} reds
69
202
  * @param {number} maxStepRounds @param {string|null} failure replan context
203
+ * @param {string[]|undefined} scopes the offered `tree-changed` menu (`legalScopes`).
204
+ * Choose-don't-describe (§4): the agent picks a value, never authors a glob and
205
+ * guesses its shape. Omitted, it derives from the signed fence — the SAME
206
+ * fail-closed derivation `validatePlan` uses, so prompt and validator can never
207
+ * disagree about what was offered.
208
+ * @param {{ balanceUsd?: number|null, remainingMs?: number|null, progress?: string }} [materials]
209
+ * what the run has LEFT (T/A). Omitted → no materials block at all, which is the
210
+ * pre-T behaviour and keeps every existing caller byte-identical.
211
+ * @param {object|null} [startingDraft] Layer 3 (D4) — a loaded bridge's plan, handed over
212
+ * as the draft to TWEAK. Omitted/null is the COLD path and renders byte-identically to
213
+ * the pre-Layer-3 prompt: the block is additive, never surgery on the prompt's interior,
214
+ * so the two paths cannot drift (the F47 works-both-ways rule). Only the runner passes
215
+ * it, and only for the FIRST draft phase — a replan drafts from the run's own state.
216
+ * @param {{ seedRed?: string[], priorChecks?: string[] }} [checkFacts] the shape-lottery
217
+ * gate rules, STATED as well as enforced (the mailbox precedent: a rule only the
218
+ * validator knows costs a redraft per draft). `seedRed`: preflight-red check names —
219
+ * Rule A-v2's law (final write step only, framed wide). `priorChecks`: the predecessor
220
+ * plan's carried checks, replan phase only — Rule B's law (keep every one). Empty/omitted
221
+ * renders byte-identically to the pre-rules prompt.
70
222
  */
71
- function planPrompt(job, scoutBlob, reds, maxStepRounds, failure) {
223
+ export function planPrompt(job, scoutBlob, reds, maxStepRounds, failure, scopes, materials, startingDraft = null, checkFacts = {}) {
224
+ const scopeMenu = Array.isArray(scopes) && scopes.length ? scopes : legalScopes(job.writeScope ?? []);
72
225
  const ceiling = Array.isArray(job.tools) ? job.tools : [...TOOL_MENU];
73
- const checkNames = (job.checks ?? []).map((/** @type {any} */ c) => c.name);
226
+ // The shape-lottery laws, stated where the check menu is offered (the mailbox
227
+ // precedent: a law only the validator knows costs a redraft per draft). Both
228
+ // render only when their FACT arrived — empty facts keep the prompt
229
+ // byte-identical to the pre-rules render.
230
+ const seedRedLaw = Array.isArray(checkFacts.seedRed) && checkFacts.seedRed.length
231
+ ? `\n Check(s) RED at seed right now: ${JSON.stringify(checkFacts.seedRed)}. A failing check
232
+ is the goal itself, not a milestone: it may ONLY appear on your plan's FINAL write step.
233
+ Make that step wide — its action covers the whole goal, free to edit any file it reports,
234
+ iterating until the check passes. A failing check on any earlier step is rejected.`
235
+ : '';
236
+ const priorChecksLaw = Array.isArray(checkFacts.priorChecks) && checkFacts.priorChecks.length
237
+ ? `\n Your previous plan carried these check-passes exits: ${JSON.stringify(checkFacts.priorChecks)}.
238
+ Keep every one of them in this new plan — a redraft that drops one of them is rejected:
239
+ the other exit forms verify form only, and a form-only green is unearned.`
240
+ : '';
241
+ // W4: the menu comes off the STAGED close — the same one derivation `runPlan`
242
+ // executes and `validatePlan` accepts against. Reading `job.close` raw here left
243
+ // the legacy object form offering nothing while the runner preflighted a stage.
244
+ // (`?? []`: a close naming no command stages nothing, so it offers nothing)
245
+ const closeStages = stageClose(job.close) ?? [];
246
+ const checkNames = checkMenu(closeStages).map((m) => m.name);
74
247
  const doc = `DRAFT-PLAN
75
248
  You are planning how to accomplish a goal in a repository, as an ordered list of bounded
76
249
  steps (schema "plan-v1"). The plan is pure declarative JSON validated by a strict schema;
@@ -81,31 +254,61 @@ Shape: { "schema": "plan-v1", "steps": [ ... 1..${MAX_PLAN_STEPS} steps ... ] }
81
254
  strictly in array order. Each step (no other fields exist):
82
255
  - "id": kebab-case slug, unique
83
256
  - "action": the step's task, precise enough for a worker that sees ONLY this step
84
- - "tools": non-empty unique subset of ${JSON.stringify(ceiling)} (write/edit are the write-class verbs)
257
+ - "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)
85
258
  - "rounds": integer 1..${maxStepRounds} — the step's per-attempt tool-round bound
86
259
  - "target": the step's deliverable path (REQUIRED when tools include write/edit), inside ${JSON.stringify(job.writeScope)}
260
+ - "model" (optional): ${STEP_MODELS.join(' | ')} — a cheaper tier for mechanical steps; omit for the default
261
+ - "scope" (optional): narrow this step's WRITE fence — copy one value from the offered scopes below.
262
+ A narrowed "scope" is the ONLY ground this step can write: its "target" must sit
263
+ inside it, and a "tree-changed" exit's scope must not be disjoint from it (one that
264
+ CONTAINS it is fine) — otherwise every write the step could make is denied, or the
265
+ exit can never pass, on every attempt.
87
266
  - "exit": 1..${MAX_EXITS_PER_STEP} form checks that ALL must pass (AND), each one of:
88
267
  {"type":"artifact-written","path":"...","pattern":"optional regex"}
89
- {"type":"tree-changed","scope":"a scope inside ${JSON.stringify(job.writeScope)}"}
268
+ {"type":"tree-changed","scope":"<copy one value from the offered scopes below>"}
90
269
  {"type":"json-valid","path":"..."}
91
- {"type":"check-passes","name":"one of ${JSON.stringify(checkNames)}"}
270
+ {"type":"check-passes","name":"<copy one name from the check menu below>"}
271
+ Check menu — a check-passes "name" is exactly one of ${JSON.stringify(checkNames)}; copy
272
+ it character for character. No other name exists, and you cannot add one.
92
273
  A check-passes on a write-granted step MUST be paired with a tree-changed exit
93
274
  (the repository starts green — a lone check would pass on the untouched tree).
275
+ A check-passes may ONLY appear on a step whose tools include write or edit: a
276
+ failing check is reported back to THAT step's worker for another attempt, so
277
+ the step must be able to act on the report. Do not plan a separate read-only
278
+ "verify" step — attach the check to the step that does the fixing; the final
279
+ verification after your plan is not yours to author.
280
+ A check judges the WHOLE goal, never just this step's deliverable: its report
281
+ can name files beyond the step's target. A step carrying a check-passes exit
282
+ must therefore be free to edit every file the check can report on — never
283
+ write an action that forbids editing other in-scope files (e.g. "do not
284
+ modify any file outside X"): a step ordered to leave a file alone while its
285
+ exit reds on that file can never finish, on any attempt.${seedRedLaw}${priorChecksLaw}
94
286
  Reference checks by NAME only; you cannot author or modify one.
95
287
 
288
+ Offered "scope" values for tree-changed — copy ONE of these exactly, character for
289
+ character. No other value is accepted, and patterns of your own (like "src/*.js")
290
+ are not accepted:
291
+ ${scopeMenu.map((s) => ` ${JSON.stringify(s)}`).join('\n')}
292
+
96
293
  Goal:
97
294
  ${job.goal}
98
-
295
+ ${materialsBlock(materials)}
99
296
  Repository survey (from a read-only scout):
100
297
  ${scoutBlob || '(no scout notes)'}`;
101
298
  let p = doc;
299
+ // BEFORE the failure/reds appendices, so the last word to a redrafting planner stays
300
+ // the validator's "fix every red". The execution probe injected at the provider seam
301
+ // and could only append at the very end; it recorded that ordering as its own known
302
+ // wart. Owning the prompt is what lets it be fixed — and on the first draft (no
303
+ // failure, no reds) the render is byte-identical to the probe's.
304
+ if (startingDraft) p += startingDraftBlock(closeStages.length, startingDraft);
102
305
  if (failure) p += `\n\nWhat happened when the previous plan ran:\n${failure}\nPlan differently — a repeat of the same steps will fail the same way.`;
103
306
  if (reds) p += `\n\nYour previous plan was REJECTED with these reds (code:path):\n${JSON.stringify(reds)}\nFix every red. Output ONLY the corrected JSON object.`;
104
307
  return p;
105
308
  }
106
309
 
107
310
  /**
108
- * Execute a validateJob-GREEN plan-shape job (goal/verdictType/close/checks[]).
311
+ * Execute a validateJob-GREEN plan-shape job (goal/verdictType/close).
109
312
  * Called by runJob after the approval gate, validation, and the smoke — this
110
313
  * function owns the plan flow only; the caller owns the job-end record and the
111
314
  * one ledger (every provider round is emitted here as `worker-round`, which
@@ -126,9 +329,25 @@ ${scoutBlob || '(no scout notes)'}`;
126
329
  * @param {() => number} opts.remainingUsd the one wallet: what is left of the signed budget right now
127
330
  * @param {() => boolean} [opts.isUnpriced] has any round come back with a null cost? (F6) — the
128
331
  * plan flow bails IN-FLIGHT on the first unpriced round instead of burning the whole plan
129
- * @param {number} [opts.capRuns] shell-owned per-step attempt cap
332
+ * @param {(tier: string) => any} [opts.providerFor] P: per-step model-tier factory
333
+ * (tier ∈ STEP_MODELS → a provider). A plan naming a tier with no factory supplied is an
334
+ * interpreter-red STOP — never silently run the default tier as if the choice was honoured.
335
+ * @param {number} [opts.capRuns] shell-owned iteration cap, RETIRED as a governor
336
+ * (v1.46 §4). It stopped bounding a plan step when the strike ladder landed, and it
337
+ * stopped bounding the close-fix loop when the close trend rule did. It survives as
338
+ * that loop's fallback for the ONE case the trend cannot read — a close whose output
339
+ * carries no number at all — because a governor that cannot see the variable must
340
+ * not be the governor, and the honest fallback is the cruder bound it replaced
341
+ * rather than "unbounded". It lifts the moment a stage reports a comparable number.
342
+ * @param {number} [opts.strikeLimit] shell-owned STRIKE ceiling for a plan step's
343
+ * ladder (src/ladder.js). Arbiter territory exactly as the count it replaced was —
344
+ * the runner sets it, the plan cannot express it, and no step may tighten or raise
345
+ * it.
130
346
  * @param {number} [opts.closeTimeoutMs] close/check wall-clock cap (shell territory)
131
347
  * @param {number} [opts.maxStepRounds] the shell's per-step rounds ceiling (validatePlan's bound)
348
+ * @param {number} [opts.scoutRounds] the read-only survey's round bound (F59: the LAST round is
349
+ * reserved — a scout that spends every round on tools gets one toolless round to write its
350
+ * survey, because the bound halts it mid-tool-use and text is its only deliverable)
132
351
  * @param {boolean} [opts.layerRoot=false] Layer R — the within-run ratchet (src/root.js),
133
352
  * scoped PER STEP's ralph loop (each micro-wheel is the Layer-1 atom). Shell-assembled from
134
353
  * the step's own books: per-attempt write-sets from the F32 workerWrites audit (teed for
@@ -138,13 +357,86 @@ ${scoutBlob || '(no scout notes)'}`;
138
357
  * first plan-flow job to emit `root-injected` runs the pre-registered ON-vs-OFF acceptance
139
358
  * read (the Layer R default-flip, LAYERS.md ⚠). Excluded on native (clipipe): the native
140
359
  * worker has no onToolResult seam, so the tee cannot settle and same-path rewrites are blind.
360
+ * @param {unknown} [opts.bridge] Layer 3 (D3/D4) — a bridge-v1 registry entry to REUSE as the
361
+ * drafter's starting point. Absent is the COLD path and is byte-identical to the pre-Layer-3
362
+ * flow. Present, the D2-split LOAD GATE runs at the door, before the close precheck and
363
+ * before any token: on a pass the newest version's plan (the run-as-executed artifact)
364
+ * rides into the FIRST draft prompt only; on a fail the run returns the distinct terminal
365
+ * `recipe-stale` having spent nothing. Selecting the entry — the listing, the pin, the
366
+ * LLM's pick — is the CALLER's, and so is falling back to cold: a silent automatic
367
+ * fall-back would spend a run's budget on a decision nobody made.
368
+ * @param {number} [opts.priorWallMs=0] RESUME (module C) — wall time a PREVIOUS, killed
369
+ * attempt of this same try already consumed, folded into the run clock (see
370
+ * `createClock`'s `priorElapsedMs`). The SIGNED `maxWallMs` is never rewritten: it is in
371
+ * the spec hash, and a resume that edited it would need a signature nobody typed. The
372
+ * restarted attempt simply starts already partly spent, so a kill can never buy a fresh
373
+ * allotment of the operator's wall.
374
+ * @param {any} [opts.resumeSeed] RESUME (module C v2) — the FINEST honest checkpoint of a
375
+ * KILLED run of this same try, read off its own spine by `readResume` (src/reuse.js) and
376
+ * by nothing else: `{ phase, plan, completedSteps: [{id, seq, by}] }`. hamr's ruling is
377
+ * the whole contract — *"it should allow resume and start last step instead from the
378
+ * beginning, why would i want to waste more money on something i already started"* — so
379
+ * with a seed this flow re-pays for NO paid unit the dead run already bought:
380
+ *
381
+ * - the SCOUT does not run (`scout-skipped`, never silence). Its survey is not on the
382
+ * spine (only its byte count is), so the ONE consequence is stated rather than
383
+ * discovered: a replan after a resume drafts from an empty survey plus the failure
384
+ * brief. Re-scouting to refill it would re-pay the exact call this exists to save.
385
+ * - the PLAN is not re-drafted. It is reloaded from the seed and RE-VALIDATED against
386
+ * the current signed spec; a plan that no longer validates is `plan-red` by name
387
+ * (`resume-plan-red`), never run. It is then emitted as this leg's own
388
+ * `plan-accepted` (`phase: 'resume'`) — R1 mints a bridge version from the plan AS
389
+ * EXECUTED, read back off the spine, so a leg that greens without one would mint
390
+ * nothing.
391
+ * - a step whose exit was already SATISFIED is skipped, in prefix order, each with its
392
+ * own `step-skipped` record naming the event that proves it. Never a fake
393
+ * step-start/step-end pair, and never silence: a reader must be able to tell skipped
394
+ * from run (the resume-to-cap invariant).
395
+ *
396
+ * The worker's conversation is deliberately NOT replayed — a transcript is not a
397
+ * checkpoint, and every attempt is fresh by the loop's own design. Absent is the
398
+ * ordinary path and is byte-identical to a run that was never killed.
399
+ * @param {{stage?: string|null, value?: number|null}[]} [opts.resumeGrades] RESUME — the
400
+ * close GRADES the dead leg recorded, oldest first (`readResume`'s `restart.grades`).
401
+ * A resumed leg is the same run continuing, so its halt readouts must judge the whole
402
+ * chain: without this the trend restarts at this leg's first close and can report
403
+ * `flat` — "revise the goal" — on a run that was converging when its allowance ran
404
+ * out. Baselines only (src/trend.js's THE SEED): it spends none of this leg's own
405
+ * bounds and mints none of its strikes. Empty/omitted is the cold path.
406
+ * @param {() => boolean} [opts.spendComplete] is the run's spend EXACT, or a floor? The
407
+ * ledger one level up owns the answer (unpriced rounds, a self-healed stall, a mid-call
408
+ * cut, an inherited resume floor); the money-halt readout states it beside the remaining
409
+ * it quotes, because `budget − floor` is a CEILING and printing it bare reads as exact
410
+ * (F6). Defaults to exact, which is what every pre-record caller was implicitly saying.
411
+ * @param {() => number} [opts.now] the wall clock's time source, injected. The real clock is the
412
+ * default; a caller supplies this to drive time deterministically (the same seam `createClock`
413
+ * already exposes — a run's terminal cannot otherwise be exercised without waiting out a cap
414
+ * whose floor is one close timeout).
141
415
  * @returns {Promise<string>} 'green' | 'already-green' | 'escalated' | 'plan-red' |
142
- * 'check-red' | 'close-red' | 'close-unsupported' | 'pricing-red' | 'cap-halt' |
143
- * 'provider-red' | 'interpreter-red' | `step-red:<id>`
416
+ * 'check-red' | 'close-red' | 'close-unsupported' | 'recipe-stale' | 'pricing-red' |
417
+ * 'cap-halt' | 'wall-halt' | 'provider-red' | 'interpreter-red' | 'step-stalled' |
418
+ * `step-red:<id>`
144
419
  */
145
- export async function runPlan(job, { workdir, provider, nativeProvider, emit, remainingUsd, isUnpriced = () => false, capRuns = 3, closeTimeoutMs, maxStepRounds = 40, layerRoot = false }) {
420
+ export async function runPlan(job, { workdir, provider, nativeProvider, providerFor, emit, remainingUsd, isUnpriced = () => false, spendComplete = () => true, capRuns = 3, strikeLimit = STRIKE_LIMIT, closeTimeoutMs, maxStepRounds = 40, layerRoot = false, scoutRounds = SCOUT_ROUNDS, bridge = null, now, priorWallMs = 0, resumeSeed = null, resumeGrades = [] }) {
146
421
  workdir = resolve(workdir);
147
- const scrub = (/** @type {string} */ s) => redact(s, { patterns: SECRET_PATTERNS });
422
+ // ONE spelling of the redaction, housed next to the inventory it reads
423
+ // (src/validate.js) — the same helper the isolate verbs scrub the litectx store
424
+ // with. Two hand-spelled copies of "what a secret looks like" is exactly how
425
+ // detection and redaction drift apart.
426
+ const scrub = redactSecrets;
427
+ // The ctx verbs' spine channel, scrubbed at the WIRING. Every field a ctx-tool
428
+ // event carries is MODEL-CHOSEN text — a recall query, a stash id, a symbol, a
429
+ // path the worker spelled — and the spine is append-only: a log that captures a
430
+ // key captures it forever. Scrubbing the emitter (once, at construction) rather
431
+ // than the seventeen call sites is what makes the leak inexpressible: a new ctx
432
+ // verb cannot forget it. Same ONE inventory (SECRET_PATTERNS) the close output
433
+ // is scrubbed with — never a second spelling of what a secret looks like.
434
+ // Shallow by shape: the events are flat records of strings, string arrays
435
+ // (`paths`) and numbers, and numbers pass through untouched.
436
+ const emitCtx = (/** @type {string} */ type, /** @type {any} */ data) => emit(type, Object.fromEntries(
437
+ Object.entries(data ?? {}).map(([k, v]) => [k, typeof v === 'string' ? scrub(v)
438
+ : (Array.isArray(v) ? v.map((x) => (typeof x === 'string' ? scrub(x) : x)) : v)]),
439
+ ));
148
440
 
149
441
  // Which worker surface? `clipipe-subscription` drives tools NATIVELY (the CLI
150
442
  // owns the turn cycle, BA-16); every other provider runs the Loop. The close,
@@ -158,10 +450,17 @@ export async function runPlan(job, { workdir, provider, nativeProvider, emit, re
158
450
  // hard-class), and a gold close carries no `cmd`; running `close.cmd.trim()`
159
451
  // on it would TypeError out of runJob with NO job-end (the spine would dangle,
160
452
  // no spend recorded). Refuse a non-predicate close cleanly, before any tokens.
161
- if (job.close.type !== 'predicate') {
453
+ // A STAGED close (PRD v1.28) is an ordered list of command stages and is the
454
+ // go-forward shape; the object form survives only for the declared-but-locked
455
+ // verdict classes (gold/rubric/hitl), which name no command to run.
456
+ // W4: the staging itself lives in ONE place (`stageClose`), shared with
457
+ // `planPrompt` and `validatePlan` — a second copy here is exactly how the
458
+ // runner came to execute a stage neither of the other two could see.
459
+ const stagedClose = stageClose(job.close);
460
+ if (!stagedClose) {
162
461
  emit('escalation', {
163
462
  category: 'close-unsupported', decisionReady: true,
164
- decision: `The job's close is a ${job.close.type} close — the plan flow executes a predicate close only (a command whose exit code is truth).`,
463
+ 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).`,
165
464
  options: ['restate the close as a predicate', 'wait for the verdict-classes rung'],
166
465
  });
167
466
  return 'close-unsupported';
@@ -181,12 +480,237 @@ export async function runPlan(job, { workdir, provider, nativeProvider, emit, re
181
480
  return 'interpreter-red';
182
481
  }
183
482
 
184
- const closeArgv = job.close.cmd.trim().split(/\s+/);
185
- const closeOpts = { timeoutMs: closeTimeoutMs, cwd: workdir, expect: job.close.expect, judged: job.close.judged, gapKeep: job.close.gapKeep };
483
+ // ── THE BRIDGE LOAD GATE (Layer 3, D2 as SPLIT by the 2026-08-01 addendum).
484
+ // Asked AT THE DOOR: *"is this the right KIND of recipe?"* — verdict type, close-stage
485
+ // kinds, verbs within the signed menu, and NOTHING instance-bound (paths, scopes and
486
+ // targets are expected to be yesterday's bricks; the full validatePlan judges the
487
+ // TWEAKED draft at draft time, D4).
488
+ //
489
+ // It sits FIRST among the things that cost anything, and above the clock and the close
490
+ // precheck deliberately: the gate is $0 and deterministic while a precheck is real wall
491
+ // time (four stages at the shipped timeout is an hour). It sits BELOW the two guards
492
+ // above it because those say the JOB cannot run at all, which outranks "this recipe does
493
+ // not fit". Consequence, named rather than discovered later: a refused run never learns
494
+ // whether the tree was already green — the caller's cold rerun does.
495
+ //
496
+ // The entry is re-validated here even though the caller is contracted to pass a valid
497
+ // one. Not defensive decoration: the very next thing this code does is reach into
498
+ // `versions.at(-1).plan`, and a half-written file that reached a reader anyway must come
499
+ // back as named reds, never as a TypeError out of the runner (`loadRegistry` already
500
+ // skips-and-reports for the same reason).
501
+ let startingDraft = null;
502
+ if (bridge != null) {
503
+ const bv = validateBridge(bridge);
504
+ const gate = bv.ok ? loadGate(bv.bridge, job) : { ok: false, reds: bv.reds };
505
+ const name = typeof (/** @type {any} */ (bridge)?.name) === 'string' ? /** @type {any} */ (bridge).name : null;
506
+ if (!gate.ok) {
507
+ emit('bridge-gate', { outcome: 'recipe-stale', name, reds: gate.reds });
508
+ emit('escalation', {
509
+ category: 'recipe-stale', decisionReady: true,
510
+ decision: `The selected workflow${name ? ` "${name}"` : ''} is not the right KIND of recipe for this job, so it was refused before anything was spent. Reusing it is not a decision this run can make for you.`,
511
+ options: ['rerun COLD (draft a new plan from scratch)', 'select a different workflow from the registry', 'change the job spec so the shapes match (a spec edit — the new hash needs re-approval)'],
512
+ detail: gate.reds.map((/** @type {any} */ r) => `${r.code}:${r.path}${r.detail ? ` — ${r.detail}` : ''}`).join('\n'),
513
+ });
514
+ return 'recipe-stale';
515
+ }
516
+ // `versions` is oldest-first and non-empty by the validator's entry bar, so the newest
517
+ // green's plan-as-executed is the one that inherits (R1) — never the founding one.
518
+ const newest = bv.bridge.versions.at(-1);
519
+ startingDraft = newest.plan;
520
+ emit('bridge-loaded', { name: bv.bridge.name, versions: bv.bridge.versions.length, runid: newest.runid });
521
+ }
522
+
523
+ // T (PRD v1.27/v1.29) — the run's wall clock, STARTED HERE, before the close
524
+ // precheck. The precheck is real wall time and F62 measured close/check gaps as
525
+ // high as 328s on an archived run: a clock created after it would under-report
526
+ // elapsed by that much and hand the planner a balance the run does not have. Time
527
+ // the operator's own instruments too, or the budget is measured against a
528
+ // different run than the one that happened.
529
+ //
530
+ // `job.maxWallMs` is operator input with budgetUsd's status and has NO DEFAULT
531
+ // (hamr's ruling): absent means honestly time-unbounded, and the record says so
532
+ // rather than leaving a reader to infer it. Both numbers are reported — addendum 1
533
+ // measured that a deadline is only readable BETWEEN rounds, so enforcement is
534
+ // requested + the close's own worst case and quoting one number would be F6 in a
535
+ // time coat.
536
+ //
537
+ // W5 — that worst case is per STAGE. `runStages` hands each stage the full
538
+ // `closeTimeoutMs`, so a run whose deadline trips just before a 4-stage close can
539
+ // overshoot by four of them, and the clock is told the count rather than assuming
540
+ // one. `stagedClose` is the SAME staging the runner executes (never a second
541
+ // count), and it is non-null here by the close-unsupported guard above — a close
542
+ // that names no command escalated before this line, so there is no null to thread.
543
+ //
544
+ // RESUME (module C): `priorWallMs` is time a killed attempt of this same try already
545
+ // consumed. It folds into the clock's start, never into `maxWallMs` — the cap stays the
546
+ // signed one in the record and in the hash, and only the REMAINDER is left to run.
547
+ const clock = createClock({ maxWallMs: job.maxWallMs ?? null, closeStages: stagedClose.length, priorElapsedMs: priorWallMs, ...(now ? { now } : {}) });
548
+ const closeTimeoutForReport = closeTimeoutMs ?? 120_000;
549
+ emit('wall-clock', {
550
+ ...clock.report(closeTimeoutForReport),
551
+ meaning: clock.bounded
552
+ ? 'a between-round deadline; a close already in flight when it trips runs to completion'
553
+ : 'NO time cap was set — time-unbounded by explicit operator choice, never by a default',
554
+ });
555
+
556
+ /**
557
+ * The per-call TIME bounds, in ONE place so the two `loop.run` sites cannot drift
558
+ * (the two-transforms class, F9). bare-agent forwards a run's options straight into
559
+ * `provider.generate` (loop.js:732), so both knobs land on the request:
560
+ *
561
+ * timeoutMs BA-18, IDLE: `req.setTimeout`, reset by every byte. Bounds a silent
562
+ * socket and nothing else.
563
+ * deadlineMs BA-19, TOTAL: a plain timer no byte resets. This is the bound for
564
+ * the F66 shape — a call that trickles forever while the run's own
565
+ * wall passes (274 minutes, with the idle bound correctly armed).
566
+ *
567
+ * `deadlineMs` is OMITTED, never zeroed, when the run is unbounded: the operator set
568
+ * no wall, so there is no ceiling to derive, and inventing one is the silent second
569
+ * ceiling F45 exists to forbid. Omission is also the honest spelling upstream — an
570
+ * absent knob inherits the provider's own default (disabled), while an explicitly
571
+ * garbage value THROWS there (`resolveTimeoutMs`, provider-http.js:53).
572
+ *
573
+ * The NATIVE (clipipe) worker does not call this and never did: the CLI owns the
574
+ * transport, there is no `ClientRequest` for either timer to arm, and that path
575
+ * passes neither bound today. Stated rather than assumed — an unbounded native call
576
+ * is bounded by the F66 stall fuse and the outside watchdog, not by this.
577
+ */
578
+ const callBounds = () => {
579
+ const deadlineMs = clock.callDeadlineMs();
580
+ return { timeoutMs: clock.callTimeoutMs(), ...(deadlineMs === null ? {} : { deadlineMs }) };
581
+ };
582
+
583
+ /**
584
+ * F64 — ONE categoriser for a throw out of a provider call, so the seams cannot
585
+ * drift. A `HaltError` is the budget gate. The run's OWN deadline coming back as
586
+ * bare-agent's `TimeoutError` (`callTimeoutMs()` is derived from the wall) is a
587
+ * GOVERNANCE stop and rides out as `wall-halt` — filing it `provider-red` would
588
+ * discard the row as a casualty (never evidence, F45/F48) and hand the human a
589
+ * transport-debugging option list for a run that simply ran out of time. A
590
+ * thrower that named its own category keeps it (the standing typed-attribution
591
+ * rule); only an UNNAMED throw is classified here.
592
+ * @param {any} e
593
+ * @returns {{ err: CategorizedError, category: string }} the same throw, stamped,
594
+ * plus the category as a plain string (so a caller reading it never has to
595
+ * re-widen an optional field back to a value)
596
+ */
597
+ const categorize = (e) => {
598
+ const err = /** @type {CategorizedError} */ (e);
599
+ const category = e instanceof HaltError ? 'cap-halt'
600
+ : (err.category ?? (isWallTimeout(err, clock) ? 'wall-halt' : 'provider-red'));
601
+ err.category = category;
602
+ return { err, category };
603
+ };
604
+
605
+ /** the run-level TIME record, one shape wherever the wall stops the run.
606
+ * `cutMidCall` splits the two readings: `true` = the deadline landed INSIDE a
607
+ * provider call (F64, seen as its timeout), `false` = read between steps, the
608
+ * ordinary path. Both are the same stop; conflating them would hide which
609
+ * instrument saw it. */
610
+ const emitWallHalt = (/** @type {object} */ extra) => emit('wall-halt', {
611
+ ...clock.report(closeTimeoutForReport),
612
+ meaning: 'not under cap — not "can\'t"',
613
+ cutMidCall: false,
614
+ ...extra,
615
+ });
616
+ /** the wall's decision-ready levers, BYTE-IDENTICAL to ralph's own `wall-halt`
617
+ * entry (ralph.js DECISIONS). One category must hand a human ONE option list: which
618
+ * site read the clock is an implementation detail, and a shorter list here quietly
619
+ * withheld the second lever (revise the goal) from every stop this file escalates.
620
+ * Both levers are spec edits — a changed spec hash the runner refuses until it is
621
+ * re-approved — which is why each carries its own re-approval note, and why the
622
+ * caller's detail carries the progress trend that says which lever fits. */
623
+ const WALL_OPTIONS = [
624
+ 'raise maxWallMs and rerun (resume-to-cap; a spec edit, so the new hash needs re-approval)',
625
+ 'revise the goal/spec so the work fits the time (same re-approval)',
626
+ 'abandon the task',
627
+ ];
628
+ /** MONEY's levers (PRD v1.46 §2), the same three shapes the wall's are, because a
629
+ * money cut is the same KIND of stop: the run is out of an operator-owned
630
+ * allowance, not out of capability, and the last verdict rendered stands. hamr's
631
+ * ask was "halt and feedback… and needs to be accurate", so the readout that
632
+ * carries these also carries the trend that says WHICH of the first two fits.
633
+ *
634
+ * The first lever is a top-up the OPERATOR performs. `budgetUsd` is in the spec
635
+ * hash, so raising it is a new spec version somebody signs — the library only ever
636
+ * names the lever, and never adjusts a budget itself (the permanent hard line). */
637
+ const MONEY_OPTIONS = [
638
+ 'top up budgetUsd and rerun with --resume (resume-to-cap; a spec edit, so the new hash needs re-approval)',
639
+ 'revise the goal/spec so the work fits the budget (same re-approval)',
640
+ 'abandon the task',
641
+ ];
642
+
643
+ const closeOpts = { timeoutMs: closeTimeoutMs, cwd: workdir };
644
+ /** @type {string|undefined} the stage that rendered the LAST close verdict (Layer R's red-set source) */
645
+ let closeStage;
646
+ /** @type {any} the LAST close verdict itself (W-2). A wall stop KEEPS the grade
647
+ * the arbiter has already rendered rather than re-deriving one: past the deadline
648
+ * the run's answer is whatever the close last said, and nothing after the deadline
649
+ * is allowed to change it (hamr: *"keep the grade we already have and stop"*). */
650
+ let lastCloseVerdict;
651
+ /** PRD v1.46 §2 — the RUN's own close trend, fed by every grade the arbiter
652
+ * renders (the precheck is the seed the work is measured against, so it is the
653
+ * first reading, not a discarded one). Read only by the halt readouts; it decides
654
+ * nothing and bounds nothing — the fix loop builds its OWN reader over its own
655
+ * grades, because "did this run make progress" and "is this loop out of ideas"
656
+ * are two questions and one instrument answering both is how they come to
657
+ * disagree. Per stage by construction (src/trend.js's accuracy law).
658
+ *
659
+ * On a RESUME it is SEEDED with the grades the dead leg recorded, so both halt
660
+ * readouts judge the whole chain rather than this leg alone: a leg that re-grades
661
+ * an unchanged tree is flat on its own evidence while the RUN — the thing the human
662
+ * is deciding about — may have been converging when its allowance ran out. Cold, the
663
+ * seed is empty and this is byte-identical to the pre-resume reader. */
664
+ const runTrend = createTrend({ stageOrder: stagedClose.map((/** @type {any} */ s) => s.name), seed: resumeGrades });
665
+ /** the run-level MONEY record, one shape wherever the wallet stops the run —
666
+ * W-2's `emitWallHalt` in a money coat, and deliberately its mirror image. hamr's
667
+ * ruling for TIME was "keep the grade we already have and stop"; a money cut is
668
+ * the same stop with a different allowance behind it, so it keeps the same grade,
669
+ * states the same kind of trend, and hands over the same shape of lever list.
670
+ * `spentUsd` is NOT re-derived here: runJob's ledger owns that number and states
671
+ * it on `job-end` (F6 — a second, weaker arithmetic for the same figure is how
672
+ * two instruments come to disagree about one run's money). What this record can
673
+ * say exactly is the SIGNED ceiling and what is left against it. */
674
+ const emitMoneyHalt = (/** @type {object} */ extra) => {
675
+ const t = runTrend.verdict();
676
+ return emit('money-halt', {
677
+ meaning: 'not under cap — not "can\'t"',
678
+ budgetUsd: job.budgetUsd,
679
+ remainingUsd: remainingUsd(),
680
+ // …and whether that figure is EXACT. `remainingUsd` is `budget − spent`, so a run
681
+ // whose spend is a FLOOR has a remaining that is a CEILING, and quoting it bare to
682
+ // four decimals reads as the number. The four causes live in the ledger one level
683
+ // up (unpriced, self-healed stall, mid-call cut, an inherited floor), which is why
684
+ // this is READ from there and not re-derived here — a second, weaker arithmetic for
685
+ // the same question is how two instruments come to disagree. `emitWallHalt` has
686
+ // carried its honest bound since W-2; this is the same duty on the money side.
687
+ spendComplete: spendComplete(),
688
+ verdict: lastCloseVerdict?.verdict ?? null,
689
+ ...(lastCloseVerdict?.stage ? { stage: lastCloseVerdict.stage } : {}),
690
+ trend: t.trend,
691
+ motion: t.motion,
692
+ reading: t.reading,
693
+ lever: t.lever,
694
+ series: t.series,
695
+ options: MONEY_OPTIONS,
696
+ ...extra,
697
+ });
698
+ };
699
+ /** the close, as ONE verdict: every stage in order, first red wins */
700
+ const judgeClose = async () => {
701
+ const v = await runStages(stagedClose, scrub, closeOpts);
702
+ closeStage = v.stage;
703
+ lastCloseVerdict = v;
704
+ // Only a RED grade is a reading. A `satisfied` ends the run and a close FAULT
705
+ // rendered no judgment at all (CLOSE_FAULTS) — folding either into the series
706
+ // would put a non-number where the instrument expects a graded one.
707
+ if (v.verdict === 'needs_revision') runTrend.record({ gap: v.gap });
708
+ return v;
709
+ };
186
710
 
187
711
  // ── 0a. close precheck (close-first, F17): already-green is a DISTINCT
188
712
  // record, zero tokens; a forbidden-zone verdict escalates before any spend
189
- const pre = runClose(closeArgv, scrub, closeOpts);
713
+ const pre = await judgeClose();
190
714
  emit('close-precheck', { ...pre });
191
715
  if (pre.verdict === 'satisfied') return 'already-green';
192
716
  const preFault = Object.hasOwn(CLOSE_FAULTS, pre.verdict) ? CLOSE_FAULTS[pre.verdict] : undefined;
@@ -195,16 +719,34 @@ export async function runPlan(job, { workdir, provider, nativeProvider, emit, re
195
719
  return 'close-red';
196
720
  }
197
721
 
198
- // ── 0b. checks preflight ($0, deterministic): every SIGNED check runs once
199
- // before tokens — an unrunnable check would fault mid-plan after real spend
200
- // (a frozen rule without a wired detector is prose; here the detector runs
201
- // first). Red and green are both fine: checks decide nothing.
202
- for (const c of job.checks ?? []) {
203
- const v = runClose(c.cmd.trim().split(/\s+/), scrub, { timeoutMs: closeTimeoutMs, cwd: workdir, expect: c.expect, judged: c.judged, gapKeep: c.gapKeep });
204
- emit('check-preflight', { name: c.name, verdict: v.verdict });
722
+ // ── 0b. check preflight ($0, deterministic): every OFFERABLE stage runs once
723
+ // before tokens — an unrunnable ruler would fault mid-plan after real spend (a
724
+ // frozen rule without a wired detector is prose; here the detector runs
725
+ // first). Red and green are both fine: checks decide nothing and mint nothing.
726
+ // The menu is DERIVED from the close (PRD v1.28) — nobody authored it, so the
727
+ // ruler and the real inspection cannot drift apart.
728
+ const menu = checkMenu(stagedClose);
729
+ /** Rule A-v2's fact: check names RED at preflight (seed-red). A seed-red
730
+ * check is the GOAL, not a guard — validatePlan confines it to the final
731
+ * write step, and the drafting prompt states the same law. Recorded HERE,
732
+ * from the same preflight the spine records, never re-derived mid-run: the
733
+ * rule is defined on the SEED verdicts (a check a step turned green mid-run
734
+ * is still the goal the plan exists to flip).
735
+ * @type {string[]} */
736
+ const seedRed = [];
737
+ emit('check-menu', {
738
+ offered: menu.map((m) => m.name),
739
+ ...(menu.length < stagedClose.length
740
+ ? { 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' }
741
+ : {}),
742
+ });
743
+ for (const m of menu) {
744
+ const v = await runStages(m.run, scrub, closeOpts);
745
+ if (v.verdict === 'needs_revision') seedRed.push(m.name);
746
+ emit('check-preflight', { name: m.name, verdict: v.verdict, ...(m.run.length > 1 ? { chain: m.run.map((s2) => s2.name) } : {}) });
205
747
  const f = Object.hasOwn(CLOSE_FAULTS, v.verdict) ? CLOSE_FAULTS[v.verdict] : undefined;
206
748
  if (f) {
207
- emit('escalation', { category: f.category, decisionReady: true, decision: `Signed check "${c.name}" rendered no judgment at preflight — every plan referencing it would fault mid-run. ${f.decision}`, options: f.options, detail: `${c.name}: ${v.detail ?? ''}` });
749
+ 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 ?? ''}` });
208
750
  return 'check-red';
209
751
  }
210
752
  }
@@ -213,16 +755,47 @@ export async function runPlan(job, { workdir, provider, nativeProvider, emit, re
213
755
  const ceiling = Array.isArray(job.tools) ? job.tools : [...TOOL_MENU];
214
756
  const fencePrefixes = job.writeScope.map((/** @type {string} */ g) => resolve(workdir, globToPrefix(g)));
215
757
  const auditPath = join(workdir, 'gate-audit.jsonl');
216
- const checksByName = new Map((job.checks ?? []).map((/** @type {any} */ c) => [c.name, c]));
758
+ const chainByName = new Map(menu.map((m) => [m.name, m.run]));
759
+ // Choose-don't-describe (§4): the offered `tree-changed` scopes, enumerated from
760
+ // the signed fence plus the directories that actually exist beneath it. ONE menu
761
+ // object feeds both the drafting prompt and the validator, so what was offered
762
+ // and what is accepted can never drift apart. Discovery is best-effort — a
763
+ // missing or unreadable fence directory degrades to the signed entries alone
764
+ // (which `snapshotScope` already handles as an empty snapshot), never a throw.
765
+ const discoveredDirs = job.writeScope.flatMap((/** @type {string} */ g) => {
766
+ const prefix = globToPrefix(g);
767
+ try {
768
+ return readdirSync(join(workdir, prefix), { recursive: true, withFileTypes: true })
769
+ .filter((d) => d.isDirectory())
770
+ .map((d) => `${prefix}/${relative(join(workdir, prefix), join(d.parentPath ?? d.path, d.name))}`);
771
+ } catch { return []; }
772
+ });
773
+ const scopeMenu = legalScopes(job.writeScope, discoveredDirs);
774
+ // No silent caps: MAX_SCOPE_MENU can drop deep directories, and a menu that
775
+ // reads as complete when it is not is the blind-instrument class. Report BOTH
776
+ // counts so a truncation is visible in the record rather than inferred.
777
+ const offerable = legalScopes(job.writeScope, discoveredDirs, Infinity).length;
778
+ emit('scope-menu', {
779
+ offered: scopeMenu,
780
+ ...(offerable > scopeMenu.length
781
+ ? { truncated: true, offerableCount: offerable, cap: MAX_SCOPE_MENU, meaning: 'deep directories were dropped from the menu; they remain reachable via a parent scope' }
782
+ : { truncated: false }),
783
+ });
217
784
 
218
785
  /** the check-passes seam evalExits delegates to: the FULL runClose machinery
219
786
  * per signed check; a forbidden-zone verdict rides out as `fault` by name so
220
787
  * the micro-loop escalates (or F32-routes a crash) instead of faking a gap */
221
788
  const runCheck = async (/** @type {string} */ name) => {
222
- const c = checksByName.get(name);
223
- if (!c) return { pass: false, fault: 'failed', gap: `no signed check named "${name}"` };
224
- const v = runClose(c.cmd.trim().split(/\s+/), scrub, { timeoutMs: closeTimeoutMs, cwd: workdir, expect: c.expect, judged: c.judged, gapKeep: c.gapKeep });
225
- emit('check-run', { name, verdict: v.verdict, ...(v.exitCode !== undefined ? { exitCode: v.exitCode } : {}) });
789
+ const chain = chainByName.get(name);
790
+ // UNREACHABLE through runPlan and deliberately kept: validatePlan reds
791
+ // `check-unknown` against `checkMenu(spec.close)` — the SAME derivation that
792
+ // built this map from the SAME close — so a name that got here was already
793
+ // proven to be on the menu. Defence in depth against a future second
794
+ // derivation, not a live path; the reachable guard is the validator's, and
795
+ // that one is mutation-covered.
796
+ if (!chain) return { pass: false, fault: 'failed', gap: `no offered close stage named "${name}"` };
797
+ const v = await runStages(chain, scrub, closeOpts);
798
+ emit('check-run', { name, verdict: v.verdict, ...(v.stage && v.stage !== name ? { stage: v.stage } : {}), ...(v.exitCode !== undefined ? { exitCode: v.exitCode } : {}) });
226
799
  if (v.verdict === 'satisfied') return { pass: true };
227
800
  if (v.verdict === 'needs_revision') return { pass: false, gap: v.gap };
228
801
  return { pass: false, fault: v.verdict, gap: v.detail };
@@ -239,41 +812,94 @@ export async function runPlan(job, { workdir, provider, nativeProvider, emit, re
239
812
  * denied, the wallet as its budget), granted tools only (the menu IS the
240
813
  * grant), per-attempt round bound via loop.stop() (F20), every round metered
241
814
  * with a phase label (F12).
242
- * @param {{granted: string[], phase: string, attemptRounds: number, attempts: number, writable: boolean, root?: ReturnType<typeof createRoot>|null}} o
815
+ * @param {{granted: string[], phase: string, attemptRounds: number, attempts: number|null, writable: boolean, root?: ReturnType<typeof createRoot>|null, fence?: string[]|null, workerProvider?: any}} o
243
816
  * `root` (Layer R): when present, the worker's write-class actions are teed
244
817
  * for the within-run ratchet — staged before the gate decides, discarded on
245
818
  * deny/halt, settled to landed-or-not after execution (the two axes, F43/F7).
246
819
  * Wired ONLY on the Loop path: the native session exposes no onToolResult seam.
820
+ *
821
+ * `attempts` is how many iterations this worker will be asked for. `null` is the
822
+ * STRIKE LADDER (src/ladder.js), where that count does not exist by design — see
823
+ * the turn-allowance note below.
247
824
  */
248
- async function mkWorker({ granted, phase, attemptRounds, attempts, writable, root = null }) {
825
+ async function mkWorker({ granted, phase, attemptRounds, attempts, writable, root = null, fence = null, workerProvider = null }) {
826
+ /**
827
+ * The gate's LLM-turn allowance THROUGH iteration `i` — the old expression
828
+ * `attemptRounds * (attempts + 1)` with the iteration count made explicit
829
+ * (`i = attempts` reproduces it exactly, so a bounded worker is unchanged to
830
+ * the number).
831
+ * @param {number} i
832
+ */
833
+ const turnsThrough = (i) => attemptRounds * (i + 1);
834
+ // A pre-multiplied ceiling could only ever pre-pay a KNOWN iteration count, and
835
+ // the strike ladder has none: iterations float under money and the wall. So on
836
+ // that path the allowance is GRANTED PER ITERATION at `setIteration` — the seam
837
+ // that already resets the per-iteration round counter — leaving the belt exactly
838
+ // as tight per iteration as the pre-multiply made it, without inventing a
839
+ // ceiling nobody signed. It is monotonic (`Math.max`), so nothing can lower it.
840
+ //
841
+ // Rebuilding the Gate per iteration was the alternative and was REJECTED: the
842
+ // audit is run_id-scoped, so a fresh gate would reset F32's crash-attribution
843
+ // write set ("files you have written this run") and Layer R's cross-attempt
844
+ // write history to a single iteration — two instruments broken to avoid one
845
+ // addition. The REAL per-iteration bound is unchanged either way: `loop.stop()`
846
+ // at `roundsThisAttempt >= attemptRounds` (the `wasBounded` mechanism).
847
+ const maxTurns = turnsThrough(attempts ?? 1);
249
848
  const gate = new Gate({
250
849
  fs: {
251
- writeScope: writable ? fencePrefixes : [],
850
+ // P: a step's `scope` narrows the fence — the per-step prefixes are always a
851
+ // SUBSET of the signed fence (validator: scope ∈ the same menu tree-changed
852
+ // uses), so this can only tighten, never widen
853
+ writeScope: writable ? (fence ?? fencePrefixes) : [],
252
854
  readScope: [workdir],
253
855
  deny: [auditPath, join(workdir, '.smoke'), join(workdir, '.litectx')],
254
856
  },
255
857
  budget: { maxCostUsd: Math.max(remainingUsd(), 0.0001) },
256
- limits: { maxTurns: attemptRounds * (attempts + 1) },
858
+ limits: { maxTurns },
257
859
  audit: { path: auditPath },
860
+ // The gate audit is the THIRD persistent, append-only channel (beside the
861
+ // spine and the ctx-verb events) and it lives IN THE TREE, so it gets the
862
+ // SAME ONE inventory. bareguard's default-on backstop covers only
863
+ // apiKey/authorization/`Bearer …`/`sk-…` — measured: a `ghp_` token landed
864
+ // in cleartext through every model-authored identifier the action carries
865
+ // (an isolate verb's `id`, a path the worker spelled), and masked under
866
+ // these patterns. Content is already unreachable here (`toolAction` reduces
867
+ // a write to `{bytes}`), but the identifiers are raw model text, and a log
868
+ // that captures a key captures it forever.
869
+ secrets: { patterns: SECRET_PATTERNS },
258
870
  humanChannel: async () => ({ decision: 'terminate' }),
259
871
  });
260
872
  await gate.init();
261
873
  // F32's instrument, run_id-scoped, write AND edit, allow-decision only —
262
- // the same audit read as interpret's (never git status, F45)
263
- const workerWrites = () => {
874
+ // the same audit read as interpret's (never git status, F45). ONE scan and ONE
875
+ // filter behind both readings: a future write-class verb that slipped into one
876
+ // and not the other would make the two instruments disagree about what a write
877
+ // is, which is the exact class the tee's `type`-not-name test exists to close.
878
+ const auditWrites = () => {
879
+ const paths = new Set();
880
+ let records = 0;
264
881
  try {
265
- const paths = new Set();
266
882
  for (const line of readFileSync(auditPath, 'utf8').split('\n')) {
267
883
  if (!line) continue;
268
884
  let rec;
269
885
  try { rec = JSON.parse(line); } catch { continue; }
270
886
  if (rec.run_id === gate.runId && rec.phase === 'gate' && rec.decision === 'allow'
271
887
  && (rec.action?.type === 'write' || rec.action?.type === 'edit')
272
- && typeof rec.action.path === 'string') paths.add(rec.action.path);
888
+ && typeof rec.action.path === 'string') { paths.add(rec.action.path); records += 1; }
273
889
  }
274
- return [...paths];
275
- } catch { return []; }
890
+ } catch { /* no audit yet → nothing written yet */ }
891
+ return { paths: [...paths], records };
276
892
  };
893
+ const workerWrites = () => auditWrites().paths;
894
+ /**
895
+ * The ladder's write signal: the cumulative RECORD count, never the path set.
896
+ * A plan step rewrites its one target every attempt, so the path set is constant
897
+ * after iteration 1 and a set-delta would read every later iteration as idle —
898
+ * the same Finding-3 trap Layer R documents, and it would strike a working
899
+ * worker out on its second attempt. The record count advances on every allowed
900
+ * write-class action, same path or not.
901
+ */
902
+ const writeCount = () => auditWrites().records;
277
903
  // Layer R tee (design record 2026-07-19; created per-step by executeStep).
278
904
  // The translator STAGES write/edit content and snapshots the pre-write hash
279
905
  // BEFORE the gate decides (Finding 6); the policy wrapper DISCARDS on a deny
@@ -342,6 +968,23 @@ export async function runPlan(job, { workdir, provider, nativeProvider, emit, re
342
968
  let roundsThisAttempt = 0;
343
969
  /** @type {number|string|undefined} */
344
970
  let attemptBounded;
971
+ /**
972
+ * Open an iteration: label the rounds, reset the per-iteration round counter,
973
+ * and — on the unbounded ladder only — GRANT this iteration's turn allowance to
974
+ * the gate. Raising `limits.maxTurns` is documented bareguard config read live
975
+ * by `Limits.preCheck()` on every record; the alternative (a fresh Gate per
976
+ * iteration) is the one that breaks instruments, so this is the smaller change.
977
+ * Monotonic by construction: a re-entry can only ever hold the allowance, never
978
+ * lower it. A bounded worker's ceiling is untouched — it was pre-paid in full.
979
+ * @param {number|string} i
980
+ */
981
+ const setIteration = (i) => {
982
+ roundIteration = i;
983
+ roundsThisAttempt = 0;
984
+ if (attempts === null && typeof i === 'number') {
985
+ gate.limits.maxTurns = Math.max(gate.limits.maxTurns, turnsThrough(i));
986
+ }
987
+ };
345
988
  const grantedNames = new Set(granted.map((v) => /** @type {Record<string, string>} */ (TOOL_BY_VERB)[v]));
346
989
  const shell = createShellTools().tools.filter((/** @type {{name: string}} */ t) => grantedNames.has(t.name));
347
990
  // F48: on native, bound shell_read below the CLI's tool-result display cap and hand back a
@@ -362,11 +1005,22 @@ export async function runPlan(job, { workdir, provider, nativeProvider, emit, re
362
1005
  }
363
1006
  }
364
1007
  const ctx = [...CTX_TOOLS].some((t) => grantedNames.has(t))
365
- ? createCtxTools(lc, workdir, emit).filter((t) => grantedNames.has(t.name))
1008
+ ? createCtxTools(lc, workdir, emitCtx).filter((t) => grantedNames.has(t.name))
366
1009
  : [];
367
- if (ctx.length) await lc.index();
1010
+ // LC-3 (litectx 0.31.0): cooperative yield — index() is async but its work is sync CPU,
1011
+ // and the default pass holds THIS event loop (the fuse's timers, the wall clock, the lag
1012
+ // sampler all live here) for its full duration: measured 4.4s at 4.5% liveness on a
1013
+ // 155-file force pass. With yield:true the worst single block measured 189–252ms (n=4),
1014
+ // below anything a second/minute-scale timer can perceive. Store output is byte-identical.
1015
+ if (ctx.length) await lc.index({ yield: true });
368
1016
  const toolDefs = [...shell, ...ctx];
369
- const system = PERSONA_TOOLS + (granted.includes('edit') ? EDIT_STRATEGY : '') + (ctx.length ? RETRIEVAL_STRATEGY : '')
1017
+ // F19 per VERB (P, corrected 2026-07-31): a strategy SENTENCE rides only with
1018
+ // the verb it names. Gating per COMPONENT was close but over-reached — a
1019
+ // worker granted only `impact` must not be steered to tools it lacks, and the
1020
+ // isolate/retrieval paragraphs named other components' verbs outright. The
1021
+ // assembly lives in tools.js beside the prose it composes, and is asserted
1022
+ // byte-identical to the full-component paragraphs on a full grant.
1023
+ const system = PERSONA_TOOLS + strategyFor(granted)
370
1024
  + (native && grantedNames.has(TOOL_BY_VERB.read) ? NATIVE_READ_STRATEGY : '');
371
1025
  /** @param {any} u @returns {{inputTokens: number, outputTokens: number, cacheReadTokens: number, cacheCreationTokens: number}} */
372
1026
  const usageOf = (u) => ({ inputTokens: u?.inputTokens ?? 0, outputTokens: u?.outputTokens ?? 0, cacheReadTokens: u?.cacheReadTokens ?? 0, cacheCreationTokens: u?.cacheCreationTokens ?? 0 });
@@ -400,14 +1054,16 @@ export async function runPlan(job, { workdir, provider, nativeProvider, emit, re
400
1054
  const provider2 = /** @type {any} */ (nativeProvider)({ policy, onTurn: nativeMetered, maxTurns: attemptRounds, hasTools: true });
401
1055
  const loop = new Loop({ provider: provider2, system }); // no Loop policy / no cacheMessages: the CLI owns the transcript
402
1056
  /** @param {string} prompt @param {typeof toolDefs} [defs] */
1057
+ /** F59: unavailable on native — a toolless native session fires no onTurn and
1058
+ * reports NO cost (F48/BA-16 live-verified), so a recovery round there would be
1059
+ * unmetered spend. The caller treats a null return as "no recovery possible". */
1060
+ const askFrom = async () => null;
403
1061
  const ask = async (prompt, defs = toolDefs) => {
404
1062
  let r;
405
1063
  try {
406
1064
  r = await loop.run([{ role: 'user', content: prompt }], defs, { maxTokens: 32000 });
407
1065
  } catch (e) {
408
- const err = /** @type {CategorizedError} */ (e);
409
- err.category = e instanceof HaltError ? 'cap-halt' : (err.category ?? 'provider-red');
410
- throw err;
1066
+ throw categorize(e).err;
411
1067
  }
412
1068
  // a maxTurns session is a BOUNDED attempt, not an escalation — the same
413
1069
  // role loop.stop() plays on the Loop path: judge the partial work and
@@ -429,7 +1085,7 @@ export async function runPlan(job, { workdir, provider, nativeProvider, emit, re
429
1085
  }
430
1086
  return r;
431
1087
  };
432
- return { ask, workerWrites, setIteration: (/** @type {number|string} */ i) => { roundIteration = i; roundsThisAttempt = 0; }, wasBounded: () => attemptBounded };
1088
+ return { ask, askFrom, workerWrites, writeCount, setIteration, wasBounded: () => attemptBounded };
433
1089
  }
434
1090
 
435
1091
  // ── LOOP path: the injected provider (anthropic-api and every other
@@ -440,36 +1096,124 @@ export async function runPlan(job, { workdir, provider, nativeProvider, emit, re
440
1096
  // onLlmResult meters exactly like an API round, so the drafter's spend is
441
1097
  // never invisible (F6/F44). The gate policy is wired but idle (no tools).
442
1098
  const loopProvider = native
443
- ? /** @type {any} */ (nativeProvider)({ policy, maxTurns: attemptRounds * (attempts + 1), hasTools: false })
444
- : provider;
445
- /** @param {{costUsd?: number|null, pricing?: string|null, usage?: any, kind?: string}} arg */
446
- const metered = async (arg) => {
447
- emit('worker-round', {
448
- phase, iteration: roundIteration, kind: arg?.kind ?? 'turn',
449
- costUsd: arg?.costUsd ?? null, pricing: arg?.pricing ?? null,
450
- tokens: (arg?.usage?.inputTokens ?? 0) + (arg?.usage?.outputTokens ?? 0),
451
- usage: usageOf(arg?.usage),
452
- });
453
- if ((arg?.kind ?? 'turn') === 'turn') {
454
- roundsThisAttempt += 1;
455
- if (roundsThisAttempt >= attemptRounds) {
456
- attemptBounded = roundIteration;
457
- emit('attempt-bounded', { phase, iteration: roundIteration, rounds: roundsThisAttempt, cap: attemptRounds });
458
- loop.stop();
1099
+ ? /** @type {any} */ (nativeProvider)({ policy, maxTurns, hasTools: false })
1100
+ : (workerProvider ?? provider);
1101
+ // F66 — the stall watchdog. bare-agent's `timeoutMs` bounds socket INACTIVITY,
1102
+ // not call duration (provider-http.js `req.setTimeout` resets on every byte),
1103
+ // so a connection that stays alive while producing nothing is invisible to it:
1104
+ // U run ms3197n8 hung 274 minutes inside ONE call and died ECONNRESET with the
1105
+ // bound correctly passed. The heartbeat here is a completed ROUND, which is the
1106
+ // one signal that cannot be faked by a live socket. One watch PER WORKER, so
1107
+ // the ceiling counts stalls across the step rather than resetting each call.
1108
+ // L4 — the watch is handed the run's WALL. Self-heal is what a run does with
1109
+ // time left: past the deadline a reissue is spend the run already declined to
1110
+ // authorize, and this is the one path no other clock read covers (a stall
1111
+ // completes no round and returns from no step, which are the only two places
1112
+ // `clock.expired()` is otherwise consulted). Same clock object as every other
1113
+ // reader — never a second time source.
1114
+ const stallWatch = createStallWatch({
1115
+ onStall: (n) => emit('stall', { phase, iteration: roundIteration, stall: n, stallMs: STALL_MS, maxStalls: MAX_STALLS }),
1116
+ expired: () => clock.expired(),
1117
+ });
1118
+ /**
1119
+ * ONE Loop PER ISSUED CALL, and every callback on it scoped to that call's
1120
+ * generation. After a stall there are TWO calls alive: the abandoned one, whose
1121
+ * socket keeps streaming rounds (ms3197n8's lived 274min past the point we moved
1122
+ * on), and the reissue. Sharing one Loop between them shares `_stopped`, so a
1123
+ * corpse's round bound would cut the LIVE call mid-flight; sharing one callback
1124
+ * left the corpse beating the watch that was timing its replacement, which meant
1125
+ * the replacement could hang forever without tripping — the fuse feeding itself
1126
+ * from the thing it abandoned. A Loop is a plain object over the shared provider,
1127
+ * holds no cross-run state (`run()` takes the transcript per call), so one per
1128
+ * issue costs nothing.
1129
+ *
1130
+ * RESIDUAL, documented not fixed: both generations still execute tools through
1131
+ * the SAME Gate (`policy`) and the same audit — deliberately, because the audit
1132
+ * is what `workerWrites` reads and a per-generation gate would split the run's
1133
+ * own books. Concurrent tool calls across a stall therefore interleave on one
1134
+ * fence. The fence itself is decision-per-call and order-independent, so the
1135
+ * denial semantics hold; what is not modelled is two calls writing the same path
1136
+ * in the same window. Latent today (Layer R off, and a stall is rare).
1137
+ * @param {number} gen the generation `stallWatch` handed this call
1138
+ */
1139
+ const newLoop = (gen) => {
1140
+ /** @type {any} */
1141
+ let self = null;
1142
+ /** @param {{costUsd?: number|null, pricing?: string|null, usage?: any, kind?: string}} arg */
1143
+ const metered = async (arg) => {
1144
+ // The meter fires for EVERY generation, live or abandoned. An orphaned round
1145
+ // was really billed, and spend that no instrument sees is the one thing this
1146
+ // repo never ships (F6/F44 — unknown is reported as unknown, never as zero;
1147
+ // stall.js says it plainly: a reissue can pay twice, and the wallet is what
1148
+ // caps that).
1149
+ emit('worker-round', {
1150
+ phase, iteration: roundIteration, kind: arg?.kind ?? 'turn',
1151
+ costUsd: arg?.costUsd ?? null, pricing: arg?.pricing ?? null,
1152
+ tokens: (arg?.usage?.inputTokens ?? 0) + (arg?.usage?.outputTokens ?? 0),
1153
+ usage: usageOf(arg?.usage),
1154
+ });
1155
+ // Everything BELOW here belongs to the live call only. A superseded call must
1156
+ // not beat the watch (that is the whole watchdog), must not spend the step's
1157
+ // round bound (stall.js: "the step's round bound is untouched" by a reissue —
1158
+ // the wallet is what bounds a reissue's spend), and must not announce a bound
1159
+ // it cannot own.
1160
+ if (!stallWatch.isCurrent(gen)) return onLlmResult(arg);
1161
+ // F66 — the heartbeat is ANY result coming back from the model layer, not
1162
+ // only a `turn`. The question the watchdog asks is "is the model still
1163
+ // answering", and a non-turn result answers it just as well; gating the beat
1164
+ // on `kind` would arm a false stall on any round shape we don't enumerate
1165
+ // here, which is the blind-instrument class in miniature.
1166
+ stallWatch.beat(gen);
1167
+ if ((arg?.kind ?? 'turn') === 'turn') {
1168
+ roundsThisAttempt += 1;
1169
+
1170
+ // The wall deadline rides the SAME seam as the round bound, because it is
1171
+ // the only seam that exists: loop.stop() is read at the round boundary and
1172
+ // cannot cut an in-flight call (F61/C1, measured — fired at 500ms, returned
1173
+ // at 4,018ms). Stopping here judges the partial work and feeds the gap
1174
+ // forward exactly as a round-bounded attempt does — this seam never decides a
1175
+ // terminal. Three sites do, each reading the clock with nothing in flight: the
1176
+ // step loop after a step returns (between steps), the head of a step's attempt
1177
+ // (a step that would BEGIN past the deadline is never funded), and the head of
1178
+ // a close-fix iteration (W-2 — the run stops on the verdict already minted).
1179
+ if (clock.expired()) {
1180
+ attemptBounded = roundIteration;
1181
+ emit('wall-bounded', { phase, iteration: roundIteration, ...clock.report(closeTimeoutForReport) });
1182
+ self.stop();
1183
+ } else if (roundsThisAttempt >= attemptRounds) {
1184
+ attemptBounded = roundIteration;
1185
+ emit('attempt-bounded', { phase, iteration: roundIteration, rounds: roundsThisAttempt, cap: attemptRounds });
1186
+ self.stop();
1187
+ }
459
1188
  }
460
- }
461
- return onLlmResult(arg);
1189
+ return onLlmResult(arg);
1190
+ };
1191
+ self = new Loop({ provider: loopProvider, system, policy, onLlmResult: metered, onToolResult: onToolOutcome });
1192
+ return self;
462
1193
  };
463
- const loop = new Loop({ provider: loopProvider, system, policy, onLlmResult: metered, onToolResult: onToolOutcome });
464
1194
  /** @param {string} prompt @param {typeof toolDefs} [defs] */
1195
+ /**
1196
+ * F59 — continue an EXISTING conversation for exactly one TOOLLESS round.
1197
+ * `defs` is `[]`, so text is the only output the model can produce: the summary
1198
+ * round is guaranteed by CONSTRUCTION, never requested in prose (F19/F37 — a
1199
+ * pacing mandate in the persona was violated 6/6). Meters like any other round
1200
+ * (F12); the bound has already fired, so this is a deliberate +1 paid once.
1201
+ * @param {any[]} msgs the prior conversation (loop.run's `msgs`)
1202
+ * @param {string} prompt
1203
+ */
1204
+ const askFrom = async (msgs, prompt) => {
1205
+ try {
1206
+ return await stallWatch.watch((gen) => newLoop(gen).run([...msgs, { role: 'user', content: prompt }], [], { cacheMessages: true, maxTokens: 32000, ...callBounds() }));
1207
+ } catch (e) {
1208
+ throw categorize(e).err;
1209
+ }
1210
+ };
465
1211
  const ask = async (prompt, defs = toolDefs) => {
466
1212
  let r;
467
1213
  try {
468
- r = await loop.run([{ role: 'user', content: prompt }], defs, { cacheMessages: true, maxTokens: 32000 });
1214
+ r = await stallWatch.watch((gen) => newLoop(gen).run([{ role: 'user', content: prompt }], defs, { cacheMessages: true, maxTokens: 32000, ...callBounds() }));
469
1215
  } catch (e) {
470
- const err = /** @type {CategorizedError} */ (e);
471
- err.category = e instanceof HaltError ? 'cap-halt' : (err.category ?? 'provider-red');
472
- throw err;
1216
+ throw categorize(e).err;
473
1217
  }
474
1218
  // same error-return taxonomy as interpret's ask (one map, same doctrine):
475
1219
  // halt → cap-halt, denial streak → gate-red, API truncation → provider-red
@@ -484,41 +1228,107 @@ export async function runPlan(job, { workdir, provider, nativeProvider, emit, re
484
1228
  }
485
1229
  return r;
486
1230
  };
487
- return { ask, workerWrites, setIteration: (/** @type {number|string} */ i) => { roundIteration = i; roundsThisAttempt = 0; }, wasBounded: () => attemptBounded };
1231
+ return { ask, askFrom, workerWrites, writeCount, setIteration, wasBounded: () => attemptBounded };
488
1232
  }
489
1233
 
490
- /** relay a throw from OUTSIDE ralph (scout/plan drafting) as its honest category */
1234
+ /** relay a throw from OUTSIDE ralph (scout/plan drafting/fix) as its honest
1235
+ * category. Three terminals, never collapsed: the budget gate, the wall clock
1236
+ * (F64 — the scout and the drafter run under the same derived call timeout the
1237
+ * worker does, so this path needs the same split or it is the blinder route),
1238
+ * and transport. */
491
1239
  const relay = (/** @type {any} */ e, /** @type {string} */ phase) => {
492
- const category = e instanceof HaltError ? 'cap-halt' : (typeof e?.category === 'string' ? e.category : 'provider-red');
493
- if (category === 'cap-halt') emit('cap-halt', { category, meaning: 'not under cap — not "can\'t"', detail: String(e?.message ?? e) });
1240
+ const { category } = categorize(e);
1241
+ const detail = String(e?.message ?? e);
1242
+ if (category === 'cap-halt') {
1243
+ emit('cap-halt', { category, meaning: 'not under cap — not "can\'t"', detail });
1244
+ // v1.46 §2 — the money record rides beside the taxonomy record exactly as the
1245
+ // wall's does one line down. Outside the loops there is usually no series yet,
1246
+ // and the readout says `unknown` rather than inventing a direction (F6).
1247
+ emitMoneyHalt({ phase, cutMidCall: true });
1248
+ }
1249
+ if (category === 'wall-halt') emitWallHalt({ cutMidCall: true, phase });
1250
+ const DECIDE = {
1251
+ 'cap-halt': [`The budget gate tripped during ${phase} — the wallet cannot fund the plan flow.`, MONEY_OPTIONS],
1252
+ 'wall-halt': [`The run reached its wall-clock cap during ${phase}. Time ran out, not capability.`, WALL_OPTIONS],
1253
+ // A stall OUTSIDE a step (scout, drafting, fix) has no step to replan, so it
1254
+ // surfaces — but it is named, never laundered into provider-red. The socket
1255
+ // did not fail here; the model stopped answering and reissuing did not help,
1256
+ // and those are different diagnoses with different remedies (F45/F48: a
1257
+ // casualty is never evidence, and a governance stop is never a casualty).
1258
+ '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']],
1259
+ };
1260
+ const [decision, options] = (Object.hasOwn(DECIDE, category) ? DECIDE[category] : undefined)
1261
+ ?? [`The ${phase} call failed (${category}) — no result exists.`, ['retry the run', 'fix the provider binding', 'abandon the run']];
494
1262
  emit('escalation', {
495
- category, decisionReady: true, phase,
496
- decision: category === 'cap-halt' ? `The budget gate tripped during ${phase} — the wallet cannot fund the plan flow.` : `The ${phase} call failed (${category}) — no result exists.`,
497
- options: category === 'cap-halt' ? ['raise the job budget and rerun', 'abandon the run'] : ['retry the run', 'fix the provider binding', 'abandon the run'],
498
- detail: String(e?.message ?? e),
1263
+ category, decisionReady: true, phase, decision, options, detail,
499
1264
  ...(typeof e?.lib === 'string' ? { lib: e.lib } : {}),
500
1265
  });
501
- return category === 'cap-halt' ? 'cap-halt' : 'provider-red';
1266
+ // `step-stalled` rides out as ITSELF, not as provider-red. Naming the
1267
+ // escalation while returning a casualty label would launder a governance stop
1268
+ // into transport noise at exactly the layer the readout reads.
1269
+ return category === 'cap-halt' || category === 'wall-halt' || category === 'step-stalled' ? category : 'provider-red';
502
1270
  };
503
1271
 
504
1272
  // ── 1. SCOUT — read-only by construction: the write-class verbs are simply
505
1273
  // not in its menu (the menu is the grant), and its gate fences zero paths
506
1274
  let scoutBlob = '';
507
- emit('scout-start', { rounds: SCOUT_ROUNDS });
508
- try {
509
- const scout = await mkWorker({ granted: ceiling.filter((v) => !WRITE_VERBS.includes(v)), phase: 'scout', attemptRounds: SCOUT_ROUNDS, attempts: 1, writable: false });
510
- scout.setIteration(1);
511
- const r = await scout.ask([
512
- 'Survey this repository READ-ONLY for the goal below. Report: the relevant layout, the key files and symbols, and your best hypothesis about what the work requires. Be concise — your notes brief a planner that cannot see the repository.',
513
- `Repository root (absolute): ${workdir}\nEvery path you pass to a tool MUST be absolute and inside this root.`,
514
- `Goal:\n${job.goal}`,
515
- pre.gap && `The job's verification is currently failing. Its output on the tree as it stands:\n${pre.gap}`,
516
- ].filter(Boolean).join('\n\n'));
517
- scoutBlob = scrub(r.text ?? '').slice(0, SCOUT_BLOB_MAX);
518
- } catch (e) {
519
- return relay(e, 'scout');
1275
+ // RESUME (module C v2): the survey is a PAID unit the killed run already bought, and
1276
+ // the plan it briefed is being reloaded rather than re-drafted — so there is nothing
1277
+ // left for it to brief. Skipping it is the whole point of the ruling ("why would i
1278
+ // want to waste more money on something i already started"), and the skip is a RECORD
1279
+ // rather than silence, with the one consequence named on it: the survey text is not on
1280
+ // the spine (only its byte count ever was), so a replan after a resume drafts from an
1281
+ // empty survey plus its failure brief. Re-scouting to refill it would re-pay exactly
1282
+ // the call this exists to save.
1283
+ if (resumeSeed) {
1284
+ emit('scout-skipped', {
1285
+ reason: 'resumed', phase: resumeSeed.phase ?? null,
1286
+ meaning: 'the killed run already paid for this survey; its plan is reloaded, not re-drafted. A replan after this point drafts from an empty survey plus the failure brief.',
1287
+ });
1288
+ } else {
1289
+ emit('scout-start', { rounds: scoutRounds });
1290
+ try {
1291
+ const scout = await mkWorker({ granted: ceiling.filter((v) => !WRITE_VERBS.includes(v) && !STORE_VERBS.includes(v)), phase: 'scout', attemptRounds: scoutRounds, attempts: 1, writable: false });
1292
+ scout.setIteration(1);
1293
+ const r = await scout.ask([
1294
+ '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.',
1295
+ `Repository root (absolute): ${workdir}\nEvery path you pass to a tool MUST be absolute and inside this root.`,
1296
+ `Goal:\n${job.goal}`,
1297
+ pre.gap && `The job's verification is currently failing. Its output on the tree as it stands:\n${pre.gap}`,
1298
+ ].filter(Boolean).join('\n\n'));
1299
+ scoutBlob = scrub(r.text ?? '').slice(0, SCOUT_BLOB_MAX);
1300
+ // F59 — RESERVE the summary round. The round bound is enforced from the
1301
+ // metering callback (loop.stop), so a scout still calling tools on its last
1302
+ // round is halted mid-tool-use and `text` is empty: it spends the whole
1303
+ // allowance exploring and never writes the survey that is its ONLY
1304
+ // deliverable. Measured on 15 of 18 archived runs, which then drafted from
1305
+ // `(no scout notes)` while still paying ~12% of the run (F55) for the walk.
1306
+ // The recovery is one TOOLLESS round on the SAME conversation (`r.msgs`
1307
+ // carries the exploration): with no tools offered, text is the only possible
1308
+ // output — a mechanical guarantee, never a prose plea to summarise (F19/F37:
1309
+ // a pacing mandate in the persona was violated 6/6).
1310
+ // The trigger is BOUNDED-and-short, never short alone: a terse survey that
1311
+ // finished on its own was not cut off and has nothing to recover. The archive
1312
+ // separates cleanly on exactly this pair — every truncated scout was bounded
1313
+ // with 0-86 bytes, and the two that finished naturally wrote 5991/8056.
1314
+ if (scout.wasBounded() && Buffer.byteLength(scoutBlob) < SCOUT_MIN_BYTES && Array.isArray(r.msgs) && r.msgs.length) {
1315
+ emit('scout-truncated', { bytes: Buffer.byteLength(scoutBlob) });
1316
+ 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.');
1317
+ const recovered = scrub(s2?.text ?? '').slice(0, SCOUT_BLOB_MAX);
1318
+ if (Buffer.byteLength(recovered) > Buffer.byteLength(scoutBlob)) scoutBlob = recovered;
1319
+ }
1320
+ } catch (e) {
1321
+ return relay(e, 'scout');
1322
+ }
1323
+ emit('scout-result', { bytes: Buffer.byteLength(scoutBlob) });
1324
+ // F59 — the LOUD half. `scout-result {bytes: 0}` was emitted faithfully 18 times
1325
+ // out of 18 and read by nobody: an instrument that works but has no consumer is a
1326
+ // log line, not evidence. A survey that is still empty after its reserved round is
1327
+ // now a NAMED condition. It is never a halt — F59's own evidence is that 3 of 5
1328
+ // archived greens had an empty scout, so failing the run here would be a worse
1329
+ // error than the one being fixed.
1330
+ if (Buffer.byteLength(scoutBlob) < SCOUT_MIN_BYTES) emit('scout-empty', { bytes: Buffer.byteLength(scoutBlob), rounds: scoutRounds });
520
1331
  }
521
- emit('scout-result', { bytes: Buffer.byteLength(scoutBlob) });
522
1332
  // F6 in-flight: an unpriced round means the cap cannot govern spend it cannot
523
1333
  // see — halt at the boundary rather than run the whole plan blind (the caller
524
1334
  // emits pricing-red; runPlan just stops burning tokens). Legacy halts per-step.
@@ -534,35 +1344,135 @@ export async function runPlan(job, { workdir, provider, nativeProvider, emit, re
534
1344
  * total run spend can never exceed the signed budget (advertised == enforced,
535
1345
  * the hard line) — a drafter built once at full budget would let the replan
536
1346
  * draft spend against money the steps had already consumed.
537
- * @param {string} phase @param {string|null} failure */
538
- const obtainPlan = async (phase, failure) => {
1347
+ * @param {string} phase @param {string|null} failure
1348
+ * @param {string} [progress] the meter's read, replan only (A's adaptation channel) */
1349
+ const obtainPlan = async (phase, failure, progress) => {
539
1350
  const drafter = await mkWorker({ granted: [], phase: 'plan', attemptRounds: 2, attempts: 3, writable: false });
1351
+ // The BALANCE, read live at each draft — never a snapshot and never a rate
1352
+ // (addendum 2). At draft it is the whole budget (scale); at replan it is what
1353
+ // is actually left, which is the adaptation channel.
1354
+ const materials = { balanceUsd: remainingUsd(), remainingMs: clock.bounded ? clock.remainingMs() : null, ...(progress ? { progress } : {}) };
1355
+ emit('materials', { phase, ...materials });
1356
+ // D5 — the bridge rides into the FIRST draft phase ONLY. A replan drafts from the
1357
+ // RUN'S OWN STATE (what failed, what is left): re-handing the recipe there would
1358
+ // answer the replan's question with the material the run just demonstrated does not
1359
+ // work, and would spend the one revision the ceiling allows on a second unmeasured
1360
+ // arm. Exactly the rule the execution probe held at the provider seam, moved to where
1361
+ // the phase is known rather than inferred from the prompt's own text.
1362
+ // The REDRAFT (a validator rejection inside this same phase) keeps it: one red must
1363
+ // not silently convert a warm run to a cold one.
1364
+ const starting = phase === 'draft' ? startingDraft : null;
1365
+ // The shape-lottery facts, gate AND prompt from the ONE object (the scope-menu
1366
+ // precedent: what is stated and what is enforced can never drift apart).
1367
+ // `seedRed` rides every phase — the rule is defined on the SEED verdicts.
1368
+ // `priorChecks` is the REPLAN gate only: the accepted plan's carried checks,
1369
+ // which the redraft may move (A-v2 decides where) but never shed (Rule B —
1370
+ // u-msdsmkid's replan dropped both checks and "greened" on form alone).
1371
+ const checkFacts = {
1372
+ seedRed,
1373
+ ...(phase === 'replan' && plan
1374
+ ? { priorChecks: [...new Set(/** @type {any[]} */ (plan.steps).flatMap((/** @type {any} */ s) => (Array.isArray(s.exit) ? s.exit : []).filter((/** @type {any} */ e) => e?.type === 'check-passes' && typeof e.name === 'string').map((/** @type {any} */ e) => e.name)))] }
1375
+ : {}),
1376
+ };
540
1377
  const draftPlan = async (/** @type {any[]|null} */ reds) => {
541
1378
  drafter.setIteration(reds ? 'redraft' : 'draft');
542
- const r = await drafter.ask(planPrompt(job, scoutBlob, reds, maxStepRounds, failure), []);
1379
+ const r = await drafter.ask(planPrompt(job, scoutBlob, reds, maxStepRounds, failure, scopeMenu, materials, starting, checkFacts), []);
543
1380
  return extractArtifact(r.text).code ?? '';
544
1381
  };
1382
+ // Scrubbed HERE, once, before any consumer reads them — the judge() precedent
1383
+ // and the same V8-quoting class it was written for: `parse-error`'s detail is
1384
+ // JSON.parse's own message, and V8 quotes a window of the SOURCE inside it (a
1385
+ // body of 20 chars or fewer is quoted whole). That source is the DRAFT, which
1386
+ // the model wrote, so a red can carry model bytes onto `plan-validate`, onto
1387
+ // `plan-red`, and back into the redraft prompt — and the spine is append-only
1388
+ // forever (the hard line). The boundary is the right place and not the three
1389
+ // emit sites: scrubbing here makes the leak inexpressible, scrubbing each
1390
+ // consumer makes it something the next consumer has to remember.
1391
+ const validate = (/** @type {string} */ t) => {
1392
+ const v = validatePlan(t, { job, maxStepRounds, scopes: scopeMenu, ...checkFacts });
1393
+ return { ...v, reds: v.reds.map((r) => (typeof r.detail === 'string' ? { ...r, detail: scrub(r.detail) } : r)) };
1394
+ };
545
1395
  let text = await draftPlan(null);
546
- let pv = validatePlan(text, { job, maxStepRounds });
1396
+ let pv = validate(text);
547
1397
  emit('plan-validate', { ok: pv.ok, reds: pv.reds, phase: `${phase}-1` });
548
1398
  if (!pv.ok) {
549
1399
  text = await draftPlan(pv.reds);
550
- pv = validatePlan(text, { job, maxStepRounds });
1400
+ pv = validate(text);
551
1401
  emit('plan-validate', { ok: pv.ok, reds: pv.reds, phase: `${phase}-2` });
552
1402
  }
553
1403
  return pv;
554
1404
  };
555
1405
  let plan;
556
- try {
557
- const pv = await obtainPlan('draft', null);
1406
+ /** RESUME: how many leading steps of the reloaded plan are already SATISFIED and are
1407
+ * therefore skipped. Zero on every other path, and reset to zero by a replan — the new
1408
+ * plan's steps are not the dead plan's steps, even where an id repeats. */
1409
+ let skipCount = 0;
1410
+ if (resumeSeed) {
1411
+ // The plan is RELOADED, not re-drafted — but it is re-validated against the spec that
1412
+ // is signed NOW. The dead run validated it against the spec it was signed under, and
1413
+ // between the kill and the resume the menu, the fence or the close can have moved
1414
+ // (registry/schema drift). A stale plan is refused BY NAME with zero spent; running
1415
+ // it would execute a plan the current signature does not admit, which is the one
1416
+ // thing no reconstruction is allowed to do.
1417
+ // The shape-lottery opts (seedRed/priorChecks) are deliberately NOT passed:
1418
+ // they are DRAFTING laws — this gate guards signature drift, and refusing a
1419
+ // paid, previously-legal plan over a shape rule minted after its draft would
1420
+ // burn the very work resume exists to keep (hamr: "why would i want to waste
1421
+ // more money on something i already started"). Omission = rules inactive, by
1422
+ // the validator's contract.
1423
+ const pv = validatePlan(resumeSeed.plan, { job, maxStepRounds, scopes: scopeMenu });
1424
+ // scrubbed at the boundary for `validate()`'s own reason: a `parse-error` detail
1425
+ // quotes a window of the SOURCE, and the spine is append-only forever
1426
+ const reds = pv.reds.map((r) => (typeof r.detail === 'string' ? { ...r, detail: scrub(r.detail) } : r));
558
1427
  if (!pv.ok) {
559
- for (const r of pv.reds) emit('plan-red', r);
1428
+ emit('resume-plan-red', { reds, phase: resumeSeed.phase ?? null });
1429
+ for (const r of reds) emit('plan-red', r);
1430
+ emit('escalation', {
1431
+ category: 'plan-red', decisionReady: true, phase: 'resume',
1432
+ decision: 'The plan the killed run was executing no longer validates against this job spec, so the resume ran nothing. A reconstruction may re-use a plan; it may never run one the current signature does not admit.',
1433
+ options: [
1434
+ 'rerun WITHOUT --resume (the run drafts a cold plan against the current spec — the killed run\'s steps are then not inherited)',
1435
+ 'restore the spec version the dead run was signed under and resume again',
1436
+ ],
1437
+ detail: reds.map((r) => `${r.code}:${r.path}${r.detail ? ` — ${r.detail}` : ''}`).join('\n'),
1438
+ });
560
1439
  return 'plan-red';
561
1440
  }
562
1441
  plan = /** @type {any} */ (pv.plan);
563
- emit('plan-accepted', { plan });
564
- } catch (e) {
565
- return relay(e, 'plan');
1442
+ // The skip set is a PREFIX match on the plan's own step ids, never a count: a count
1443
+ // would skip whatever happens to sit at that index. Steps run strictly sequentially
1444
+ // and idx only advances on a satisfied exit, so the dead run's completions ARE a
1445
+ // prefix — and a seed that does not line up stops the skipping there and says so on
1446
+ // the record, which re-runs work rather than skipping work nobody did (the safe
1447
+ // direction of the two).
1448
+ const completed = Array.isArray(resumeSeed.completedSteps) ? resumeSeed.completedSteps : [];
1449
+ while (skipCount < completed.length && skipCount < plan.steps.length
1450
+ && completed[skipCount]?.id === plan.steps[skipCount].id) skipCount += 1;
1451
+ emit('resume-seed', {
1452
+ phase: resumeSeed.phase ?? null,
1453
+ planSteps: plan.steps.map((/** @type {any} */ s) => s.id),
1454
+ completed: completed.map((/** @type {any} */ c) => c?.id ?? null),
1455
+ skipping: skipCount,
1456
+ ...(skipCount < completed.length
1457
+ ? { divergence: `${completed.length} step(s) were completed but only ${skipCount} line up with the reloaded plan in order — the rest are RUN, never skipped on an id that does not match` }
1458
+ : {}),
1459
+ });
1460
+ // this leg's own plan-accepted: R1 mints a bridge version from the plan AS EXECUTED,
1461
+ // read back off the spine, so a resumed leg that greens without one would mint
1462
+ // nothing. `phase` says where it came from — it was accepted, not drafted.
1463
+ emit('plan-accepted', { plan, phase: 'resume' });
1464
+ } else {
1465
+ try {
1466
+ const pv = await obtainPlan('draft', null);
1467
+ if (!pv.ok) {
1468
+ for (const r of pv.reds) emit('plan-red', r);
1469
+ return 'plan-red';
1470
+ }
1471
+ plan = /** @type {any} */ (pv.plan);
1472
+ emit('plan-accepted', { plan });
1473
+ } catch (e) {
1474
+ return relay(e, 'plan');
1475
+ }
566
1476
  }
567
1477
  if (isUnpriced()) return 'pricing-red'; // F6: the plan drafting round came back unpriced — halt before steps
568
1478
 
@@ -574,6 +1484,13 @@ export async function runPlan(job, { workdir, provider, nativeProvider, emit, re
574
1484
  const stepOutcomes = [];
575
1485
  let replanned = false;
576
1486
  const planExecuted = () => emit('plan-executed', { steps: stepOutcomes, replanned });
1487
+ /** @type {object|null} W-2 at the STEP site — set when the WALL refused to open a
1488
+ * step's attempt. It carries that stop's own record fields, and its presence is what
1489
+ * lets the step loop's terminal tell this reading apart from F64's mid-call one
1490
+ * without consulting the clock a second time (two clock reads for one event is how
1491
+ * the two instruments come to disagree). Exactly the fix loop's `wallStop`, one
1492
+ * layer up. */
1493
+ let stepWallStop = null;
577
1494
 
578
1495
  /** @param {any} step */
579
1496
  const executeStep = async (step) => {
@@ -598,11 +1515,115 @@ export async function runPlan(job, { workdir, provider, nativeProvider, emit, re
598
1515
  const root = layerRoot && !native
599
1516
  ? createRoot({ gapKeep: '\\S', redact: scrub, writesInformative: true })
600
1517
  : null;
601
- const w = await mkWorker({ granted: step.tools, phase: `step:${step.id}`, attemptRounds: step.rounds, attempts: capRuns, writable: true, root });
1518
+ // P: the widened step fields, each wired or refused — a silently-ignored
1519
+ // optional field is the F50 blind-instrument class. `scope` narrows the fence to
1520
+ // a menu value (a subset by construction); `model` swaps the worker's provider
1521
+ // via the runner's factory — absent factory it must STOP, not silently run the
1522
+ // default tier as if the choice had been honoured. `attempts` is the third and
1523
+ // is now TOLERATED-INERT (see validatePlan): the ladder governs iterations by
1524
+ // progress, and a field that could only ever TIGHTEN a count is the wrong shape
1525
+ // for that — drafters measurably tightened it to 3, then to 2 on replan, which
1526
+ // is the opposite of the heal a converging step needs.
1527
+ if (step.model !== undefined && !providerFor) {
1528
+ 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`));
1529
+ err.category = 'interpreter-red';
1530
+ throw err;
1531
+ }
1532
+ const w = await mkWorker({
1533
+ granted: step.tools, phase: `step:${step.id}`, attemptRounds: step.rounds, attempts: null, writable: true, root,
1534
+ fence: step.scope !== undefined ? [resolve(workdir, globToPrefix(step.scope))] : null,
1535
+ workerProvider: step.model !== undefined && providerFor ? providerFor(step.model) : null,
1536
+ });
1537
+ // ONE ladder per step: the seen-set and the strike count are this step's own
1538
+ // history, and a shared one would strike a fresh step for a gap an earlier step
1539
+ // had already seen. The write signal is the gate audit's own record count (F32's
1540
+ // filter), never git status and never a tree diff.
1541
+ const ladder = createLadder({ limit: strikeLimit, writeCount: w.writeCount });
602
1542
  let lastText = '';
603
1543
  let iterationNow = 0;
1544
+ // The meter's baseline: what the RUN had left when this step began. Shares are
1545
+ // computed against this, never against the original budget — a late step is
1546
+ // measured against what actually remains for it, which is what "eating the run"
1547
+ // means. Read once, here, so the denominator cannot drift mid-step.
1548
+ const stepStartUsd = remainingUsd();
1549
+ const stepStartMs = clock.bounded ? clock.remainingMs() : Infinity;
604
1550
  /** @param {number} iteration @param {string} [gap] */
605
1551
  const middle = async (iteration, gap) => {
1552
+ // A — the variance check, at the HEAD of an attempt so no work is discarded
1553
+ // (attempts 1..n-1 accrued the spend; their writes are already on disk). Never
1554
+ // fires on attempt 1: the share is 0 there by construction. Throwing with a
1555
+ // category is the seam ralph already owns; it routes the category and the step
1556
+ // loop reads it as a REPLAN, not a stop.
1557
+ if (iteration > 1) {
1558
+ const moneyShare = stepStartUsd > 0 ? (stepStartUsd - remainingUsd()) / stepStartUsd : 0;
1559
+ const timeShare = clock.bounded && stepStartMs > 0 ? (stepStartMs - clock.remainingMs()) / stepStartMs : 0;
1560
+ if (moneyShare >= VARIANCE_THRESHOLD || timeShare >= VARIANCE_THRESHOLD) {
1561
+ emit('variance', {
1562
+ step: step.id, iteration, threshold: VARIANCE_THRESHOLD,
1563
+ moneyShare: Number(moneyShare.toFixed(3)),
1564
+ timeShare: clock.bounded ? Number(timeShare.toFixed(3)) : null,
1565
+ axis: moneyShare >= VARIANCE_THRESHOLD ? 'money' : 'time',
1566
+ });
1567
+ const err = /** @type {CategorizedError} */ (new Error(
1568
+ `step "${step.id}" consumed ${Math.round(Math.max(moneyShare, timeShare) * 100)}% of the run's remaining `
1569
+ + `${moneyShare >= VARIANCE_THRESHOLD ? 'money' : 'time'} across ${iteration - 1} attempt(s) with its exits still red`));
1570
+ err.category = 'step-variance';
1571
+ throw err;
1572
+ }
1573
+ }
1574
+ // T/W-2 at the STEP site, and it sits BELOW the meter deliberately. The meter is
1575
+ // the instrument for a step that was ALREADY RUNNING when the deadline passed:
1576
+ // that step reads a time share of 1 and routes to the ONE replan exactly as it
1577
+ // always has. The case it is structurally blind to is a step that STARTS past the
1578
+ // deadline — its share is measured against what the run had left when it began,
1579
+ // which is zero, so the share is zero forever and the step burns every attempt it
1580
+ // is allowed. (Measured on the real flow: three attempts, three judged exits, all
1581
+ // past the deadline.) The two are exhaustive and do not overlap: past the deadline
1582
+ // a step that began with time left reads a share of exactly 1, so the meter always
1583
+ // fires first for it — which is why this check only ever reaches attempt 1, and why
1584
+ // the routing of a step that was already running is untouched.
1585
+ //
1586
+ // Every one of those attempts is worthless by construction: the metered clock
1587
+ // check stops the worker on its FIRST round, so it writes nothing, and the judge
1588
+ // still runs the step's exits — themselves close stages, at the full close timeout
1589
+ // each — to re-mint the red already on the record. hamr's ruling, the same one the
1590
+ // close-fix loop applies: when time is up, keep the grade we already have and
1591
+ // stop. Resume-to-cap extended from money to time — the stop IS the checkpoint.
1592
+ //
1593
+ // The placement IS the mechanism, and the in-flight case falls out of it with
1594
+ // nothing added: a deadline landing DURING an attempt still lets that attempt and
1595
+ // its judge run to completion, because the clock is not consulted again until the
1596
+ // next attempt is about to start. Grading real work always completes — a wall that
1597
+ // kills grading leaves the run unreadable after the money is spent (F45).
1598
+ if (clock.expired()) {
1599
+ stepWallStop = {
1600
+ cutMidCall: false,
1601
+ phase: `step:${step.id}`,
1602
+ stepsDone: idx,
1603
+ stepsPlanned: plan.steps.length,
1604
+ attemptsUsed: iteration - 1,
1605
+ // The ladder's own state, because the ladder is what was governing this
1606
+ // step when the wall stopped it. This record used to carry `capRuns` and
1607
+ // `stepAttemptCap` — two counts that no longer bound anything here — and a
1608
+ // field name that means something it no longer measures is worse than an
1609
+ // absent one: a reader cannot tell it went stale. The FIX site's copy of this
1610
+ // record dropped `capRuns` for the same reason at v1.46 §4, when the count
1611
+ // stopped governing that loop too — neither wall record quotes a denominator
1612
+ // now, because neither loop has one.
1613
+ strikes: ladder.report().strikes,
1614
+ strikeLimit: ladder.report().limit,
1615
+ distinctGaps: ladder.report().distinctGaps,
1616
+ };
1617
+ // No trend here, and none invented: the trend instrument reads CLOSE grades,
1618
+ // and a step that never ran has no grade of its own to compare. The lever the
1619
+ // human needs is on the run-level record either way.
1620
+ const err = /** @type {CategorizedError} */ (new Error(
1621
+ `the wall-clock cap passed before step "${step.id}" attempt ${iteration} could start — `
1622
+ + `the step was never funded to run. ${idx} of ${plan.steps.length} step(s) completed before it. `
1623
+ + 'Nothing after the deadline is allowed to change the verdict the run already has.'));
1624
+ err.category = 'wall-halt';
1625
+ throw err;
1626
+ }
606
1627
  w.setIteration(iteration);
607
1628
  iterationNow = iteration;
608
1629
  // Layer R observe: finalize the prior attempt from the books (workerWrites
@@ -623,8 +1644,23 @@ export async function runPlan(job, { workdir, provider, nativeProvider, emit, re
623
1644
  ].filter(Boolean).join('\n\n'));
624
1645
  lastText = scrub(r.text ?? '').slice(0, ARTIFACT_MAX);
625
1646
  };
1647
+ let lastGap = '';
626
1648
  const judge = async () => {
627
- const { pass, results } = await evalExits(step.exit, { dir: workdir, snapshot, runCheck });
1649
+ const { pass, results: raw } = await evalExits(step.exit, { dir: workdir, snapshot, runCheck });
1650
+ // Scrubbed HERE, once, before the results are read by anyone. An exit detail is
1651
+ // supposed to be names and counts, but `json-valid` embeds `JSON.parse`'s own
1652
+ // message and V8 quotes a window of the SOURCE inside it (a body of 20 chars or
1653
+ // fewer is quoted whole) — so the detail can carry FILE BYTES the worker chose,
1654
+ // and the spine is append-only forever (the hard line). ONE inventory
1655
+ // (SECRET_PATTERNS), the same `scrub` the close output and the ctx-verb channel
1656
+ // already ride through — never a second spelling of what a secret looks like.
1657
+ // The boundary is the right place and not the three call sites below it: these
1658
+ // same details become the `exit-eval` record, a fault's escalation `detail`
1659
+ // (via ralph's close-verdict + CLOSE_FAULTS emissions), and the worker gap
1660
+ // (which ralph also puts on the spine as close-verdict.gap). Scrubbing the
1661
+ // results makes the leak inexpressible; scrubbing each consumer makes it
1662
+ // something the next consumer has to remember.
1663
+ const results = raw.map((r) => (typeof r.detail === 'string' ? { ...r, detail: scrub(r.detail) } : r));
628
1664
  emit('exit-eval', { step: step.id, iteration: iterationNow, results });
629
1665
  // an instrument fault rides out by its runClose verdict NAME: ralph
630
1666
  // escalates it through CLOSE_FAULTS (or F32-routes a crash after writes)
@@ -632,15 +1668,38 @@ export async function runPlan(job, { workdir, provider, nativeProvider, emit, re
632
1668
  if (faulty) return { verdict: /** @type {string} */ (faulty.fault), detail: faulty.detail };
633
1669
  if (pass) return { verdict: 'satisfied' };
634
1670
  // AND-only: the gap names EVERY failing wall (mechanical genre, F38)
635
- return { verdict: 'needs_revision', gap: results.filter((r) => !r.pass).map((r) => r.detail).join('\n') };
1671
+ lastGap = results.filter((r) => !r.pass).map((r) => r.detail).join('\n');
1672
+ return { verdict: 'needs_revision', gap: lastGap };
636
1673
  };
637
- const outcome = await ralph({ middle, judge, capRuns, emit: emitL, workerWrites: w.workerWrites });
638
- return { outcome, artifact: lastText };
1674
+ const outcome = await ralph({ middle, judge, ladder, emit: emitL, workerWrites: w.workerWrites });
1675
+ // gap + writes ride out for the replan brief's mismatch line (the books this
1676
+ // step already kept; the escalation's detail carries the meter/strike sentence,
1677
+ // never the gap, so the brief cannot recover this fact from anywhere else)
1678
+ return { outcome, artifact: lastText, ladder, writes: w.workerWrites(), gap: lastGap };
639
1679
  };
640
1680
 
641
1681
  let idx = 0;
642
1682
  while (idx < plan.steps.length) {
643
1683
  const step = plan.steps[idx];
1684
+ // RESUME: this step's exit was already satisfied before the kill. It is not re-run —
1685
+ // that is the money and the time the ruling is about — and it is not silent either:
1686
+ // the record is its OWN type carrying the event that proves it, so a reader can tell
1687
+ // a skipped step from a run one (the resume-to-cap invariant, which a fake
1688
+ // step-start/step-end pair would destroy). The step's ARTIFACT is not reconstructed:
1689
+ // its text was a prompt ingredient, never a checkpoint, and its real product is on
1690
+ // disk where the next step will find it.
1691
+ if (idx < skipCount) {
1692
+ const proof = resumeSeed.completedSteps[idx];
1693
+ emit('step-skipped', {
1694
+ step: step.id,
1695
+ provenBy: proof?.by ?? null,
1696
+ provenSeq: typeof proof?.seq === 'number' ? proof.seq : null,
1697
+ meaning: 'the killed run satisfied this step\'s exits — it is not re-run and not re-paid',
1698
+ });
1699
+ stepOutcomes.push({ id: step.id, outcome: 'skipped' });
1700
+ idx += 1;
1701
+ continue;
1702
+ }
644
1703
  let res;
645
1704
  try {
646
1705
  res = await executeStep(step);
@@ -655,7 +1714,16 @@ export async function runPlan(job, { workdir, provider, nativeProvider, emit, re
655
1714
  const err = /** @type {CategorizedError} */ (e);
656
1715
  const category = err instanceof HaltError ? 'cap-halt' : (typeof err.category === 'string' ? err.category : 'interpreter-red');
657
1716
  stepOutcomes.push({ id: step.id, outcome: category });
658
- if (category === 'cap-halt') emit('cap-halt', { category, meaning: 'not under cap — not "can\'t"', detail: String(err?.message ?? err) });
1717
+ if (category === 'cap-halt') {
1718
+ emit('cap-halt', { category, meaning: 'not under cap — not "can\'t"', detail: String(err?.message ?? err) });
1719
+ // …and the MONEY record beside it, as every other cap-halt site emits one. A
1720
+ // readout a human gets at three seams and not at the fourth is the readout being
1721
+ // a coincidence of where the stop happened. UNREACHABLE today — nothing in
1722
+ // mkWorker/gate.init/snapshotScope buys a round, so no wallet can drain here —
1723
+ // which is why this is symmetry rather than a fix, and why it stays a `HaltError`
1724
+ // branch instead of a widened one.
1725
+ emitMoneyHalt({ phase: `step:${step.id}`, cutMidCall: false, stepsDone: idx, stepsPlanned: plan.steps.length });
1726
+ }
659
1727
  emit('escalation', {
660
1728
  category, decisionReady: true, phase: `step:${step.id}`,
661
1729
  decision: category === 'cap-halt'
@@ -674,6 +1742,23 @@ export async function runPlan(job, { workdir, provider, nativeProvider, emit, re
674
1742
  if (res.outcome === 'green') {
675
1743
  artifacts.push({ id: step.id, text: res.artifact });
676
1744
  idx += 1;
1745
+ // T's terminal: a run that cannot fit its time budget STOPS, and the stop is
1746
+ // the result (build-ladder discipline, v1.27 — unchanged). Checked after a
1747
+ // GREEN step too, not just a red one: the alternative silently starts a step
1748
+ // it cannot fund, which is the same class as the money cap binding mid-attempt
1749
+ // (F45). Resume-to-cap applies exactly as it does to money — the stop IS the
1750
+ // checkpoint, so the completed steps are not wasted.
1751
+ if (clock.expired() && idx < plan.steps.length) {
1752
+ emitWallHalt({ stepsDone: idx, stepsPlanned: plan.steps.length });
1753
+ emit('escalation', {
1754
+ category: 'wall-halt', decisionReady: true, phase: `step:${step.id}`,
1755
+ decision: `The run reached its wall-clock cap after ${idx} of ${plan.steps.length} steps. Time ran out, not capability.`,
1756
+ options: WALL_OPTIONS,
1757
+ detail: `requested ${clock.requestedMs}ms, elapsed ${clock.elapsedMs()}ms`,
1758
+ });
1759
+ planExecuted();
1760
+ return 'wall-halt';
1761
+ }
677
1762
  continue;
678
1763
  }
679
1764
  // ONE replan, and only for EXHAUSTION with FUNDS LEFT (review #5): ralph
@@ -685,16 +1770,70 @@ export async function runPlan(job, { workdir, provider, nativeProvider, emit, re
685
1770
  // exhaustion leaves money on the table. An instrument/governance stop that
686
1771
  // is not cap-halt never replans either. (An unpriced step already returned
687
1772
  // pricing-red at the step-end guard above, so isUnpriced() is false here.)
1773
+ //
1774
+ // A (PRD v1.27) adds the SECOND trigger: `step-variance` — the meter stopped a
1775
+ // step that had eaten a declared share of the run with its exits unmoved. That
1776
+ // is the case exhaustion-only could never reach (F56: a replan has fired zero
1777
+ // times in the programme), and it is a genuine re-allocation rather than a stop.
1778
+ // Both triggers still need FUNDS LEFT, and both are still bounded by the ONE
1779
+ // replan ceiling — this changes the trigger, never the ceiling (v1.12).
688
1780
  const cat = lastEscalation?.category;
689
- if (!replanned && cat === 'cap-halt' && remainingUsd() > MONEY_MIN) {
1781
+ // F66 adds the THIRD trigger: `step-stalled` — the stall watchdog reissued the
1782
+ // model call MAX_STALLS times and never got a round back. hamr's ruling is that
1783
+ // a stall must self-heal rather than end the run ("killing and coming back is
1784
+ // not an option"), so the watchdog reissues silently; only once reissuing has
1785
+ // demonstrably stopped working does the run adapt instead — by replanning, not
1786
+ // by stopping. Same ONE-replan ceiling and same FUNDS-LEFT condition as the
1787
+ // other two: this changes the trigger, never the ceiling (v1.12).
1788
+ const replanTrigger = cat === 'cap-halt' ? 'step exhausted its attempts with exits still red'
1789
+ : cat === 'step-variance' ? 'the meter stopped a step that was consuming the run with its exits unmoved'
1790
+ : cat === 'step-stalled' ? 'the model stopped producing rounds and reissuing the call did not recover it'
1791
+ : null;
1792
+ if (!replanned && replanTrigger && remainingUsd() > MONEY_MIN) {
690
1793
  replanned = true;
691
- emit('replan', { step: step.id, reason: 'step exhausted its attempts with exits still red' });
692
- const failure = `Step "${step.id}" (${step.action}) ran ${capRuns} attempts and its exits were still red. `
1794
+ emit('replan', { step: step.id, reason: replanTrigger, trigger: cat });
1795
+ // The brief names the trigger HONESTLY, one branch per trigger: it is the
1796
+ // only channel the redrafting planner adapts to, so a wrong diagnosis here
1797
+ // is handed to the one component whose whole job is to respond to it. A
1798
+ // stall in particular never judged its exits at all — the exhaustion
1799
+ // sentence would invent a red the run never saw (F28's rule, replan side).
1800
+ // The exhaustion branch is the one the ladder rewrote. "It ran N attempts and
1801
+ // its exits were still red" described a bound that no longer exists AND said
1802
+ // nothing about the mechanism — the planner could not tell a step that was
1803
+ // converging from one repeating itself, which are opposite problems with
1804
+ // opposite fixes. The ladder's own books answer that, and the balance says what
1805
+ // the stop left on the table (the u-msd916dh reading: $1.30 and ~6min unspent
1806
+ // behind a step that was still shrinking its error count). Time is reported as
1807
+ // UNBOUNDED when the operator set no wall — never rendered as a number (F6
1808
+ // extends to time).
1809
+ const leftOver = `$${remainingUsd().toFixed(2)} and `
1810
+ + `${clock.bounded ? `${Math.round(clock.remainingMs() / 60_000)} minute(s)` : 'time UNBOUNDED'} `
1811
+ + 'of the run were still unspent when it stopped.';
1812
+ const why = cat === 'step-variance'
1813
+ ? 'It was stopped by the run\'s meter for consuming too large a share of what was left.'
1814
+ : cat === 'step-stalled'
1815
+ ? 'It stalled: the model stopped producing rounds and reissuing the call did not recover it, so its exits were never judged.'
1816
+ : `${res.ladder.brief()} ${leftOver}`;
1817
+ // The prose-prohibition trap's mechanical fact (u-msdpuaej): gap-named files
1818
+ // the step never wrote. Computed from the step's own books; capped with the
1819
+ // trim announced (the F32 gap convention). Empty extraction = no line.
1820
+ const missed = gapFilesNeverWritten(res?.gap, res?.writes);
1821
+ const missedLine = missed.length
1822
+ ? `The last exit output names file(s) this step never wrote: ${missed.slice(0, 8).join(', ')}${missed.length > 8 ? ` (+${missed.length - 8} more)` : ''}.\n`
1823
+ : '';
1824
+ const failure = `Step "${step.id}" (${step.action}) did not reach its exits. `
1825
+ + `${why}\n`
1826
+ + missedLine
693
1827
  + `Last exit state:\n${lastEscalation?.detail ?? '(none)'}\n`
694
1828
  + `Steps completed so far: ${artifacts.map((a) => a.id).join(', ') || 'none'}.`;
1829
+ // The progress line IS the adaptation channel (addendum 2): the balance rides
1830
+ // in via obtainPlan's live read, and this says where the run got to. Both are
1831
+ // the planner re-allocating what remains across what is left — never a rate.
1832
+ const progress = `step ${idx + 1} of ${plan.steps.length} ("${step.id}") did not finish; `
1833
+ + `${artifacts.length} step(s) completed before it`;
695
1834
  let pv;
696
1835
  try {
697
- pv = await obtainPlan('replan', failure);
1836
+ pv = await obtainPlan('replan', failure, progress);
698
1837
  } catch (e) {
699
1838
  planExecuted();
700
1839
  return relay(e, 'replan');
@@ -711,15 +1850,51 @@ export async function runPlan(job, { workdir, provider, nativeProvider, emit, re
711
1850
  // it names steps the current plan does not contain)
712
1851
  artifacts.length = 0;
713
1852
  idx = 0;
1853
+ // and for exactly the same reason a RESUME's skip set dies here: an id the new
1854
+ // plan happens to re-use names a step this run never did, so inheriting the
1855
+ // dead run's green for it would skip work nobody has done
1856
+ skipCount = 0;
714
1857
  continue;
715
1858
  }
1859
+ // F64 — the wall stopped this attempt from INSIDE a provider call (the derived
1860
+ // call timeout is the deadline in the provider's own coin). ralph already
1861
+ // emitted the escalation under this category, so the outcome and the record
1862
+ // agree (F11); what is still missing is the run-level TIME record, which the
1863
+ // between-steps site emits and this path must too. Never a replan trigger and
1864
+ // never a casualty: time ran out, and the stop IS the checkpoint.
1865
+ // W-2 adds the step loop's SECOND wall reading: the deadline seen at the head of
1866
+ // an attempt, with no call in flight. `stepWallStop` is that reading's own record
1867
+ // (attempts used, steps done); its absence means the deadline landed inside a call
1868
+ // instead, which is F64's cutMidCall stop — and run.js keys the job-end money floor
1869
+ // on exactly that field, so conflating the two would report an unknown as exact.
1870
+ if (cat === 'wall-halt') {
1871
+ emitWallHalt(stepWallStop ?? { cutMidCall: true, phase: `step:${step.id}`, stepsDone: idx, stepsPlanned: plan.steps.length });
1872
+ planExecuted();
1873
+ return 'wall-halt';
1874
+ }
716
1875
  // A money-gate halt (wallet drained) is an honest cap-halt terminal, never a
717
1876
  // step-red: the exits never ran because the money ran out, not because the
718
1877
  // work failed. Attempt-exhaustion WITH funds after the one replan is spent
719
1878
  // stays a step-red (the stop is a result).
720
1879
  if (cat === 'cap-halt') {
721
1880
  planExecuted();
722
- return remainingUsd() <= MONEY_MIN ? 'cap-halt' : `step-red:${step.id}`;
1881
+ if (remainingUsd() > MONEY_MIN) return `step-red:${step.id}`;
1882
+ // v1.46 §2: the money cut is decision-ready HERE too, and for the same reason
1883
+ // W-2 gave the wall a record at every site it stops a run — a category that
1884
+ // hands the human a readout at one seam and silence at another is the readout
1885
+ // being a coincidence of where the stop happened.
1886
+ emitMoneyHalt({ phase: `step:${step.id}`, cutMidCall: false, stepsDone: idx, stepsPlanned: plan.steps.length });
1887
+ return 'cap-halt';
1888
+ }
1889
+ // A second `step-variance` after the one replan is spent is a STOP, and the stop
1890
+ // is the result — but it rides out as `step-red:<id>`, NOT as its own top-level
1891
+ // outcome. The category is a replan TRIGGER, never a run verdict: leaking it
1892
+ // upward would mint an outcome run.js and the ledger's class table do not know,
1893
+ // and an unmapped category is counted as a library bug (ledger.js) when this is a
1894
+ // planning story. The escalation ralph already emitted carries the real detail.
1895
+ if (cat === 'step-variance') {
1896
+ planExecuted();
1897
+ return `step-red:${step.id}`;
723
1898
  }
724
1899
  // Any OTHER terminal escalation category is NOT a capability failure: a
725
1900
  // provider-red is a transport CASUALTY, a gate-red/interpreter-red/close
@@ -734,7 +1909,7 @@ export async function runPlan(job, { workdir, provider, nativeProvider, emit, re
734
1909
  // ── 4. THE CLOSE — the operator's signed command, the only truth. Red →
735
1910
  // the gap feeds ONE bounded fix loop judged by the REAL close (v1.12 §4);
736
1911
  // still red → the escalation ralph already emitted stands.
737
- const post = runClose(closeArgv, scrub, closeOpts);
1912
+ const post = await judgeClose();
738
1913
  emit('outer-close', { ...post });
739
1914
  if (post.verdict === 'satisfied') {
740
1915
  planExecuted();
@@ -748,22 +1923,183 @@ export async function runPlan(job, { workdir, provider, nativeProvider, emit, re
748
1923
  }
749
1924
  emit('fix-loop', { gapBytes: Buffer.byteLength(post.gap ?? '') });
750
1925
  let fixOutcome;
1926
+ /** @type {object|null} W-2 — set when the WALL stopped the fix loop BETWEEN
1927
+ * iterations. It carries that stop's own record fields, and its presence is what
1928
+ * lets the post-loop branch tell this reading apart from F64's mid-call one
1929
+ * without consulting the clock a second time (two clock reads for one event is
1930
+ * how the two instruments come to disagree). */
1931
+ let wallStop = null;
1932
+ /** how many fix ITERATIONS the loop actually bought — counted here rather than
1933
+ * read back off the trend reader, which lives inside the try and is out of scope
1934
+ * by the time a terminal needs the number. */
1935
+ let fixIterationsUsed = 0;
751
1936
  try {
752
1937
  // Layer R for the close-fix loop — the plan flow's single ralph loop judged
753
- // by the REAL close (the closest analog to interpret.js's loop, and the
1938
+ // by the REAL close (the plan flow's single ralph loop, and the
754
1939
  // likeliest place fixation manifests: the fix worker has the full menu and
755
- // is judged by a command, not a form-only exit). The red-set is the CLOSE's
756
- // own gapKeep (the gap here is the raw close output, unwrapped — so the
1940
+ // is judged by a command, not a form-only exit). The red-set comes from the
1941
+ // stage that ACTUALLY rendered the verdict this attempt, not from one pattern
1942
+ // fixed for the run: a staged close has N gapKeeps and the wall the worker hit
1943
+ // varies attempt to attempt (the gap here is the raw close output, so the
757
1944
  // `^`-anchored pattern matches, unlike the exec steps' exit-eval gap). Same
758
1945
  // native exclusion (no onToolResult seam ⇒ the tee cannot settle).
759
1946
  const fixRoot = layerRoot && !native
760
- ? createRoot({ gapKeep: job.close.gapKeep, redact: scrub, writesInformative: true })
1947
+ ? createRoot({ redact: scrub, writesInformative: true })
761
1948
  : null;
762
- const w = await mkWorker({ granted: ceiling, phase: 'fix', attemptRounds: maxStepRounds, attempts: capRuns, writable: true, root: fixRoot });
1949
+ // `attempts: null` is the STRIKE-LADDER shape (mkWorker's own contract): the fix
1950
+ // loop's iteration count no longer exists at construction time, so the gate's
1951
+ // turn allowance is granted per iteration at `setIteration` instead of being
1952
+ // pre-multiplied by a number nobody signed. The per-iteration bound is
1953
+ // unchanged — `loop.stop()` at `attemptRounds` — which is the belt that actually
1954
+ // holds. (v1.46 §4: `capRuns` retires as the governor.)
1955
+ const w = await mkWorker({ granted: ceiling, phase: 'fix', attemptRounds: maxStepRounds, attempts: null, writable: true, root: fixRoot });
1956
+ // ── v1.46 §4: the fix loop's GOVERNOR — progress, not a count ──
1957
+ //
1958
+ // MEASURED reason, the same shape as the step ladder's. The fixed `capRuns`
1959
+ // could not tell converging work from thrash, so it stopped both at the same
1960
+ // place: u-msew1uy5 was cut mid-convergence at fix iteration 3 while
1961
+ // reuse-msc6w93z sat dead flat at 2 errors for SEVEN fix verdicts until the wall
1962
+ // killed it. The $0 replay over all 8 archived fix loops came back clean both
1963
+ // directions — 0 greens harmed (every historical green converted in <= 2
1964
+ // verdicts), 1 waste case caught.
1965
+ //
1966
+ // The reader is SEEDED with the grade the loop opened on, because that is what
1967
+ // the first fix attempt is measured against; without it the first attempt would
1968
+ // have nothing to be compared to and the loop would spend a free iteration
1969
+ // establishing what it already knew.
1970
+ //
1971
+ // `blindCap: capRuns` is not the count surviving in disguise. It binds ONLY
1972
+ // while the instrument has never been able to compare anything (a close whose
1973
+ // output carries no number at all), which is precisely the case where a strike
1974
+ // would be minted out of ignorance. The moment a stage reports a comparable
1975
+ // number the count is gone, and money and the wall are what remain.
1976
+ //
1977
+ // DELIBERATELY NOT SEEDED with `resumeGrades`, and this is the one place the
1978
+ // resume chain stops. The two readers answer two different questions (see
1979
+ // `runTrend` above, and src/trend.js's "ONE PER SERIES"): the halt readout asks
1980
+ // *was the RUN converging when its allowance cut it*, which spans both legs; this
1981
+ // governor asks *is THIS loop out of ideas*, which is leg-local by definition.
1982
+ // Seeding it was built and measured: a resumed loop that struck out dead flat
1983
+ // rendered its own terminal as "still progressing — verdict 2 → 1 → 1 → 1 → 1",
1984
+ // because the chain HAD fallen before the handover — a record contradicting
1985
+ // itself, offering a conditional top-up ("if the trend above says it was still
1986
+ // converging") on the exact run that had just proven it was not. It would also
1987
+ // have moved the STRIKE rule on a resume (an attempt judged against the best the
1988
+ // run ever reached rather than against the grade this loop opened on) — an
1989
+ // unmeasured governance change, which is arbiter-adjacent territory. Both are the
1990
+ // same error: one instance answering two questions.
1991
+ const fixTrend = createTrend({
1992
+ stageOrder: stagedClose.map((/** @type {any} */ s) => s.name),
1993
+ limit: FIX_STRIKE_LIMIT,
1994
+ blindCap: capRuns,
1995
+ });
1996
+ fixTrend.record({ gap: post.gap ?? '' });
1997
+ /** ralph's `ladder` seam, filled by the trend reader instead of the step
1998
+ * ladder's repeat/write pair. ONE exhaustion terminal, two triggers (ralph's own
1999
+ * rule): the category stays `cap-halt` and the outcome stays `escalated`, so the
2000
+ * ledger's class table, bridge grading and every downstream reader are untouched
2001
+ * — only the TRIGGER changed. What it overrides is the terminal's PROSE: the
2002
+ * step ladder's copy offers a replan, and there is no planner at the close. */
2003
+ const fixGovernor = {
2004
+ record: (/** @type {{iteration: number, gap?: string}} */ o) => {
2005
+ fixIterationsUsed = o.iteration;
2006
+ return { governor: 'close-trend', ...fixTrend.record({ gap: o.gap }), iteration: o.iteration };
2007
+ },
2008
+ struckOut: fixTrend.struckOut,
2009
+ report: fixTrend.report,
2010
+ brief: () => fixTrend.verdict().reading,
2011
+ terminal: () => {
2012
+ const rep = fixTrend.report();
2013
+ const t = fixTrend.verdict();
2014
+ return {
2015
+ decision: rep.blind
2016
+ ? `${rep.iterations - 1} fix iteration(s) spent and the close still red. Its output carries no number this run can compare, so no progress reading exists — the retired iteration count is what bounded the loop. Continue, change approach, or stop?`
2017
+ : `${rep.strikes}/${rep.limit} strikes — the fix loop stopped making progress against the close's own numbers (${t.reading}). Continue, change approach, or stop?`,
2018
+ options: [
2019
+ 'revise the goal/spec so the work is reachable (a spec edit, so the new hash needs re-approval)',
2020
+ 'top up budgetUsd and rerun with --resume, if the trend above says it was still converging',
2021
+ 'abandon the task',
2022
+ ],
2023
+ };
2024
+ },
2025
+ };
763
2026
  /** @param {number} iteration @param {string} [gap] */
764
2027
  const middle = async (iteration, gap) => {
2028
+ // T/W-2 — the wall, read before a NEW fix attempt is started, and never
2029
+ // around the close itself. hamr's ruling: *"when time is up, keep the grade
2030
+ // we already have and stop … run tests (free) and when done if original time
2031
+ // is past due, pause and ask user to increase time or adjust prompt"*.
2032
+ //
2033
+ // Past the deadline this iteration is guaranteed to be worthless: the metered
2034
+ // round check stops the fix worker on its FIRST round, so it writes nothing,
2035
+ // and the iteration then re-runs the FULL staged close over an unchanged tree
2036
+ // (four stages × closeTimeoutMs = 60 minutes on the shipped spec) only to mint
2037
+ // the verdict already on the record — capRuns times over. So the run stops
2038
+ // here, ON that verdict. Resume-to-cap extended from money to time: the stop
2039
+ // IS the checkpoint, and the human tops up (v1.12).
2040
+ //
2041
+ // The placement IS the mechanism, and the in-flight case falls out of it with
2042
+ // nothing added: a deadline that lands DURING an attempt still lets that
2043
+ // attempt's close run to completion, because the clock is not consulted again
2044
+ // until the next iteration is about to start. The close is never bounded — a
2045
+ // wall that kills grading leaves the run unreadable after the money is spent
2046
+ // (the F45 class), so the one thing this must never do is stop a close.
2047
+ if (clock.expired()) {
2048
+ // ONE progress instrument, shared with the money halt (PRD v1.46 §2/§4).
2049
+ // This site used to run its own `gapTrend`: byte equality of the last two
2050
+ // close gaps, reading `stalled`/`moving` beside the money halt's per-stage
2051
+ // `flat`/`converging` on the very same run. Two readers for one question is
2052
+ // how the two come to disagree, and the byte reader could only ever report
2053
+ // MOTION — that something changed, never which way. Folded into src/trend.js,
2054
+ // where a per-stage NUMBER decides whenever one exists and the byte
2055
+ // comparison survives as the reading inside `unknown` (never as a direction:
2056
+ // "the output changed" is not "it got better", which is F6 in a trend's coat).
2057
+ // `runTrend` is the run's own reader — the same instance the money halt reads
2058
+ // — so a resume's inherited baselines reach this readout too.
2059
+ const t = runTrend.verdict();
2060
+ wallStop = {
2061
+ cutMidCall: false,
2062
+ phase: 'fix',
2063
+ iterationsUsed: iteration - 1,
2064
+ // `capRuns` used to ride here as the DENOMINATOR ("0 of 3"). It retired as
2065
+ // this loop's governor (v1.46 §4), so quoting it as the total would state a
2066
+ // bound that no longer decides anything — the iterations spent are the fact,
2067
+ // and there is no longer a number they are "of".
2068
+ verdict: lastCloseVerdict?.verdict,
2069
+ ...(lastCloseVerdict?.stage ? { stage: lastCloseVerdict.stage } : {}),
2070
+ trend: t.trend,
2071
+ // MOTION rides as its OWN field rather than being mapped onto `trend`.
2072
+ // Mapping `unchanged` → `flat` would dress a byte comparison as a measured
2073
+ // direction on the spine, which is precisely what the unification refuses;
2074
+ // a separate field keeps the old signal's substance without the false
2075
+ // promotion. (Grepped first: no src/ or scripts/ reader consumed the old
2076
+ // `stalled`/`moving` values — only this file's own tests, updated with it.)
2077
+ motion: t.motion,
2078
+ };
2079
+ // The detail carries what the human needs to pick a lever and nothing else:
2080
+ // the verdict that stands, how much of the loop was actually spent, and the
2081
+ // trend that says which of the two spec edits fits. It never names a culprit
2082
+ // file (F28) — that is the worker's job, not the escalation's.
2083
+ const err = /** @type {CategorizedError} */ (new Error(
2084
+ `the wall-clock cap passed before fix attempt ${iteration} could start. `
2085
+ + `The verdict stands as the last close rendered it: ${lastCloseVerdict?.verdict ?? 'unknown'}`
2086
+ + `${lastCloseVerdict?.stage ? ` at stage "${lastCloseVerdict.stage}"` : ''}, `
2087
+ + `after ${iteration - 1} fix iteration(s). `
2088
+ + `Progress trend: ${t.trend} — ${t.reading}; ${t.lever}.`));
2089
+ err.category = 'wall-halt';
2090
+ throw err;
2091
+ }
765
2092
  w.setIteration(iteration);
766
- const rootInj = fixRoot ? fixRoot.observe({ iteration, gap, writes: w.workerWrites() }) : null;
2093
+ // the red-set's source travels WITH the gap: `closeStage` is the stage the
2094
+ // judge stopped at for the attempt this gap came from, and its own gapKeep
2095
+ // designates that stage's failures
2096
+ const rootInj = fixRoot
2097
+ ? fixRoot.observe({
2098
+ iteration, gap, writes: w.workerWrites(),
2099
+ redStage: closeStage,
2100
+ redKeep: stagedClose.find((s2) => s2.name === closeStage)?.gapKeep,
2101
+ })
2102
+ : null;
767
2103
  if (rootInj) emit('root-injected', { phase: 'fix', ...rootInj.event });
768
2104
  await w.ask([
769
2105
  'The job\'s final verification is failing. Fix the repository so it passes.',
@@ -775,14 +2111,63 @@ export async function runPlan(job, { workdir, provider, nativeProvider, emit, re
775
2111
  ].filter(Boolean).join('\n\n'));
776
2112
  };
777
2113
  fixOutcome = await ralph({
778
- middle, close: closeArgv, capRuns, emit: emitL, redact: scrub,
779
- closeTimeoutMs, cwd: workdir, expect: job.close.expect, judged: job.close.judged, gapKeep: job.close.gapKeep,
780
- workerWrites: w.workerWrites,
2114
+ // the staged close rides in as the JUDGE seam (one verdict, every stage in
2115
+ // order) — ralph's single-command path stays exactly what it was
2116
+ // v1.46 §4: the PROGRESS governor rides in where `capRuns` used to. ralph's
2117
+ // two exhaustion rules are alternatives and never both at once, so the count
2118
+ // is not passed — it survives only inside the governor, as the bound for the
2119
+ // case the trend instrument is blind.
2120
+ middle, judge: async () => judgeClose(), ladder: fixGovernor, emit: emitL, redact: scrub,
2121
+ closeTimeoutMs, cwd: workdir, workerWrites: w.workerWrites,
781
2122
  });
782
2123
  } catch (e) {
783
2124
  planExecuted();
784
2125
  return relay(e, 'fix');
785
2126
  }
786
2127
  planExecuted();
2128
+ if (fixOutcome !== 'green' && lastEscalation?.category === 'wall-halt') {
2129
+ // F64 in the close-fix loop: the same governance stop, and it must not ride out
2130
+ // as a bare `escalated` either — that reads as "the fix failed" when the fix was
2131
+ // never given the time to run.
2132
+ // W-2 adds the loop's SECOND wall reading: the deadline seen between iterations,
2133
+ // where a real grade already exists and is kept. `wallStop` is that reading's own
2134
+ // record (verdict, iterations used, trend); its absence means the deadline landed
2135
+ // inside a call instead, which is F64's cutMidCall stop.
2136
+ emitWallHalt(wallStop ?? { cutMidCall: true, phase: 'fix' });
2137
+ return 'wall-halt';
2138
+ }
2139
+ if (fixOutcome !== 'green' && lastEscalation?.category === 'cap-halt') {
2140
+ // ralph spells BOTH of its terminals cap-halt — attempt-exhaustion AND a
2141
+ // money-gate halt thrown mid-attempt — so the category alone cannot tell them
2142
+ // apart, and the wallet is the only instrument that can. The step loop already
2143
+ // splits this exact pair on the same reading; the fix loop must too, because
2144
+ // the fix worker's gate is built with the wallet at its MOST drained (every
2145
+ // step's spend is behind it), which is precisely where a money cut masquerades
2146
+ // as "the fix failed". F45: a money cut is never a capability read — it rides
2147
+ // out as cap-halt, the resume-to-cap checkpoint (the stop IS the checkpoint).
2148
+ // Attempts spent with money still on the table stays the designed terminal
2149
+ // ("close still red") and keeps riding out as `escalated` below.
2150
+ // (MED-4, fixed with hamr's explicit go, 2026-07-30.)
2151
+ if (remainingUsd() <= MONEY_MIN) {
2152
+ // v1.46 §2 — and THIS is the site hamr's ask was about: the money runs out
2153
+ // deepest in the run, on the loop that holds a real grade. So the readout keeps
2154
+ // that grade, names the stage that rendered it, and carries the run's own
2155
+ // per-stage trend so the choice between "top up" and "revise the goal" is made
2156
+ // on evidence rather than on how the operator felt about the last gap.
2157
+ emitMoneyHalt({
2158
+ phase: 'fix',
2159
+ cutMidCall: false,
2160
+ iterationsUsed: fixIterationsUsed,
2161
+ });
2162
+ return 'cap-halt';
2163
+ }
2164
+ } else if (fixOutcome !== 'green' && typeof lastEscalation?.category === 'string') {
2165
+ // The step loop's category restoration (F11), mirrored: ralph returns the flat
2166
+ // 'escalated' on a middle throw while its escalation carries the real name — a
2167
+ // provider-red here is a transport CASUALTY, and run.js keys the F44
2168
+ // spendComplete:false floor on the OUTCOME, so a laundered label would report
2169
+ // an exact-looking total for a call that never billed back.
2170
+ return lastEscalation.category;
2171
+ }
787
2172
  return fixOutcome === 'green' ? 'green' : 'escalated';
788
2173
  }