bareloop 0.6.0 → 0.8.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
@@ -19,15 +19,18 @@ import { createHash } from 'node:crypto';
19
19
  import { join, resolve, relative } from 'node:path';
20
20
  import { Gate } from 'bareguard';
21
21
  import { LiteCtx } from 'litectx';
22
- import { runClose, runStages, ralph, CLOSE_FAULTS } from './ralph.js';
22
+ import { runClose, runStages, ralph, CLOSE_FAULTS, boundGap } from './ralph.js';
23
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
- import { snapshotScope, evalExits } from './exits.js';
24
+ import { WRITE_VERBS, EXIT_TYPES, MAX_EXITS_PER_STEP, MAX_PLAN_STEPS, MAX_SCOPE_MENU } from './plan.js';
25
+ import { snapshotScope, evalExits, CHECK_GAP_MAX } from './exits.js';
26
26
  import { createRoot } from './root.js';
27
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';
28
30
  import { TOOL_MENU, STORE_VERBS, checkMenu } from './job.js';
29
31
  import { TOOL_BY_VERB, CTX_TOOLS, createCtxTools, toolAction, PERSONA_TOOLS, strategyFor } from './tools.js';
30
32
  import { globToPrefix, redactSecrets, SECRET_PATTERNS } from './validate.js';
33
+ import { validateBridge, loadGate } from './bridges.js';
31
34
  import { extractArtifact } from './text.js';
32
35
  import { createClock, isWallTimeout } from './clock.js';
33
36
 
@@ -120,43 +123,138 @@ function materialsBlock(m) {
120
123
  }
121
124
 
122
125
  /**
123
- * W-2 — the progress TREND, read off the two most recent close gaps.
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.
124
130
  *
125
- * A run stopped by its wall hands the human a lever choice, and the two levers
126
- * point opposite ways: more TIME only helps work that was still moving, and a goal
127
- * the run cannot reach does not become reachable by being given longer. The
128
- * cheapest honest discriminator is already in the fix loop's hand — consecutive
129
- * close output. A byte-identical gap means the last attempt moved nothing the
130
- * arbiter can see; a different one means the tree was still changing under it.
131
+ * 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.
131
136
  *
132
- * Deliberately NOT a new instrument (no red counting, no red-set diffing): two
133
- * strings the loop already holds, compared for equality. `unknown` is the honest
134
- * reading when only one grade exists — never rounded up to "stalled", which would
135
- * recommend rewriting a goal on no evidence at all (F6's rule, applied to a trend
136
- * instead of a number).
137
- * @param {string[]} gaps close gaps, oldest first
138
- * @returns {{trend: 'stalled'|'moving'|'unknown', reading: string, lever: string}}
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
139
145
  */
140
- function gapTrend(gaps) {
141
- if (gaps.length < 2) {
142
- return {
143
- trend: 'unknown',
144
- reading: 'only one close grade exists, so there is nothing to compare',
145
- lever: 'read the last close output before choosing between more time and a different goal',
146
- };
147
- }
148
- const [prev, last] = gaps.slice(-2);
149
- return prev === last
150
- ? {
151
- trend: 'stalled',
152
- reading: 'the last two close outputs are byte-identical — the previous attempt moved nothing the close can see',
153
- lever: 'more time alone is unlikely to help; revise the goal/spec first',
154
- }
155
- : {
156
- trend: 'moving',
157
- reading: 'the last two close outputs differ — the tree was still changing under the close when time ran out',
158
- lever: 'the work was progressing; raising maxWallMs is the lever that fits',
159
- };
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
+ /** The red-set for the replan brief's copy of a step's exit gap: every non-blank
161
+ * line. It is `\S` and not the close stage's own `gapKeep` for the reason Layer R
162
+ * already pays at the step seam (see `createRoot` below) — a shipped `gapKeep` is
163
+ * `^`-anchored (`^BAREAGENT `, `^red`, `^FAILED`) and the exit evaluator wraps the
164
+ * stage's output in `check "x" red: …`, so the anchor no longer sits where the
165
+ * pattern expects it. A gap that reaches here is ALREADY a red-set: `judge` builds
166
+ * it out of the failing exits only, so every line in it is a red line and keeping
167
+ * all of them is the correct instrument, not a widened one. */
168
+ const REPLAN_GAP_KEEP = '\\S';
169
+
170
+ /**
171
+ * This step's own last EXIT results, rendered for the REPLAN brief.
172
+ *
173
+ * The label says exits and not "the close", and the distinction is the whole of one
174
+ * fix: `res.gap` is `lastGap`, the join of EVERY failing exit's detail in exit order.
175
+ * With `MAX_EXITS_PER_STEP` at 2 and the mandatory `tree-changed` pairing on a write
176
+ * step, a step that wrote nothing leads the block with the EVALUATOR's own prose
177
+ * (`0 files changed under src/** …`) — captured verbatim from a real replan prompt,
178
+ * under a label reading "the verification's own output". The close stage's output is
179
+ * one of the details here, never reliably all of them. The payload is left alone: "the
180
+ * step wrote nothing" is exactly what a replanner should be told, and stripping it to
181
+ * make an old label true would trade a real fact for a tidy sentence.
182
+ *
183
+ * u-mshcpdg4: a run one strict error from done, killed with $1.10 and 82 seconds
184
+ * unspent. The close named the remaining work exactly — `src/recurse.js(978,115)`
185
+ * — and the worker read that every attempt. The REPLANNER, which is the component
186
+ * that chooses which files the next plan targets, was handed only the trend line
187
+ * (`still progressing — typecheck 30 → 15 → 15 → 1`): converging, and silent about
188
+ * WHERE. It re-targeted `src/loop.js`, already at zero errors; that step wrote
189
+ * nothing and struck out. F85 gave the brief the trajectory; this gives it the
190
+ * artifact the trajectory is a summary OF.
191
+ *
192
+ * Handed over as TEXT, never as a parsed file list, and the trap is worth naming
193
+ * because the parse is the obvious-looking fix: line 1 of that very gap reads
194
+ * `reports 1 error(s) in src/recurse.js, src/loop.js` — every file in SCOPE — and
195
+ * only line 2 names the culprit. The `never wrote` advisory that used to sit here
196
+ * did exactly that parse, and on this gap it resolved to `src/loop.js` — the file
197
+ * already CLEAN — then said so as a directive beside the artifact that said the
198
+ * opposite. It was deleted with this change (hamr, 2026-08-05): two readers of one
199
+ * question, the parsed one wrong, is the same failure the variance meter just had.
200
+ * Nothing generic can tell a summary line from a detail line, and the
201
+ * shapes differ per close anyway (tsc file:line, pytest test ids, a count close
202
+ * that names no file at all). A model reading the artifact can make that
203
+ * distinction; a regex reproduces the bug. So: no extraction, no digest, no
204
+ * inference — the artifact, bounded and scrubbed.
205
+ *
206
+ * Two boundaries, both reused rather than respelled:
207
+ * - SCRUB. A prompt is an egress point and this repo has ONE secret inventory
208
+ * (`SECRET_PATTERNS` via `redactSecrets`). The gap arrives already scrubbed
209
+ * from `judge`, which makes this defense in depth rather than the only guard —
210
+ * and defense in depth at the egress is exactly where it belongs, because the
211
+ * next caller of this helper will not remember the upstream one.
212
+ * - BOUND, and it is a BACKSTOP rather than an envelope. What arrives here has
213
+ * ALREADY been bounded: `runClose` ran `boundGap` with the stage's own `gapKeep`,
214
+ * which deliberately rescues the `not ok`/`FAILED` names out of the elided middle
215
+ * — the mechanical gap F46's conversion mechanism feeds the worker. Re-bounding
216
+ * that with a fresh 400/1500 envelope deletes exactly those names a second time,
217
+ * which is F28 reintroduced; measured on a 120-red-line close output, the first
218
+ * bound rescued 78 names and this one dropped 12 of them. src/exits.js states the
219
+ * same rule for the same artifact one seam earlier, and this now uses ITS number:
220
+ * `CHECK_GAP_MAX`, imported rather than respelled, so the two seams cannot drift
221
+ * on how big a check gap may be. Under it the gap passes through VERBATIM.
222
+ *
223
+ * A backstop is still needed, and the headroom is why the ceiling is not smaller.
224
+ * `res.gap` is `lastGap`, the JOIN of up to `MAX_EXITS_PER_STEP` failing exit
225
+ * details, and the check-passes branch slices EACH at `CHECK_GAP_MAX` — so what
226
+ * arrives is bounded per detail, never in total. Measured, the real maximum is
227
+ * comfortably under: `runClose`'s own envelope tops out at ~10.1KB (400 head +
228
+ * 8192 keep + 1500 tail + labels), and joined with a `tree-changed` line that is
229
+ * ~10.2KB, so the shipped path passes through untouched with ~1.8KB to spare. The
230
+ * backstop therefore fires only where src/exits.js says it should: a seam that
231
+ * returned something never bounded at all.
232
+ *
233
+ * Over the line `boundGap` runs with `REPLAN_GAP_KEEP`, the same envelope the
234
+ * close path uses, and every trim announces itself (F28). Note what that keep-set
235
+ * cannot do: `\S` matches every non-blank line, so the keep block degenerates into
236
+ * "the first 50 lines" and re-surfaces nothing from the middle. That is the honest
237
+ * limit of a red-set that cannot know the stage's own pattern (see
238
+ * `REPLAN_GAP_KEEP`), and it is affordable precisely because it now applies only
239
+ * to a gap nothing upstream ever bounded — on that gap ANY envelope loses names,
240
+ * and the choice is a bounded prompt over an unbounded one.
241
+ *
242
+ * No gap → the empty string, so the brief renders byte-identically to the pre-F86
243
+ * one. A labelled empty section would be an invitation to explain an absence the
244
+ * run never observed (a stall never judged its exits at all).
245
+ *
246
+ * @param {string | null | undefined} gap the step's last exit gap text
247
+ * @returns {string} the labelled block, or '' when there is nothing to show
248
+ */
249
+ export function closeGapBlock(gap) {
250
+ if (typeof gap !== 'string' || gap.trim() === '') return '';
251
+ const scrubbed = redactSecrets(gap);
252
+ // BACKSTOP, not an envelope: over the line only. Scrubbing can only grow a string
253
+ // (a mask is wider than most literals it replaces), so the length is measured
254
+ // AFTER it — bounding on the raw length would let a gap cross the ceiling on its
255
+ // way through the one transformation that runs unconditionally.
256
+ return '\nWhat this step\'s exits reported on its last attempt (their own output, verbatim):\n'
257
+ + (scrubbed.length > CHECK_GAP_MAX ? boundGap(scrubbed, REPLAN_GAP_KEEP) : scrubbed);
160
258
  }
161
259
 
162
260
  /**
@@ -174,19 +272,42 @@ function gapTrend(gaps) {
174
272
  * @param {{ balanceUsd?: number|null, remainingMs?: number|null, progress?: string }} [materials]
175
273
  * what the run has LEFT (T/A). Omitted → no materials block at all, which is the
176
274
  * pre-T behaviour and keeps every existing caller byte-identical.
177
- * @param {number} [capRuns] the shell's attempt cap — the ceiling `attempts` may only
178
- * TIGHTEN. Interpolated the way every sibling bound is: `validatePlan` reds outside
179
- * `1..capRuns`, and a prompt that says only "integer" spends a round teaching it.
180
- * Defaults to validatePlan's own default so existing callers are unchanged.
275
+ * @param {object|null} [startingDraft] Layer 3 (D4) — a loaded bridge's plan, handed over
276
+ * as the draft to TWEAK. Omitted/null is the COLD path and renders byte-identically to
277
+ * the pre-Layer-3 prompt: the block is additive, never surgery on the prompt's interior,
278
+ * so the two paths cannot drift (the F47 works-both-ways rule). Only the runner passes
279
+ * it, and only for the FIRST draft phase — a replan drafts from the run's own state.
280
+ * @param {{ seedRed?: string[], priorChecks?: string[] }} [checkFacts] the shape-lottery
281
+ * gate rules, STATED as well as enforced (the mailbox precedent: a rule only the
282
+ * validator knows costs a redraft per draft). `seedRed`: preflight-red check names —
283
+ * Rule A-v2's law (final write step only, framed wide). `priorChecks`: the predecessor
284
+ * plan's carried checks, replan phase only — Rule B's law (keep every one). Empty/omitted
285
+ * renders byte-identically to the pre-rules prompt.
181
286
  */
182
- export function planPrompt(job, scoutBlob, reds, maxStepRounds, failure, scopes, materials, capRuns = 3) {
287
+ export function planPrompt(job, scoutBlob, reds, maxStepRounds, failure, scopes, materials, startingDraft = null, checkFacts = {}) {
183
288
  const scopeMenu = Array.isArray(scopes) && scopes.length ? scopes : legalScopes(job.writeScope ?? []);
184
289
  const ceiling = Array.isArray(job.tools) ? job.tools : [...TOOL_MENU];
290
+ // The shape-lottery laws, stated where the check menu is offered (the mailbox
291
+ // precedent: a law only the validator knows costs a redraft per draft). Both
292
+ // render only when their FACT arrived — empty facts keep the prompt
293
+ // byte-identical to the pre-rules render.
294
+ const seedRedLaw = Array.isArray(checkFacts.seedRed) && checkFacts.seedRed.length
295
+ ? `\n Check(s) RED at seed right now: ${JSON.stringify(checkFacts.seedRed)}. A failing check
296
+ is the goal itself, not a milestone: it may ONLY appear on your plan's FINAL write step.
297
+ Make that step wide — its action covers the whole goal, free to edit any file it reports,
298
+ iterating until the check passes. A failing check on any earlier step is rejected.`
299
+ : '';
300
+ const priorChecksLaw = Array.isArray(checkFacts.priorChecks) && checkFacts.priorChecks.length
301
+ ? `\n Your previous plan carried these check-passes exits: ${JSON.stringify(checkFacts.priorChecks)}.
302
+ Keep every one of them in this new plan — a redraft that drops one of them is rejected:
303
+ the other exit forms verify form only, and a form-only green is unearned.`
304
+ : '';
185
305
  // W4: the menu comes off the STAGED close — the same one derivation `runPlan`
186
306
  // executes and `validatePlan` accepts against. Reading `job.close` raw here left
187
307
  // the legacy object form offering nothing while the runner preflighted a stage.
188
308
  // (`?? []`: a close naming no command stages nothing, so it offers nothing)
189
- const checkNames = checkMenu(stageClose(job.close) ?? []).map((m) => m.name);
309
+ const closeStages = stageClose(job.close) ?? [];
310
+ const checkNames = checkMenu(closeStages).map((m) => m.name);
190
311
  const doc = `DRAFT-PLAN
191
312
  You are planning how to accomplish a goal in a repository, as an ordered list of bounded
192
313
  steps (schema "plan-v1"). The plan is pure declarative JSON validated by a strict schema;
@@ -200,8 +321,6 @@ strictly in array order. Each step (no other fields exist):
200
321
  - "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)
201
322
  - "rounds": integer 1..${maxStepRounds} — the step's per-attempt tool-round bound
202
323
  - "target": the step's deliverable path (REQUIRED when tools include write/edit), inside ${JSON.stringify(job.writeScope)}
203
- - "model" (optional): ${STEP_MODELS.join(' | ')} — a cheaper tier for mechanical steps; omit for the default
204
- - "attempts" (optional): integer 1..${capRuns} — TIGHTEN this step's retry cap below the shell's; you may never raise it
205
324
  - "scope" (optional): narrow this step's WRITE fence — copy one value from the offered scopes below.
206
325
  A narrowed "scope" is the ONLY ground this step can write: its "target" must sit
207
326
  inside it, and a "tree-changed" exit's scope must not be disjoint from it (one that
@@ -221,6 +340,12 @@ strictly in array order. Each step (no other fields exist):
221
340
  the step must be able to act on the report. Do not plan a separate read-only
222
341
  "verify" step — attach the check to the step that does the fixing; the final
223
342
  verification after your plan is not yours to author.
343
+ A check judges the WHOLE goal, never just this step's deliverable: its report
344
+ can name files beyond the step's target. A step carrying a check-passes exit
345
+ must therefore be free to edit every file the check can report on — never
346
+ write an action that forbids editing other in-scope files (e.g. "do not
347
+ modify any file outside X"): a step ordered to leave a file alone while its
348
+ exit reds on that file can never finish, on any attempt.${seedRedLaw}${priorChecksLaw}
224
349
  Reference checks by NAME only; you cannot author or modify one.
225
350
 
226
351
  Offered "scope" values for tree-changed — copy ONE of these exactly, character for
@@ -234,6 +359,12 @@ ${materialsBlock(materials)}
234
359
  Repository survey (from a read-only scout):
235
360
  ${scoutBlob || '(no scout notes)'}`;
236
361
  let p = doc;
362
+ // BEFORE the failure/reds appendices, so the last word to a redrafting planner stays
363
+ // the validator's "fix every red". The execution probe injected at the provider seam
364
+ // and could only append at the very end; it recorded that ordering as its own known
365
+ // wart. Owning the prompt is what lets it be fixed — and on the first draft (no
366
+ // failure, no reds) the render is byte-identical to the probe's.
367
+ if (startingDraft) p += startingDraftBlock(closeStages.length, startingDraft);
237
368
  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.`;
238
369
  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.`;
239
370
  return p;
@@ -264,7 +395,17 @@ ${scoutBlob || '(no scout notes)'}`;
264
395
  * @param {(tier: string) => any} [opts.providerFor] P: per-step model-tier factory
265
396
  * (tier ∈ STEP_MODELS → a provider). A plan naming a tier with no factory supplied is an
266
397
  * interpreter-red STOP — never silently run the default tier as if the choice was honoured.
267
- * @param {number} [opts.capRuns] shell-owned per-step attempt cap
398
+ * @param {number} [opts.capRuns] shell-owned iteration cap, RETIRED as a governor
399
+ * (v1.46 §4). It stopped bounding a plan step when the strike ladder landed, and it
400
+ * stopped bounding the close-fix loop when the close trend rule did. It survives as
401
+ * that loop's fallback for the ONE case the trend cannot read — a close whose output
402
+ * carries no number at all — because a governor that cannot see the variable must
403
+ * not be the governor, and the honest fallback is the cruder bound it replaced
404
+ * rather than "unbounded". It lifts the moment a stage reports a comparable number.
405
+ * @param {number} [opts.strikeLimit] shell-owned STRIKE ceiling for a plan step's
406
+ * ladder (src/ladder.js). Arbiter territory exactly as the count it replaced was —
407
+ * the runner sets it, the plan cannot express it, and no step may tighten or raise
408
+ * it.
268
409
  * @param {number} [opts.closeTimeoutMs] close/check wall-clock cap (shell territory)
269
410
  * @param {number} [opts.maxStepRounds] the shell's per-step rounds ceiling (validatePlan's bound)
270
411
  * @param {number} [opts.scoutRounds] the read-only survey's round bound (F59: the LAST round is
@@ -279,15 +420,101 @@ ${scoutBlob || '(no scout notes)'}`;
279
420
  * first plan-flow job to emit `root-injected` runs the pre-registered ON-vs-OFF acceptance
280
421
  * read (the Layer R default-flip, LAYERS.md ⚠). Excluded on native (clipipe): the native
281
422
  * worker has no onToolResult seam, so the tee cannot settle and same-path rewrites are blind.
423
+ * @param {unknown} [opts.bridge] Layer 3 (D3/D4) — a bridge-v1 registry entry to REUSE as the
424
+ * drafter's starting point. Absent is the COLD path and is byte-identical to the pre-Layer-3
425
+ * flow. Present, the D2-split LOAD GATE runs at the door, before the close precheck and
426
+ * before any token: on a pass the newest version's plan (the run-as-executed artifact)
427
+ * rides into the FIRST draft prompt only; on a fail the run returns the distinct terminal
428
+ * `recipe-stale` having spent nothing. Selecting the entry — the listing, the pin, the
429
+ * LLM's pick — is the CALLER's, and so is falling back to cold: a silent automatic
430
+ * fall-back would spend a run's budget on a decision nobody made.
431
+ * @param {number} [opts.priorWallMs=0] RESUME (module C) — wall time a PREVIOUS, killed
432
+ * attempt of this same try already consumed, folded into the run clock (see
433
+ * `createClock`'s `priorElapsedMs`). The SIGNED `maxWallMs` is never rewritten: it is in
434
+ * the spec hash, and a resume that edited it would need a signature nobody typed. The
435
+ * restarted attempt simply starts already partly spent, so a kill can never buy a fresh
436
+ * allotment of the operator's wall.
437
+ * @param {any} [opts.resumeSeed] RESUME (module C v2) — the FINEST honest checkpoint of a
438
+ * KILLED run of this same try, read off its own spine by `readResume` (src/reuse.js) and
439
+ * by nothing else: `{ phase, plan, completedSteps: [{id, seq, by}] }`. hamr's ruling is
440
+ * the whole contract — *"it should allow resume and start last step instead from the
441
+ * beginning, why would i want to waste more money on something i already started"* — so
442
+ * with a seed this flow re-pays for NO paid unit the dead run already bought:
443
+ *
444
+ * - the SCOUT does not run (`scout-skipped`, never silence). Its survey is not on the
445
+ * spine (only its byte count is), so the ONE consequence is stated rather than
446
+ * discovered: a replan after a resume drafts from an empty survey plus the failure
447
+ * brief. Re-scouting to refill it would re-pay the exact call this exists to save.
448
+ * - the PLAN is not re-drafted. It is reloaded from the seed and RE-VALIDATED against
449
+ * the current signed spec; a plan that no longer validates is `plan-red` by name
450
+ * (`resume-plan-red`), never run. It is then emitted as this leg's own
451
+ * `plan-accepted` (`phase: 'resume'`) — R1 mints a bridge version from the plan AS
452
+ * EXECUTED, read back off the spine, so a leg that greens without one would mint
453
+ * nothing.
454
+ * - a step whose exit was already SATISFIED is skipped, in prefix order, each with its
455
+ * own `step-skipped` record naming the event that proves it. Never a fake
456
+ * step-start/step-end pair, and never silence: a reader must be able to tell skipped
457
+ * from run (the resume-to-cap invariant).
458
+ *
459
+ * The worker's conversation is deliberately NOT replayed — a transcript is not a
460
+ * checkpoint, and every attempt is fresh by the loop's own design. Absent is the
461
+ * ordinary path and is byte-identical to a run that was never killed.
462
+ * @param {{stage?: string|null, value?: number|null}[]} [opts.resumeGrades] RESUME — the
463
+ * close GRADES the dead leg recorded, oldest first (`readResume`'s `restart.grades`).
464
+ * A resumed leg is the same run continuing, so its halt readouts must judge the whole
465
+ * chain: without this the trend restarts at this leg's first close and can report
466
+ * `flat` — "revise the goal" — on a run that was converging when its allowance ran
467
+ * out. Baselines only (src/trend.js's THE SEED): it spends none of this leg's own
468
+ * bounds and mints none of its strikes. Empty/omitted is the cold path.
469
+ * @param {{count?: number, grantUsed?: boolean}|null} [opts.resumeReplans] RESUME — the
470
+ * replan LEDGER the chain has already spent (`readResume`'s `restart.replans` /
471
+ * `restart.replanGrantUsed`), seeding the ceiling below. `resumeGrades`' sibling and
472
+ * deliberately NOT its twin: a grade seed feeds a READOUT, this one feeds a BOUND.
473
+ *
474
+ * Without it the ceiling is reborn on every call, and a resume is another call:
475
+ * measured, a leg that spent both replans and step-redded handed its checkpoint to a
476
+ * second leg that replanned twice more. Two became four by being killed once, which is
477
+ * the creep the latch exists to make impossible ("unlimited replanning launders thrash
478
+ * as adaptation", PRD v1.12).
479
+ *
480
+ * WHY THIS SEED SPENDS THE LEG'S BOUND WHERE `resumeGrades` DOES NOT. src/trend.js's
481
+ * THE SEED refuses to seed the fix loop's ITERATIONS from history "because the leg's
482
+ * own bounds must not be spent by history" — and that is right, because an attempt
483
+ * allowance is a LEG bound: a restarted leg buys its own attempts with its own money.
484
+ * The replan ceiling is a RUN bound by doctrine. It bounds how many times the WORKFLOW
485
+ * may be redrawn before redrawing it is thrash, and that question spans exactly the
486
+ * chain the halt readout already spans. The two seeds therefore disagree on purpose;
487
+ * they are not the same rule applied inconsistently.
488
+ *
489
+ * CHAIN SEMANTICS, and the one place this differs from the grade seed's mechanism.
490
+ * `readGradeSeed`'s documented KNOWN LIMIT is that it reads ONE spine, so a resume of a
491
+ * resume inherits leg 2's grades and not leg 1's — the chain shortens by one leg per
492
+ * resume, which is fail-safe for a READOUT (a shorter chain can only under-claim a
493
+ * direction). It is the DANGEROUS direction for a ceiling: an under-claimed ledger is a
494
+ * refilled allowance, and every kill would buy one. So this seed does NOT reuse that
495
+ * mechanism. It follows the MONEY fold instead (`priorSpentUsd`, `try-start`/
496
+ * `job-start`): each leg DECLARES the ledger it inherited on its own spine, and the
497
+ * next reader adds only that leg's own `replan` records to the declared number. Leg 3
498
+ * therefore inherits the whole chain, not leg 2's slice.
499
+ *
500
+ * `count` seeds `replans` (and, above zero, latches `replanned`); `grantUsed` seeds the
501
+ * F85-C variance latch, so a killed leg cannot re-earn an extra the arbiter has already
502
+ * granted. Null/omitted is the cold path and is byte-identical to a run nobody resumed.
503
+ * @param {() => boolean} [opts.spendComplete] is the run's spend EXACT, or a floor? The
504
+ * ledger one level up owns the answer (unpriced rounds, a self-healed stall, a mid-call
505
+ * cut, an inherited resume floor); the money-halt readout states it beside the remaining
506
+ * it quotes, because `budget − floor` is a CEILING and printing it bare reads as exact
507
+ * (F6). Defaults to exact, which is what every pre-record caller was implicitly saying.
282
508
  * @param {() => number} [opts.now] the wall clock's time source, injected. The real clock is the
283
509
  * default; a caller supplies this to drive time deterministically (the same seam `createClock`
284
510
  * already exposes — a run's terminal cannot otherwise be exercised without waiting out a cap
285
511
  * whose floor is one close timeout).
286
512
  * @returns {Promise<string>} 'green' | 'already-green' | 'escalated' | 'plan-red' |
287
- * 'check-red' | 'close-red' | 'close-unsupported' | 'pricing-red' | 'cap-halt' |
288
- * 'wall-halt' | 'provider-red' | 'interpreter-red' | 'step-stalled' | `step-red:<id>`
513
+ * 'check-red' | 'close-red' | 'close-unsupported' | 'recipe-stale' | 'pricing-red' |
514
+ * 'cap-halt' | 'wall-halt' | 'provider-red' | 'interpreter-red' | 'step-stalled' |
515
+ * `step-red:<id>`
289
516
  */
290
- export async function runPlan(job, { workdir, provider, nativeProvider, providerFor, emit, remainingUsd, isUnpriced = () => false, capRuns = 3, closeTimeoutMs, maxStepRounds = 40, layerRoot = false, scoutRounds = SCOUT_ROUNDS, now }) {
517
+ 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 = [], resumeReplans = null }) {
291
518
  workdir = resolve(workdir);
292
519
  // ONE spelling of the redaction, housed next to the inventory it reads
293
520
  // (src/validate.js) — the same helper the isolate verbs scrub the litectx store
@@ -350,6 +577,46 @@ export async function runPlan(job, { workdir, provider, nativeProvider, provider
350
577
  return 'interpreter-red';
351
578
  }
352
579
 
580
+ // ── THE BRIDGE LOAD GATE (Layer 3, D2 as SPLIT by the 2026-08-01 addendum).
581
+ // Asked AT THE DOOR: *"is this the right KIND of recipe?"* — verdict type, close-stage
582
+ // kinds, verbs within the signed menu, and NOTHING instance-bound (paths, scopes and
583
+ // targets are expected to be yesterday's bricks; the full validatePlan judges the
584
+ // TWEAKED draft at draft time, D4).
585
+ //
586
+ // It sits FIRST among the things that cost anything, and above the clock and the close
587
+ // precheck deliberately: the gate is $0 and deterministic while a precheck is real wall
588
+ // time (four stages at the shipped timeout is an hour). It sits BELOW the two guards
589
+ // above it because those say the JOB cannot run at all, which outranks "this recipe does
590
+ // not fit". Consequence, named rather than discovered later: a refused run never learns
591
+ // whether the tree was already green — the caller's cold rerun does.
592
+ //
593
+ // The entry is re-validated here even though the caller is contracted to pass a valid
594
+ // one. Not defensive decoration: the very next thing this code does is reach into
595
+ // `versions.at(-1).plan`, and a half-written file that reached a reader anyway must come
596
+ // back as named reds, never as a TypeError out of the runner (`loadRegistry` already
597
+ // skips-and-reports for the same reason).
598
+ let startingDraft = null;
599
+ if (bridge != null) {
600
+ const bv = validateBridge(bridge);
601
+ const gate = bv.ok ? loadGate(bv.bridge, job) : { ok: false, reds: bv.reds };
602
+ const name = typeof (/** @type {any} */ (bridge)?.name) === 'string' ? /** @type {any} */ (bridge).name : null;
603
+ if (!gate.ok) {
604
+ emit('bridge-gate', { outcome: 'recipe-stale', name, reds: gate.reds });
605
+ emit('escalation', {
606
+ category: 'recipe-stale', decisionReady: true,
607
+ 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.`,
608
+ 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)'],
609
+ detail: gate.reds.map((/** @type {any} */ r) => `${r.code}:${r.path}${r.detail ? ` — ${r.detail}` : ''}`).join('\n'),
610
+ });
611
+ return 'recipe-stale';
612
+ }
613
+ // `versions` is oldest-first and non-empty by the validator's entry bar, so the newest
614
+ // green's plan-as-executed is the one that inherits (R1) — never the founding one.
615
+ const newest = bv.bridge.versions.at(-1);
616
+ startingDraft = newest.plan;
617
+ emit('bridge-loaded', { name: bv.bridge.name, versions: bv.bridge.versions.length, runid: newest.runid });
618
+ }
619
+
353
620
  // T (PRD v1.27/v1.29) — the run's wall clock, STARTED HERE, before the close
354
621
  // precheck. The precheck is real wall time and F62 measured close/check gaps as
355
622
  // high as 328s on an archived run: a clock created after it would under-report
@@ -370,7 +637,11 @@ export async function runPlan(job, { workdir, provider, nativeProvider, provider
370
637
  // one. `stagedClose` is the SAME staging the runner executes (never a second
371
638
  // count), and it is non-null here by the close-unsupported guard above — a close
372
639
  // that names no command escalated before this line, so there is no null to thread.
373
- const clock = createClock({ maxWallMs: job.maxWallMs ?? null, closeStages: stagedClose.length, ...(now ? { now } : {}) });
640
+ //
641
+ // RESUME (module C): `priorWallMs` is time a killed attempt of this same try already
642
+ // consumed. It folds into the clock's start, never into `maxWallMs` — the cap stays the
643
+ // signed one in the record and in the hash, and only the REMAINDER is left to run.
644
+ const clock = createClock({ maxWallMs: job.maxWallMs ?? null, closeStages: stagedClose.length, priorElapsedMs: priorWallMs, ...(now ? { now } : {}) });
374
645
  const closeTimeoutForReport = closeTimeoutMs ?? 120_000;
375
646
  emit('wall-clock', {
376
647
  ...clock.report(closeTimeoutForReport),
@@ -379,6 +650,33 @@ export async function runPlan(job, { workdir, provider, nativeProvider, provider
379
650
  : 'NO time cap was set — time-unbounded by explicit operator choice, never by a default',
380
651
  });
381
652
 
653
+ /**
654
+ * The per-call TIME bounds, in ONE place so the two `loop.run` sites cannot drift
655
+ * (the two-transforms class, F9). bare-agent forwards a run's options straight into
656
+ * `provider.generate` (loop.js:732), so both knobs land on the request:
657
+ *
658
+ * timeoutMs BA-18, IDLE: `req.setTimeout`, reset by every byte. Bounds a silent
659
+ * socket and nothing else.
660
+ * deadlineMs BA-19, TOTAL: a plain timer no byte resets. This is the bound for
661
+ * the F66 shape — a call that trickles forever while the run's own
662
+ * wall passes (274 minutes, with the idle bound correctly armed).
663
+ *
664
+ * `deadlineMs` is OMITTED, never zeroed, when the run is unbounded: the operator set
665
+ * no wall, so there is no ceiling to derive, and inventing one is the silent second
666
+ * ceiling F45 exists to forbid. Omission is also the honest spelling upstream — an
667
+ * absent knob inherits the provider's own default (disabled), while an explicitly
668
+ * garbage value THROWS there (`resolveTimeoutMs`, provider-http.js:53).
669
+ *
670
+ * The NATIVE (clipipe) worker does not call this and never did: the CLI owns the
671
+ * transport, there is no `ClientRequest` for either timer to arm, and that path
672
+ * passes neither bound today. Stated rather than assumed — an unbounded native call
673
+ * is bounded by the F66 stall fuse and the outside watchdog, not by this.
674
+ */
675
+ const callBounds = () => {
676
+ const deadlineMs = clock.callDeadlineMs();
677
+ return { timeoutMs: clock.callTimeoutMs(), ...(deadlineMs === null ? {} : { deadlineMs }) };
678
+ };
679
+
382
680
  /**
383
681
  * F64 — ONE categoriser for a throw out of a provider call, so the seams cannot
384
682
  * drift. A `HaltError` is the budget gate. The run's OWN deadline coming back as
@@ -424,6 +722,20 @@ export async function runPlan(job, { workdir, provider, nativeProvider, provider
424
722
  'revise the goal/spec so the work fits the time (same re-approval)',
425
723
  'abandon the task',
426
724
  ];
725
+ /** MONEY's levers (PRD v1.46 §2), the same three shapes the wall's are, because a
726
+ * money cut is the same KIND of stop: the run is out of an operator-owned
727
+ * allowance, not out of capability, and the last verdict rendered stands. hamr's
728
+ * ask was "halt and feedback… and needs to be accurate", so the readout that
729
+ * carries these also carries the trend that says WHICH of the first two fits.
730
+ *
731
+ * The first lever is a top-up the OPERATOR performs. `budgetUsd` is in the spec
732
+ * hash, so raising it is a new spec version somebody signs — the library only ever
733
+ * names the lever, and never adjusts a budget itself (the permanent hard line). */
734
+ const MONEY_OPTIONS = [
735
+ 'top up budgetUsd and rerun with --resume (resume-to-cap; a spec edit, so the new hash needs re-approval)',
736
+ 'revise the goal/spec so the work fits the budget (same re-approval)',
737
+ 'abandon the task',
738
+ ];
427
739
 
428
740
  const closeOpts = { timeoutMs: closeTimeoutMs, cwd: workdir };
429
741
  /** @type {string|undefined} the stage that rendered the LAST close verdict (Layer R's red-set source) */
@@ -433,11 +745,63 @@ export async function runPlan(job, { workdir, provider, nativeProvider, provider
433
745
  * the run's answer is whatever the close last said, and nothing after the deadline
434
746
  * is allowed to change it (hamr: *"keep the grade we already have and stop"*). */
435
747
  let lastCloseVerdict;
748
+ /** PRD v1.46 §2 — the RUN's own close trend, fed by every grade the arbiter
749
+ * renders (the precheck is the seed the work is measured against, so it is the
750
+ * first reading, not a discarded one). Read only by the halt readouts; it decides
751
+ * nothing and bounds nothing — the fix loop builds its OWN reader over its own
752
+ * grades, because "did this run make progress" and "is this loop out of ideas"
753
+ * are two questions and one instrument answering both is how they come to
754
+ * disagree. Per stage by construction (src/trend.js's accuracy law).
755
+ *
756
+ * On a RESUME it is SEEDED with the grades the dead leg recorded, so both halt
757
+ * readouts judge the whole chain rather than this leg alone: a leg that re-grades
758
+ * an unchanged tree is flat on its own evidence while the RUN — the thing the human
759
+ * is deciding about — may have been converging when its allowance ran out. Cold, the
760
+ * seed is empty and this is byte-identical to the pre-resume reader. */
761
+ const runTrend = createTrend({ stageOrder: stagedClose.map((/** @type {any} */ s) => s.name), seed: resumeGrades });
762
+ /** the run-level MONEY record, one shape wherever the wallet stops the run —
763
+ * W-2's `emitWallHalt` in a money coat, and deliberately its mirror image. hamr's
764
+ * ruling for TIME was "keep the grade we already have and stop"; a money cut is
765
+ * the same stop with a different allowance behind it, so it keeps the same grade,
766
+ * states the same kind of trend, and hands over the same shape of lever list.
767
+ * `spentUsd` is NOT re-derived here: runJob's ledger owns that number and states
768
+ * it on `job-end` (F6 — a second, weaker arithmetic for the same figure is how
769
+ * two instruments come to disagree about one run's money). What this record can
770
+ * say exactly is the SIGNED ceiling and what is left against it. */
771
+ const emitMoneyHalt = (/** @type {object} */ extra) => {
772
+ const t = runTrend.verdict();
773
+ return emit('money-halt', {
774
+ meaning: 'not under cap — not "can\'t"',
775
+ budgetUsd: job.budgetUsd,
776
+ remainingUsd: remainingUsd(),
777
+ // …and whether that figure is EXACT. `remainingUsd` is `budget − spent`, so a run
778
+ // whose spend is a FLOOR has a remaining that is a CEILING, and quoting it bare to
779
+ // four decimals reads as the number. The four causes live in the ledger one level
780
+ // up (unpriced, self-healed stall, mid-call cut, an inherited floor), which is why
781
+ // this is READ from there and not re-derived here — a second, weaker arithmetic for
782
+ // the same question is how two instruments come to disagree. `emitWallHalt` has
783
+ // carried its honest bound since W-2; this is the same duty on the money side.
784
+ spendComplete: spendComplete(),
785
+ verdict: lastCloseVerdict?.verdict ?? null,
786
+ ...(lastCloseVerdict?.stage ? { stage: lastCloseVerdict.stage } : {}),
787
+ trend: t.trend,
788
+ motion: t.motion,
789
+ reading: t.reading,
790
+ lever: t.lever,
791
+ series: t.series,
792
+ options: MONEY_OPTIONS,
793
+ ...extra,
794
+ });
795
+ };
436
796
  /** the close, as ONE verdict: every stage in order, first red wins */
437
797
  const judgeClose = async () => {
438
798
  const v = await runStages(stagedClose, scrub, closeOpts);
439
799
  closeStage = v.stage;
440
800
  lastCloseVerdict = v;
801
+ // Only a RED grade is a reading. A `satisfied` ends the run and a close FAULT
802
+ // rendered no judgment at all (CLOSE_FAULTS) — folding either into the series
803
+ // would put a non-number where the instrument expects a graded one.
804
+ if (v.verdict === 'needs_revision') runTrend.record({ gap: v.gap });
441
805
  return v;
442
806
  };
443
807
 
@@ -459,6 +823,14 @@ export async function runPlan(job, { workdir, provider, nativeProvider, provider
459
823
  // The menu is DERIVED from the close (PRD v1.28) — nobody authored it, so the
460
824
  // ruler and the real inspection cannot drift apart.
461
825
  const menu = checkMenu(stagedClose);
826
+ /** Rule A-v2's fact: check names RED at preflight (seed-red). A seed-red
827
+ * check is the GOAL, not a guard — validatePlan confines it to the final
828
+ * write step, and the drafting prompt states the same law. Recorded HERE,
829
+ * from the same preflight the spine records, never re-derived mid-run: the
830
+ * rule is defined on the SEED verdicts (a check a step turned green mid-run
831
+ * is still the goal the plan exists to flip).
832
+ * @type {string[]} */
833
+ const seedRed = [];
462
834
  emit('check-menu', {
463
835
  offered: menu.map((m) => m.name),
464
836
  ...(menu.length < stagedClose.length
@@ -467,6 +839,25 @@ export async function runPlan(job, { workdir, provider, nativeProvider, provider
467
839
  });
468
840
  for (const m of menu) {
469
841
  const v = await runStages(m.run, scrub, closeOpts);
842
+ if (v.verdict === 'needs_revision') {
843
+ seedRed.push(m.name);
844
+ // …and the preflight grade is a READING, for the same reason the precheck one
845
+ // above is: it is the seed the run's work is measured against. The precheck
846
+ // only ever reds at the close's FIRST failing stage (first-red-wins), so on
847
+ // every shipped close — each of which opens with `changed-from-seed` — the
848
+ // numeric stages have no baseline at all without it. A step's first grade
849
+ // would then have nothing to compare against and the whole run would read
850
+ // `unknown` on exactly the stop the readout exists for.
851
+ //
852
+ // ONCE PER STAGE, though. The precheck and this loop grade the SAME unchanged
853
+ // tree back to back with no work in between, so a stage both reach reports the
854
+ // same number twice — and a repeated baseline in a series reads as an attempt
855
+ // that achieved nothing (`verdict 2 → 2 → 1`). That is a phantom flat step
856
+ // handed to a human and to the replanner, which is the exact class of false
857
+ // story this change exists to remove. Asked of the reader's own books, so
858
+ // there is no second record of what has been graded.
859
+ if (!runTrend.report().stages.some((s) => s.stage === v.stage)) runTrend.record({ gap: v.gap });
860
+ }
470
861
  emit('check-preflight', { name: m.name, verdict: v.verdict, ...(m.run.length > 1 ? { chain: m.run.map((s2) => s2.name) } : {}) });
471
862
  const f = Object.hasOwn(CLOSE_FAULTS, v.verdict) ? CLOSE_FAULTS[v.verdict] : undefined;
472
863
  if (f) {
@@ -521,7 +912,24 @@ export async function runPlan(job, { workdir, provider, nativeProvider, provider
521
912
  const v = await runStages(chain, scrub, closeOpts);
522
913
  emit('check-run', { name, verdict: v.verdict, ...(v.stage && v.stage !== name ? { stage: v.stage } : {}), ...(v.exitCode !== undefined ? { exitCode: v.exitCode } : {}) });
523
914
  if (v.verdict === 'satisfied') return { pass: true };
524
- if (v.verdict === 'needs_revision') return { pass: false, gap: v.gap };
915
+ if (v.verdict === 'needs_revision') {
916
+ // The run trend is fed by EVERY grade the arbiter renders, and a check IS an
917
+ // arbiter-rendered close grade: the same `runStages`, the same chain, the same
918
+ // stage names, so it lands in the same per-stage buckets and the accuracy law
919
+ // holds unchanged. Without this the reader is blind to everything a STEP ever
920
+ // achieves — it sees only the precheck and the outer fix loop — which is why
921
+ // u-msh70zla's meter, firing mid-step, had nothing to report about a step that
922
+ // had just gone 24 → 15 → 14.
923
+ //
924
+ // Folded HERE, on `v.gap`, and the seam is load-bearing: `evalExits` wraps this
925
+ // gap as `check "<name>" red: …`, and THAT line carries the word "red" with no
926
+ // number on it, so `readGrade` reads the wrapper instead of the wall and every
927
+ // step grade donates a null. Measured on u-msh70zla's own archived gaps: raw
928
+ // reads `typecheck 24 → 15 → 14`, wrapped reads nothing at all. Same seam the
929
+ // Layer R note below already calls out for a check's `^`-anchored gapKeep.
930
+ runTrend.record({ gap: v.gap });
931
+ return { pass: false, gap: v.gap };
932
+ }
525
933
  return { pass: false, fault: v.verdict, gap: v.detail };
526
934
  };
527
935
 
@@ -536,13 +944,39 @@ export async function runPlan(job, { workdir, provider, nativeProvider, provider
536
944
  * denied, the wallet as its budget), granted tools only (the menu IS the
537
945
  * grant), per-attempt round bound via loop.stop() (F20), every round metered
538
946
  * with a phase label (F12).
539
- * @param {{granted: string[], phase: string, attemptRounds: number, attempts: number, writable: boolean, root?: ReturnType<typeof createRoot>|null, fence?: string[]|null, workerProvider?: any}} o
947
+ * @param {{granted: string[], phase: string, attemptRounds: number, attempts: number|null, writable: boolean, root?: ReturnType<typeof createRoot>|null, fence?: string[]|null, workerProvider?: any}} o
540
948
  * `root` (Layer R): when present, the worker's write-class actions are teed
541
949
  * for the within-run ratchet — staged before the gate decides, discarded on
542
950
  * deny/halt, settled to landed-or-not after execution (the two axes, F43/F7).
543
951
  * Wired ONLY on the Loop path: the native session exposes no onToolResult seam.
952
+ *
953
+ * `attempts` is how many iterations this worker will be asked for. `null` is the
954
+ * STRIKE LADDER (src/ladder.js), where that count does not exist by design — see
955
+ * the turn-allowance note below.
544
956
  */
545
957
  async function mkWorker({ granted, phase, attemptRounds, attempts, writable, root = null, fence = null, workerProvider = null }) {
958
+ /**
959
+ * The gate's LLM-turn allowance THROUGH iteration `i` — the old expression
960
+ * `attemptRounds * (attempts + 1)` with the iteration count made explicit
961
+ * (`i = attempts` reproduces it exactly, so a bounded worker is unchanged to
962
+ * the number).
963
+ * @param {number} i
964
+ */
965
+ const turnsThrough = (i) => attemptRounds * (i + 1);
966
+ // A pre-multiplied ceiling could only ever pre-pay a KNOWN iteration count, and
967
+ // the strike ladder has none: iterations float under money and the wall. So on
968
+ // that path the allowance is GRANTED PER ITERATION at `setIteration` — the seam
969
+ // that already resets the per-iteration round counter — leaving the belt exactly
970
+ // as tight per iteration as the pre-multiply made it, without inventing a
971
+ // ceiling nobody signed. It is monotonic (`Math.max`), so nothing can lower it.
972
+ //
973
+ // Rebuilding the Gate per iteration was the alternative and was REJECTED: the
974
+ // audit is run_id-scoped, so a fresh gate would reset F32's crash-attribution
975
+ // write set ("files you have written this run") and Layer R's cross-attempt
976
+ // write history to a single iteration — two instruments broken to avoid one
977
+ // addition. The REAL per-iteration bound is unchanged either way: `loop.stop()`
978
+ // at `roundsThisAttempt >= attemptRounds` (the `wasBounded` mechanism).
979
+ const maxTurns = turnsThrough(attempts ?? 1);
546
980
  const gate = new Gate({
547
981
  fs: {
548
982
  // P: a step's `scope` narrows the fence — the per-step prefixes are always a
@@ -553,7 +987,7 @@ export async function runPlan(job, { workdir, provider, nativeProvider, provider
553
987
  deny: [auditPath, join(workdir, '.smoke'), join(workdir, '.litectx')],
554
988
  },
555
989
  budget: { maxCostUsd: Math.max(remainingUsd(), 0.0001) },
556
- limits: { maxTurns: attemptRounds * (attempts + 1) },
990
+ limits: { maxTurns },
557
991
  audit: { path: auditPath },
558
992
  // The gate audit is the THIRD persistent, append-only channel (beside the
559
993
  // spine and the ctx-verb events) and it lives IN THE TREE, so it gets the
@@ -569,21 +1003,35 @@ export async function runPlan(job, { workdir, provider, nativeProvider, provider
569
1003
  });
570
1004
  await gate.init();
571
1005
  // F32's instrument, run_id-scoped, write AND edit, allow-decision only —
572
- // the same audit read as interpret's (never git status, F45)
573
- const workerWrites = () => {
1006
+ // the same audit read as interpret's (never git status, F45). ONE scan and ONE
1007
+ // filter behind both readings: a future write-class verb that slipped into one
1008
+ // and not the other would make the two instruments disagree about what a write
1009
+ // is, which is the exact class the tee's `type`-not-name test exists to close.
1010
+ const auditWrites = () => {
1011
+ const paths = new Set();
1012
+ let records = 0;
574
1013
  try {
575
- const paths = new Set();
576
1014
  for (const line of readFileSync(auditPath, 'utf8').split('\n')) {
577
1015
  if (!line) continue;
578
1016
  let rec;
579
1017
  try { rec = JSON.parse(line); } catch { continue; }
580
1018
  if (rec.run_id === gate.runId && rec.phase === 'gate' && rec.decision === 'allow'
581
1019
  && (rec.action?.type === 'write' || rec.action?.type === 'edit')
582
- && typeof rec.action.path === 'string') paths.add(rec.action.path);
1020
+ && typeof rec.action.path === 'string') { paths.add(rec.action.path); records += 1; }
583
1021
  }
584
- return [...paths];
585
- } catch { return []; }
1022
+ } catch { /* no audit yet → nothing written yet */ }
1023
+ return { paths: [...paths], records };
586
1024
  };
1025
+ const workerWrites = () => auditWrites().paths;
1026
+ /**
1027
+ * The ladder's write signal: the cumulative RECORD count, never the path set.
1028
+ * A plan step rewrites its one target every attempt, so the path set is constant
1029
+ * after iteration 1 and a set-delta would read every later iteration as idle —
1030
+ * the same Finding-3 trap Layer R documents, and it would strike a working
1031
+ * worker out on its second attempt. The record count advances on every allowed
1032
+ * write-class action, same path or not.
1033
+ */
1034
+ const writeCount = () => auditWrites().records;
587
1035
  // Layer R tee (design record 2026-07-19; created per-step by executeStep).
588
1036
  // The translator STAGES write/edit content and snapshots the pre-write hash
589
1037
  // BEFORE the gate decides (Finding 6); the policy wrapper DISCARDS on a deny
@@ -652,6 +1100,23 @@ export async function runPlan(job, { workdir, provider, nativeProvider, provider
652
1100
  let roundsThisAttempt = 0;
653
1101
  /** @type {number|string|undefined} */
654
1102
  let attemptBounded;
1103
+ /**
1104
+ * Open an iteration: label the rounds, reset the per-iteration round counter,
1105
+ * and — on the unbounded ladder only — GRANT this iteration's turn allowance to
1106
+ * the gate. Raising `limits.maxTurns` is documented bareguard config read live
1107
+ * by `Limits.preCheck()` on every record; the alternative (a fresh Gate per
1108
+ * iteration) is the one that breaks instruments, so this is the smaller change.
1109
+ * Monotonic by construction: a re-entry can only ever hold the allowance, never
1110
+ * lower it. A bounded worker's ceiling is untouched — it was pre-paid in full.
1111
+ * @param {number|string} i
1112
+ */
1113
+ const setIteration = (i) => {
1114
+ roundIteration = i;
1115
+ roundsThisAttempt = 0;
1116
+ if (attempts === null && typeof i === 'number') {
1117
+ gate.limits.maxTurns = Math.max(gate.limits.maxTurns, turnsThrough(i));
1118
+ }
1119
+ };
655
1120
  const grantedNames = new Set(granted.map((v) => /** @type {Record<string, string>} */ (TOOL_BY_VERB)[v]));
656
1121
  const shell = createShellTools().tools.filter((/** @type {{name: string}} */ t) => grantedNames.has(t.name));
657
1122
  // F48: on native, bound shell_read below the CLI's tool-result display cap and hand back a
@@ -752,7 +1217,7 @@ export async function runPlan(job, { workdir, provider, nativeProvider, provider
752
1217
  }
753
1218
  return r;
754
1219
  };
755
- return { ask, askFrom, workerWrites, setIteration: (/** @type {number|string} */ i) => { roundIteration = i; roundsThisAttempt = 0; }, wasBounded: () => attemptBounded };
1220
+ return { ask, askFrom, workerWrites, writeCount, setIteration, wasBounded: () => attemptBounded };
756
1221
  }
757
1222
 
758
1223
  // ── LOOP path: the injected provider (anthropic-api and every other
@@ -763,7 +1228,7 @@ export async function runPlan(job, { workdir, provider, nativeProvider, provider
763
1228
  // onLlmResult meters exactly like an API round, so the drafter's spend is
764
1229
  // never invisible (F6/F44). The gate policy is wired but idle (no tools).
765
1230
  const loopProvider = native
766
- ? /** @type {any} */ (nativeProvider)({ policy, maxTurns: attemptRounds * (attempts + 1), hasTools: false })
1231
+ ? /** @type {any} */ (nativeProvider)({ policy, maxTurns, hasTools: false })
767
1232
  : (workerProvider ?? provider);
768
1233
  // F66 — the stall watchdog. bare-agent's `timeoutMs` bounds socket INACTIVITY,
769
1234
  // not call duration (provider-http.js `req.setTimeout` resets on every byte),
@@ -870,7 +1335,7 @@ export async function runPlan(job, { workdir, provider, nativeProvider, provider
870
1335
  */
871
1336
  const askFrom = async (msgs, prompt) => {
872
1337
  try {
873
- return await stallWatch.watch((gen) => newLoop(gen).run([...msgs, { role: 'user', content: prompt }], [], { cacheMessages: true, maxTokens: 32000, timeoutMs: clock.callTimeoutMs() }));
1338
+ return await stallWatch.watch((gen) => newLoop(gen).run([...msgs, { role: 'user', content: prompt }], [], { cacheMessages: true, maxTokens: 32000, ...callBounds() }));
874
1339
  } catch (e) {
875
1340
  throw categorize(e).err;
876
1341
  }
@@ -878,7 +1343,7 @@ export async function runPlan(job, { workdir, provider, nativeProvider, provider
878
1343
  const ask = async (prompt, defs = toolDefs) => {
879
1344
  let r;
880
1345
  try {
881
- r = await stallWatch.watch((gen) => newLoop(gen).run([{ role: 'user', content: prompt }], defs, { cacheMessages: true, maxTokens: 32000, timeoutMs: clock.callTimeoutMs() }));
1346
+ r = await stallWatch.watch((gen) => newLoop(gen).run([{ role: 'user', content: prompt }], defs, { cacheMessages: true, maxTokens: 32000, ...callBounds() }));
882
1347
  } catch (e) {
883
1348
  throw categorize(e).err;
884
1349
  }
@@ -895,7 +1360,7 @@ export async function runPlan(job, { workdir, provider, nativeProvider, provider
895
1360
  }
896
1361
  return r;
897
1362
  };
898
- return { ask, askFrom, workerWrites, setIteration: (/** @type {number|string} */ i) => { roundIteration = i; roundsThisAttempt = 0; }, wasBounded: () => attemptBounded };
1363
+ return { ask, askFrom, workerWrites, writeCount, setIteration, wasBounded: () => attemptBounded };
899
1364
  }
900
1365
 
901
1366
  /** relay a throw from OUTSIDE ralph (scout/plan drafting/fix) as its honest
@@ -906,10 +1371,16 @@ export async function runPlan(job, { workdir, provider, nativeProvider, provider
906
1371
  const relay = (/** @type {any} */ e, /** @type {string} */ phase) => {
907
1372
  const { category } = categorize(e);
908
1373
  const detail = String(e?.message ?? e);
909
- if (category === 'cap-halt') emit('cap-halt', { category, meaning: 'not under cap — not "can\'t"', detail });
1374
+ if (category === 'cap-halt') {
1375
+ emit('cap-halt', { category, meaning: 'not under cap — not "can\'t"', detail });
1376
+ // v1.46 §2 — the money record rides beside the taxonomy record exactly as the
1377
+ // wall's does one line down. Outside the loops there is usually no series yet,
1378
+ // and the readout says `unknown` rather than inventing a direction (F6).
1379
+ emitMoneyHalt({ phase, cutMidCall: true });
1380
+ }
910
1381
  if (category === 'wall-halt') emitWallHalt({ cutMidCall: true, phase });
911
1382
  const DECIDE = {
912
- 'cap-halt': [`The budget gate tripped during ${phase} — the wallet cannot fund the plan flow.`, ['raise the job budget and rerun', 'abandon the run']],
1383
+ 'cap-halt': [`The budget gate tripped during ${phase} — the wallet cannot fund the plan flow.`, MONEY_OPTIONS],
913
1384
  'wall-halt': [`The run reached its wall-clock cap during ${phase}. Time ran out, not capability.`, WALL_OPTIONS],
914
1385
  // A stall OUTSIDE a step (scout, drafting, fix) has no step to replan, so it
915
1386
  // surfaces — but it is named, never laundered into provider-red. The socket
@@ -933,48 +1404,63 @@ export async function runPlan(job, { workdir, provider, nativeProvider, provider
933
1404
  // ── 1. SCOUT — read-only by construction: the write-class verbs are simply
934
1405
  // not in its menu (the menu is the grant), and its gate fences zero paths
935
1406
  let scoutBlob = '';
936
- emit('scout-start', { rounds: scoutRounds });
937
- try {
938
- const scout = await mkWorker({ granted: ceiling.filter((v) => !WRITE_VERBS.includes(v) && !STORE_VERBS.includes(v)), phase: 'scout', attemptRounds: scoutRounds, attempts: 1, writable: false });
939
- scout.setIteration(1);
940
- const r = await scout.ask([
941
- 'Survey this repository READ-ONLY for the goal below. Report: the relevant layout, the key files and symbols, and your best hypothesis about what the work requires. Be concise — your notes brief a planner that cannot see the repository.',
942
- `Repository root (absolute): ${workdir}\nEvery path you pass to a tool MUST be absolute and inside this root.`,
943
- `Goal:\n${job.goal}`,
944
- pre.gap && `The job's verification is currently failing. Its output on the tree as it stands:\n${pre.gap}`,
945
- ].filter(Boolean).join('\n\n'));
946
- scoutBlob = scrub(r.text ?? '').slice(0, SCOUT_BLOB_MAX);
947
- // F59 — RESERVE the summary round. The round bound is enforced from the
948
- // metering callback (loop.stop), so a scout still calling tools on its last
949
- // round is halted mid-tool-use and `text` is empty: it spends the whole
950
- // allowance exploring and never writes the survey that is its ONLY
951
- // deliverable. Measured on 15 of 18 archived runs, which then drafted from
952
- // `(no scout notes)` while still paying ~12% of the run (F55) for the walk.
953
- // The recovery is one TOOLLESS round on the SAME conversation (`r.msgs`
954
- // carries the exploration): with no tools offered, text is the only possible
955
- // output — a mechanical guarantee, never a prose plea to summarise (F19/F37:
956
- // a pacing mandate in the persona was violated 6/6).
957
- // The trigger is BOUNDED-and-short, never short alone: a terse survey that
958
- // finished on its own was not cut off and has nothing to recover. The archive
959
- // separates cleanly on exactly this pair — every truncated scout was bounded
960
- // with 0-86 bytes, and the two that finished naturally wrote 5991/8056.
961
- if (scout.wasBounded() && Buffer.byteLength(scoutBlob) < SCOUT_MIN_BYTES && Array.isArray(r.msgs) && r.msgs.length) {
962
- emit('scout-truncated', { bytes: Buffer.byteLength(scoutBlob) });
963
- const s2 = await scout.askFrom(r.msgs, 'You are out of exploration turns. Write your survey NOW, from what you have already read: the relevant layout, the key files and symbols, and your best hypothesis about what the work requires. Text only.');
964
- const recovered = scrub(s2?.text ?? '').slice(0, SCOUT_BLOB_MAX);
965
- if (Buffer.byteLength(recovered) > Buffer.byteLength(scoutBlob)) scoutBlob = recovered;
1407
+ // RESUME (module C v2): the survey is a PAID unit the killed run already bought, and
1408
+ // the plan it briefed is being reloaded rather than re-drafted — so there is nothing
1409
+ // left for it to brief. Skipping it is the whole point of the ruling ("why would i
1410
+ // want to waste more money on something i already started"), and the skip is a RECORD
1411
+ // rather than silence, with the one consequence named on it: the survey text is not on
1412
+ // the spine (only its byte count ever was), so a replan after a resume drafts from an
1413
+ // empty survey plus its failure brief. Re-scouting to refill it would re-pay exactly
1414
+ // the call this exists to save.
1415
+ if (resumeSeed) {
1416
+ emit('scout-skipped', {
1417
+ reason: 'resumed', phase: resumeSeed.phase ?? null,
1418
+ 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.',
1419
+ });
1420
+ } else {
1421
+ emit('scout-start', { rounds: scoutRounds });
1422
+ try {
1423
+ const scout = await mkWorker({ granted: ceiling.filter((v) => !WRITE_VERBS.includes(v) && !STORE_VERBS.includes(v)), phase: 'scout', attemptRounds: scoutRounds, attempts: 1, writable: false });
1424
+ scout.setIteration(1);
1425
+ const r = await scout.ask([
1426
+ '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.',
1427
+ `Repository root (absolute): ${workdir}\nEvery path you pass to a tool MUST be absolute and inside this root.`,
1428
+ `Goal:\n${job.goal}`,
1429
+ pre.gap && `The job's verification is currently failing. Its output on the tree as it stands:\n${pre.gap}`,
1430
+ ].filter(Boolean).join('\n\n'));
1431
+ scoutBlob = scrub(r.text ?? '').slice(0, SCOUT_BLOB_MAX);
1432
+ // F59 — RESERVE the summary round. The round bound is enforced from the
1433
+ // metering callback (loop.stop), so a scout still calling tools on its last
1434
+ // round is halted mid-tool-use and `text` is empty: it spends the whole
1435
+ // allowance exploring and never writes the survey that is its ONLY
1436
+ // deliverable. Measured on 15 of 18 archived runs, which then drafted from
1437
+ // `(no scout notes)` while still paying ~12% of the run (F55) for the walk.
1438
+ // The recovery is one TOOLLESS round on the SAME conversation (`r.msgs`
1439
+ // carries the exploration): with no tools offered, text is the only possible
1440
+ // output — a mechanical guarantee, never a prose plea to summarise (F19/F37:
1441
+ // a pacing mandate in the persona was violated 6/6).
1442
+ // The trigger is BOUNDED-and-short, never short alone: a terse survey that
1443
+ // finished on its own was not cut off and has nothing to recover. The archive
1444
+ // separates cleanly on exactly this pair — every truncated scout was bounded
1445
+ // with 0-86 bytes, and the two that finished naturally wrote 5991/8056.
1446
+ if (scout.wasBounded() && Buffer.byteLength(scoutBlob) < SCOUT_MIN_BYTES && Array.isArray(r.msgs) && r.msgs.length) {
1447
+ emit('scout-truncated', { bytes: Buffer.byteLength(scoutBlob) });
1448
+ 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.');
1449
+ const recovered = scrub(s2?.text ?? '').slice(0, SCOUT_BLOB_MAX);
1450
+ if (Buffer.byteLength(recovered) > Buffer.byteLength(scoutBlob)) scoutBlob = recovered;
1451
+ }
1452
+ } catch (e) {
1453
+ return relay(e, 'scout');
966
1454
  }
967
- } catch (e) {
968
- return relay(e, 'scout');
1455
+ emit('scout-result', { bytes: Buffer.byteLength(scoutBlob) });
1456
+ // F59 — the LOUD half. `scout-result {bytes: 0}` was emitted faithfully 18 times
1457
+ // out of 18 and read by nobody: an instrument that works but has no consumer is a
1458
+ // log line, not evidence. A survey that is still empty after its reserved round is
1459
+ // now a NAMED condition. It is never a halt — F59's own evidence is that 3 of 5
1460
+ // archived greens had an empty scout, so failing the run here would be a worse
1461
+ // error than the one being fixed.
1462
+ if (Buffer.byteLength(scoutBlob) < SCOUT_MIN_BYTES) emit('scout-empty', { bytes: Buffer.byteLength(scoutBlob), rounds: scoutRounds });
969
1463
  }
970
- emit('scout-result', { bytes: Buffer.byteLength(scoutBlob) });
971
- // F59 — the LOUD half. `scout-result {bytes: 0}` was emitted faithfully 18 times
972
- // out of 18 and read by nobody: an instrument that works but has no consumer is a
973
- // log line, not evidence. A survey that is still empty after its reserved round is
974
- // now a NAMED condition. It is never a halt — F59's own evidence is that 3 of 5
975
- // archived greens had an empty scout, so failing the run here would be a worse
976
- // error than the one being fixed.
977
- if (Buffer.byteLength(scoutBlob) < SCOUT_MIN_BYTES) emit('scout-empty', { bytes: Buffer.byteLength(scoutBlob), rounds: scoutRounds });
978
1464
  // F6 in-flight: an unpriced round means the cap cannot govern spend it cannot
979
1465
  // see — halt at the boundary rather than run the whole plan blind (the caller
980
1466
  // emits pricing-red; runPlan just stops burning tokens). Legacy halts per-step.
@@ -999,11 +1485,30 @@ export async function runPlan(job, { workdir, provider, nativeProvider, provider
999
1485
  // is actually left, which is the adaptation channel.
1000
1486
  const materials = { balanceUsd: remainingUsd(), remainingMs: clock.bounded ? clock.remainingMs() : null, ...(progress ? { progress } : {}) };
1001
1487
  emit('materials', { phase, ...materials });
1488
+ // D5 — the bridge rides into the FIRST draft phase ONLY. A replan drafts from the
1489
+ // RUN'S OWN STATE (what failed, what is left): re-handing the recipe there would
1490
+ // answer the replan's question with the material the run just demonstrated does not
1491
+ // work, and would spend the one revision the ceiling allows on a second unmeasured
1492
+ // arm. Exactly the rule the execution probe held at the provider seam, moved to where
1493
+ // the phase is known rather than inferred from the prompt's own text.
1494
+ // The REDRAFT (a validator rejection inside this same phase) keeps it: one red must
1495
+ // not silently convert a warm run to a cold one.
1496
+ const starting = phase === 'draft' ? startingDraft : null;
1497
+ // The shape-lottery facts, gate AND prompt from the ONE object (the scope-menu
1498
+ // precedent: what is stated and what is enforced can never drift apart).
1499
+ // `seedRed` rides every phase — the rule is defined on the SEED verdicts.
1500
+ // `priorChecks` is the REPLAN gate only: the accepted plan's carried checks,
1501
+ // which the redraft may move (A-v2 decides where) but never shed (Rule B —
1502
+ // u-msdsmkid's replan dropped both checks and "greened" on form alone).
1503
+ const checkFacts = {
1504
+ seedRed,
1505
+ ...(phase === 'replan' && plan
1506
+ ? { 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)))] }
1507
+ : {}),
1508
+ };
1002
1509
  const draftPlan = async (/** @type {any[]|null} */ reds) => {
1003
1510
  drafter.setIteration(reds ? 'redraft' : 'draft');
1004
- // capRuns is the SAME number validatePlan bounds `attempts` against below —
1005
- // one source, so the prompt and the validator cannot drift apart
1006
- const r = await drafter.ask(planPrompt(job, scoutBlob, reds, maxStepRounds, failure, scopeMenu, materials, capRuns), []);
1511
+ const r = await drafter.ask(planPrompt(job, scoutBlob, reds, maxStepRounds, failure, scopeMenu, materials, starting, checkFacts), []);
1007
1512
  return extractArtifact(r.text).code ?? '';
1008
1513
  };
1009
1514
  // Scrubbed HERE, once, before any consumer reads them — the judge() precedent
@@ -1016,7 +1521,7 @@ export async function runPlan(job, { workdir, provider, nativeProvider, provider
1016
1521
  // emit sites: scrubbing here makes the leak inexpressible, scrubbing each
1017
1522
  // consumer makes it something the next consumer has to remember.
1018
1523
  const validate = (/** @type {string} */ t) => {
1019
- const v = validatePlan(t, { job, maxStepRounds, scopes: scopeMenu, capRuns });
1524
+ const v = validatePlan(t, { job, maxStepRounds, scopes: scopeMenu, ...checkFacts });
1020
1525
  return { ...v, reds: v.reds.map((r) => (typeof r.detail === 'string' ? { ...r, detail: scrub(r.detail) } : r)) };
1021
1526
  };
1022
1527
  let text = await draftPlan(null);
@@ -1030,16 +1535,76 @@ export async function runPlan(job, { workdir, provider, nativeProvider, provider
1030
1535
  return pv;
1031
1536
  };
1032
1537
  let plan;
1033
- try {
1034
- const pv = await obtainPlan('draft', null);
1538
+ /** RESUME: how many leading steps of the reloaded plan are already SATISFIED and are
1539
+ * therefore skipped. Zero on every other path, and reset to zero by a replan — the new
1540
+ * plan's steps are not the dead plan's steps, even where an id repeats. */
1541
+ let skipCount = 0;
1542
+ if (resumeSeed) {
1543
+ // The plan is RELOADED, not re-drafted — but it is re-validated against the spec that
1544
+ // is signed NOW. The dead run validated it against the spec it was signed under, and
1545
+ // between the kill and the resume the menu, the fence or the close can have moved
1546
+ // (registry/schema drift). A stale plan is refused BY NAME with zero spent; running
1547
+ // it would execute a plan the current signature does not admit, which is the one
1548
+ // thing no reconstruction is allowed to do.
1549
+ // The shape-lottery opts (seedRed/priorChecks) are deliberately NOT passed:
1550
+ // they are DRAFTING laws — this gate guards signature drift, and refusing a
1551
+ // paid, previously-legal plan over a shape rule minted after its draft would
1552
+ // burn the very work resume exists to keep (hamr: "why would i want to waste
1553
+ // more money on something i already started"). Omission = rules inactive, by
1554
+ // the validator's contract.
1555
+ const pv = validatePlan(resumeSeed.plan, { job, maxStepRounds, scopes: scopeMenu });
1556
+ // scrubbed at the boundary for `validate()`'s own reason: a `parse-error` detail
1557
+ // quotes a window of the SOURCE, and the spine is append-only forever
1558
+ const reds = pv.reds.map((r) => (typeof r.detail === 'string' ? { ...r, detail: scrub(r.detail) } : r));
1035
1559
  if (!pv.ok) {
1036
- for (const r of pv.reds) emit('plan-red', r);
1560
+ emit('resume-plan-red', { reds, phase: resumeSeed.phase ?? null });
1561
+ for (const r of reds) emit('plan-red', r);
1562
+ emit('escalation', {
1563
+ category: 'plan-red', decisionReady: true, phase: 'resume',
1564
+ 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.',
1565
+ options: [
1566
+ 'rerun WITHOUT --resume (the run drafts a cold plan against the current spec — the killed run\'s steps are then not inherited)',
1567
+ 'restore the spec version the dead run was signed under and resume again',
1568
+ ],
1569
+ detail: reds.map((r) => `${r.code}:${r.path}${r.detail ? ` — ${r.detail}` : ''}`).join('\n'),
1570
+ });
1037
1571
  return 'plan-red';
1038
1572
  }
1039
1573
  plan = /** @type {any} */ (pv.plan);
1040
- emit('plan-accepted', { plan });
1041
- } catch (e) {
1042
- return relay(e, 'plan');
1574
+ // The skip set is a PREFIX match on the plan's own step ids, never a count: a count
1575
+ // would skip whatever happens to sit at that index. Steps run strictly sequentially
1576
+ // and idx only advances on a satisfied exit, so the dead run's completions ARE a
1577
+ // prefix — and a seed that does not line up stops the skipping there and says so on
1578
+ // the record, which re-runs work rather than skipping work nobody did (the safe
1579
+ // direction of the two).
1580
+ const completed = Array.isArray(resumeSeed.completedSteps) ? resumeSeed.completedSteps : [];
1581
+ while (skipCount < completed.length && skipCount < plan.steps.length
1582
+ && completed[skipCount]?.id === plan.steps[skipCount].id) skipCount += 1;
1583
+ emit('resume-seed', {
1584
+ phase: resumeSeed.phase ?? null,
1585
+ planSteps: plan.steps.map((/** @type {any} */ s) => s.id),
1586
+ completed: completed.map((/** @type {any} */ c) => c?.id ?? null),
1587
+ skipping: skipCount,
1588
+ ...(skipCount < completed.length
1589
+ ? { 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` }
1590
+ : {}),
1591
+ });
1592
+ // this leg's own plan-accepted: R1 mints a bridge version from the plan AS EXECUTED,
1593
+ // read back off the spine, so a resumed leg that greens without one would mint
1594
+ // nothing. `phase` says where it came from — it was accepted, not drafted.
1595
+ emit('plan-accepted', { plan, phase: 'resume' });
1596
+ } else {
1597
+ try {
1598
+ const pv = await obtainPlan('draft', null);
1599
+ if (!pv.ok) {
1600
+ for (const r of pv.reds) emit('plan-red', r);
1601
+ return 'plan-red';
1602
+ }
1603
+ plan = /** @type {any} */ (pv.plan);
1604
+ emit('plan-accepted', { plan });
1605
+ } catch (e) {
1606
+ return relay(e, 'plan');
1607
+ }
1043
1608
  }
1044
1609
  if (isUnpriced()) return 'pricing-red'; // F6: the plan drafting round came back unpriced — halt before steps
1045
1610
 
@@ -1049,8 +1614,39 @@ export async function runPlan(job, { workdir, provider, nativeProvider, provider
1049
1614
  const artifacts = [];
1050
1615
  /** @type {{id: string, outcome: string}[]} */
1051
1616
  const stepOutcomes = [];
1052
- let replanned = false;
1053
- const planExecuted = () => emit('plan-executed', { steps: stepOutcomes, replanned });
1617
+ /** RESUME — the replan ledger the CHAIN has already spent, belted the way every
1618
+ * other declared fold entering the arbiter's arithmetic is (`priorSpentUsd`,
1619
+ * `priorWallMs`, `declaredRounds`): a garbage or negative seed reads as 0 rather
1620
+ * than as a NaN that would poison every later comparison, and a negative one must
1621
+ * never cancel a real replan and widen the ceiling (reuse's `> 0` belt, F-4). */
1622
+ const seededReplans = typeof resumeReplans?.count === 'number' && Number.isFinite(resumeReplans.count) && resumeReplans.count > 0
1623
+ ? Math.floor(resumeReplans.count) : 0;
1624
+ // Seeded, not reset. These three were locals reborn on every `runPlan` call — and a
1625
+ // resume IS another call, so the ceiling was refilled by every kill (see the
1626
+ // `resumeReplans` param doc for the measurement and for why this bound spans the
1627
+ // chain while the trend's iteration bound deliberately does not).
1628
+ let replanned = seededReplans > 0;
1629
+ /** F85 (C): how many replans this run has actually drafted, and whether the
1630
+ * arbiter has already spent its ONE extra grant. Two variables and not one
1631
+ * counter compared against a limit, deliberately — a latch cannot be widened by
1632
+ * arithmetic, and the ceiling is the thing this rung must not move (v1.12). */
1633
+ let replans = seededReplans;
1634
+ // the F85-C latch travels BESIDE the count and is not derived from it: a leg can
1635
+ // inherit a spent ordinary ceiling with the grant still unearned (count 1, false),
1636
+ // and deriving one from the other would collapse those two states into one.
1637
+ let varianceGrantUsed = resumeReplans?.grantUsed === true;
1638
+ // `replanned` stays the shipped boolean this record has always carried; `replans`
1639
+ // rides BESIDE it (append-only — a field's meaning is never repurposed).
1640
+ //
1641
+ // Both now count the CHAIN rather than the leg, and that IS a meaning change — made
1642
+ // rather than shadowed by a second field because `replans` shipped only on this
1643
+ // unreleased branch, so no archived spine carries the leg reading for a reader to
1644
+ // be confused by. The leg's own number is not lost by it: a leg emits one `replan`
1645
+ // record per replan it drafts, so `chain − records-in-this-window` is the fold it
1646
+ // inherited, and that is exactly the arithmetic `readResume` runs to rebuild the
1647
+ // ledger for the next leg. A second field stating a number already derivable from
1648
+ // the window would be a second reader of one question (F6's structural cousin).
1649
+ const planExecuted = () => emit('plan-executed', { steps: stepOutcomes, replanned, replans });
1054
1650
  /** @type {object|null} W-2 at the STEP site — set when the WALL refused to open a
1055
1651
  * step's attempt. It carries that stop's own record fields, and its presence is what
1056
1652
  * lets the step loop's terminal tell this reading apart from F64's mid-call one
@@ -1082,23 +1678,30 @@ export async function runPlan(job, { workdir, provider, nativeProvider, provider
1082
1678
  const root = layerRoot && !native
1083
1679
  ? createRoot({ gapKeep: '\\S', redact: scrub, writesInformative: true })
1084
1680
  : null;
1085
- // P: the three widened step fields, each wired or refused — a silently-ignored
1086
- // optional field is the F50 blind-instrument class. `attempts` tightens the
1087
- // shell cap (validator already bounds it); `scope` narrows the fence to a
1088
- // menu value (a subset by construction); `model` swaps the worker's provider
1089
- // via the runner's factory — absent factory it must STOP, not silently run
1090
- // the default tier as if the choice had been honoured.
1091
- const stepCap = step.attempts !== undefined ? Math.min(step.attempts, capRuns) : capRuns;
1681
+ // P: the widened step fields, each wired or refused — a silently-ignored
1682
+ // optional field is the F50 blind-instrument class. `scope` narrows the fence to
1683
+ // a menu value (a subset by construction); `model` swaps the worker's provider
1684
+ // via the runner's factory — absent factory it must STOP, not silently run the
1685
+ // default tier as if the choice had been honoured. `attempts` is the third and
1686
+ // is now TOLERATED-INERT (see validatePlan): the ladder governs iterations by
1687
+ // progress, and a field that could only ever TIGHTEN a count is the wrong shape
1688
+ // for that — drafters measurably tightened it to 3, then to 2 on replan, which
1689
+ // is the opposite of the heal a converging step needs.
1092
1690
  if (step.model !== undefined && !providerFor) {
1093
1691
  const err = /** @type {any} */ (new Error(`step "${step.id}" selects model tier "${step.model}" but the runner supplied no providerFor factory — wiring gap, not a plan defect`));
1094
1692
  err.category = 'interpreter-red';
1095
1693
  throw err;
1096
1694
  }
1097
1695
  const w = await mkWorker({
1098
- granted: step.tools, phase: `step:${step.id}`, attemptRounds: step.rounds, attempts: stepCap, writable: true, root,
1696
+ granted: step.tools, phase: `step:${step.id}`, attemptRounds: step.rounds, attempts: null, writable: true, root,
1099
1697
  fence: step.scope !== undefined ? [resolve(workdir, globToPrefix(step.scope))] : null,
1100
1698
  workerProvider: step.model !== undefined && providerFor ? providerFor(step.model) : null,
1101
1699
  });
1700
+ // ONE ladder per step: the seen-set and the strike count are this step's own
1701
+ // history, and a shared one would strike a fresh step for a gap an earlier step
1702
+ // had already seen. The write signal is the gate audit's own record count (F32's
1703
+ // filter), never git status and never a tree diff.
1704
+ const ladder = createLadder({ limit: strikeLimit, writeCount: w.writeCount });
1102
1705
  let lastText = '';
1103
1706
  let iterationNow = 0;
1104
1707
  // The meter's baseline: what the RUN had left when this step began. Shares are
@@ -1118,15 +1721,41 @@ export async function runPlan(job, { workdir, provider, nativeProvider, provider
1118
1721
  const moneyShare = stepStartUsd > 0 ? (stepStartUsd - remainingUsd()) / stepStartUsd : 0;
1119
1722
  const timeShare = clock.bounded && stepStartMs > 0 ? (stepStartMs - clock.remainingMs()) / stepStartMs : 0;
1120
1723
  if (moneyShare >= VARIANCE_THRESHOLD || timeShare >= VARIANCE_THRESHOLD) {
1724
+ // F85 — the meter REPORTS progress; it does not decide on it. The
1725
+ // condition above is the whole trigger and stays the whole trigger: a
1726
+ // converging step that is eating the run is still stopped, because this is
1727
+ // a governance instrument over an operator-owned allowance and a governor
1728
+ // that suppressed itself whenever the work looked promising would be
1729
+ // judging capability with the budget. What was missing was never a term in
1730
+ // the condition — it was the READING. u-msh70zla stopped a step whose close
1731
+ // had gone 24 → 15 → 14 and then told the replanner its exits were
1732
+ // "unmoved", so the replanner threw the progress away and re-targeted a
1733
+ // file that was already clean.
1734
+ //
1735
+ // The reading comes off `runTrend` — the SAME instance the money halt reads
1736
+ // (src/trend.js's ONE PER SERIES). A second reader for one question is how
1737
+ // two readers come to disagree, which is precisely the defect being fixed.
1738
+ // It is per-stage, numbers-and-stage-names only, and `unknown` stays
1739
+ // `unknown` — a close that reports no number donates nothing (F6).
1740
+ const t = runTrend.verdict();
1121
1741
  emit('variance', {
1122
1742
  step: step.id, iteration, threshold: VARIANCE_THRESHOLD,
1123
1743
  moneyShare: Number(moneyShare.toFixed(3)),
1124
1744
  timeShare: clock.bounded ? Number(timeShare.toFixed(3)) : null,
1125
1745
  axis: moneyShare >= VARIANCE_THRESHOLD ? 'money' : 'time',
1746
+ // ADDED fields, never repurposed ones (the spine is append-only forever).
1747
+ // Same four names and same shapes the money halt already emits, so one
1748
+ // reader parses a progress reading wherever it appears.
1749
+ trend: t.trend, motion: t.motion, reading: t.reading, series: t.series,
1126
1750
  });
1127
1751
  const err = /** @type {CategorizedError} */ (new Error(
1128
1752
  `step "${step.id}" consumed ${Math.round(Math.max(moneyShare, timeShare) * 100)}% of the run's remaining `
1129
- + `${moneyShare >= VARIANCE_THRESHOLD ? 'money' : 'time'} across ${iteration - 1} attempt(s) with its exits still red`));
1753
+ + `${moneyShare >= VARIANCE_THRESHOLD ? 'money' : 'time'} across ${iteration - 1} attempt(s) with its exits still red. `
1754
+ // The escalation `detail` is this message (ralph passes it through), and
1755
+ // the replan brief quotes the detail — so this sentence is what a human
1756
+ // AND the redrafting planner read. It states what the run achieved; it
1757
+ // never states what to do about it, which is the operator's lever list.
1758
+ + `Run progress: ${t.reading}.`));
1130
1759
  err.category = 'step-variance';
1131
1760
  throw err;
1132
1761
  }
@@ -1162,16 +1791,19 @@ export async function runPlan(job, { workdir, provider, nativeProvider, provider
1162
1791
  stepsDone: idx,
1163
1792
  stepsPlanned: plan.steps.length,
1164
1793
  attemptsUsed: iteration - 1,
1165
- // BOTH, honestly named. `capRuns` means the RUN's attempt cap on every
1166
- // wall-halt record — the fix site writes exactly that — so this site must
1167
- // not quietly put the step's TIGHTENED cap under the same key: two records
1168
- // of one type disagreeing about what a number counts, with nothing on the
1169
- // record saying which, is the two-instruments class. The tightening is real
1170
- // information (it is what bounded THIS step) and keeps its own name.
1171
- capRuns,
1172
- stepAttemptCap: stepCap,
1794
+ // The ladder's own state, because the ladder is what was governing this
1795
+ // step when the wall stopped it. This record used to carry `capRuns` and
1796
+ // `stepAttemptCap` — two counts that no longer bound anything here — and a
1797
+ // field name that means something it no longer measures is worse than an
1798
+ // absent one: a reader cannot tell it went stale. The FIX site's copy of this
1799
+ // record dropped `capRuns` for the same reason at v1.46 §4, when the count
1800
+ // stopped governing that loop too — neither wall record quotes a denominator
1801
+ // now, because neither loop has one.
1802
+ strikes: ladder.report().strikes,
1803
+ strikeLimit: ladder.report().limit,
1804
+ distinctGaps: ladder.report().distinctGaps,
1173
1805
  };
1174
- // No trend here, and none invented: `gapTrend` reads consecutive CLOSE output,
1806
+ // No trend here, and none invented: the trend instrument reads CLOSE grades,
1175
1807
  // and a step that never ran has no grade of its own to compare. The lever the
1176
1808
  // human needs is on the run-level record either way.
1177
1809
  const err = /** @type {CategorizedError} */ (new Error(
@@ -1201,6 +1833,7 @@ export async function runPlan(job, { workdir, provider, nativeProvider, provider
1201
1833
  ].filter(Boolean).join('\n\n'));
1202
1834
  lastText = scrub(r.text ?? '').slice(0, ARTIFACT_MAX);
1203
1835
  };
1836
+ let lastGap = '';
1204
1837
  const judge = async () => {
1205
1838
  const { pass, results: raw } = await evalExits(step.exit, { dir: workdir, snapshot, runCheck });
1206
1839
  // Scrubbed HERE, once, before the results are read by anyone. An exit detail is
@@ -1224,15 +1857,38 @@ export async function runPlan(job, { workdir, provider, nativeProvider, provider
1224
1857
  if (faulty) return { verdict: /** @type {string} */ (faulty.fault), detail: faulty.detail };
1225
1858
  if (pass) return { verdict: 'satisfied' };
1226
1859
  // AND-only: the gap names EVERY failing wall (mechanical genre, F38)
1227
- return { verdict: 'needs_revision', gap: results.filter((r) => !r.pass).map((r) => r.detail).join('\n') };
1860
+ lastGap = results.filter((r) => !r.pass).map((r) => r.detail).join('\n');
1861
+ return { verdict: 'needs_revision', gap: lastGap };
1228
1862
  };
1229
- const outcome = await ralph({ middle, judge, capRuns: stepCap, emit: emitL, workerWrites: w.workerWrites });
1230
- return { outcome, artifact: lastText };
1863
+ const outcome = await ralph({ middle, judge, ladder, emit: emitL, workerWrites: w.workerWrites });
1864
+ // gap + writes ride out for the replan brief's mismatch line (the books this
1865
+ // step already kept; the escalation's detail carries the meter/strike sentence,
1866
+ // never the gap, so the brief cannot recover this fact from anywhere else)
1867
+ return { outcome, artifact: lastText, ladder, writes: w.workerWrites(), gap: lastGap };
1231
1868
  };
1232
1869
 
1233
1870
  let idx = 0;
1234
1871
  while (idx < plan.steps.length) {
1235
1872
  const step = plan.steps[idx];
1873
+ // RESUME: this step's exit was already satisfied before the kill. It is not re-run —
1874
+ // that is the money and the time the ruling is about — and it is not silent either:
1875
+ // the record is its OWN type carrying the event that proves it, so a reader can tell
1876
+ // a skipped step from a run one (the resume-to-cap invariant, which a fake
1877
+ // step-start/step-end pair would destroy). The step's ARTIFACT is not reconstructed:
1878
+ // its text was a prompt ingredient, never a checkpoint, and its real product is on
1879
+ // disk where the next step will find it.
1880
+ if (idx < skipCount) {
1881
+ const proof = resumeSeed.completedSteps[idx];
1882
+ emit('step-skipped', {
1883
+ step: step.id,
1884
+ provenBy: proof?.by ?? null,
1885
+ provenSeq: typeof proof?.seq === 'number' ? proof.seq : null,
1886
+ meaning: 'the killed run satisfied this step\'s exits — it is not re-run and not re-paid',
1887
+ });
1888
+ stepOutcomes.push({ id: step.id, outcome: 'skipped' });
1889
+ idx += 1;
1890
+ continue;
1891
+ }
1236
1892
  let res;
1237
1893
  try {
1238
1894
  res = await executeStep(step);
@@ -1247,7 +1903,16 @@ export async function runPlan(job, { workdir, provider, nativeProvider, provider
1247
1903
  const err = /** @type {CategorizedError} */ (e);
1248
1904
  const category = err instanceof HaltError ? 'cap-halt' : (typeof err.category === 'string' ? err.category : 'interpreter-red');
1249
1905
  stepOutcomes.push({ id: step.id, outcome: category });
1250
- if (category === 'cap-halt') emit('cap-halt', { category, meaning: 'not under cap — not "can\'t"', detail: String(err?.message ?? err) });
1906
+ if (category === 'cap-halt') {
1907
+ emit('cap-halt', { category, meaning: 'not under cap — not "can\'t"', detail: String(err?.message ?? err) });
1908
+ // …and the MONEY record beside it, as every other cap-halt site emits one. A
1909
+ // readout a human gets at three seams and not at the fourth is the readout being
1910
+ // a coincidence of where the stop happened. UNREACHABLE today — nothing in
1911
+ // mkWorker/gate.init/snapshotScope buys a round, so no wallet can drain here —
1912
+ // which is why this is symmetry rather than a fix, and why it stays a `HaltError`
1913
+ // branch instead of a widened one.
1914
+ emitMoneyHalt({ phase: `step:${step.id}`, cutMidCall: false, stepsDone: idx, stepsPlanned: plan.steps.length });
1915
+ }
1251
1916
  emit('escalation', {
1252
1917
  category, decisionReady: true, phase: `step:${step.id}`,
1253
1918
  decision: category === 'cap-halt'
@@ -1296,12 +1961,15 @@ export async function runPlan(job, { workdir, provider, nativeProvider, provider
1296
1961
  // pricing-red at the step-end guard above, so isUnpriced() is false here.)
1297
1962
  //
1298
1963
  // A (PRD v1.27) adds the SECOND trigger: `step-variance` — the meter stopped a
1299
- // step that had eaten a declared share of the run with its exits unmoved. That
1300
- // is the case exhaustion-only could never reach (F56: a replan has fired zero
1301
- // times in the programme), and it is a genuine re-allocation rather than a stop.
1302
- // Both triggers still need FUNDS LEFT, and both are still bounded by the ONE
1303
- // replan ceiling — this changes the trigger, never the ceiling (v1.12).
1964
+ // step that had eaten a declared share of the run. That is the case
1965
+ // exhaustion-only could never reach (F56: a replan has fired zero times in the
1966
+ // programme), and it is a genuine re-allocation rather than a stop. Both
1967
+ // triggers still need FUNDS LEFT, and both are bounded by the replan ceiling.
1304
1968
  const cat = lastEscalation?.category;
1969
+ /** F85 — the run's measured trajectory at the moment of this stop, read ONCE
1970
+ * here off the same instance the meter and the money halt read. Both consumers
1971
+ * below are the replanner's: the trigger sentence and the brief. */
1972
+ const stopTrend = runTrend.verdict();
1305
1973
  // F66 adds the THIRD trigger: `step-stalled` — the stall watchdog reissued the
1306
1974
  // model call MAX_STALLS times and never got a round back. hamr's ruling is that
1307
1975
  // a stall must self-heal rather than end the run ("killing and coming back is
@@ -1309,32 +1977,114 @@ export async function runPlan(job, { workdir, provider, nativeProvider, provider
1309
1977
  // demonstrably stopped working does the run adapt instead — by replanning, not
1310
1978
  // by stopping. Same ONE-replan ceiling and same FUNDS-LEFT condition as the
1311
1979
  // other two: this changes the trigger, never the ceiling (v1.12).
1980
+ //
1981
+ // F85 rewrote the variance branch. It used to be the FIXED string "…with its
1982
+ // exits unmoved", printed for every step-variance stop whatever had happened —
1983
+ // and on u-msh70zla it was simply false: the exits had moved 24 → 15 → 14. The
1984
+ // sentence now states the meter's fact (a share of the run) and then the trend
1985
+ // instrument's MEASURED reading, which is the same reading the `variance` record
1986
+ // and the money halt carry. Nothing here asserts a direction the reader did not
1987
+ // report: `unknown` says unknown.
1312
1988
  const replanTrigger = cat === 'cap-halt' ? 'step exhausted its attempts with exits still red'
1313
- : cat === 'step-variance' ? 'the meter stopped a step that was consuming the run with its exits unmoved'
1989
+ : cat === 'step-variance' ? `the meter stopped a step that was consuming the run — ${stopTrend.reading}`
1314
1990
  : cat === 'step-stalled' ? 'the model stopped producing rounds and reissuing the call did not recover it'
1315
1991
  : null;
1316
- if (!replanned && replanTrigger && remainingUsd() > MONEY_MIN) {
1992
+ // F85 (C) — the ARBITER's one additional replan. A second `step-variance` after
1993
+ // the ceiling is spent used to be a hard stop, and on a run that was still
1994
+ // converging that stop threw away work the run had already paid for. So the
1995
+ // arbiter may grant ONE more, and every clause of that sentence is a constraint:
1996
+ //
1997
+ // * ONE. Bounded, and bounded by a latch rather than a comparison, so the
1998
+ // ceiling cannot creep: unlimited replanning launders thrash as adaptation
1999
+ // (v1.12). A third variance stop is the stop, however well it is going.
2000
+ // * the ARBITER. Read mechanically off `runTrend` — the same instance the
2001
+ // meter and the money halt read. The agent never asks for it, never sees it
2002
+ // offered, and cannot influence it: no self-adjusted budgets, ever.
2003
+ // * `converging`, the trend instrument's OWN category. No new number is
2004
+ // invented here — threshold-setting is the operator's, and a fresh
2005
+ // comparison would be a second reader of the one question (src/trend.js).
2006
+ // `flat` and `unknown` are refusals; an unknown is never rounded up into a
2007
+ // reason to spend (F6).
2008
+ // * step-variance only. An exhaustion or a stall after the ceiling is
2009
+ // unchanged — this widens nothing but the case it was measured on.
2010
+ const grantExtraReplan = replanned
2011
+ && cat === 'step-variance'
2012
+ && !varianceGrantUsed
2013
+ && stopTrend.trend === 'converging';
2014
+ if ((!replanned || grantExtraReplan) && replanTrigger && remainingUsd() > MONEY_MIN) {
2015
+ if (grantExtraReplan) varianceGrantUsed = true;
1317
2016
  replanned = true;
1318
- emit('replan', { step: step.id, reason: replanTrigger, trigger: cat });
2017
+ replans += 1;
2018
+ emit('replan', {
2019
+ step: step.id, reason: replanTrigger, trigger: cat,
2020
+ // ADDED fields (append-only): which replan this is, and — only when the
2021
+ // arbiter granted one past the ordinary ceiling — the reading it granted it
2022
+ // on. Absent on the ordinary replan, so a reader can never mistake the
2023
+ // ceiling for a grant.
2024
+ //
2025
+ // The index is the RUN's, so on a resumed leg it opens above 1 — the ceiling
2026
+ // it is counted against spans the chain, and an index that restarted at 1 on
2027
+ // every leg would read as a fresh allowance on the one record that could have
2028
+ // shown it was not.
2029
+ replan: replans,
2030
+ ...(grantExtraReplan ? { granted: stopTrend.trend } : {}),
2031
+ });
1319
2032
  // The brief names the trigger HONESTLY, one branch per trigger: it is the
1320
2033
  // only channel the redrafting planner adapts to, so a wrong diagnosis here
1321
2034
  // is handed to the one component whose whole job is to respond to it. A
1322
2035
  // stall in particular never judged its exits at all — the exhaustion
1323
2036
  // sentence would invent a red the run never saw (F28's rule, replan side).
2037
+ // The exhaustion branch is the one the ladder rewrote. "It ran N attempts and
2038
+ // its exits were still red" described a bound that no longer exists AND said
2039
+ // nothing about the mechanism — the planner could not tell a step that was
2040
+ // converging from one repeating itself, which are opposite problems with
2041
+ // opposite fixes. The ladder's own books answer that, and the balance says what
2042
+ // the stop left on the table (the u-msd916dh reading: $1.30 and ~6min unspent
2043
+ // behind a step that was still shrinking its error count). Time is reported as
2044
+ // UNBOUNDED when the operator set no wall — never rendered as a number (F6
2045
+ // extends to time).
2046
+ const leftOver = `$${remainingUsd().toFixed(2)} and `
2047
+ + `${clock.bounded ? `${Math.round(clock.remainingMs() / 60_000)} minute(s)` : 'time UNBOUNDED'} `
2048
+ + 'of the run were still unspent when it stopped.';
2049
+ // F85: the meter's branch states the METER's fact and then the run's measured
2050
+ // one, in that order and as two separate claims — "it was stopped for eating
2051
+ // the run" and "here is what the run achieved" are different things, and the
2052
+ // old sentence quietly fused them into a verdict on the work.
1324
2053
  const why = cat === 'step-variance'
1325
- ? 'It was stopped by the run\'s meter for consuming too large a share of what was left.'
2054
+ ? 'It was stopped by the run\'s meter for consuming too large a share of what was left. '
2055
+ + `Its work so far, from the close's own numbers: ${stopTrend.reading}. ${leftOver}`
1326
2056
  : cat === 'step-stalled'
1327
2057
  ? 'It stalled: the model stopped producing rounds and reissuing the call did not recover it, so its exits were never judged.'
1328
- : `It ran ${step.attempts !== undefined ? Math.min(step.attempts, capRuns) : capRuns} attempts and its exits were still red.`;
2058
+ : `${res.ladder.brief()} ${leftOver}`;
2059
+ // F86 — this step's OWN last exit results, verbatim (u-mshcpdg4). Everything
2060
+ // above this line is the run's narration of the stop: the meter's share
2061
+ // sentence, the trend's direction, the never-wrote fact, the escalation detail.
2062
+ // None of them says WHERE the remaining work is, because none of them is the
2063
+ // close. The close's output is in HERE — as one of the failing exits' details,
2064
+ // beside the evaluator's own (a step that wrote nothing leads with
2065
+ // `tree-changed`), which is why the block is labelled by its exits rather than
2066
+ // by the close. It goes over as text — see `closeGapBlock` for why a parsed
2067
+ // file list would reproduce the exact bug it is fixing. Empty gap → empty
2068
+ // string → the brief is byte-identical to the pre-F86 one.
2069
+ const gapBlock = closeGapBlock(res?.gap);
1329
2070
  const failure = `Step "${step.id}" (${step.action}) did not reach its exits. `
1330
2071
  + `${why}\n`
1331
2072
  + `Last exit state:\n${lastEscalation?.detail ?? '(none)'}\n`
1332
- + `Steps completed so far: ${artifacts.map((a) => a.id).join(', ') || 'none'}.`;
2073
+ + `Steps completed so far: ${artifacts.map((a) => a.id).join(', ') || 'none'}.`
2074
+ + gapBlock;
1333
2075
  // The progress line IS the adaptation channel (addendum 2): the balance rides
1334
2076
  // in via obtainPlan's live read, and this says where the run got to. Both are
1335
2077
  // the planner re-allocating what remains across what is left — never a rate.
2078
+ //
2079
+ // F85 adds the second half. This line used to count STEPS only ("step 1 of 1
2080
+ // did not finish; 0 step(s) completed before it") — structurally true and
2081
+ // silent about outcome, so a replanner reading it could only conclude that
2082
+ // nothing had been achieved. On u-msh70zla it concluded exactly that and threw
2083
+ // three attempts of real convergence away. The structural sentence stays (a
2084
+ // plan's shape is what the planner re-allocates); the measured one joins it.
1336
2085
  const progress = `step ${idx + 1} of ${plan.steps.length} ("${step.id}") did not finish; `
1337
- + `${artifacts.length} step(s) completed before it`;
2086
+ + `${artifacts.length} step(s) completed before it; `
2087
+ + `close trend so far: ${stopTrend.reading}`;
1338
2088
  let pv;
1339
2089
  try {
1340
2090
  pv = await obtainPlan('replan', failure, progress);
@@ -1354,6 +2104,10 @@ export async function runPlan(job, { workdir, provider, nativeProvider, provider
1354
2104
  // it names steps the current plan does not contain)
1355
2105
  artifacts.length = 0;
1356
2106
  idx = 0;
2107
+ // and for exactly the same reason a RESUME's skip set dies here: an id the new
2108
+ // plan happens to re-use names a step this run never did, so inheriting the
2109
+ // dead run's green for it would skip work nobody has done
2110
+ skipCount = 0;
1357
2111
  continue;
1358
2112
  }
1359
2113
  // F64 — the wall stopped this attempt from INSIDE a provider call (the derived
@@ -1378,10 +2132,18 @@ export async function runPlan(job, { workdir, provider, nativeProvider, provider
1378
2132
  // stays a step-red (the stop is a result).
1379
2133
  if (cat === 'cap-halt') {
1380
2134
  planExecuted();
1381
- return remainingUsd() <= MONEY_MIN ? 'cap-halt' : `step-red:${step.id}`;
2135
+ if (remainingUsd() > MONEY_MIN) return `step-red:${step.id}`;
2136
+ // v1.46 §2: the money cut is decision-ready HERE too, and for the same reason
2137
+ // W-2 gave the wall a record at every site it stops a run — a category that
2138
+ // hands the human a readout at one seam and silence at another is the readout
2139
+ // being a coincidence of where the stop happened.
2140
+ emitMoneyHalt({ phase: `step:${step.id}`, cutMidCall: false, stepsDone: idx, stepsPlanned: plan.steps.length });
2141
+ return 'cap-halt';
1382
2142
  }
1383
- // A second `step-variance` after the one replan is spent is a STOP, and the stop
1384
- // is the result — but it rides out as `step-red:<id>`, NOT as its own top-level
2143
+ // A `step-variance` that earned no replan above is a STOP, and the stop is the
2144
+ // result. Reached in three ways now: the ordinary ceiling with the run reading
2145
+ // `flat` or `unknown`; the arbiter's one grant already spent (F85's bound); or
2146
+ // the wallet at dust. Each rides out as `step-red:<id>`, NOT as its own top-level
1385
2147
  // outcome. The category is a replan TRIGGER, never a run verdict: leaking it
1386
2148
  // upward would mint an outcome run.js and the ledger's class table do not know,
1387
2149
  // and an unmapped category is counted as a library bug (ledger.js) when this is a
@@ -1423,6 +2185,10 @@ export async function runPlan(job, { workdir, provider, nativeProvider, provider
1423
2185
  * without consulting the clock a second time (two clock reads for one event is
1424
2186
  * how the two instruments come to disagree). */
1425
2187
  let wallStop = null;
2188
+ /** how many fix ITERATIONS the loop actually bought — counted here rather than
2189
+ * read back off the trend reader, which lives inside the try and is out of scope
2190
+ * by the time a terminal needs the number. */
2191
+ let fixIterationsUsed = 0;
1426
2192
  try {
1427
2193
  // Layer R for the close-fix loop — the plan flow's single ralph loop judged
1428
2194
  // by the REAL close (the plan flow's single ralph loop, and the
@@ -1436,16 +2202,85 @@ export async function runPlan(job, { workdir, provider, nativeProvider, provider
1436
2202
  const fixRoot = layerRoot && !native
1437
2203
  ? createRoot({ redact: scrub, writesInformative: true })
1438
2204
  : null;
1439
- const w = await mkWorker({ granted: ceiling, phase: 'fix', attemptRounds: maxStepRounds, attempts: capRuns, writable: true, root: fixRoot });
1440
- // W-2's only input, and it is already in this loop's hand: the close gaps seen
1441
- // so far, oldest first. `post.gap` is the grade the loop OPENED on; every later
1442
- // entry is what ralph carried back from the close that judged the previous
1443
- // attempt (or, after a worker-crash, F32's routed gap — still the feedback that
1444
- // attempt actually received). Nothing is instrumented for this.
1445
- const gaps = [post.gap ?? ''];
2205
+ // `attempts: null` is the STRIKE-LADDER shape (mkWorker's own contract): the fix
2206
+ // loop's iteration count no longer exists at construction time, so the gate's
2207
+ // turn allowance is granted per iteration at `setIteration` instead of being
2208
+ // pre-multiplied by a number nobody signed. The per-iteration bound is
2209
+ // unchanged — `loop.stop()` at `attemptRounds` — which is the belt that actually
2210
+ // holds. (v1.46 §4: `capRuns` retires as the governor.)
2211
+ const w = await mkWorker({ granted: ceiling, phase: 'fix', attemptRounds: maxStepRounds, attempts: null, writable: true, root: fixRoot });
2212
+ // ── v1.46 §4: the fix loop's GOVERNOR — progress, not a count ──
2213
+ //
2214
+ // MEASURED reason, the same shape as the step ladder's. The fixed `capRuns`
2215
+ // could not tell converging work from thrash, so it stopped both at the same
2216
+ // place: u-msew1uy5 was cut mid-convergence at fix iteration 3 while
2217
+ // reuse-msc6w93z sat dead flat at 2 errors for SEVEN fix verdicts until the wall
2218
+ // killed it. The $0 replay over all 8 archived fix loops came back clean both
2219
+ // directions — 0 greens harmed (every historical green converted in <= 2
2220
+ // verdicts), 1 waste case caught.
2221
+ //
2222
+ // The reader is SEEDED with the grade the loop opened on, because that is what
2223
+ // the first fix attempt is measured against; without it the first attempt would
2224
+ // have nothing to be compared to and the loop would spend a free iteration
2225
+ // establishing what it already knew.
2226
+ //
2227
+ // `blindCap: capRuns` is not the count surviving in disguise. It binds ONLY
2228
+ // while the instrument has never been able to compare anything (a close whose
2229
+ // output carries no number at all), which is precisely the case where a strike
2230
+ // would be minted out of ignorance. The moment a stage reports a comparable
2231
+ // number the count is gone, and money and the wall are what remain.
2232
+ //
2233
+ // DELIBERATELY NOT SEEDED with `resumeGrades`, and this is the one place the
2234
+ // resume chain stops. The two readers answer two different questions (see
2235
+ // `runTrend` above, and src/trend.js's "ONE PER SERIES"): the halt readout asks
2236
+ // *was the RUN converging when its allowance cut it*, which spans both legs; this
2237
+ // governor asks *is THIS loop out of ideas*, which is leg-local by definition.
2238
+ // Seeding it was built and measured: a resumed loop that struck out dead flat
2239
+ // rendered its own terminal as "still progressing — verdict 2 → 1 → 1 → 1 → 1",
2240
+ // because the chain HAD fallen before the handover — a record contradicting
2241
+ // itself, offering a conditional top-up ("if the trend above says it was still
2242
+ // converging") on the exact run that had just proven it was not. It would also
2243
+ // have moved the STRIKE rule on a resume (an attempt judged against the best the
2244
+ // run ever reached rather than against the grade this loop opened on) — an
2245
+ // unmeasured governance change, which is arbiter-adjacent territory. Both are the
2246
+ // same error: one instance answering two questions.
2247
+ const fixTrend = createTrend({
2248
+ stageOrder: stagedClose.map((/** @type {any} */ s) => s.name),
2249
+ limit: FIX_STRIKE_LIMIT,
2250
+ blindCap: capRuns,
2251
+ });
2252
+ fixTrend.record({ gap: post.gap ?? '' });
2253
+ /** ralph's `ladder` seam, filled by the trend reader instead of the step
2254
+ * ladder's repeat/write pair. ONE exhaustion terminal, two triggers (ralph's own
2255
+ * rule): the category stays `cap-halt` and the outcome stays `escalated`, so the
2256
+ * ledger's class table, bridge grading and every downstream reader are untouched
2257
+ * — only the TRIGGER changed. What it overrides is the terminal's PROSE: the
2258
+ * step ladder's copy offers a replan, and there is no planner at the close. */
2259
+ const fixGovernor = {
2260
+ record: (/** @type {{iteration: number, gap?: string}} */ o) => {
2261
+ fixIterationsUsed = o.iteration;
2262
+ return { governor: 'close-trend', ...fixTrend.record({ gap: o.gap }), iteration: o.iteration };
2263
+ },
2264
+ struckOut: fixTrend.struckOut,
2265
+ report: fixTrend.report,
2266
+ brief: () => fixTrend.verdict().reading,
2267
+ terminal: () => {
2268
+ const rep = fixTrend.report();
2269
+ const t = fixTrend.verdict();
2270
+ return {
2271
+ decision: rep.blind
2272
+ ? `${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?`
2273
+ : `${rep.strikes}/${rep.limit} strikes — the fix loop stopped making progress against the close's own numbers (${t.reading}). Continue, change approach, or stop?`,
2274
+ options: [
2275
+ 'revise the goal/spec so the work is reachable (a spec edit, so the new hash needs re-approval)',
2276
+ 'top up budgetUsd and rerun with --resume, if the trend above says it was still converging',
2277
+ 'abandon the task',
2278
+ ],
2279
+ };
2280
+ },
2281
+ };
1446
2282
  /** @param {number} iteration @param {string} [gap] */
1447
2283
  const middle = async (iteration, gap) => {
1448
- if (gap !== undefined) gaps.push(gap);
1449
2284
  // T/W-2 — the wall, read before a NEW fix attempt is started, and never
1450
2285
  // around the close itself. hamr's ruling: *"when time is up, keep the grade
1451
2286
  // we already have and stop … run tests (free) and when done if original time
@@ -1466,15 +2301,36 @@ export async function runPlan(job, { workdir, provider, nativeProvider, provider
1466
2301
  // wall that kills grading leaves the run unreadable after the money is spent
1467
2302
  // (the F45 class), so the one thing this must never do is stop a close.
1468
2303
  if (clock.expired()) {
1469
- const t = gapTrend(gaps);
2304
+ // ONE progress instrument, shared with the money halt (PRD v1.46 §2/§4).
2305
+ // This site used to run its own `gapTrend`: byte equality of the last two
2306
+ // close gaps, reading `stalled`/`moving` beside the money halt's per-stage
2307
+ // `flat`/`converging` on the very same run. Two readers for one question is
2308
+ // how the two come to disagree, and the byte reader could only ever report
2309
+ // MOTION — that something changed, never which way. Folded into src/trend.js,
2310
+ // where a per-stage NUMBER decides whenever one exists and the byte
2311
+ // comparison survives as the reading inside `unknown` (never as a direction:
2312
+ // "the output changed" is not "it got better", which is F6 in a trend's coat).
2313
+ // `runTrend` is the run's own reader — the same instance the money halt reads
2314
+ // — so a resume's inherited baselines reach this readout too.
2315
+ const t = runTrend.verdict();
1470
2316
  wallStop = {
1471
2317
  cutMidCall: false,
1472
2318
  phase: 'fix',
1473
2319
  iterationsUsed: iteration - 1,
1474
- capRuns,
2320
+ // `capRuns` used to ride here as the DENOMINATOR ("0 of 3"). It retired as
2321
+ // this loop's governor (v1.46 §4), so quoting it as the total would state a
2322
+ // bound that no longer decides anything — the iterations spent are the fact,
2323
+ // and there is no longer a number they are "of".
1475
2324
  verdict: lastCloseVerdict?.verdict,
1476
2325
  ...(lastCloseVerdict?.stage ? { stage: lastCloseVerdict.stage } : {}),
1477
2326
  trend: t.trend,
2327
+ // MOTION rides as its OWN field rather than being mapped onto `trend`.
2328
+ // Mapping `unchanged` → `flat` would dress a byte comparison as a measured
2329
+ // direction on the spine, which is precisely what the unification refuses;
2330
+ // a separate field keeps the old signal's substance without the false
2331
+ // promotion. (Grepped first: no src/ or scripts/ reader consumed the old
2332
+ // `stalled`/`moving` values — only this file's own tests, updated with it.)
2333
+ motion: t.motion,
1478
2334
  };
1479
2335
  // The detail carries what the human needs to pick a lever and nothing else:
1480
2336
  // the verdict that stands, how much of the loop was actually spent, and the
@@ -1484,7 +2340,7 @@ export async function runPlan(job, { workdir, provider, nativeProvider, provider
1484
2340
  `the wall-clock cap passed before fix attempt ${iteration} could start. `
1485
2341
  + `The verdict stands as the last close rendered it: ${lastCloseVerdict?.verdict ?? 'unknown'}`
1486
2342
  + `${lastCloseVerdict?.stage ? ` at stage "${lastCloseVerdict.stage}"` : ''}, `
1487
- + `after ${iteration - 1} of ${capRuns} fix iteration(s). `
2343
+ + `after ${iteration - 1} fix iteration(s). `
1488
2344
  + `Progress trend: ${t.trend} — ${t.reading}; ${t.lever}.`));
1489
2345
  err.category = 'wall-halt';
1490
2346
  throw err;
@@ -1513,7 +2369,11 @@ export async function runPlan(job, { workdir, provider, nativeProvider, provider
1513
2369
  fixOutcome = await ralph({
1514
2370
  // the staged close rides in as the JUDGE seam (one verdict, every stage in
1515
2371
  // order) — ralph's single-command path stays exactly what it was
1516
- middle, judge: async () => judgeClose(), capRuns, emit: emitL, redact: scrub,
2372
+ // v1.46 §4: the PROGRESS governor rides in where `capRuns` used to. ralph's
2373
+ // two exhaustion rules are alternatives and never both at once, so the count
2374
+ // is not passed — it survives only inside the governor, as the bound for the
2375
+ // case the trend instrument is blind.
2376
+ middle, judge: async () => judgeClose(), ladder: fixGovernor, emit: emitL, redact: scrub,
1517
2377
  closeTimeoutMs, cwd: workdir, workerWrites: w.workerWrites,
1518
2378
  });
1519
2379
  } catch (e) {
@@ -1544,7 +2404,19 @@ export async function runPlan(job, { workdir, provider, nativeProvider, provider
1544
2404
  // Attempts spent with money still on the table stays the designed terminal
1545
2405
  // ("close still red") and keeps riding out as `escalated` below.
1546
2406
  // (MED-4, fixed with hamr's explicit go, 2026-07-30.)
1547
- if (remainingUsd() <= MONEY_MIN) return 'cap-halt';
2407
+ if (remainingUsd() <= MONEY_MIN) {
2408
+ // v1.46 §2 — and THIS is the site hamr's ask was about: the money runs out
2409
+ // deepest in the run, on the loop that holds a real grade. So the readout keeps
2410
+ // that grade, names the stage that rendered it, and carries the run's own
2411
+ // per-stage trend so the choice between "top up" and "revise the goal" is made
2412
+ // on evidence rather than on how the operator felt about the last gap.
2413
+ emitMoneyHalt({
2414
+ phase: 'fix',
2415
+ cutMidCall: false,
2416
+ iterationsUsed: fixIterationsUsed,
2417
+ });
2418
+ return 'cap-halt';
2419
+ }
1548
2420
  } else if (fixOutcome !== 'green' && typeof lastEscalation?.category === 'string') {
1549
2421
  // The step loop's category restoration (F11), mirrored: ralph returns the flat
1550
2422
  // 'escalated' on a middle throw while its escalation carries the real name — a