bareloop 0.7.0 → 0.9.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,10 +19,10 @@ 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';
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';
22
+ import { runClose, runStages, ralph, CLOSE_FAULTS, boundGap } from './ralph.js';
23
+ import { validatePlan, legalScopes, closeStagesOf } from './plan.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
28
  import { createLadder, STRIKE_LIMIT } from './ladder.js';
@@ -33,6 +33,9 @@ import { globToPrefix, redactSecrets, SECRET_PATTERNS } from './validate.js';
33
33
  import { validateBridge, loadGate } from './bridges.js';
34
34
  import { extractArtifact } from './text.js';
35
35
  import { createClock, isWallTimeout } from './clock.js';
36
+ import { isDeclaredClose, runDeclaredStages, validateCloseDecl, closeGrade } from './declaredclose.js';
37
+ import { seedAtHead, seedListing } from './kinds.js';
38
+ import { workBranchName, prepareWorkBranch } from './workbranch.js';
36
39
 
37
40
  const require = createRequire(import.meta.url);
38
41
  const { Loop, wireGate, HaltError } = require('bare-agent');
@@ -157,40 +160,104 @@ required above, and nothing else.
157
160
  ${JSON.stringify(plan, null, 2)}`;
158
161
  }
159
162
 
163
+ /** The red-set for the replan brief's copy of a step's exit gap: every non-blank
164
+ * line. It is `\S` and not the close stage's own `gapKeep` for the reason Layer R
165
+ * already pays at the step seam (see `createRoot` below) — a shipped `gapKeep` is
166
+ * `^`-anchored (`^BAREAGENT `, `^red`, `^FAILED`) and the exit evaluator wraps the
167
+ * stage's output in `check "x" red: …`, so the anchor no longer sits where the
168
+ * pattern expects it. A gap that reaches here is ALREADY a red-set: `judge` builds
169
+ * it out of the failing exits only, so every line in it is a red line and keeping
170
+ * all of them is the correct instrument, not a widened one. */
171
+ const REPLAN_GAP_KEEP = '\\S';
172
+
160
173
  /**
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.
174
+ * This step's own last EXIT results, rendered for the REPLAN brief.
175
+ *
176
+ * The label says exits and not "the close", and the distinction is the whole of one
177
+ * fix: `res.gap` is `lastGap`, the join of EVERY failing exit's detail in exit order.
178
+ * With `MAX_EXITS_PER_STEP` at 2 and the mandatory `tree-changed` pairing on a write
179
+ * step, a step that wrote nothing leads the block with the EVALUATOR's own prose
180
+ * (`0 files changed under src/** …`) — captured verbatim from a real replan prompt,
181
+ * under a label reading "the verification's own output". The close stage's output is
182
+ * one of the details here, never reliably all of them. The payload is left alone: "the
183
+ * step wrote nothing" is exactly what a replanner should be told, and stripping it to
184
+ * make an old label true would trade a real fact for a tidy sentence.
185
+ *
186
+ * u-mshcpdg4: a run one strict error from done, killed with $1.10 and 82 seconds
187
+ * unspent. The close named the remaining work exactly — `src/recurse.js(978,115)`
188
+ * — and the worker read that every attempt. The REPLANNER, which is the component
189
+ * that chooses which files the next plan targets, was handed only the trend line
190
+ * (`still progressing — typecheck 30 → 15 → 15 → 1`): converging, and silent about
191
+ * WHERE. It re-targeted `src/loop.js`, already at zero errors; that step wrote
192
+ * nothing and struck out. F85 gave the brief the trajectory; this gives it the
193
+ * artifact the trajectory is a summary OF.
194
+ *
195
+ * Handed over as TEXT, never as a parsed file list, and the trap is worth naming
196
+ * because the parse is the obvious-looking fix: line 1 of that very gap reads
197
+ * `reports 1 error(s) in src/recurse.js, src/loop.js` — every file in SCOPE — and
198
+ * only line 2 names the culprit. The `never wrote` advisory that used to sit here
199
+ * did exactly that parse, and on this gap it resolved to `src/loop.js` — the file
200
+ * already CLEAN — then said so as a directive beside the artifact that said the
201
+ * opposite. It was deleted with this change (hamr, 2026-08-05): two readers of one
202
+ * question, the parsed one wrong, is the same failure the variance meter just had.
203
+ * Nothing generic can tell a summary line from a detail line, and the
204
+ * shapes differ per close anyway (tsc file:line, pytest test ids, a count close
205
+ * that names no file at all). A model reading the artifact can make that
206
+ * distinction; a regex reproduces the bug. So: no extraction, no digest, no
207
+ * inference — the artifact, bounded and scrubbed.
208
+ *
209
+ * Two boundaries, both reused rather than respelled:
210
+ * - SCRUB. A prompt is an egress point and this repo has ONE secret inventory
211
+ * (`SECRET_PATTERNS` via `redactSecrets`). The gap arrives already scrubbed
212
+ * from `judge`, which makes this defense in depth rather than the only guard —
213
+ * and defense in depth at the egress is exactly where it belongs, because the
214
+ * next caller of this helper will not remember the upstream one.
215
+ * - BOUND, and it is a BACKSTOP rather than an envelope. What arrives here has
216
+ * ALREADY been bounded: `runClose` ran `boundGap` with the stage's own `gapKeep`,
217
+ * which deliberately rescues the `not ok`/`FAILED` names out of the elided middle
218
+ * — the mechanical gap F46's conversion mechanism feeds the worker. Re-bounding
219
+ * that with a fresh 400/1500 envelope deletes exactly those names a second time,
220
+ * which is F28 reintroduced; measured on a 120-red-line close output, the first
221
+ * bound rescued 78 names and this one dropped 12 of them. src/exits.js states the
222
+ * same rule for the same artifact one seam earlier, and this now uses ITS number:
223
+ * `CHECK_GAP_MAX`, imported rather than respelled, so the two seams cannot drift
224
+ * on how big a check gap may be. Under it the gap passes through VERBATIM.
225
+ *
226
+ * A backstop is still needed, and the headroom is why the ceiling is not smaller.
227
+ * `res.gap` is `lastGap`, the JOIN of up to `MAX_EXITS_PER_STEP` failing exit
228
+ * details, and the check-passes branch slices EACH at `CHECK_GAP_MAX` — so what
229
+ * arrives is bounded per detail, never in total. Measured, the real maximum is
230
+ * comfortably under: `runClose`'s own envelope tops out at ~10.1KB (400 head +
231
+ * 8192 keep + 1500 tail + labels), and joined with a `tree-changed` line that is
232
+ * ~10.2KB, so the shipped path passes through untouched with ~1.8KB to spare. The
233
+ * backstop therefore fires only where src/exits.js says it should: a seam that
234
+ * returned something never bounded at all.
235
+ *
236
+ * Over the line `boundGap` runs with `REPLAN_GAP_KEEP`, the same envelope the
237
+ * close path uses, and every trim announces itself (F28). Note what that keep-set
238
+ * cannot do: `\S` matches every non-blank line, so the keep block degenerates into
239
+ * "the first 50 lines" and re-surfaces nothing from the middle. That is the honest
240
+ * limit of a red-set that cannot know the stage's own pattern (see
241
+ * `REPLAN_GAP_KEEP`), and it is affordable precisely because it now applies only
242
+ * to a gap nothing upstream ever bounded — on that gap ANY envelope loses names,
243
+ * and the choice is a bounded prompt over an unbounded one.
244
+ *
245
+ * No gap → the empty string, so the brief renders byte-identically to the pre-F86
246
+ * one. A labelled empty section would be an invitation to explain an absence the
247
+ * run never observed (a stall never judged its exits at all).
172
248
  *
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
249
+ * @param {string | null | undefined} gap the step's last exit gap text
250
+ * @returns {string} the labelled block, or '' when there is nothing to show
176
251
  */
177
- export function gapFilesNeverWritten(gap, writes) {
178
- if (typeof gap !== 'string' || gap === '') return [];
179
- const tokens = gap.match(/[\w.-]+(?:\/[\w.-]+)+/g) ?? [];
180
- const seen = new Set();
181
- const out = [];
182
- for (const raw of tokens) {
183
- const p = raw.replace(/^\.\//, '');
184
- if (!/\.[A-Za-z0-9]+$/.test(p.split('/').at(-1) ?? '')) continue;
185
- if (seen.has(p)) continue;
186
- seen.add(p);
187
- const written = (writes ?? []).some((w) => {
188
- const ws = String(w);
189
- return ws === p || ws.endsWith(`/${p}`) || p.endsWith(`/${ws}`);
190
- });
191
- if (!written) out.push(p);
192
- }
193
- return out;
252
+ export function closeGapBlock(gap) {
253
+ if (typeof gap !== 'string' || gap.trim() === '') return '';
254
+ const scrubbed = redactSecrets(gap);
255
+ // BACKSTOP, not an envelope: over the line only. Scrubbing can only grow a string
256
+ // (a mask is wider than most literals it replaces), so the length is measured
257
+ // AFTER it — bounding on the raw length would let a gap cross the ceiling on its
258
+ // way through the one transformation that runs unconditionally.
259
+ return '\nWhat this step\'s exits reported on its last attempt (their own output, verbatim):\n'
260
+ + (scrubbed.length > CHECK_GAP_MAX ? boundGap(scrubbed, REPLAN_GAP_KEEP) : scrubbed);
194
261
  }
195
262
 
196
263
  /**
@@ -242,7 +309,7 @@ export function planPrompt(job, scoutBlob, reds, maxStepRounds, failure, scopes,
242
309
  // executes and `validatePlan` accepts against. Reading `job.close` raw here left
243
310
  // the legacy object form offering nothing while the runner preflighted a stage.
244
311
  // (`?? []`: a close naming no command stages nothing, so it offers nothing)
245
- const closeStages = stageClose(job.close) ?? [];
312
+ const closeStages = closeStagesOf(job) ?? [];
246
313
  const checkNames = checkMenu(closeStages).map((m) => m.name);
247
314
  const doc = `DRAFT-PLAN
248
315
  You are planning how to accomplish a goal in a repository, as an ordered list of bounded
@@ -257,7 +324,6 @@ strictly in array order. Each step (no other fields exist):
257
324
  - "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)
258
325
  - "rounds": integer 1..${maxStepRounds} — the step's per-attempt tool-round bound
259
326
  - "target": the step's deliverable path (REQUIRED when tools include write/edit), inside ${JSON.stringify(job.writeScope)}
260
- - "model" (optional): ${STEP_MODELS.join(' | ')} — a cheaper tier for mechanical steps; omit for the default
261
327
  - "scope" (optional): narrow this step's WRITE fence — copy one value from the offered scopes below.
262
328
  A narrowed "scope" is the ONLY ground this step can write: its "target" must sit
263
329
  inside it, and a "tree-changed" exit's scope must not be disjoint from it (one that
@@ -403,6 +469,40 @@ ${scoutBlob || '(no scout notes)'}`;
403
469
  * `flat` — "revise the goal" — on a run that was converging when its allowance ran
404
470
  * out. Baselines only (src/trend.js's THE SEED): it spends none of this leg's own
405
471
  * bounds and mints none of its strikes. Empty/omitted is the cold path.
472
+ * @param {{count?: number, grantUsed?: boolean}|null} [opts.resumeReplans] RESUME — the
473
+ * replan LEDGER the chain has already spent (`readResume`'s `restart.replans` /
474
+ * `restart.replanGrantUsed`), seeding the ceiling below. `resumeGrades`' sibling and
475
+ * deliberately NOT its twin: a grade seed feeds a READOUT, this one feeds a BOUND.
476
+ *
477
+ * Without it the ceiling is reborn on every call, and a resume is another call:
478
+ * measured, a leg that spent both replans and step-redded handed its checkpoint to a
479
+ * second leg that replanned twice more. Two became four by being killed once, which is
480
+ * the creep the latch exists to make impossible ("unlimited replanning launders thrash
481
+ * as adaptation", PRD v1.12).
482
+ *
483
+ * WHY THIS SEED SPENDS THE LEG'S BOUND WHERE `resumeGrades` DOES NOT. src/trend.js's
484
+ * THE SEED refuses to seed the fix loop's ITERATIONS from history "because the leg's
485
+ * own bounds must not be spent by history" — and that is right, because an attempt
486
+ * allowance is a LEG bound: a restarted leg buys its own attempts with its own money.
487
+ * The replan ceiling is a RUN bound by doctrine. It bounds how many times the WORKFLOW
488
+ * may be redrawn before redrawing it is thrash, and that question spans exactly the
489
+ * chain the halt readout already spans. The two seeds therefore disagree on purpose;
490
+ * they are not the same rule applied inconsistently.
491
+ *
492
+ * CHAIN SEMANTICS, and the one place this differs from the grade seed's mechanism.
493
+ * `readGradeSeed`'s documented KNOWN LIMIT is that it reads ONE spine, so a resume of a
494
+ * resume inherits leg 2's grades and not leg 1's — the chain shortens by one leg per
495
+ * resume, which is fail-safe for a READOUT (a shorter chain can only under-claim a
496
+ * direction). It is the DANGEROUS direction for a ceiling: an under-claimed ledger is a
497
+ * refilled allowance, and every kill would buy one. So this seed does NOT reuse that
498
+ * mechanism. It follows the MONEY fold instead (`priorSpentUsd`, `try-start`/
499
+ * `job-start`): each leg DECLARES the ledger it inherited on its own spine, and the
500
+ * next reader adds only that leg's own `replan` records to the declared number. Leg 3
501
+ * therefore inherits the whole chain, not leg 2's slice.
502
+ *
503
+ * `count` seeds `replans` (and, above zero, latches `replanned`); `grantUsed` seeds the
504
+ * F85-C variance latch, so a killed leg cannot re-earn an extra the arbiter has already
505
+ * granted. Null/omitted is the cold path and is byte-identical to a run nobody resumed.
406
506
  * @param {() => boolean} [opts.spendComplete] is the run's spend EXACT, or a floor? The
407
507
  * ledger one level up owns the answer (unpriced rounds, a self-healed stall, a mid-call
408
508
  * cut, an inherited resume floor); the money-halt readout states it beside the remaining
@@ -412,12 +512,18 @@ ${scoutBlob || '(no scout notes)'}`;
412
512
  * default; a caller supplies this to drive time deterministically (the same seam `createClock`
413
513
  * already exposes — a run's terminal cannot otherwise be exercised without waiting out a cap
414
514
  * whose floor is one close timeout).
515
+ * @param {string|null} [opts.resumeBranch] RESUME — the WORK BRANCH the killed leg
516
+ * recorded on its own spine (`readResume`'s `restart.branch`, off the `work-branch`
517
+ * event). With it the resume returns to the branch its own work is sitting on; without
518
+ * it the deterministic name would collide with that very branch and the collision walk
519
+ * would mint a `-2` beside the progress the resume exists to keep. Absent is the cold
520
+ * path. A recorded branch that no longer exists is a STOP, never a fresh start.
415
521
  * @returns {Promise<string>} 'green' | 'already-green' | 'escalated' | 'plan-red' |
416
522
  * '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>`
523
+ * 'branch-red' | 'cap-halt' | 'wall-halt' | 'provider-red' | 'interpreter-red' |
524
+ * 'step-stalled' | `step-red:<id>`
419
525
  */
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 = [] }) {
526
+ 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, resumeBranch = null }) {
421
527
  workdir = resolve(workdir);
422
528
  // ONE spelling of the redaction, housed next to the inventory it reads
423
529
  // (src/validate.js) — the same helper the isolate verbs scrub the litectx store
@@ -453,14 +559,17 @@ export async function runPlan(job, { workdir, provider, nativeProvider, provider
453
559
  // A STAGED close (PRD v1.28) is an ordered list of command stages and is the
454
560
  // go-forward shape; the object form survives only for the declared-but-locked
455
561
  // verdict classes (gold/rubric/hitl), which name no command to run.
456
- // W4: the staging itself lives in ONE place (`stageClose`), shared with
562
+ // W4: the staging itself lives in ONE place (`closeStagesOf`), shared with
457
563
  // `planPrompt` and `validatePlan` — a second copy here is exactly how the
458
- // runner came to execute a stage neither of the other two could see.
459
- const stagedClose = stageClose(job.close);
564
+ // runner came to execute a stage neither of the other two could see. M4 widened
565
+ // that one derivation to the AUTHORED close (`closeDecl`) rather than adding a
566
+ // second: a consumer reading only `close` sees a declared job as closeless.
567
+ const stagedClose = closeStagesOf(job);
568
+ const declared = isDeclaredClose(job);
460
569
  if (!stagedClose) {
461
570
  emit('escalation', {
462
571
  category: 'close-unsupported', decisionReady: true,
463
- decision: `The job's close is a ${job.close.type} close — the plan flow executes commands whose exit codes are truth (a staged close, or a single predicate).`,
572
+ decision: `The job's close is a ${job.close?.type ?? 'non-command'} close — the plan flow executes commands whose exit codes are truth (a staged close, a single predicate, or an authored declaration).`,
464
573
  options: ['restate the close as a predicate', 'wait for the verdict-classes rung'],
465
574
  });
466
575
  return 'close-unsupported';
@@ -641,6 +750,22 @@ export async function runPlan(job, { workdir, provider, nativeProvider, provider
641
750
  ];
642
751
 
643
752
  const closeOpts = { timeoutMs: closeTimeoutMs, cwd: workdir };
753
+ /** D8 — the DECLARED close's seed, filled in below before anything runs.
754
+ * `null` for a command close, which has no baseline to measure against. */
755
+ /** @type {{seedRef: string}|null} */
756
+ let declaredCtx = null;
757
+ /**
758
+ * ONE close-execution seam, chosen once by which field the SIGNED spec carries.
759
+ * Both executors return `runClose`'s verdict shape, so everything downstream —
760
+ * the precheck, the preflight, the check seam, ralph's judge, `CLOSE_FAULTS`,
761
+ * the trend reader and Layer R — is the same code for both.
762
+ *
763
+ * The command path is byte-identical to what it was: same call, same options.
764
+ * @param {any[]} stages
765
+ */
766
+ const runCloseStages = (stages) => (declared
767
+ ? runDeclaredStages(stages, scrub, { ...closeOpts, seedRef: /** @type {any} */ (declaredCtx).seedRef })
768
+ : runStages(stages, scrub, closeOpts));
644
769
  /** @type {string|undefined} the stage that rendered the LAST close verdict (Layer R's red-set source) */
645
770
  let closeStage;
646
771
  /** @type {any} the LAST close verdict itself (W-2). A wall stop KEEPS the grade
@@ -698,16 +823,172 @@ export async function runPlan(job, { workdir, provider, nativeProvider, provider
698
823
  };
699
824
  /** the close, as ONE verdict: every stage in order, first red wins */
700
825
  const judgeClose = async () => {
701
- const v = await runStages(stagedClose, scrub, closeOpts);
826
+ const v = await runCloseStages(stagedClose);
702
827
  closeStage = v.stage;
703
828
  lastCloseVerdict = v;
704
829
  // Only a RED grade is a reading. A `satisfied` ends the run and a close FAULT
705
830
  // rendered no judgment at all (CLOSE_FAULTS) — folding either into the series
706
831
  // would put a non-number where the instrument expects a graded one.
707
- if (v.verdict === 'needs_revision') runTrend.record({ gap: v.gap });
832
+ // `closeGrade` is the reader's input for EITHER executor: a command close
833
+ // hands over its gap and `readGrade` scrapes the stage and the count out of
834
+ // it; a declared close already KNOWS both and says them, rather than
835
+ // round-tripping a number through prose it would then have to parse back.
836
+ if (v.verdict === 'needs_revision') runTrend.record(closeGrade(v));
708
837
  return v;
709
838
  };
710
839
 
840
+ // ── 0c. THE WORK BRANCH — the HARD RULE (PRD Addendum v1.57 §3, hamr):
841
+ // *"The agent creates a NEW BRANCH before it touches any code — a HARD RULE, no
842
+ // exceptions. Not a default, not a preference: no job edits the branch it was
843
+ // handed, and none edits `main`."*
844
+ //
845
+ // ONE SEAM, TWO PLACEMENTS. The split is COLD vs RESUME, and every half of it is
846
+ // ruled rather than convenient:
847
+ //
848
+ // * BEFORE the scout on BOTH paths, the scout being the run's first PAID call. A
849
+ // patient that is not a git checkout, a namespace with no free name, a resume
850
+ // whose branch a human deleted — each is an instrument stop that must cost zero
851
+ // tokens, and a branch prepared after the scout would already have bought one.
852
+ // * COLD: AFTER the precheck and the preflight, which are $0 and deterministic.
853
+ // An ALREADY-GREEN tree returns below without ever reaching the seam and leaves
854
+ // no branch behind: a run with no work to do has no blast radius to bound, and
855
+ // minting a branch for it would litter every patient with the record of a run
856
+ // that did nothing.
857
+ // * RESUME: BEFORE them — here, above 0*. There is nothing to mint on a resume
858
+ // (the recorded branch exists, `created:false`, and the arm below refuses
859
+ // rather than creating), so the leave-no-branch-behind clause has nothing to
860
+ // say; what IS at stake is WHICH TREE the $0 instruments measure. The recorded
861
+ // branch holds the work the operator already paid for and IS the run being
862
+ // continued, while the ref the operator handed back can be anything at all. Run
863
+ // from where the run happens to stand, the precheck could read `already-green`
864
+ // off a tree that is not this run's and every `baseline: "seed"` stage would
865
+ // baseline against it — an instrument measuring the wrong subject, reading
866
+ // honestly and saying nothing true. Moving the seam up is also strictly cheaper
867
+ // for the failure case: a resume whose branch is gone or foreign now stops
868
+ // before the arbiter grades anything at all.
869
+ //
870
+ // The scout itself needs no branch and is not what this gates: its menu simply
871
+ // has no write-class verb in it (the menu IS the grant), so it cannot touch code
872
+ // wherever it stands. What the branch gates is the WRITE-CAPABLE phase, and that
873
+ // gate is enforced structurally rather than by this ordering — `mkWorker` refuses
874
+ // to build a `writable` worker while `workBranch` is null (below). Ordering says
875
+ // where the honest stop happens; the guard says the rule cannot be bypassed.
876
+ //
877
+ // Creating a branch at HEAD moves no commit and changes nothing the close can
878
+ // measure: the declared seed is HEAD either way, and the tree — dirty or clean —
879
+ // is carried across untouched.
880
+ /** @type {string|null} the branch this run's work lands on, once it exists */
881
+ let workBranch = null;
882
+ /** is this leg continuing work that already exists? Spelled EXACTLY as
883
+ * `prepareWorkBranch`'s own resume test, so the two cannot disagree about which
884
+ * arm a given input takes. */
885
+ const resuming = resumeBranch !== null && resumeBranch !== undefined;
886
+ /**
887
+ * 0c itself — ONE definition for both placements. The refusal below is written
888
+ * once on purpose: two call sites emitting their own branch-red would be two
889
+ * messages free to drift apart, and this one is what a human reads when a run
890
+ * stops having spent nothing.
891
+ * @returns {Promise<'branch-red'|null>} the terminal to return, or `null` once the
892
+ * run is standing on a branch nobody handed it
893
+ */
894
+ const prepareBranch = async () => {
895
+ const wb = await prepareWorkBranch(workdir, { name: workBranchName(job), resume: resumeBranch ?? null });
896
+ if (wb.stop !== null) {
897
+ // Never a fallback to the handed branch. That fallback IS the thing the rule
898
+ // forbids, and a run that silently took it would report a green whose blast
899
+ // radius nobody bounded — so this is a named terminal of its own rather than
900
+ // a wiring fault (`interpreter-red` aims an upstream ask at a library; a
901
+ // patient that is not a git checkout is the operator's own state speaking).
902
+ emit('escalation', {
903
+ category: 'branch-red', decisionReady: true,
904
+ decision: 'The run could not create its own work branch, and no job runs on the branch it was handed (PRD v1.57 §3). Nothing was spent.',
905
+ options: [
906
+ 'make the patient a git checkout with at least one commit',
907
+ 'free the work-branch name (delete or rename the stale branches this run collided with)',
908
+ 'abandon the run',
909
+ ],
910
+ detail: scrub(wb.stop),
911
+ });
912
+ return 'branch-red';
913
+ }
914
+ workBranch = wb.branch;
915
+ // The run's own books say where its work went. A human reading a spine — or a
916
+ // resume reading it back (`readResume`'s `restart.branch`) — must not have to
917
+ // re-derive the name from the spec and guess how many collisions there were.
918
+ emit('work-branch', {
919
+ branch: wb.branch,
920
+ created: wb.created,
921
+ resumed: wb.resumed,
922
+ from: wb.from,
923
+ base: wb.base,
924
+ repo: wb.repo,
925
+ ...(wb.collided > 0 ? { collided: wb.collided } : {}),
926
+ meaning: wb.resumed
927
+ ? 'returned to the branch this run was killed on — its work is on this branch, and a fresh one would strand it'
928
+ : 'the HARD RULE (PRD v1.57 §3): the run works here, never on the branch it was handed and never on main',
929
+ });
930
+ return null;
931
+ };
932
+ if (resuming) {
933
+ const stop = await prepareBranch();
934
+ if (stop !== null) return stop;
935
+ }
936
+
937
+ // ── 0*. the DECLARED close's two run-start facts. Both are $0 and both sit
938
+ // INSIDE the clock (they are the arbiter's own instruments, and time the
939
+ // operator's instruments too or the budget measures a different run).
940
+ //
941
+ // (1) THE SEED (D8/D12). HEAD at run start, READ and recorded, never typed.
942
+ // The close stores the counting RULE and never the number, so every
943
+ // `baseline: "seed"` stage is measured against THIS run's own starting
944
+ // point — a constant frozen at signing would judge run 5 against run 1's
945
+ // tree, which is the one-experiment shape D12 retires.
946
+ //
947
+ // (2) THE GROUNDED RE-VALIDATION (D9 gate 1). The job validator judged the
948
+ // declaration with no repository in hand, so hamr's listing rule and the
949
+ // scoped-job derivation that arms the F84 one-population law were
950
+ // DEFERRED, not skipped. They run here, against the real seed tree,
951
+ // before any stage and before any token. A declaration that names a path
952
+ // the tree does not have is a broken close, not a red about the worker —
953
+ // round 2's arm A invented `src/alertEmail.js` and silently reclassified
954
+ // 15 real errors into the wrong population.
955
+ if (declared) {
956
+ const s = await seedAtHead(workdir);
957
+ if (s.stop !== null) {
958
+ emit('escalation', {
959
+ category: CLOSE_FAULTS.failed.category, decisionReady: true,
960
+ decision: `The authored close measures against this run's own seed, and the seed could not be read. ${CLOSE_FAULTS.failed.decision}`,
961
+ options: ['check the repository is a git checkout with at least one commit', ...CLOSE_FAULTS.failed.options],
962
+ detail: scrub(s.stop),
963
+ });
964
+ return 'close-red';
965
+ }
966
+ const listed = await seedListing(workdir, s.seedRef);
967
+ const v = listed.stop === null
968
+ ? validateCloseDecl(job.closeDecl, { at: 'closeDecl', listing: listed.files, verdictType: job.verdictType })
969
+ : { ok: false, grounded: false, reds: [{ code: 'listing-unreadable', path: 'closeDecl', detail: listed.stop }] };
970
+ emit('close-decl', {
971
+ genre: job.closeDecl.genre,
972
+ lang: job.closeDecl.lang,
973
+ seedRef: s.seedRef,
974
+ stages: stagedClose.map((/** @type {any} */ st) => st.name),
975
+ grounded: v.grounded,
976
+ ok: v.ok,
977
+ ...(v.ok ? {} : { reds: v.reds.map((/** @type {any} */ r) => ({ ...r, detail: scrub(String(r.detail ?? '')) })) }),
978
+ });
979
+ if (!v.ok) {
980
+ emit('escalation', {
981
+ category: CLOSE_FAULTS.failed.category, decisionReady: true,
982
+ decision: 'The authored close does not validate against the repository it is about to judge — a path it names, '
983
+ + 'or a population it counts, does not match the seed tree. Nothing it rendered would be trustworthy.',
984
+ options: ['re-author the close against this repository (a new declaration is a new spec hash, and needs re-signing)', ...CLOSE_FAULTS.failed.options],
985
+ detail: v.reds.map((/** @type {any} */ r) => `${r.code}:${r.path}${r.detail ? ` — ${scrub(String(r.detail))}` : ''}`).join('\n'),
986
+ });
987
+ return 'close-red';
988
+ }
989
+ declaredCtx = { seedRef: s.seedRef };
990
+ }
991
+
711
992
  // ── 0a. close precheck (close-first, F17): already-green is a DISTINCT
712
993
  // record, zero tokens; a forbidden-zone verdict escalates before any spend
713
994
  const pre = await judgeClose();
@@ -741,8 +1022,26 @@ export async function runPlan(job, { workdir, provider, nativeProvider, provider
741
1022
  : {}),
742
1023
  });
743
1024
  for (const m of menu) {
744
- const v = await runStages(m.run, scrub, closeOpts);
745
- if (v.verdict === 'needs_revision') seedRed.push(m.name);
1025
+ const v = await runCloseStages(m.run);
1026
+ if (v.verdict === 'needs_revision') {
1027
+ seedRed.push(m.name);
1028
+ // …and the preflight grade is a READING, for the same reason the precheck one
1029
+ // above is: it is the seed the run's work is measured against. The precheck
1030
+ // only ever reds at the close's FIRST failing stage (first-red-wins), so on
1031
+ // every shipped close — each of which opens with `changed-from-seed` — the
1032
+ // numeric stages have no baseline at all without it. A step's first grade
1033
+ // would then have nothing to compare against and the whole run would read
1034
+ // `unknown` on exactly the stop the readout exists for.
1035
+ //
1036
+ // ONCE PER STAGE, though. The precheck and this loop grade the SAME unchanged
1037
+ // tree back to back with no work in between, so a stage both reach reports the
1038
+ // same number twice — and a repeated baseline in a series reads as an attempt
1039
+ // that achieved nothing (`verdict 2 → 2 → 1`). That is a phantom flat step
1040
+ // handed to a human and to the replanner, which is the exact class of false
1041
+ // story this change exists to remove. Asked of the reader's own books, so
1042
+ // there is no second record of what has been graded.
1043
+ if (!runTrend.report().stages.some((s) => s.stage === v.stage)) runTrend.record(closeGrade(v));
1044
+ }
746
1045
  emit('check-preflight', { name: m.name, verdict: v.verdict, ...(m.run.length > 1 ? { chain: m.run.map((s2) => s2.name) } : {}) });
747
1046
  const f = Object.hasOwn(CLOSE_FAULTS, v.verdict) ? CLOSE_FAULTS[v.verdict] : undefined;
748
1047
  if (f) {
@@ -751,6 +1050,15 @@ export async function runPlan(job, { workdir, provider, nativeProvider, provider
751
1050
  }
752
1051
  }
753
1052
 
1053
+ // ── 0c, COLD. The seam and both halves of its placement ruling are stated
1054
+ // above, where a RESUME reaches it; this is where a run that is NOT resuming
1055
+ // does — after the $0 precheck and preflight, so an already-green tree has
1056
+ // already returned and left no branch behind.
1057
+ if (!resuming) {
1058
+ const stop = await prepareBranch();
1059
+ if (stop !== null) return stop;
1060
+ }
1061
+
754
1062
  const lc = new LiteCtx({ root: workdir });
755
1063
  const ceiling = Array.isArray(job.tools) ? job.tools : [...TOOL_MENU];
756
1064
  const fencePrefixes = job.writeScope.map((/** @type {string} */ g) => resolve(workdir, globToPrefix(g)));
@@ -794,10 +1102,27 @@ export async function runPlan(job, { workdir, provider, nativeProvider, provider
794
1102
  // derivation, not a live path; the reachable guard is the validator's, and
795
1103
  // that one is mutation-covered.
796
1104
  if (!chain) return { pass: false, fault: 'failed', gap: `no offered close stage named "${name}"` };
797
- const v = await runStages(chain, scrub, closeOpts);
1105
+ const v = await runCloseStages(chain);
798
1106
  emit('check-run', { name, verdict: v.verdict, ...(v.stage && v.stage !== name ? { stage: v.stage } : {}), ...(v.exitCode !== undefined ? { exitCode: v.exitCode } : {}) });
799
1107
  if (v.verdict === 'satisfied') return { pass: true };
800
- if (v.verdict === 'needs_revision') return { pass: false, gap: v.gap };
1108
+ if (v.verdict === 'needs_revision') {
1109
+ // The run trend is fed by EVERY grade the arbiter renders, and a check IS an
1110
+ // arbiter-rendered close grade: the same `runStages`, the same chain, the same
1111
+ // stage names, so it lands in the same per-stage buckets and the accuracy law
1112
+ // holds unchanged. Without this the reader is blind to everything a STEP ever
1113
+ // achieves — it sees only the precheck and the outer fix loop — which is why
1114
+ // u-msh70zla's meter, firing mid-step, had nothing to report about a step that
1115
+ // had just gone 24 → 15 → 14.
1116
+ //
1117
+ // Folded HERE, on `v.gap`, and the seam is load-bearing: `evalExits` wraps this
1118
+ // gap as `check "<name>" red: …`, and THAT line carries the word "red" with no
1119
+ // number on it, so `readGrade` reads the wrapper instead of the wall and every
1120
+ // step grade donates a null. Measured on u-msh70zla's own archived gaps: raw
1121
+ // reads `typecheck 24 → 15 → 14`, wrapped reads nothing at all. Same seam the
1122
+ // Layer R note below already calls out for a check's `^`-anchored gapKeep.
1123
+ runTrend.record(closeGrade(v));
1124
+ return { pass: false, gap: v.gap };
1125
+ }
801
1126
  return { pass: false, fault: v.verdict, gap: v.detail };
802
1127
  };
803
1128
 
@@ -823,6 +1148,27 @@ export async function runPlan(job, { workdir, provider, nativeProvider, provider
823
1148
  * the turn-allowance note below.
824
1149
  */
825
1150
  async function mkWorker({ granted, phase, attemptRounds, attempts, writable, root = null, fence = null, workerProvider = null }) {
1151
+ // THE HARD RULE, ENFORCED STRUCTURALLY (PRD v1.57 §3). This is the ONE seam
1152
+ // that grants write-class verbs — `writeScope` is empty for every other
1153
+ // worker, so a read-only phase cannot reach a file whatever it intends — and
1154
+ // a branch is therefore a PRECONDITION of it, not a courtesy the ordering
1155
+ // above happens to provide. "No exceptions" has to mean the rule survives a
1156
+ // future caller who adds a third writable worker and forgets the ordering.
1157
+ //
1158
+ // Unreachable through `runPlan` as it stands (0c runs before the scout and
1159
+ // returns `branch-red` on any fault), which is exactly what a backstop should
1160
+ // be. Categorised so it lands as the wiring fault it is: the step site and
1161
+ // `relay` both return `interpreter-red` verbatim, so the escalation a human
1162
+ // reads and the outcome the spine records name the same thing.
1163
+ if (writable && workBranch === null) {
1164
+ const err = /** @type {any} */ (new Error(
1165
+ `HARD RULE (PRD v1.57 §3): a write-capable worker was requested for phase "${phase}" with no work branch prepared — `
1166
+ + 'no job edits the branch it was handed. The run is refused rather than allowed to write where it stands.',
1167
+ ));
1168
+ err.category = 'interpreter-red';
1169
+ err.lib = 'bareloop';
1170
+ throw err;
1171
+ }
826
1172
  /**
827
1173
  * The gate's LLM-turn allowance THROUGH iteration `i` — the old expression
828
1174
  * `attemptRounds * (attempts + 1)` with the iteration count made explicit
@@ -1256,6 +1602,14 @@ export async function runPlan(job, { workdir, provider, nativeProvider, provider
1256
1602
  // and those are different diagnoses with different remedies (F45/F48: a
1257
1603
  // casualty is never evidence, and a governance stop is never a casualty).
1258
1604
  'step-stalled': [`The model stopped producing rounds during ${phase} and reissuing the call did not recover it.`, ['retry the run', 'check provider status', 'abandon the run']],
1605
+ // A WIRING/INVARIANT fault is not a transport casualty, and the default
1606
+ // below would file it as one. The step loop already restores this category
1607
+ // verbatim at its own setup site ("the RECORDED outcome must match the
1608
+ // escalation the human reads", review #6/F11); the fix loop reached `relay`
1609
+ // instead and every such fault came back `provider-red` — a broken runner
1610
+ // reported as the provider's fault, and an upstream ask aimed at a library
1611
+ // for our own missing wiring (the step-stalled lesson).
1612
+ 'interpreter-red': [`The ${phase} phase could not be set up — the runner or a primitive it was handed is not correctly bound.`, ['fix the wiring the detail names', 'retry the run', 'abandon the run']],
1259
1613
  };
1260
1614
  const [decision, options] = (Object.hasOwn(DECIDE, category) ? DECIDE[category] : undefined)
1261
1615
  ?? [`The ${phase} call failed (${category}) — no result exists.`, ['retry the run', 'fix the provider binding', 'abandon the run']];
@@ -1266,7 +1620,8 @@ export async function runPlan(job, { workdir, provider, nativeProvider, provider
1266
1620
  // `step-stalled` rides out as ITSELF, not as provider-red. Naming the
1267
1621
  // escalation while returning a casualty label would launder a governance stop
1268
1622
  // into transport noise at exactly the layer the readout reads.
1269
- return category === 'cap-halt' || category === 'wall-halt' || category === 'step-stalled' ? category : 'provider-red';
1623
+ return category === 'cap-halt' || category === 'wall-halt' || category === 'step-stalled' || category === 'interpreter-red'
1624
+ ? category : 'provider-red';
1270
1625
  };
1271
1626
 
1272
1627
  // ── 1. SCOUT — read-only by construction: the write-class verbs are simply
@@ -1482,8 +1837,39 @@ export async function runPlan(job, { workdir, provider, nativeProvider, provider
1482
1837
  const artifacts = [];
1483
1838
  /** @type {{id: string, outcome: string}[]} */
1484
1839
  const stepOutcomes = [];
1485
- let replanned = false;
1486
- const planExecuted = () => emit('plan-executed', { steps: stepOutcomes, replanned });
1840
+ /** RESUME — the replan ledger the CHAIN has already spent, belted the way every
1841
+ * other declared fold entering the arbiter's arithmetic is (`priorSpentUsd`,
1842
+ * `priorWallMs`, `declaredRounds`): a garbage or negative seed reads as 0 rather
1843
+ * than as a NaN that would poison every later comparison, and a negative one must
1844
+ * never cancel a real replan and widen the ceiling (reuse's `> 0` belt, F-4). */
1845
+ const seededReplans = typeof resumeReplans?.count === 'number' && Number.isFinite(resumeReplans.count) && resumeReplans.count > 0
1846
+ ? Math.floor(resumeReplans.count) : 0;
1847
+ // Seeded, not reset. These three were locals reborn on every `runPlan` call — and a
1848
+ // resume IS another call, so the ceiling was refilled by every kill (see the
1849
+ // `resumeReplans` param doc for the measurement and for why this bound spans the
1850
+ // chain while the trend's iteration bound deliberately does not).
1851
+ let replanned = seededReplans > 0;
1852
+ /** F85 (C): how many replans this run has actually drafted, and whether the
1853
+ * arbiter has already spent its ONE extra grant. Two variables and not one
1854
+ * counter compared against a limit, deliberately — a latch cannot be widened by
1855
+ * arithmetic, and the ceiling is the thing this rung must not move (v1.12). */
1856
+ let replans = seededReplans;
1857
+ // the F85-C latch travels BESIDE the count and is not derived from it: a leg can
1858
+ // inherit a spent ordinary ceiling with the grant still unearned (count 1, false),
1859
+ // and deriving one from the other would collapse those two states into one.
1860
+ let varianceGrantUsed = resumeReplans?.grantUsed === true;
1861
+ // `replanned` stays the shipped boolean this record has always carried; `replans`
1862
+ // rides BESIDE it (append-only — a field's meaning is never repurposed).
1863
+ //
1864
+ // Both now count the CHAIN rather than the leg, and that IS a meaning change — made
1865
+ // rather than shadowed by a second field because `replans` shipped only on this
1866
+ // unreleased branch, so no archived spine carries the leg reading for a reader to
1867
+ // be confused by. The leg's own number is not lost by it: a leg emits one `replan`
1868
+ // record per replan it drafts, so `chain − records-in-this-window` is the fold it
1869
+ // inherited, and that is exactly the arithmetic `readResume` runs to rebuild the
1870
+ // ledger for the next leg. A second field stating a number already derivable from
1871
+ // the window would be a second reader of one question (F6's structural cousin).
1872
+ const planExecuted = () => emit('plan-executed', { steps: stepOutcomes, replanned, replans });
1487
1873
  /** @type {object|null} W-2 at the STEP site — set when the WALL refused to open a
1488
1874
  * step's attempt. It carries that stop's own record fields, and its presence is what
1489
1875
  * lets the step loop's terminal tell this reading apart from F64's mid-call one
@@ -1558,15 +1944,41 @@ export async function runPlan(job, { workdir, provider, nativeProvider, provider
1558
1944
  const moneyShare = stepStartUsd > 0 ? (stepStartUsd - remainingUsd()) / stepStartUsd : 0;
1559
1945
  const timeShare = clock.bounded && stepStartMs > 0 ? (stepStartMs - clock.remainingMs()) / stepStartMs : 0;
1560
1946
  if (moneyShare >= VARIANCE_THRESHOLD || timeShare >= VARIANCE_THRESHOLD) {
1947
+ // F85 — the meter REPORTS progress; it does not decide on it. The
1948
+ // condition above is the whole trigger and stays the whole trigger: a
1949
+ // converging step that is eating the run is still stopped, because this is
1950
+ // a governance instrument over an operator-owned allowance and a governor
1951
+ // that suppressed itself whenever the work looked promising would be
1952
+ // judging capability with the budget. What was missing was never a term in
1953
+ // the condition — it was the READING. u-msh70zla stopped a step whose close
1954
+ // had gone 24 → 15 → 14 and then told the replanner its exits were
1955
+ // "unmoved", so the replanner threw the progress away and re-targeted a
1956
+ // file that was already clean.
1957
+ //
1958
+ // The reading comes off `runTrend` — the SAME instance the money halt reads
1959
+ // (src/trend.js's ONE PER SERIES). A second reader for one question is how
1960
+ // two readers come to disagree, which is precisely the defect being fixed.
1961
+ // It is per-stage, numbers-and-stage-names only, and `unknown` stays
1962
+ // `unknown` — a close that reports no number donates nothing (F6).
1963
+ const t = runTrend.verdict();
1561
1964
  emit('variance', {
1562
1965
  step: step.id, iteration, threshold: VARIANCE_THRESHOLD,
1563
1966
  moneyShare: Number(moneyShare.toFixed(3)),
1564
1967
  timeShare: clock.bounded ? Number(timeShare.toFixed(3)) : null,
1565
1968
  axis: moneyShare >= VARIANCE_THRESHOLD ? 'money' : 'time',
1969
+ // ADDED fields, never repurposed ones (the spine is append-only forever).
1970
+ // Same four names and same shapes the money halt already emits, so one
1971
+ // reader parses a progress reading wherever it appears.
1972
+ trend: t.trend, motion: t.motion, reading: t.reading, series: t.series,
1566
1973
  });
1567
1974
  const err = /** @type {CategorizedError} */ (new Error(
1568
1975
  `step "${step.id}" consumed ${Math.round(Math.max(moneyShare, timeShare) * 100)}% of the run's remaining `
1569
- + `${moneyShare >= VARIANCE_THRESHOLD ? 'money' : 'time'} across ${iteration - 1} attempt(s) with its exits still red`));
1976
+ + `${moneyShare >= VARIANCE_THRESHOLD ? 'money' : 'time'} across ${iteration - 1} attempt(s) with its exits still red. `
1977
+ // The escalation `detail` is this message (ralph passes it through), and
1978
+ // the replan brief quotes the detail — so this sentence is what a human
1979
+ // AND the redrafting planner read. It states what the run achieved; it
1980
+ // never states what to do about it, which is the operator's lever list.
1981
+ + `Run progress: ${t.reading}.`));
1570
1982
  err.category = 'step-variance';
1571
1983
  throw err;
1572
1984
  }
@@ -1772,12 +2184,15 @@ export async function runPlan(job, { workdir, provider, nativeProvider, provider
1772
2184
  // pricing-red at the step-end guard above, so isUnpriced() is false here.)
1773
2185
  //
1774
2186
  // A (PRD v1.27) adds the SECOND trigger: `step-variance` — the meter stopped a
1775
- // step that had eaten a declared share of the run with its exits unmoved. That
1776
- // is the case exhaustion-only could never reach (F56: a replan has fired zero
1777
- // times in the programme), and it is a genuine re-allocation rather than a stop.
1778
- // Both triggers still need FUNDS LEFT, and both are still bounded by the ONE
1779
- // replan ceiling — this changes the trigger, never the ceiling (v1.12).
2187
+ // step that had eaten a declared share of the run. That is the case
2188
+ // exhaustion-only could never reach (F56: a replan has fired zero times in the
2189
+ // programme), and it is a genuine re-allocation rather than a stop. Both
2190
+ // triggers still need FUNDS LEFT, and both are bounded by the replan ceiling.
1780
2191
  const cat = lastEscalation?.category;
2192
+ /** F85 — the run's measured trajectory at the moment of this stop, read ONCE
2193
+ * here off the same instance the meter and the money halt read. Both consumers
2194
+ * below are the replanner's: the trigger sentence and the brief. */
2195
+ const stopTrend = runTrend.verdict();
1781
2196
  // F66 adds the THIRD trigger: `step-stalled` — the stall watchdog reissued the
1782
2197
  // model call MAX_STALLS times and never got a round back. hamr's ruling is that
1783
2198
  // a stall must self-heal rather than end the run ("killing and coming back is
@@ -1785,13 +2200,58 @@ export async function runPlan(job, { workdir, provider, nativeProvider, provider
1785
2200
  // demonstrably stopped working does the run adapt instead — by replanning, not
1786
2201
  // by stopping. Same ONE-replan ceiling and same FUNDS-LEFT condition as the
1787
2202
  // other two: this changes the trigger, never the ceiling (v1.12).
2203
+ //
2204
+ // F85 rewrote the variance branch. It used to be the FIXED string "…with its
2205
+ // exits unmoved", printed for every step-variance stop whatever had happened —
2206
+ // and on u-msh70zla it was simply false: the exits had moved 24 → 15 → 14. The
2207
+ // sentence now states the meter's fact (a share of the run) and then the trend
2208
+ // instrument's MEASURED reading, which is the same reading the `variance` record
2209
+ // and the money halt carry. Nothing here asserts a direction the reader did not
2210
+ // report: `unknown` says unknown.
1788
2211
  const replanTrigger = cat === 'cap-halt' ? 'step exhausted its attempts with exits still red'
1789
- : cat === 'step-variance' ? 'the meter stopped a step that was consuming the run with its exits unmoved'
2212
+ : cat === 'step-variance' ? `the meter stopped a step that was consuming the run — ${stopTrend.reading}`
1790
2213
  : cat === 'step-stalled' ? 'the model stopped producing rounds and reissuing the call did not recover it'
1791
2214
  : null;
1792
- if (!replanned && replanTrigger && remainingUsd() > MONEY_MIN) {
2215
+ // F85 (C) — the ARBITER's one additional replan. A second `step-variance` after
2216
+ // the ceiling is spent used to be a hard stop, and on a run that was still
2217
+ // converging that stop threw away work the run had already paid for. So the
2218
+ // arbiter may grant ONE more, and every clause of that sentence is a constraint:
2219
+ //
2220
+ // * ONE. Bounded, and bounded by a latch rather than a comparison, so the
2221
+ // ceiling cannot creep: unlimited replanning launders thrash as adaptation
2222
+ // (v1.12). A third variance stop is the stop, however well it is going.
2223
+ // * the ARBITER. Read mechanically off `runTrend` — the same instance the
2224
+ // meter and the money halt read. The agent never asks for it, never sees it
2225
+ // offered, and cannot influence it: no self-adjusted budgets, ever.
2226
+ // * `converging`, the trend instrument's OWN category. No new number is
2227
+ // invented here — threshold-setting is the operator's, and a fresh
2228
+ // comparison would be a second reader of the one question (src/trend.js).
2229
+ // `flat` and `unknown` are refusals; an unknown is never rounded up into a
2230
+ // reason to spend (F6).
2231
+ // * step-variance only. An exhaustion or a stall after the ceiling is
2232
+ // unchanged — this widens nothing but the case it was measured on.
2233
+ const grantExtraReplan = replanned
2234
+ && cat === 'step-variance'
2235
+ && !varianceGrantUsed
2236
+ && stopTrend.trend === 'converging';
2237
+ if ((!replanned || grantExtraReplan) && replanTrigger && remainingUsd() > MONEY_MIN) {
2238
+ if (grantExtraReplan) varianceGrantUsed = true;
1793
2239
  replanned = true;
1794
- emit('replan', { step: step.id, reason: replanTrigger, trigger: cat });
2240
+ replans += 1;
2241
+ emit('replan', {
2242
+ step: step.id, reason: replanTrigger, trigger: cat,
2243
+ // ADDED fields (append-only): which replan this is, and — only when the
2244
+ // arbiter granted one past the ordinary ceiling — the reading it granted it
2245
+ // on. Absent on the ordinary replan, so a reader can never mistake the
2246
+ // ceiling for a grant.
2247
+ //
2248
+ // The index is the RUN's, so on a resumed leg it opens above 1 — the ceiling
2249
+ // it is counted against spans the chain, and an index that restarted at 1 on
2250
+ // every leg would read as a fresh allowance on the one record that could have
2251
+ // shown it was not.
2252
+ replan: replans,
2253
+ ...(grantExtraReplan ? { granted: stopTrend.trend } : {}),
2254
+ });
1795
2255
  // The brief names the trigger HONESTLY, one branch per trigger: it is the
1796
2256
  // only channel the redrafting planner adapts to, so a wrong diagnosis here
1797
2257
  // is handed to the one component whose whole job is to respond to it. A
@@ -1809,28 +2269,45 @@ export async function runPlan(job, { workdir, provider, nativeProvider, provider
1809
2269
  const leftOver = `$${remainingUsd().toFixed(2)} and `
1810
2270
  + `${clock.bounded ? `${Math.round(clock.remainingMs() / 60_000)} minute(s)` : 'time UNBOUNDED'} `
1811
2271
  + 'of the run were still unspent when it stopped.';
2272
+ // F85: the meter's branch states the METER's fact and then the run's measured
2273
+ // one, in that order and as two separate claims — "it was stopped for eating
2274
+ // the run" and "here is what the run achieved" are different things, and the
2275
+ // old sentence quietly fused them into a verdict on the work.
1812
2276
  const why = cat === 'step-variance'
1813
- ? 'It was stopped by the run\'s meter for consuming too large a share of what was left.'
2277
+ ? 'It was stopped by the run\'s meter for consuming too large a share of what was left. '
2278
+ + `Its work so far, from the close's own numbers: ${stopTrend.reading}. ${leftOver}`
1814
2279
  : cat === 'step-stalled'
1815
2280
  ? 'It stalled: the model stopped producing rounds and reissuing the call did not recover it, so its exits were never judged.'
1816
2281
  : `${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
- : '';
2282
+ // F86 — this step's OWN last exit results, verbatim (u-mshcpdg4). Everything
2283
+ // above this line is the run's narration of the stop: the meter's share
2284
+ // sentence, the trend's direction, the never-wrote fact, the escalation detail.
2285
+ // None of them says WHERE the remaining work is, because none of them is the
2286
+ // close. The close's output is in HERE — as one of the failing exits' details,
2287
+ // beside the evaluator's own (a step that wrote nothing leads with
2288
+ // `tree-changed`), which is why the block is labelled by its exits rather than
2289
+ // by the close. It goes over as text — see `closeGapBlock` for why a parsed
2290
+ // file list would reproduce the exact bug it is fixing. Empty gap → empty
2291
+ // string → the brief is byte-identical to the pre-F86 one.
2292
+ const gapBlock = closeGapBlock(res?.gap);
1824
2293
  const failure = `Step "${step.id}" (${step.action}) did not reach its exits. `
1825
2294
  + `${why}\n`
1826
- + missedLine
1827
2295
  + `Last exit state:\n${lastEscalation?.detail ?? '(none)'}\n`
1828
- + `Steps completed so far: ${artifacts.map((a) => a.id).join(', ') || 'none'}.`;
2296
+ + `Steps completed so far: ${artifacts.map((a) => a.id).join(', ') || 'none'}.`
2297
+ + gapBlock;
1829
2298
  // The progress line IS the adaptation channel (addendum 2): the balance rides
1830
2299
  // in via obtainPlan's live read, and this says where the run got to. Both are
1831
2300
  // the planner re-allocating what remains across what is left — never a rate.
2301
+ //
2302
+ // F85 adds the second half. This line used to count STEPS only ("step 1 of 1
2303
+ // did not finish; 0 step(s) completed before it") — structurally true and
2304
+ // silent about outcome, so a replanner reading it could only conclude that
2305
+ // nothing had been achieved. On u-msh70zla it concluded exactly that and threw
2306
+ // three attempts of real convergence away. The structural sentence stays (a
2307
+ // plan's shape is what the planner re-allocates); the measured one joins it.
1832
2308
  const progress = `step ${idx + 1} of ${plan.steps.length} ("${step.id}") did not finish; `
1833
- + `${artifacts.length} step(s) completed before it`;
2309
+ + `${artifacts.length} step(s) completed before it; `
2310
+ + `close trend so far: ${stopTrend.reading}`;
1834
2311
  let pv;
1835
2312
  try {
1836
2313
  pv = await obtainPlan('replan', failure, progress);
@@ -1886,8 +2363,10 @@ export async function runPlan(job, { workdir, provider, nativeProvider, provider
1886
2363
  emitMoneyHalt({ phase: `step:${step.id}`, cutMidCall: false, stepsDone: idx, stepsPlanned: plan.steps.length });
1887
2364
  return 'cap-halt';
1888
2365
  }
1889
- // A second `step-variance` after the one replan is spent is a STOP, and the stop
1890
- // is the result — but it rides out as `step-red:<id>`, NOT as its own top-level
2366
+ // A `step-variance` that earned no replan above is a STOP, and the stop is the
2367
+ // result. Reached in three ways now: the ordinary ceiling with the run reading
2368
+ // `flat` or `unknown`; the arbiter's one grant already spent (F85's bound); or
2369
+ // the wallet at dust. Each rides out as `step-red:<id>`, NOT as its own top-level
1891
2370
  // outcome. The category is a replan TRIGGER, never a run verdict: leaking it
1892
2371
  // upward would mint an outcome run.js and the ledger's class table do not know,
1893
2372
  // and an unmapped category is counted as a library bug (ledger.js) when this is a