bareloop 0.6.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/planrun.js CHANGED
@@ -25,9 +25,12 @@ import { WRITE_VERBS, EXIT_TYPES, MAX_EXITS_PER_STEP, MAX_PLAN_STEPS, MAX_SCOPE_
25
25
  import { snapshotScope, evalExits } 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,74 @@ 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
- };
146
+ function startingDraftBlock(n, plan) {
147
+ const count = ['no', 'one', 'two', 'three', 'four', 'five', 'six', 'seven', 'eight', 'nine', 'ten'][n] ?? String(n);
148
+ return `
149
+
150
+ YOUR STARTING DRAFT — begin from the plan below and tweak it.
151
+ This plan greened a same-shape job (the same ${count} close stages) on a DIFFERENT repository. Take it
152
+ as your starting draft rather than starting from a blank page: keep what carries over, change what
153
+ this repository needs. Everything you submit must be legal for THIS job as described above — its
154
+ paths, scopes, verbs, bounds and check names. Output the finished plan as the single JSON object
155
+ required above, and nothing else.
156
+
157
+ ${JSON.stringify(plan, null, 2)}`;
158
+ }
159
+
160
+ /**
161
+ * Files named in an exit gap that the step's worker never wrote — the mechanical
162
+ * fact behind the prose-prohibition trap (u-msdpuaej, 2026-08-03): a drafter wrote
163
+ * "do not modify any file outside X" into a step whose exit check judges the whole
164
+ * goal, the worker obeyed, and the replanner — handed the gap AND the stall note —
165
+ * rebuilt the same shape, because inferring "the exit reds on files this step never
166
+ * touched" is exactly the semantic leap that fails (F38: mechanical gaps convert,
167
+ * semantic ones stall; 3/3 across the mailbox class). This computes that fact from
168
+ * the two books that already exist: path-like tokens in the gap text (extension
169
+ * required — a bare `step/two` is not a file claim) suffix-matched against the F32
170
+ * write audit's absolute paths. Advisory-only and fail-safe: extraction that finds
171
+ * nothing yields no line, never a guess.
172
+ *
173
+ * @param {string | undefined} gap the step's last exit gap text
174
+ * @param {string[] | undefined} writes the step's cumulative write-audit paths
175
+ * @returns {string[]} gap-named files with no matching write, in gap order
176
+ */
177
+ export function gapFilesNeverWritten(gap, writes) {
178
+ if (typeof gap !== 'string' || gap === '') return [];
179
+ const tokens = gap.match(/[\w.-]+(?:\/[\w.-]+)+/g) ?? [];
180
+ const seen = new Set();
181
+ const out = [];
182
+ for (const raw of tokens) {
183
+ const p = raw.replace(/^\.\//, '');
184
+ if (!/\.[A-Za-z0-9]+$/.test(p.split('/').at(-1) ?? '')) continue;
185
+ if (seen.has(p)) continue;
186
+ seen.add(p);
187
+ const written = (writes ?? []).some((w) => {
188
+ const ws = String(w);
189
+ return ws === p || ws.endsWith(`/${p}`) || p.endsWith(`/${ws}`);
190
+ });
191
+ if (!written) out.push(p);
147
192
  }
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
- };
193
+ return out;
160
194
  }
161
195
 
162
196
  /**
@@ -174,19 +208,42 @@ function gapTrend(gaps) {
174
208
  * @param {{ balanceUsd?: number|null, remainingMs?: number|null, progress?: string }} [materials]
175
209
  * what the run has LEFT (T/A). Omitted → no materials block at all, which is the
176
210
  * 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.
211
+ * @param {object|null} [startingDraft] Layer 3 (D4) — a loaded bridge's plan, handed over
212
+ * as the draft to TWEAK. Omitted/null is the COLD path and renders byte-identically to
213
+ * the pre-Layer-3 prompt: the block is additive, never surgery on the prompt's interior,
214
+ * so the two paths cannot drift (the F47 works-both-ways rule). Only the runner passes
215
+ * it, and only for the FIRST draft phase — a replan drafts from the run's own state.
216
+ * @param {{ seedRed?: string[], priorChecks?: string[] }} [checkFacts] the shape-lottery
217
+ * gate rules, STATED as well as enforced (the mailbox precedent: a rule only the
218
+ * validator knows costs a redraft per draft). `seedRed`: preflight-red check names —
219
+ * Rule A-v2's law (final write step only, framed wide). `priorChecks`: the predecessor
220
+ * plan's carried checks, replan phase only — Rule B's law (keep every one). Empty/omitted
221
+ * renders byte-identically to the pre-rules prompt.
181
222
  */
182
- export function planPrompt(job, scoutBlob, reds, maxStepRounds, failure, scopes, materials, capRuns = 3) {
223
+ export function planPrompt(job, scoutBlob, reds, maxStepRounds, failure, scopes, materials, startingDraft = null, checkFacts = {}) {
183
224
  const scopeMenu = Array.isArray(scopes) && scopes.length ? scopes : legalScopes(job.writeScope ?? []);
184
225
  const ceiling = Array.isArray(job.tools) ? job.tools : [...TOOL_MENU];
226
+ // The shape-lottery laws, stated where the check menu is offered (the mailbox
227
+ // precedent: a law only the validator knows costs a redraft per draft). Both
228
+ // render only when their FACT arrived — empty facts keep the prompt
229
+ // byte-identical to the pre-rules render.
230
+ const seedRedLaw = Array.isArray(checkFacts.seedRed) && checkFacts.seedRed.length
231
+ ? `\n Check(s) RED at seed right now: ${JSON.stringify(checkFacts.seedRed)}. A failing check
232
+ is the goal itself, not a milestone: it may ONLY appear on your plan's FINAL write step.
233
+ Make that step wide — its action covers the whole goal, free to edit any file it reports,
234
+ iterating until the check passes. A failing check on any earlier step is rejected.`
235
+ : '';
236
+ const priorChecksLaw = Array.isArray(checkFacts.priorChecks) && checkFacts.priorChecks.length
237
+ ? `\n Your previous plan carried these check-passes exits: ${JSON.stringify(checkFacts.priorChecks)}.
238
+ Keep every one of them in this new plan — a redraft that drops one of them is rejected:
239
+ the other exit forms verify form only, and a form-only green is unearned.`
240
+ : '';
185
241
  // W4: the menu comes off the STAGED close — the same one derivation `runPlan`
186
242
  // executes and `validatePlan` accepts against. Reading `job.close` raw here left
187
243
  // the legacy object form offering nothing while the runner preflighted a stage.
188
244
  // (`?? []`: a close naming no command stages nothing, so it offers nothing)
189
- const checkNames = checkMenu(stageClose(job.close) ?? []).map((m) => m.name);
245
+ const closeStages = stageClose(job.close) ?? [];
246
+ const checkNames = checkMenu(closeStages).map((m) => m.name);
190
247
  const doc = `DRAFT-PLAN
191
248
  You are planning how to accomplish a goal in a repository, as an ordered list of bounded
192
249
  steps (schema "plan-v1"). The plan is pure declarative JSON validated by a strict schema;
@@ -201,7 +258,6 @@ strictly in array order. Each step (no other fields exist):
201
258
  - "rounds": integer 1..${maxStepRounds} — the step's per-attempt tool-round bound
202
259
  - "target": the step's deliverable path (REQUIRED when tools include write/edit), inside ${JSON.stringify(job.writeScope)}
203
260
  - "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
261
  - "scope" (optional): narrow this step's WRITE fence — copy one value from the offered scopes below.
206
262
  A narrowed "scope" is the ONLY ground this step can write: its "target" must sit
207
263
  inside it, and a "tree-changed" exit's scope must not be disjoint from it (one that
@@ -221,6 +277,12 @@ strictly in array order. Each step (no other fields exist):
221
277
  the step must be able to act on the report. Do not plan a separate read-only
222
278
  "verify" step — attach the check to the step that does the fixing; the final
223
279
  verification after your plan is not yours to author.
280
+ A check judges the WHOLE goal, never just this step's deliverable: its report
281
+ can name files beyond the step's target. A step carrying a check-passes exit
282
+ must therefore be free to edit every file the check can report on — never
283
+ write an action that forbids editing other in-scope files (e.g. "do not
284
+ modify any file outside X"): a step ordered to leave a file alone while its
285
+ exit reds on that file can never finish, on any attempt.${seedRedLaw}${priorChecksLaw}
224
286
  Reference checks by NAME only; you cannot author or modify one.
225
287
 
226
288
  Offered "scope" values for tree-changed — copy ONE of these exactly, character for
@@ -234,6 +296,12 @@ ${materialsBlock(materials)}
234
296
  Repository survey (from a read-only scout):
235
297
  ${scoutBlob || '(no scout notes)'}`;
236
298
  let p = doc;
299
+ // BEFORE the failure/reds appendices, so the last word to a redrafting planner stays
300
+ // the validator's "fix every red". The execution probe injected at the provider seam
301
+ // and could only append at the very end; it recorded that ordering as its own known
302
+ // wart. Owning the prompt is what lets it be fixed — and on the first draft (no
303
+ // failure, no reds) the render is byte-identical to the probe's.
304
+ if (startingDraft) p += startingDraftBlock(closeStages.length, startingDraft);
237
305
  if (failure) p += `\n\nWhat happened when the previous plan ran:\n${failure}\nPlan differently — a repeat of the same steps will fail the same way.`;
238
306
  if (reds) p += `\n\nYour previous plan was REJECTED with these reds (code:path):\n${JSON.stringify(reds)}\nFix every red. Output ONLY the corrected JSON object.`;
239
307
  return p;
@@ -264,7 +332,17 @@ ${scoutBlob || '(no scout notes)'}`;
264
332
  * @param {(tier: string) => any} [opts.providerFor] P: per-step model-tier factory
265
333
  * (tier ∈ STEP_MODELS → a provider). A plan naming a tier with no factory supplied is an
266
334
  * 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
335
+ * @param {number} [opts.capRuns] shell-owned iteration cap, RETIRED as a governor
336
+ * (v1.46 §4). It stopped bounding a plan step when the strike ladder landed, and it
337
+ * stopped bounding the close-fix loop when the close trend rule did. It survives as
338
+ * that loop's fallback for the ONE case the trend cannot read — a close whose output
339
+ * carries no number at all — because a governor that cannot see the variable must
340
+ * not be the governor, and the honest fallback is the cruder bound it replaced
341
+ * rather than "unbounded". It lifts the moment a stage reports a comparable number.
342
+ * @param {number} [opts.strikeLimit] shell-owned STRIKE ceiling for a plan step's
343
+ * ladder (src/ladder.js). Arbiter territory exactly as the count it replaced was —
344
+ * the runner sets it, the plan cannot express it, and no step may tighten or raise
345
+ * it.
268
346
  * @param {number} [opts.closeTimeoutMs] close/check wall-clock cap (shell territory)
269
347
  * @param {number} [opts.maxStepRounds] the shell's per-step rounds ceiling (validatePlan's bound)
270
348
  * @param {number} [opts.scoutRounds] the read-only survey's round bound (F59: the LAST round is
@@ -279,15 +357,67 @@ ${scoutBlob || '(no scout notes)'}`;
279
357
  * first plan-flow job to emit `root-injected` runs the pre-registered ON-vs-OFF acceptance
280
358
  * read (the Layer R default-flip, LAYERS.md ⚠). Excluded on native (clipipe): the native
281
359
  * worker has no onToolResult seam, so the tee cannot settle and same-path rewrites are blind.
360
+ * @param {unknown} [opts.bridge] Layer 3 (D3/D4) — a bridge-v1 registry entry to REUSE as the
361
+ * drafter's starting point. Absent is the COLD path and is byte-identical to the pre-Layer-3
362
+ * flow. Present, the D2-split LOAD GATE runs at the door, before the close precheck and
363
+ * before any token: on a pass the newest version's plan (the run-as-executed artifact)
364
+ * rides into the FIRST draft prompt only; on a fail the run returns the distinct terminal
365
+ * `recipe-stale` having spent nothing. Selecting the entry — the listing, the pin, the
366
+ * LLM's pick — is the CALLER's, and so is falling back to cold: a silent automatic
367
+ * fall-back would spend a run's budget on a decision nobody made.
368
+ * @param {number} [opts.priorWallMs=0] RESUME (module C) — wall time a PREVIOUS, killed
369
+ * attempt of this same try already consumed, folded into the run clock (see
370
+ * `createClock`'s `priorElapsedMs`). The SIGNED `maxWallMs` is never rewritten: it is in
371
+ * the spec hash, and a resume that edited it would need a signature nobody typed. The
372
+ * restarted attempt simply starts already partly spent, so a kill can never buy a fresh
373
+ * allotment of the operator's wall.
374
+ * @param {any} [opts.resumeSeed] RESUME (module C v2) — the FINEST honest checkpoint of a
375
+ * KILLED run of this same try, read off its own spine by `readResume` (src/reuse.js) and
376
+ * by nothing else: `{ phase, plan, completedSteps: [{id, seq, by}] }`. hamr's ruling is
377
+ * the whole contract — *"it should allow resume and start last step instead from the
378
+ * beginning, why would i want to waste more money on something i already started"* — so
379
+ * with a seed this flow re-pays for NO paid unit the dead run already bought:
380
+ *
381
+ * - the SCOUT does not run (`scout-skipped`, never silence). Its survey is not on the
382
+ * spine (only its byte count is), so the ONE consequence is stated rather than
383
+ * discovered: a replan after a resume drafts from an empty survey plus the failure
384
+ * brief. Re-scouting to refill it would re-pay the exact call this exists to save.
385
+ * - the PLAN is not re-drafted. It is reloaded from the seed and RE-VALIDATED against
386
+ * the current signed spec; a plan that no longer validates is `plan-red` by name
387
+ * (`resume-plan-red`), never run. It is then emitted as this leg's own
388
+ * `plan-accepted` (`phase: 'resume'`) — R1 mints a bridge version from the plan AS
389
+ * EXECUTED, read back off the spine, so a leg that greens without one would mint
390
+ * nothing.
391
+ * - a step whose exit was already SATISFIED is skipped, in prefix order, each with its
392
+ * own `step-skipped` record naming the event that proves it. Never a fake
393
+ * step-start/step-end pair, and never silence: a reader must be able to tell skipped
394
+ * from run (the resume-to-cap invariant).
395
+ *
396
+ * The worker's conversation is deliberately NOT replayed — a transcript is not a
397
+ * checkpoint, and every attempt is fresh by the loop's own design. Absent is the
398
+ * ordinary path and is byte-identical to a run that was never killed.
399
+ * @param {{stage?: string|null, value?: number|null}[]} [opts.resumeGrades] RESUME — the
400
+ * close GRADES the dead leg recorded, oldest first (`readResume`'s `restart.grades`).
401
+ * A resumed leg is the same run continuing, so its halt readouts must judge the whole
402
+ * chain: without this the trend restarts at this leg's first close and can report
403
+ * `flat` — "revise the goal" — on a run that was converging when its allowance ran
404
+ * out. Baselines only (src/trend.js's THE SEED): it spends none of this leg's own
405
+ * bounds and mints none of its strikes. Empty/omitted is the cold path.
406
+ * @param {() => boolean} [opts.spendComplete] is the run's spend EXACT, or a floor? The
407
+ * ledger one level up owns the answer (unpriced rounds, a self-healed stall, a mid-call
408
+ * cut, an inherited resume floor); the money-halt readout states it beside the remaining
409
+ * it quotes, because `budget − floor` is a CEILING and printing it bare reads as exact
410
+ * (F6). Defaults to exact, which is what every pre-record caller was implicitly saying.
282
411
  * @param {() => number} [opts.now] the wall clock's time source, injected. The real clock is the
283
412
  * default; a caller supplies this to drive time deterministically (the same seam `createClock`
284
413
  * already exposes — a run's terminal cannot otherwise be exercised without waiting out a cap
285
414
  * whose floor is one close timeout).
286
415
  * @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>`
416
+ * 'check-red' | 'close-red' | 'close-unsupported' | 'recipe-stale' | 'pricing-red' |
417
+ * 'cap-halt' | 'wall-halt' | 'provider-red' | 'interpreter-red' | 'step-stalled' |
418
+ * `step-red:<id>`
289
419
  */
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 }) {
420
+ export async function runPlan(job, { workdir, provider, nativeProvider, providerFor, emit, remainingUsd, isUnpriced = () => false, spendComplete = () => true, capRuns = 3, strikeLimit = STRIKE_LIMIT, closeTimeoutMs, maxStepRounds = 40, layerRoot = false, scoutRounds = SCOUT_ROUNDS, bridge = null, now, priorWallMs = 0, resumeSeed = null, resumeGrades = [] }) {
291
421
  workdir = resolve(workdir);
292
422
  // ONE spelling of the redaction, housed next to the inventory it reads
293
423
  // (src/validate.js) — the same helper the isolate verbs scrub the litectx store
@@ -350,6 +480,46 @@ export async function runPlan(job, { workdir, provider, nativeProvider, provider
350
480
  return 'interpreter-red';
351
481
  }
352
482
 
483
+ // ── THE BRIDGE LOAD GATE (Layer 3, D2 as SPLIT by the 2026-08-01 addendum).
484
+ // Asked AT THE DOOR: *"is this the right KIND of recipe?"* — verdict type, close-stage
485
+ // kinds, verbs within the signed menu, and NOTHING instance-bound (paths, scopes and
486
+ // targets are expected to be yesterday's bricks; the full validatePlan judges the
487
+ // TWEAKED draft at draft time, D4).
488
+ //
489
+ // It sits FIRST among the things that cost anything, and above the clock and the close
490
+ // precheck deliberately: the gate is $0 and deterministic while a precheck is real wall
491
+ // time (four stages at the shipped timeout is an hour). It sits BELOW the two guards
492
+ // above it because those say the JOB cannot run at all, which outranks "this recipe does
493
+ // not fit". Consequence, named rather than discovered later: a refused run never learns
494
+ // whether the tree was already green — the caller's cold rerun does.
495
+ //
496
+ // The entry is re-validated here even though the caller is contracted to pass a valid
497
+ // one. Not defensive decoration: the very next thing this code does is reach into
498
+ // `versions.at(-1).plan`, and a half-written file that reached a reader anyway must come
499
+ // back as named reds, never as a TypeError out of the runner (`loadRegistry` already
500
+ // skips-and-reports for the same reason).
501
+ let startingDraft = null;
502
+ if (bridge != null) {
503
+ const bv = validateBridge(bridge);
504
+ const gate = bv.ok ? loadGate(bv.bridge, job) : { ok: false, reds: bv.reds };
505
+ const name = typeof (/** @type {any} */ (bridge)?.name) === 'string' ? /** @type {any} */ (bridge).name : null;
506
+ if (!gate.ok) {
507
+ emit('bridge-gate', { outcome: 'recipe-stale', name, reds: gate.reds });
508
+ emit('escalation', {
509
+ category: 'recipe-stale', decisionReady: true,
510
+ decision: `The selected workflow${name ? ` "${name}"` : ''} is not the right KIND of recipe for this job, so it was refused before anything was spent. Reusing it is not a decision this run can make for you.`,
511
+ options: ['rerun COLD (draft a new plan from scratch)', 'select a different workflow from the registry', 'change the job spec so the shapes match (a spec edit — the new hash needs re-approval)'],
512
+ detail: gate.reds.map((/** @type {any} */ r) => `${r.code}:${r.path}${r.detail ? ` — ${r.detail}` : ''}`).join('\n'),
513
+ });
514
+ return 'recipe-stale';
515
+ }
516
+ // `versions` is oldest-first and non-empty by the validator's entry bar, so the newest
517
+ // green's plan-as-executed is the one that inherits (R1) — never the founding one.
518
+ const newest = bv.bridge.versions.at(-1);
519
+ startingDraft = newest.plan;
520
+ emit('bridge-loaded', { name: bv.bridge.name, versions: bv.bridge.versions.length, runid: newest.runid });
521
+ }
522
+
353
523
  // T (PRD v1.27/v1.29) — the run's wall clock, STARTED HERE, before the close
354
524
  // precheck. The precheck is real wall time and F62 measured close/check gaps as
355
525
  // high as 328s on an archived run: a clock created after it would under-report
@@ -370,7 +540,11 @@ export async function runPlan(job, { workdir, provider, nativeProvider, provider
370
540
  // one. `stagedClose` is the SAME staging the runner executes (never a second
371
541
  // count), and it is non-null here by the close-unsupported guard above — a close
372
542
  // 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 } : {}) });
543
+ //
544
+ // RESUME (module C): `priorWallMs` is time a killed attempt of this same try already
545
+ // consumed. It folds into the clock's start, never into `maxWallMs` — the cap stays the
546
+ // signed one in the record and in the hash, and only the REMAINDER is left to run.
547
+ const clock = createClock({ maxWallMs: job.maxWallMs ?? null, closeStages: stagedClose.length, priorElapsedMs: priorWallMs, ...(now ? { now } : {}) });
374
548
  const closeTimeoutForReport = closeTimeoutMs ?? 120_000;
375
549
  emit('wall-clock', {
376
550
  ...clock.report(closeTimeoutForReport),
@@ -379,6 +553,33 @@ export async function runPlan(job, { workdir, provider, nativeProvider, provider
379
553
  : 'NO time cap was set — time-unbounded by explicit operator choice, never by a default',
380
554
  });
381
555
 
556
+ /**
557
+ * The per-call TIME bounds, in ONE place so the two `loop.run` sites cannot drift
558
+ * (the two-transforms class, F9). bare-agent forwards a run's options straight into
559
+ * `provider.generate` (loop.js:732), so both knobs land on the request:
560
+ *
561
+ * timeoutMs BA-18, IDLE: `req.setTimeout`, reset by every byte. Bounds a silent
562
+ * socket and nothing else.
563
+ * deadlineMs BA-19, TOTAL: a plain timer no byte resets. This is the bound for
564
+ * the F66 shape — a call that trickles forever while the run's own
565
+ * wall passes (274 minutes, with the idle bound correctly armed).
566
+ *
567
+ * `deadlineMs` is OMITTED, never zeroed, when the run is unbounded: the operator set
568
+ * no wall, so there is no ceiling to derive, and inventing one is the silent second
569
+ * ceiling F45 exists to forbid. Omission is also the honest spelling upstream — an
570
+ * absent knob inherits the provider's own default (disabled), while an explicitly
571
+ * garbage value THROWS there (`resolveTimeoutMs`, provider-http.js:53).
572
+ *
573
+ * The NATIVE (clipipe) worker does not call this and never did: the CLI owns the
574
+ * transport, there is no `ClientRequest` for either timer to arm, and that path
575
+ * passes neither bound today. Stated rather than assumed — an unbounded native call
576
+ * is bounded by the F66 stall fuse and the outside watchdog, not by this.
577
+ */
578
+ const callBounds = () => {
579
+ const deadlineMs = clock.callDeadlineMs();
580
+ return { timeoutMs: clock.callTimeoutMs(), ...(deadlineMs === null ? {} : { deadlineMs }) };
581
+ };
582
+
382
583
  /**
383
584
  * F64 — ONE categoriser for a throw out of a provider call, so the seams cannot
384
585
  * drift. A `HaltError` is the budget gate. The run's OWN deadline coming back as
@@ -424,6 +625,20 @@ export async function runPlan(job, { workdir, provider, nativeProvider, provider
424
625
  'revise the goal/spec so the work fits the time (same re-approval)',
425
626
  'abandon the task',
426
627
  ];
628
+ /** MONEY's levers (PRD v1.46 §2), the same three shapes the wall's are, because a
629
+ * money cut is the same KIND of stop: the run is out of an operator-owned
630
+ * allowance, not out of capability, and the last verdict rendered stands. hamr's
631
+ * ask was "halt and feedback… and needs to be accurate", so the readout that
632
+ * carries these also carries the trend that says WHICH of the first two fits.
633
+ *
634
+ * The first lever is a top-up the OPERATOR performs. `budgetUsd` is in the spec
635
+ * hash, so raising it is a new spec version somebody signs — the library only ever
636
+ * names the lever, and never adjusts a budget itself (the permanent hard line). */
637
+ const MONEY_OPTIONS = [
638
+ 'top up budgetUsd and rerun with --resume (resume-to-cap; a spec edit, so the new hash needs re-approval)',
639
+ 'revise the goal/spec so the work fits the budget (same re-approval)',
640
+ 'abandon the task',
641
+ ];
427
642
 
428
643
  const closeOpts = { timeoutMs: closeTimeoutMs, cwd: workdir };
429
644
  /** @type {string|undefined} the stage that rendered the LAST close verdict (Layer R's red-set source) */
@@ -433,11 +648,63 @@ export async function runPlan(job, { workdir, provider, nativeProvider, provider
433
648
  * the run's answer is whatever the close last said, and nothing after the deadline
434
649
  * is allowed to change it (hamr: *"keep the grade we already have and stop"*). */
435
650
  let lastCloseVerdict;
651
+ /** PRD v1.46 §2 — the RUN's own close trend, fed by every grade the arbiter
652
+ * renders (the precheck is the seed the work is measured against, so it is the
653
+ * first reading, not a discarded one). Read only by the halt readouts; it decides
654
+ * nothing and bounds nothing — the fix loop builds its OWN reader over its own
655
+ * grades, because "did this run make progress" and "is this loop out of ideas"
656
+ * are two questions and one instrument answering both is how they come to
657
+ * disagree. Per stage by construction (src/trend.js's accuracy law).
658
+ *
659
+ * On a RESUME it is SEEDED with the grades the dead leg recorded, so both halt
660
+ * readouts judge the whole chain rather than this leg alone: a leg that re-grades
661
+ * an unchanged tree is flat on its own evidence while the RUN — the thing the human
662
+ * is deciding about — may have been converging when its allowance ran out. Cold, the
663
+ * seed is empty and this is byte-identical to the pre-resume reader. */
664
+ const runTrend = createTrend({ stageOrder: stagedClose.map((/** @type {any} */ s) => s.name), seed: resumeGrades });
665
+ /** the run-level MONEY record, one shape wherever the wallet stops the run —
666
+ * W-2's `emitWallHalt` in a money coat, and deliberately its mirror image. hamr's
667
+ * ruling for TIME was "keep the grade we already have and stop"; a money cut is
668
+ * the same stop with a different allowance behind it, so it keeps the same grade,
669
+ * states the same kind of trend, and hands over the same shape of lever list.
670
+ * `spentUsd` is NOT re-derived here: runJob's ledger owns that number and states
671
+ * it on `job-end` (F6 — a second, weaker arithmetic for the same figure is how
672
+ * two instruments come to disagree about one run's money). What this record can
673
+ * say exactly is the SIGNED ceiling and what is left against it. */
674
+ const emitMoneyHalt = (/** @type {object} */ extra) => {
675
+ const t = runTrend.verdict();
676
+ return emit('money-halt', {
677
+ meaning: 'not under cap — not "can\'t"',
678
+ budgetUsd: job.budgetUsd,
679
+ remainingUsd: remainingUsd(),
680
+ // …and whether that figure is EXACT. `remainingUsd` is `budget − spent`, so a run
681
+ // whose spend is a FLOOR has a remaining that is a CEILING, and quoting it bare to
682
+ // four decimals reads as the number. The four causes live in the ledger one level
683
+ // up (unpriced, self-healed stall, mid-call cut, an inherited floor), which is why
684
+ // this is READ from there and not re-derived here — a second, weaker arithmetic for
685
+ // the same question is how two instruments come to disagree. `emitWallHalt` has
686
+ // carried its honest bound since W-2; this is the same duty on the money side.
687
+ spendComplete: spendComplete(),
688
+ verdict: lastCloseVerdict?.verdict ?? null,
689
+ ...(lastCloseVerdict?.stage ? { stage: lastCloseVerdict.stage } : {}),
690
+ trend: t.trend,
691
+ motion: t.motion,
692
+ reading: t.reading,
693
+ lever: t.lever,
694
+ series: t.series,
695
+ options: MONEY_OPTIONS,
696
+ ...extra,
697
+ });
698
+ };
436
699
  /** the close, as ONE verdict: every stage in order, first red wins */
437
700
  const judgeClose = async () => {
438
701
  const v = await runStages(stagedClose, scrub, closeOpts);
439
702
  closeStage = v.stage;
440
703
  lastCloseVerdict = v;
704
+ // Only a RED grade is a reading. A `satisfied` ends the run and a close FAULT
705
+ // rendered no judgment at all (CLOSE_FAULTS) — folding either into the series
706
+ // would put a non-number where the instrument expects a graded one.
707
+ if (v.verdict === 'needs_revision') runTrend.record({ gap: v.gap });
441
708
  return v;
442
709
  };
443
710
 
@@ -459,6 +726,14 @@ export async function runPlan(job, { workdir, provider, nativeProvider, provider
459
726
  // The menu is DERIVED from the close (PRD v1.28) — nobody authored it, so the
460
727
  // ruler and the real inspection cannot drift apart.
461
728
  const menu = checkMenu(stagedClose);
729
+ /** Rule A-v2's fact: check names RED at preflight (seed-red). A seed-red
730
+ * check is the GOAL, not a guard — validatePlan confines it to the final
731
+ * write step, and the drafting prompt states the same law. Recorded HERE,
732
+ * from the same preflight the spine records, never re-derived mid-run: the
733
+ * rule is defined on the SEED verdicts (a check a step turned green mid-run
734
+ * is still the goal the plan exists to flip).
735
+ * @type {string[]} */
736
+ const seedRed = [];
462
737
  emit('check-menu', {
463
738
  offered: menu.map((m) => m.name),
464
739
  ...(menu.length < stagedClose.length
@@ -467,6 +742,7 @@ export async function runPlan(job, { workdir, provider, nativeProvider, provider
467
742
  });
468
743
  for (const m of menu) {
469
744
  const v = await runStages(m.run, scrub, closeOpts);
745
+ if (v.verdict === 'needs_revision') seedRed.push(m.name);
470
746
  emit('check-preflight', { name: m.name, verdict: v.verdict, ...(m.run.length > 1 ? { chain: m.run.map((s2) => s2.name) } : {}) });
471
747
  const f = Object.hasOwn(CLOSE_FAULTS, v.verdict) ? CLOSE_FAULTS[v.verdict] : undefined;
472
748
  if (f) {
@@ -536,13 +812,39 @@ export async function runPlan(job, { workdir, provider, nativeProvider, provider
536
812
  * denied, the wallet as its budget), granted tools only (the menu IS the
537
813
  * grant), per-attempt round bound via loop.stop() (F20), every round metered
538
814
  * 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
815
+ * @param {{granted: string[], phase: string, attemptRounds: number, attempts: number|null, writable: boolean, root?: ReturnType<typeof createRoot>|null, fence?: string[]|null, workerProvider?: any}} o
540
816
  * `root` (Layer R): when present, the worker's write-class actions are teed
541
817
  * for the within-run ratchet — staged before the gate decides, discarded on
542
818
  * deny/halt, settled to landed-or-not after execution (the two axes, F43/F7).
543
819
  * Wired ONLY on the Loop path: the native session exposes no onToolResult seam.
820
+ *
821
+ * `attempts` is how many iterations this worker will be asked for. `null` is the
822
+ * STRIKE LADDER (src/ladder.js), where that count does not exist by design — see
823
+ * the turn-allowance note below.
544
824
  */
545
825
  async function mkWorker({ granted, phase, attemptRounds, attempts, writable, root = null, fence = null, workerProvider = null }) {
826
+ /**
827
+ * The gate's LLM-turn allowance THROUGH iteration `i` — the old expression
828
+ * `attemptRounds * (attempts + 1)` with the iteration count made explicit
829
+ * (`i = attempts` reproduces it exactly, so a bounded worker is unchanged to
830
+ * the number).
831
+ * @param {number} i
832
+ */
833
+ const turnsThrough = (i) => attemptRounds * (i + 1);
834
+ // A pre-multiplied ceiling could only ever pre-pay a KNOWN iteration count, and
835
+ // the strike ladder has none: iterations float under money and the wall. So on
836
+ // that path the allowance is GRANTED PER ITERATION at `setIteration` — the seam
837
+ // that already resets the per-iteration round counter — leaving the belt exactly
838
+ // as tight per iteration as the pre-multiply made it, without inventing a
839
+ // ceiling nobody signed. It is monotonic (`Math.max`), so nothing can lower it.
840
+ //
841
+ // Rebuilding the Gate per iteration was the alternative and was REJECTED: the
842
+ // audit is run_id-scoped, so a fresh gate would reset F32's crash-attribution
843
+ // write set ("files you have written this run") and Layer R's cross-attempt
844
+ // write history to a single iteration — two instruments broken to avoid one
845
+ // addition. The REAL per-iteration bound is unchanged either way: `loop.stop()`
846
+ // at `roundsThisAttempt >= attemptRounds` (the `wasBounded` mechanism).
847
+ const maxTurns = turnsThrough(attempts ?? 1);
546
848
  const gate = new Gate({
547
849
  fs: {
548
850
  // P: a step's `scope` narrows the fence — the per-step prefixes are always a
@@ -553,7 +855,7 @@ export async function runPlan(job, { workdir, provider, nativeProvider, provider
553
855
  deny: [auditPath, join(workdir, '.smoke'), join(workdir, '.litectx')],
554
856
  },
555
857
  budget: { maxCostUsd: Math.max(remainingUsd(), 0.0001) },
556
- limits: { maxTurns: attemptRounds * (attempts + 1) },
858
+ limits: { maxTurns },
557
859
  audit: { path: auditPath },
558
860
  // The gate audit is the THIRD persistent, append-only channel (beside the
559
861
  // spine and the ctx-verb events) and it lives IN THE TREE, so it gets the
@@ -569,21 +871,35 @@ export async function runPlan(job, { workdir, provider, nativeProvider, provider
569
871
  });
570
872
  await gate.init();
571
873
  // 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 = () => {
874
+ // the same audit read as interpret's (never git status, F45). ONE scan and ONE
875
+ // filter behind both readings: a future write-class verb that slipped into one
876
+ // and not the other would make the two instruments disagree about what a write
877
+ // is, which is the exact class the tee's `type`-not-name test exists to close.
878
+ const auditWrites = () => {
879
+ const paths = new Set();
880
+ let records = 0;
574
881
  try {
575
- const paths = new Set();
576
882
  for (const line of readFileSync(auditPath, 'utf8').split('\n')) {
577
883
  if (!line) continue;
578
884
  let rec;
579
885
  try { rec = JSON.parse(line); } catch { continue; }
580
886
  if (rec.run_id === gate.runId && rec.phase === 'gate' && rec.decision === 'allow'
581
887
  && (rec.action?.type === 'write' || rec.action?.type === 'edit')
582
- && typeof rec.action.path === 'string') paths.add(rec.action.path);
888
+ && typeof rec.action.path === 'string') { paths.add(rec.action.path); records += 1; }
583
889
  }
584
- return [...paths];
585
- } catch { return []; }
890
+ } catch { /* no audit yet → nothing written yet */ }
891
+ return { paths: [...paths], records };
586
892
  };
893
+ const workerWrites = () => auditWrites().paths;
894
+ /**
895
+ * The ladder's write signal: the cumulative RECORD count, never the path set.
896
+ * A plan step rewrites its one target every attempt, so the path set is constant
897
+ * after iteration 1 and a set-delta would read every later iteration as idle —
898
+ * the same Finding-3 trap Layer R documents, and it would strike a working
899
+ * worker out on its second attempt. The record count advances on every allowed
900
+ * write-class action, same path or not.
901
+ */
902
+ const writeCount = () => auditWrites().records;
587
903
  // Layer R tee (design record 2026-07-19; created per-step by executeStep).
588
904
  // The translator STAGES write/edit content and snapshots the pre-write hash
589
905
  // BEFORE the gate decides (Finding 6); the policy wrapper DISCARDS on a deny
@@ -652,6 +968,23 @@ export async function runPlan(job, { workdir, provider, nativeProvider, provider
652
968
  let roundsThisAttempt = 0;
653
969
  /** @type {number|string|undefined} */
654
970
  let attemptBounded;
971
+ /**
972
+ * Open an iteration: label the rounds, reset the per-iteration round counter,
973
+ * and — on the unbounded ladder only — GRANT this iteration's turn allowance to
974
+ * the gate. Raising `limits.maxTurns` is documented bareguard config read live
975
+ * by `Limits.preCheck()` on every record; the alternative (a fresh Gate per
976
+ * iteration) is the one that breaks instruments, so this is the smaller change.
977
+ * Monotonic by construction: a re-entry can only ever hold the allowance, never
978
+ * lower it. A bounded worker's ceiling is untouched — it was pre-paid in full.
979
+ * @param {number|string} i
980
+ */
981
+ const setIteration = (i) => {
982
+ roundIteration = i;
983
+ roundsThisAttempt = 0;
984
+ if (attempts === null && typeof i === 'number') {
985
+ gate.limits.maxTurns = Math.max(gate.limits.maxTurns, turnsThrough(i));
986
+ }
987
+ };
655
988
  const grantedNames = new Set(granted.map((v) => /** @type {Record<string, string>} */ (TOOL_BY_VERB)[v]));
656
989
  const shell = createShellTools().tools.filter((/** @type {{name: string}} */ t) => grantedNames.has(t.name));
657
990
  // F48: on native, bound shell_read below the CLI's tool-result display cap and hand back a
@@ -752,7 +1085,7 @@ export async function runPlan(job, { workdir, provider, nativeProvider, provider
752
1085
  }
753
1086
  return r;
754
1087
  };
755
- return { ask, askFrom, workerWrites, setIteration: (/** @type {number|string} */ i) => { roundIteration = i; roundsThisAttempt = 0; }, wasBounded: () => attemptBounded };
1088
+ return { ask, askFrom, workerWrites, writeCount, setIteration, wasBounded: () => attemptBounded };
756
1089
  }
757
1090
 
758
1091
  // ── LOOP path: the injected provider (anthropic-api and every other
@@ -763,7 +1096,7 @@ export async function runPlan(job, { workdir, provider, nativeProvider, provider
763
1096
  // onLlmResult meters exactly like an API round, so the drafter's spend is
764
1097
  // never invisible (F6/F44). The gate policy is wired but idle (no tools).
765
1098
  const loopProvider = native
766
- ? /** @type {any} */ (nativeProvider)({ policy, maxTurns: attemptRounds * (attempts + 1), hasTools: false })
1099
+ ? /** @type {any} */ (nativeProvider)({ policy, maxTurns, hasTools: false })
767
1100
  : (workerProvider ?? provider);
768
1101
  // F66 — the stall watchdog. bare-agent's `timeoutMs` bounds socket INACTIVITY,
769
1102
  // not call duration (provider-http.js `req.setTimeout` resets on every byte),
@@ -870,7 +1203,7 @@ export async function runPlan(job, { workdir, provider, nativeProvider, provider
870
1203
  */
871
1204
  const askFrom = async (msgs, prompt) => {
872
1205
  try {
873
- return await stallWatch.watch((gen) => newLoop(gen).run([...msgs, { role: 'user', content: prompt }], [], { cacheMessages: true, maxTokens: 32000, timeoutMs: clock.callTimeoutMs() }));
1206
+ return await stallWatch.watch((gen) => newLoop(gen).run([...msgs, { role: 'user', content: prompt }], [], { cacheMessages: true, maxTokens: 32000, ...callBounds() }));
874
1207
  } catch (e) {
875
1208
  throw categorize(e).err;
876
1209
  }
@@ -878,7 +1211,7 @@ export async function runPlan(job, { workdir, provider, nativeProvider, provider
878
1211
  const ask = async (prompt, defs = toolDefs) => {
879
1212
  let r;
880
1213
  try {
881
- r = await stallWatch.watch((gen) => newLoop(gen).run([{ role: 'user', content: prompt }], defs, { cacheMessages: true, maxTokens: 32000, timeoutMs: clock.callTimeoutMs() }));
1214
+ r = await stallWatch.watch((gen) => newLoop(gen).run([{ role: 'user', content: prompt }], defs, { cacheMessages: true, maxTokens: 32000, ...callBounds() }));
882
1215
  } catch (e) {
883
1216
  throw categorize(e).err;
884
1217
  }
@@ -895,7 +1228,7 @@ export async function runPlan(job, { workdir, provider, nativeProvider, provider
895
1228
  }
896
1229
  return r;
897
1230
  };
898
- return { ask, askFrom, workerWrites, setIteration: (/** @type {number|string} */ i) => { roundIteration = i; roundsThisAttempt = 0; }, wasBounded: () => attemptBounded };
1231
+ return { ask, askFrom, workerWrites, writeCount, setIteration, wasBounded: () => attemptBounded };
899
1232
  }
900
1233
 
901
1234
  /** relay a throw from OUTSIDE ralph (scout/plan drafting/fix) as its honest
@@ -906,10 +1239,16 @@ export async function runPlan(job, { workdir, provider, nativeProvider, provider
906
1239
  const relay = (/** @type {any} */ e, /** @type {string} */ phase) => {
907
1240
  const { category } = categorize(e);
908
1241
  const detail = String(e?.message ?? e);
909
- if (category === 'cap-halt') emit('cap-halt', { category, meaning: 'not under cap — not "can\'t"', detail });
1242
+ if (category === 'cap-halt') {
1243
+ emit('cap-halt', { category, meaning: 'not under cap — not "can\'t"', detail });
1244
+ // v1.46 §2 — the money record rides beside the taxonomy record exactly as the
1245
+ // wall's does one line down. Outside the loops there is usually no series yet,
1246
+ // and the readout says `unknown` rather than inventing a direction (F6).
1247
+ emitMoneyHalt({ phase, cutMidCall: true });
1248
+ }
910
1249
  if (category === 'wall-halt') emitWallHalt({ cutMidCall: true, phase });
911
1250
  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']],
1251
+ 'cap-halt': [`The budget gate tripped during ${phase} — the wallet cannot fund the plan flow.`, MONEY_OPTIONS],
913
1252
  'wall-halt': [`The run reached its wall-clock cap during ${phase}. Time ran out, not capability.`, WALL_OPTIONS],
914
1253
  // A stall OUTSIDE a step (scout, drafting, fix) has no step to replan, so it
915
1254
  // surfaces — but it is named, never laundered into provider-red. The socket
@@ -933,48 +1272,63 @@ export async function runPlan(job, { workdir, provider, nativeProvider, provider
933
1272
  // ── 1. SCOUT — read-only by construction: the write-class verbs are simply
934
1273
  // not in its menu (the menu is the grant), and its gate fences zero paths
935
1274
  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;
1275
+ // RESUME (module C v2): the survey is a PAID unit the killed run already bought, and
1276
+ // the plan it briefed is being reloaded rather than re-drafted — so there is nothing
1277
+ // left for it to brief. Skipping it is the whole point of the ruling ("why would i
1278
+ // want to waste more money on something i already started"), and the skip is a RECORD
1279
+ // rather than silence, with the one consequence named on it: the survey text is not on
1280
+ // the spine (only its byte count ever was), so a replan after a resume drafts from an
1281
+ // empty survey plus its failure brief. Re-scouting to refill it would re-pay exactly
1282
+ // the call this exists to save.
1283
+ if (resumeSeed) {
1284
+ emit('scout-skipped', {
1285
+ reason: 'resumed', phase: resumeSeed.phase ?? null,
1286
+ meaning: 'the killed run already paid for this survey; its plan is reloaded, not re-drafted. A replan after this point drafts from an empty survey plus the failure brief.',
1287
+ });
1288
+ } else {
1289
+ emit('scout-start', { rounds: scoutRounds });
1290
+ try {
1291
+ const scout = await mkWorker({ granted: ceiling.filter((v) => !WRITE_VERBS.includes(v) && !STORE_VERBS.includes(v)), phase: 'scout', attemptRounds: scoutRounds, attempts: 1, writable: false });
1292
+ scout.setIteration(1);
1293
+ const r = await scout.ask([
1294
+ 'Survey this repository READ-ONLY for the goal below. Report: the relevant layout, the key files and symbols, and your best hypothesis about what the work requires. Be concise — your notes brief a planner that cannot see the repository.',
1295
+ `Repository root (absolute): ${workdir}\nEvery path you pass to a tool MUST be absolute and inside this root.`,
1296
+ `Goal:\n${job.goal}`,
1297
+ pre.gap && `The job's verification is currently failing. Its output on the tree as it stands:\n${pre.gap}`,
1298
+ ].filter(Boolean).join('\n\n'));
1299
+ scoutBlob = scrub(r.text ?? '').slice(0, SCOUT_BLOB_MAX);
1300
+ // F59 — RESERVE the summary round. The round bound is enforced from the
1301
+ // metering callback (loop.stop), so a scout still calling tools on its last
1302
+ // round is halted mid-tool-use and `text` is empty: it spends the whole
1303
+ // allowance exploring and never writes the survey that is its ONLY
1304
+ // deliverable. Measured on 15 of 18 archived runs, which then drafted from
1305
+ // `(no scout notes)` while still paying ~12% of the run (F55) for the walk.
1306
+ // The recovery is one TOOLLESS round on the SAME conversation (`r.msgs`
1307
+ // carries the exploration): with no tools offered, text is the only possible
1308
+ // output — a mechanical guarantee, never a prose plea to summarise (F19/F37:
1309
+ // a pacing mandate in the persona was violated 6/6).
1310
+ // The trigger is BOUNDED-and-short, never short alone: a terse survey that
1311
+ // finished on its own was not cut off and has nothing to recover. The archive
1312
+ // separates cleanly on exactly this pair — every truncated scout was bounded
1313
+ // with 0-86 bytes, and the two that finished naturally wrote 5991/8056.
1314
+ if (scout.wasBounded() && Buffer.byteLength(scoutBlob) < SCOUT_MIN_BYTES && Array.isArray(r.msgs) && r.msgs.length) {
1315
+ emit('scout-truncated', { bytes: Buffer.byteLength(scoutBlob) });
1316
+ const s2 = await scout.askFrom(r.msgs, 'You are out of exploration turns. Write your survey NOW, from what you have already read: the relevant layout, the key files and symbols, and your best hypothesis about what the work requires. Text only.');
1317
+ const recovered = scrub(s2?.text ?? '').slice(0, SCOUT_BLOB_MAX);
1318
+ if (Buffer.byteLength(recovered) > Buffer.byteLength(scoutBlob)) scoutBlob = recovered;
1319
+ }
1320
+ } catch (e) {
1321
+ return relay(e, 'scout');
966
1322
  }
967
- } catch (e) {
968
- return relay(e, 'scout');
1323
+ emit('scout-result', { bytes: Buffer.byteLength(scoutBlob) });
1324
+ // F59 — the LOUD half. `scout-result {bytes: 0}` was emitted faithfully 18 times
1325
+ // out of 18 and read by nobody: an instrument that works but has no consumer is a
1326
+ // log line, not evidence. A survey that is still empty after its reserved round is
1327
+ // now a NAMED condition. It is never a halt — F59's own evidence is that 3 of 5
1328
+ // archived greens had an empty scout, so failing the run here would be a worse
1329
+ // error than the one being fixed.
1330
+ if (Buffer.byteLength(scoutBlob) < SCOUT_MIN_BYTES) emit('scout-empty', { bytes: Buffer.byteLength(scoutBlob), rounds: scoutRounds });
969
1331
  }
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
1332
  // F6 in-flight: an unpriced round means the cap cannot govern spend it cannot
979
1333
  // see — halt at the boundary rather than run the whole plan blind (the caller
980
1334
  // emits pricing-red; runPlan just stops burning tokens). Legacy halts per-step.
@@ -999,11 +1353,30 @@ export async function runPlan(job, { workdir, provider, nativeProvider, provider
999
1353
  // is actually left, which is the adaptation channel.
1000
1354
  const materials = { balanceUsd: remainingUsd(), remainingMs: clock.bounded ? clock.remainingMs() : null, ...(progress ? { progress } : {}) };
1001
1355
  emit('materials', { phase, ...materials });
1356
+ // D5 — the bridge rides into the FIRST draft phase ONLY. A replan drafts from the
1357
+ // RUN'S OWN STATE (what failed, what is left): re-handing the recipe there would
1358
+ // answer the replan's question with the material the run just demonstrated does not
1359
+ // work, and would spend the one revision the ceiling allows on a second unmeasured
1360
+ // arm. Exactly the rule the execution probe held at the provider seam, moved to where
1361
+ // the phase is known rather than inferred from the prompt's own text.
1362
+ // The REDRAFT (a validator rejection inside this same phase) keeps it: one red must
1363
+ // not silently convert a warm run to a cold one.
1364
+ const starting = phase === 'draft' ? startingDraft : null;
1365
+ // The shape-lottery facts, gate AND prompt from the ONE object (the scope-menu
1366
+ // precedent: what is stated and what is enforced can never drift apart).
1367
+ // `seedRed` rides every phase — the rule is defined on the SEED verdicts.
1368
+ // `priorChecks` is the REPLAN gate only: the accepted plan's carried checks,
1369
+ // which the redraft may move (A-v2 decides where) but never shed (Rule B —
1370
+ // u-msdsmkid's replan dropped both checks and "greened" on form alone).
1371
+ const checkFacts = {
1372
+ seedRed,
1373
+ ...(phase === 'replan' && plan
1374
+ ? { priorChecks: [...new Set(/** @type {any[]} */ (plan.steps).flatMap((/** @type {any} */ s) => (Array.isArray(s.exit) ? s.exit : []).filter((/** @type {any} */ e) => e?.type === 'check-passes' && typeof e.name === 'string').map((/** @type {any} */ e) => e.name)))] }
1375
+ : {}),
1376
+ };
1002
1377
  const draftPlan = async (/** @type {any[]|null} */ reds) => {
1003
1378
  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), []);
1379
+ const r = await drafter.ask(planPrompt(job, scoutBlob, reds, maxStepRounds, failure, scopeMenu, materials, starting, checkFacts), []);
1007
1380
  return extractArtifact(r.text).code ?? '';
1008
1381
  };
1009
1382
  // Scrubbed HERE, once, before any consumer reads them — the judge() precedent
@@ -1016,7 +1389,7 @@ export async function runPlan(job, { workdir, provider, nativeProvider, provider
1016
1389
  // emit sites: scrubbing here makes the leak inexpressible, scrubbing each
1017
1390
  // consumer makes it something the next consumer has to remember.
1018
1391
  const validate = (/** @type {string} */ t) => {
1019
- const v = validatePlan(t, { job, maxStepRounds, scopes: scopeMenu, capRuns });
1392
+ const v = validatePlan(t, { job, maxStepRounds, scopes: scopeMenu, ...checkFacts });
1020
1393
  return { ...v, reds: v.reds.map((r) => (typeof r.detail === 'string' ? { ...r, detail: scrub(r.detail) } : r)) };
1021
1394
  };
1022
1395
  let text = await draftPlan(null);
@@ -1030,16 +1403,76 @@ export async function runPlan(job, { workdir, provider, nativeProvider, provider
1030
1403
  return pv;
1031
1404
  };
1032
1405
  let plan;
1033
- try {
1034
- const pv = await obtainPlan('draft', null);
1406
+ /** RESUME: how many leading steps of the reloaded plan are already SATISFIED and are
1407
+ * therefore skipped. Zero on every other path, and reset to zero by a replan — the new
1408
+ * plan's steps are not the dead plan's steps, even where an id repeats. */
1409
+ let skipCount = 0;
1410
+ if (resumeSeed) {
1411
+ // The plan is RELOADED, not re-drafted — but it is re-validated against the spec that
1412
+ // is signed NOW. The dead run validated it against the spec it was signed under, and
1413
+ // between the kill and the resume the menu, the fence or the close can have moved
1414
+ // (registry/schema drift). A stale plan is refused BY NAME with zero spent; running
1415
+ // it would execute a plan the current signature does not admit, which is the one
1416
+ // thing no reconstruction is allowed to do.
1417
+ // The shape-lottery opts (seedRed/priorChecks) are deliberately NOT passed:
1418
+ // they are DRAFTING laws — this gate guards signature drift, and refusing a
1419
+ // paid, previously-legal plan over a shape rule minted after its draft would
1420
+ // burn the very work resume exists to keep (hamr: "why would i want to waste
1421
+ // more money on something i already started"). Omission = rules inactive, by
1422
+ // the validator's contract.
1423
+ const pv = validatePlan(resumeSeed.plan, { job, maxStepRounds, scopes: scopeMenu });
1424
+ // scrubbed at the boundary for `validate()`'s own reason: a `parse-error` detail
1425
+ // quotes a window of the SOURCE, and the spine is append-only forever
1426
+ const reds = pv.reds.map((r) => (typeof r.detail === 'string' ? { ...r, detail: scrub(r.detail) } : r));
1035
1427
  if (!pv.ok) {
1036
- for (const r of pv.reds) emit('plan-red', r);
1428
+ emit('resume-plan-red', { reds, phase: resumeSeed.phase ?? null });
1429
+ for (const r of reds) emit('plan-red', r);
1430
+ emit('escalation', {
1431
+ category: 'plan-red', decisionReady: true, phase: 'resume',
1432
+ decision: 'The plan the killed run was executing no longer validates against this job spec, so the resume ran nothing. A reconstruction may re-use a plan; it may never run one the current signature does not admit.',
1433
+ options: [
1434
+ 'rerun WITHOUT --resume (the run drafts a cold plan against the current spec — the killed run\'s steps are then not inherited)',
1435
+ 'restore the spec version the dead run was signed under and resume again',
1436
+ ],
1437
+ detail: reds.map((r) => `${r.code}:${r.path}${r.detail ? ` — ${r.detail}` : ''}`).join('\n'),
1438
+ });
1037
1439
  return 'plan-red';
1038
1440
  }
1039
1441
  plan = /** @type {any} */ (pv.plan);
1040
- emit('plan-accepted', { plan });
1041
- } catch (e) {
1042
- return relay(e, 'plan');
1442
+ // The skip set is a PREFIX match on the plan's own step ids, never a count: a count
1443
+ // would skip whatever happens to sit at that index. Steps run strictly sequentially
1444
+ // and idx only advances on a satisfied exit, so the dead run's completions ARE a
1445
+ // prefix — and a seed that does not line up stops the skipping there and says so on
1446
+ // the record, which re-runs work rather than skipping work nobody did (the safe
1447
+ // direction of the two).
1448
+ const completed = Array.isArray(resumeSeed.completedSteps) ? resumeSeed.completedSteps : [];
1449
+ while (skipCount < completed.length && skipCount < plan.steps.length
1450
+ && completed[skipCount]?.id === plan.steps[skipCount].id) skipCount += 1;
1451
+ emit('resume-seed', {
1452
+ phase: resumeSeed.phase ?? null,
1453
+ planSteps: plan.steps.map((/** @type {any} */ s) => s.id),
1454
+ completed: completed.map((/** @type {any} */ c) => c?.id ?? null),
1455
+ skipping: skipCount,
1456
+ ...(skipCount < completed.length
1457
+ ? { divergence: `${completed.length} step(s) were completed but only ${skipCount} line up with the reloaded plan in order — the rest are RUN, never skipped on an id that does not match` }
1458
+ : {}),
1459
+ });
1460
+ // this leg's own plan-accepted: R1 mints a bridge version from the plan AS EXECUTED,
1461
+ // read back off the spine, so a resumed leg that greens without one would mint
1462
+ // nothing. `phase` says where it came from — it was accepted, not drafted.
1463
+ emit('plan-accepted', { plan, phase: 'resume' });
1464
+ } else {
1465
+ try {
1466
+ const pv = await obtainPlan('draft', null);
1467
+ if (!pv.ok) {
1468
+ for (const r of pv.reds) emit('plan-red', r);
1469
+ return 'plan-red';
1470
+ }
1471
+ plan = /** @type {any} */ (pv.plan);
1472
+ emit('plan-accepted', { plan });
1473
+ } catch (e) {
1474
+ return relay(e, 'plan');
1475
+ }
1043
1476
  }
1044
1477
  if (isUnpriced()) return 'pricing-red'; // F6: the plan drafting round came back unpriced — halt before steps
1045
1478
 
@@ -1082,23 +1515,30 @@ export async function runPlan(job, { workdir, provider, nativeProvider, provider
1082
1515
  const root = layerRoot && !native
1083
1516
  ? createRoot({ gapKeep: '\\S', redact: scrub, writesInformative: true })
1084
1517
  : 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;
1518
+ // P: the widened step fields, each wired or refused — a silently-ignored
1519
+ // optional field is the F50 blind-instrument class. `scope` narrows the fence to
1520
+ // a menu value (a subset by construction); `model` swaps the worker's provider
1521
+ // via the runner's factory — absent factory it must STOP, not silently run the
1522
+ // default tier as if the choice had been honoured. `attempts` is the third and
1523
+ // is now TOLERATED-INERT (see validatePlan): the ladder governs iterations by
1524
+ // progress, and a field that could only ever TIGHTEN a count is the wrong shape
1525
+ // for that — drafters measurably tightened it to 3, then to 2 on replan, which
1526
+ // is the opposite of the heal a converging step needs.
1092
1527
  if (step.model !== undefined && !providerFor) {
1093
1528
  const err = /** @type {any} */ (new Error(`step "${step.id}" selects model tier "${step.model}" but the runner supplied no providerFor factory — wiring gap, not a plan defect`));
1094
1529
  err.category = 'interpreter-red';
1095
1530
  throw err;
1096
1531
  }
1097
1532
  const w = await mkWorker({
1098
- granted: step.tools, phase: `step:${step.id}`, attemptRounds: step.rounds, attempts: stepCap, writable: true, root,
1533
+ granted: step.tools, phase: `step:${step.id}`, attemptRounds: step.rounds, attempts: null, writable: true, root,
1099
1534
  fence: step.scope !== undefined ? [resolve(workdir, globToPrefix(step.scope))] : null,
1100
1535
  workerProvider: step.model !== undefined && providerFor ? providerFor(step.model) : null,
1101
1536
  });
1537
+ // ONE ladder per step: the seen-set and the strike count are this step's own
1538
+ // history, and a shared one would strike a fresh step for a gap an earlier step
1539
+ // had already seen. The write signal is the gate audit's own record count (F32's
1540
+ // filter), never git status and never a tree diff.
1541
+ const ladder = createLadder({ limit: strikeLimit, writeCount: w.writeCount });
1102
1542
  let lastText = '';
1103
1543
  let iterationNow = 0;
1104
1544
  // The meter's baseline: what the RUN had left when this step began. Shares are
@@ -1162,16 +1602,19 @@ export async function runPlan(job, { workdir, provider, nativeProvider, provider
1162
1602
  stepsDone: idx,
1163
1603
  stepsPlanned: plan.steps.length,
1164
1604
  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,
1605
+ // The ladder's own state, because the ladder is what was governing this
1606
+ // step when the wall stopped it. This record used to carry `capRuns` and
1607
+ // `stepAttemptCap` — two counts that no longer bound anything here — and a
1608
+ // field name that means something it no longer measures is worse than an
1609
+ // absent one: a reader cannot tell it went stale. The FIX site's copy of this
1610
+ // record dropped `capRuns` for the same reason at v1.46 §4, when the count
1611
+ // stopped governing that loop too — neither wall record quotes a denominator
1612
+ // now, because neither loop has one.
1613
+ strikes: ladder.report().strikes,
1614
+ strikeLimit: ladder.report().limit,
1615
+ distinctGaps: ladder.report().distinctGaps,
1173
1616
  };
1174
- // No trend here, and none invented: `gapTrend` reads consecutive CLOSE output,
1617
+ // No trend here, and none invented: the trend instrument reads CLOSE grades,
1175
1618
  // and a step that never ran has no grade of its own to compare. The lever the
1176
1619
  // human needs is on the run-level record either way.
1177
1620
  const err = /** @type {CategorizedError} */ (new Error(
@@ -1201,6 +1644,7 @@ export async function runPlan(job, { workdir, provider, nativeProvider, provider
1201
1644
  ].filter(Boolean).join('\n\n'));
1202
1645
  lastText = scrub(r.text ?? '').slice(0, ARTIFACT_MAX);
1203
1646
  };
1647
+ let lastGap = '';
1204
1648
  const judge = async () => {
1205
1649
  const { pass, results: raw } = await evalExits(step.exit, { dir: workdir, snapshot, runCheck });
1206
1650
  // Scrubbed HERE, once, before the results are read by anyone. An exit detail is
@@ -1224,15 +1668,38 @@ export async function runPlan(job, { workdir, provider, nativeProvider, provider
1224
1668
  if (faulty) return { verdict: /** @type {string} */ (faulty.fault), detail: faulty.detail };
1225
1669
  if (pass) return { verdict: 'satisfied' };
1226
1670
  // 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') };
1671
+ lastGap = results.filter((r) => !r.pass).map((r) => r.detail).join('\n');
1672
+ return { verdict: 'needs_revision', gap: lastGap };
1228
1673
  };
1229
- const outcome = await ralph({ middle, judge, capRuns: stepCap, emit: emitL, workerWrites: w.workerWrites });
1230
- return { outcome, artifact: lastText };
1674
+ const outcome = await ralph({ middle, judge, ladder, emit: emitL, workerWrites: w.workerWrites });
1675
+ // gap + writes ride out for the replan brief's mismatch line (the books this
1676
+ // step already kept; the escalation's detail carries the meter/strike sentence,
1677
+ // never the gap, so the brief cannot recover this fact from anywhere else)
1678
+ return { outcome, artifact: lastText, ladder, writes: w.workerWrites(), gap: lastGap };
1231
1679
  };
1232
1680
 
1233
1681
  let idx = 0;
1234
1682
  while (idx < plan.steps.length) {
1235
1683
  const step = plan.steps[idx];
1684
+ // RESUME: this step's exit was already satisfied before the kill. It is not re-run —
1685
+ // that is the money and the time the ruling is about — and it is not silent either:
1686
+ // the record is its OWN type carrying the event that proves it, so a reader can tell
1687
+ // a skipped step from a run one (the resume-to-cap invariant, which a fake
1688
+ // step-start/step-end pair would destroy). The step's ARTIFACT is not reconstructed:
1689
+ // its text was a prompt ingredient, never a checkpoint, and its real product is on
1690
+ // disk where the next step will find it.
1691
+ if (idx < skipCount) {
1692
+ const proof = resumeSeed.completedSteps[idx];
1693
+ emit('step-skipped', {
1694
+ step: step.id,
1695
+ provenBy: proof?.by ?? null,
1696
+ provenSeq: typeof proof?.seq === 'number' ? proof.seq : null,
1697
+ meaning: 'the killed run satisfied this step\'s exits — it is not re-run and not re-paid',
1698
+ });
1699
+ stepOutcomes.push({ id: step.id, outcome: 'skipped' });
1700
+ idx += 1;
1701
+ continue;
1702
+ }
1236
1703
  let res;
1237
1704
  try {
1238
1705
  res = await executeStep(step);
@@ -1247,7 +1714,16 @@ export async function runPlan(job, { workdir, provider, nativeProvider, provider
1247
1714
  const err = /** @type {CategorizedError} */ (e);
1248
1715
  const category = err instanceof HaltError ? 'cap-halt' : (typeof err.category === 'string' ? err.category : 'interpreter-red');
1249
1716
  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) });
1717
+ if (category === 'cap-halt') {
1718
+ emit('cap-halt', { category, meaning: 'not under cap — not "can\'t"', detail: String(err?.message ?? err) });
1719
+ // …and the MONEY record beside it, as every other cap-halt site emits one. A
1720
+ // readout a human gets at three seams and not at the fourth is the readout being
1721
+ // a coincidence of where the stop happened. UNREACHABLE today — nothing in
1722
+ // mkWorker/gate.init/snapshotScope buys a round, so no wallet can drain here —
1723
+ // which is why this is symmetry rather than a fix, and why it stays a `HaltError`
1724
+ // branch instead of a widened one.
1725
+ emitMoneyHalt({ phase: `step:${step.id}`, cutMidCall: false, stepsDone: idx, stepsPlanned: plan.steps.length });
1726
+ }
1251
1727
  emit('escalation', {
1252
1728
  category, decisionReady: true, phase: `step:${step.id}`,
1253
1729
  decision: category === 'cap-halt'
@@ -1321,13 +1797,33 @@ export async function runPlan(job, { workdir, provider, nativeProvider, provider
1321
1797
  // is handed to the one component whose whole job is to respond to it. A
1322
1798
  // stall in particular never judged its exits at all — the exhaustion
1323
1799
  // sentence would invent a red the run never saw (F28's rule, replan side).
1800
+ // The exhaustion branch is the one the ladder rewrote. "It ran N attempts and
1801
+ // its exits were still red" described a bound that no longer exists AND said
1802
+ // nothing about the mechanism — the planner could not tell a step that was
1803
+ // converging from one repeating itself, which are opposite problems with
1804
+ // opposite fixes. The ladder's own books answer that, and the balance says what
1805
+ // the stop left on the table (the u-msd916dh reading: $1.30 and ~6min unspent
1806
+ // behind a step that was still shrinking its error count). Time is reported as
1807
+ // UNBOUNDED when the operator set no wall — never rendered as a number (F6
1808
+ // extends to time).
1809
+ const leftOver = `$${remainingUsd().toFixed(2)} and `
1810
+ + `${clock.bounded ? `${Math.round(clock.remainingMs() / 60_000)} minute(s)` : 'time UNBOUNDED'} `
1811
+ + 'of the run were still unspent when it stopped.';
1324
1812
  const why = cat === 'step-variance'
1325
1813
  ? 'It was stopped by the run\'s meter for consuming too large a share of what was left.'
1326
1814
  : cat === 'step-stalled'
1327
1815
  ? '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.`;
1816
+ : `${res.ladder.brief()} ${leftOver}`;
1817
+ // The prose-prohibition trap's mechanical fact (u-msdpuaej): gap-named files
1818
+ // the step never wrote. Computed from the step's own books; capped with the
1819
+ // trim announced (the F32 gap convention). Empty extraction = no line.
1820
+ const missed = gapFilesNeverWritten(res?.gap, res?.writes);
1821
+ const missedLine = missed.length
1822
+ ? `The last exit output names file(s) this step never wrote: ${missed.slice(0, 8).join(', ')}${missed.length > 8 ? ` (+${missed.length - 8} more)` : ''}.\n`
1823
+ : '';
1329
1824
  const failure = `Step "${step.id}" (${step.action}) did not reach its exits. `
1330
1825
  + `${why}\n`
1826
+ + missedLine
1331
1827
  + `Last exit state:\n${lastEscalation?.detail ?? '(none)'}\n`
1332
1828
  + `Steps completed so far: ${artifacts.map((a) => a.id).join(', ') || 'none'}.`;
1333
1829
  // The progress line IS the adaptation channel (addendum 2): the balance rides
@@ -1354,6 +1850,10 @@ export async function runPlan(job, { workdir, provider, nativeProvider, provider
1354
1850
  // it names steps the current plan does not contain)
1355
1851
  artifacts.length = 0;
1356
1852
  idx = 0;
1853
+ // and for exactly the same reason a RESUME's skip set dies here: an id the new
1854
+ // plan happens to re-use names a step this run never did, so inheriting the
1855
+ // dead run's green for it would skip work nobody has done
1856
+ skipCount = 0;
1357
1857
  continue;
1358
1858
  }
1359
1859
  // F64 — the wall stopped this attempt from INSIDE a provider call (the derived
@@ -1378,7 +1878,13 @@ export async function runPlan(job, { workdir, provider, nativeProvider, provider
1378
1878
  // stays a step-red (the stop is a result).
1379
1879
  if (cat === 'cap-halt') {
1380
1880
  planExecuted();
1381
- return remainingUsd() <= MONEY_MIN ? 'cap-halt' : `step-red:${step.id}`;
1881
+ if (remainingUsd() > MONEY_MIN) return `step-red:${step.id}`;
1882
+ // v1.46 §2: the money cut is decision-ready HERE too, and for the same reason
1883
+ // W-2 gave the wall a record at every site it stops a run — a category that
1884
+ // hands the human a readout at one seam and silence at another is the readout
1885
+ // being a coincidence of where the stop happened.
1886
+ emitMoneyHalt({ phase: `step:${step.id}`, cutMidCall: false, stepsDone: idx, stepsPlanned: plan.steps.length });
1887
+ return 'cap-halt';
1382
1888
  }
1383
1889
  // A second `step-variance` after the one replan is spent is a STOP, and the stop
1384
1890
  // is the result — but it rides out as `step-red:<id>`, NOT as its own top-level
@@ -1423,6 +1929,10 @@ export async function runPlan(job, { workdir, provider, nativeProvider, provider
1423
1929
  * without consulting the clock a second time (two clock reads for one event is
1424
1930
  * how the two instruments come to disagree). */
1425
1931
  let wallStop = null;
1932
+ /** how many fix ITERATIONS the loop actually bought — counted here rather than
1933
+ * read back off the trend reader, which lives inside the try and is out of scope
1934
+ * by the time a terminal needs the number. */
1935
+ let fixIterationsUsed = 0;
1426
1936
  try {
1427
1937
  // Layer R for the close-fix loop — the plan flow's single ralph loop judged
1428
1938
  // by the REAL close (the plan flow's single ralph loop, and the
@@ -1436,16 +1946,85 @@ export async function runPlan(job, { workdir, provider, nativeProvider, provider
1436
1946
  const fixRoot = layerRoot && !native
1437
1947
  ? createRoot({ redact: scrub, writesInformative: true })
1438
1948
  : 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 ?? ''];
1949
+ // `attempts: null` is the STRIKE-LADDER shape (mkWorker's own contract): the fix
1950
+ // loop's iteration count no longer exists at construction time, so the gate's
1951
+ // turn allowance is granted per iteration at `setIteration` instead of being
1952
+ // pre-multiplied by a number nobody signed. The per-iteration bound is
1953
+ // unchanged — `loop.stop()` at `attemptRounds` — which is the belt that actually
1954
+ // holds. (v1.46 §4: `capRuns` retires as the governor.)
1955
+ const w = await mkWorker({ granted: ceiling, phase: 'fix', attemptRounds: maxStepRounds, attempts: null, writable: true, root: fixRoot });
1956
+ // ── v1.46 §4: the fix loop's GOVERNOR — progress, not a count ──
1957
+ //
1958
+ // MEASURED reason, the same shape as the step ladder's. The fixed `capRuns`
1959
+ // could not tell converging work from thrash, so it stopped both at the same
1960
+ // place: u-msew1uy5 was cut mid-convergence at fix iteration 3 while
1961
+ // reuse-msc6w93z sat dead flat at 2 errors for SEVEN fix verdicts until the wall
1962
+ // killed it. The $0 replay over all 8 archived fix loops came back clean both
1963
+ // directions — 0 greens harmed (every historical green converted in <= 2
1964
+ // verdicts), 1 waste case caught.
1965
+ //
1966
+ // The reader is SEEDED with the grade the loop opened on, because that is what
1967
+ // the first fix attempt is measured against; without it the first attempt would
1968
+ // have nothing to be compared to and the loop would spend a free iteration
1969
+ // establishing what it already knew.
1970
+ //
1971
+ // `blindCap: capRuns` is not the count surviving in disguise. It binds ONLY
1972
+ // while the instrument has never been able to compare anything (a close whose
1973
+ // output carries no number at all), which is precisely the case where a strike
1974
+ // would be minted out of ignorance. The moment a stage reports a comparable
1975
+ // number the count is gone, and money and the wall are what remain.
1976
+ //
1977
+ // DELIBERATELY NOT SEEDED with `resumeGrades`, and this is the one place the
1978
+ // resume chain stops. The two readers answer two different questions (see
1979
+ // `runTrend` above, and src/trend.js's "ONE PER SERIES"): the halt readout asks
1980
+ // *was the RUN converging when its allowance cut it*, which spans both legs; this
1981
+ // governor asks *is THIS loop out of ideas*, which is leg-local by definition.
1982
+ // Seeding it was built and measured: a resumed loop that struck out dead flat
1983
+ // rendered its own terminal as "still progressing — verdict 2 → 1 → 1 → 1 → 1",
1984
+ // because the chain HAD fallen before the handover — a record contradicting
1985
+ // itself, offering a conditional top-up ("if the trend above says it was still
1986
+ // converging") on the exact run that had just proven it was not. It would also
1987
+ // have moved the STRIKE rule on a resume (an attempt judged against the best the
1988
+ // run ever reached rather than against the grade this loop opened on) — an
1989
+ // unmeasured governance change, which is arbiter-adjacent territory. Both are the
1990
+ // same error: one instance answering two questions.
1991
+ const fixTrend = createTrend({
1992
+ stageOrder: stagedClose.map((/** @type {any} */ s) => s.name),
1993
+ limit: FIX_STRIKE_LIMIT,
1994
+ blindCap: capRuns,
1995
+ });
1996
+ fixTrend.record({ gap: post.gap ?? '' });
1997
+ /** ralph's `ladder` seam, filled by the trend reader instead of the step
1998
+ * ladder's repeat/write pair. ONE exhaustion terminal, two triggers (ralph's own
1999
+ * rule): the category stays `cap-halt` and the outcome stays `escalated`, so the
2000
+ * ledger's class table, bridge grading and every downstream reader are untouched
2001
+ * — only the TRIGGER changed. What it overrides is the terminal's PROSE: the
2002
+ * step ladder's copy offers a replan, and there is no planner at the close. */
2003
+ const fixGovernor = {
2004
+ record: (/** @type {{iteration: number, gap?: string}} */ o) => {
2005
+ fixIterationsUsed = o.iteration;
2006
+ return { governor: 'close-trend', ...fixTrend.record({ gap: o.gap }), iteration: o.iteration };
2007
+ },
2008
+ struckOut: fixTrend.struckOut,
2009
+ report: fixTrend.report,
2010
+ brief: () => fixTrend.verdict().reading,
2011
+ terminal: () => {
2012
+ const rep = fixTrend.report();
2013
+ const t = fixTrend.verdict();
2014
+ return {
2015
+ decision: rep.blind
2016
+ ? `${rep.iterations - 1} fix iteration(s) spent and the close still red. Its output carries no number this run can compare, so no progress reading exists — the retired iteration count is what bounded the loop. Continue, change approach, or stop?`
2017
+ : `${rep.strikes}/${rep.limit} strikes — the fix loop stopped making progress against the close's own numbers (${t.reading}). Continue, change approach, or stop?`,
2018
+ options: [
2019
+ 'revise the goal/spec so the work is reachable (a spec edit, so the new hash needs re-approval)',
2020
+ 'top up budgetUsd and rerun with --resume, if the trend above says it was still converging',
2021
+ 'abandon the task',
2022
+ ],
2023
+ };
2024
+ },
2025
+ };
1446
2026
  /** @param {number} iteration @param {string} [gap] */
1447
2027
  const middle = async (iteration, gap) => {
1448
- if (gap !== undefined) gaps.push(gap);
1449
2028
  // T/W-2 — the wall, read before a NEW fix attempt is started, and never
1450
2029
  // around the close itself. hamr's ruling: *"when time is up, keep the grade
1451
2030
  // we already have and stop … run tests (free) and when done if original time
@@ -1466,15 +2045,36 @@ export async function runPlan(job, { workdir, provider, nativeProvider, provider
1466
2045
  // wall that kills grading leaves the run unreadable after the money is spent
1467
2046
  // (the F45 class), so the one thing this must never do is stop a close.
1468
2047
  if (clock.expired()) {
1469
- const t = gapTrend(gaps);
2048
+ // ONE progress instrument, shared with the money halt (PRD v1.46 §2/§4).
2049
+ // This site used to run its own `gapTrend`: byte equality of the last two
2050
+ // close gaps, reading `stalled`/`moving` beside the money halt's per-stage
2051
+ // `flat`/`converging` on the very same run. Two readers for one question is
2052
+ // how the two come to disagree, and the byte reader could only ever report
2053
+ // MOTION — that something changed, never which way. Folded into src/trend.js,
2054
+ // where a per-stage NUMBER decides whenever one exists and the byte
2055
+ // comparison survives as the reading inside `unknown` (never as a direction:
2056
+ // "the output changed" is not "it got better", which is F6 in a trend's coat).
2057
+ // `runTrend` is the run's own reader — the same instance the money halt reads
2058
+ // — so a resume's inherited baselines reach this readout too.
2059
+ const t = runTrend.verdict();
1470
2060
  wallStop = {
1471
2061
  cutMidCall: false,
1472
2062
  phase: 'fix',
1473
2063
  iterationsUsed: iteration - 1,
1474
- capRuns,
2064
+ // `capRuns` used to ride here as the DENOMINATOR ("0 of 3"). It retired as
2065
+ // this loop's governor (v1.46 §4), so quoting it as the total would state a
2066
+ // bound that no longer decides anything — the iterations spent are the fact,
2067
+ // and there is no longer a number they are "of".
1475
2068
  verdict: lastCloseVerdict?.verdict,
1476
2069
  ...(lastCloseVerdict?.stage ? { stage: lastCloseVerdict.stage } : {}),
1477
2070
  trend: t.trend,
2071
+ // MOTION rides as its OWN field rather than being mapped onto `trend`.
2072
+ // Mapping `unchanged` → `flat` would dress a byte comparison as a measured
2073
+ // direction on the spine, which is precisely what the unification refuses;
2074
+ // a separate field keeps the old signal's substance without the false
2075
+ // promotion. (Grepped first: no src/ or scripts/ reader consumed the old
2076
+ // `stalled`/`moving` values — only this file's own tests, updated with it.)
2077
+ motion: t.motion,
1478
2078
  };
1479
2079
  // The detail carries what the human needs to pick a lever and nothing else:
1480
2080
  // the verdict that stands, how much of the loop was actually spent, and the
@@ -1484,7 +2084,7 @@ export async function runPlan(job, { workdir, provider, nativeProvider, provider
1484
2084
  `the wall-clock cap passed before fix attempt ${iteration} could start. `
1485
2085
  + `The verdict stands as the last close rendered it: ${lastCloseVerdict?.verdict ?? 'unknown'}`
1486
2086
  + `${lastCloseVerdict?.stage ? ` at stage "${lastCloseVerdict.stage}"` : ''}, `
1487
- + `after ${iteration - 1} of ${capRuns} fix iteration(s). `
2087
+ + `after ${iteration - 1} fix iteration(s). `
1488
2088
  + `Progress trend: ${t.trend} — ${t.reading}; ${t.lever}.`));
1489
2089
  err.category = 'wall-halt';
1490
2090
  throw err;
@@ -1513,7 +2113,11 @@ export async function runPlan(job, { workdir, provider, nativeProvider, provider
1513
2113
  fixOutcome = await ralph({
1514
2114
  // the staged close rides in as the JUDGE seam (one verdict, every stage in
1515
2115
  // order) — ralph's single-command path stays exactly what it was
1516
- middle, judge: async () => judgeClose(), capRuns, emit: emitL, redact: scrub,
2116
+ // v1.46 §4: the PROGRESS governor rides in where `capRuns` used to. ralph's
2117
+ // two exhaustion rules are alternatives and never both at once, so the count
2118
+ // is not passed — it survives only inside the governor, as the bound for the
2119
+ // case the trend instrument is blind.
2120
+ middle, judge: async () => judgeClose(), ladder: fixGovernor, emit: emitL, redact: scrub,
1517
2121
  closeTimeoutMs, cwd: workdir, workerWrites: w.workerWrites,
1518
2122
  });
1519
2123
  } catch (e) {
@@ -1544,7 +2148,19 @@ export async function runPlan(job, { workdir, provider, nativeProvider, provider
1544
2148
  // Attempts spent with money still on the table stays the designed terminal
1545
2149
  // ("close still red") and keeps riding out as `escalated` below.
1546
2150
  // (MED-4, fixed with hamr's explicit go, 2026-07-30.)
1547
- if (remainingUsd() <= MONEY_MIN) return 'cap-halt';
2151
+ if (remainingUsd() <= MONEY_MIN) {
2152
+ // v1.46 §2 — and THIS is the site hamr's ask was about: the money runs out
2153
+ // deepest in the run, on the loop that holds a real grade. So the readout keeps
2154
+ // that grade, names the stage that rendered it, and carries the run's own
2155
+ // per-stage trend so the choice between "top up" and "revise the goal" is made
2156
+ // on evidence rather than on how the operator felt about the last gap.
2157
+ emitMoneyHalt({
2158
+ phase: 'fix',
2159
+ cutMidCall: false,
2160
+ iterationsUsed: fixIterationsUsed,
2161
+ });
2162
+ return 'cap-halt';
2163
+ }
1548
2164
  } else if (fixOutcome !== 'green' && typeof lastEscalation?.category === 'string') {
1549
2165
  // The step loop's category restoration (F11), mirrored: ralph returns the flat
1550
2166
  // 'escalated' on a middle throw while its escalation carries the real name — a