bareloop 0.7.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/planrun.js CHANGED
@@ -19,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';
22
+ import { runClose, runStages, ralph, CLOSE_FAULTS, boundGap } from './ralph.js';
23
23
  import { validatePlan, legalScopes, stageClose } from './plan.js';
24
- import { WRITE_VERBS, EXIT_TYPES, MAX_EXITS_PER_STEP, MAX_PLAN_STEPS, MAX_SCOPE_MENU, STEP_MODELS } from './plan.js';
25
- import { snapshotScope, evalExits } from './exits.js';
24
+ import { WRITE_VERBS, EXIT_TYPES, MAX_EXITS_PER_STEP, MAX_PLAN_STEPS, MAX_SCOPE_MENU } from './plan.js';
25
+ import { snapshotScope, evalExits, CHECK_GAP_MAX } from './exits.js';
26
26
  import { createRoot } from './root.js';
27
27
  import { createStallWatch, STALL_MS, MAX_STALLS } from './stall.js';
28
28
  import { createLadder, STRIKE_LIMIT } from './ladder.js';
@@ -157,40 +157,104 @@ required above, and nothing else.
157
157
  ${JSON.stringify(plan, null, 2)}`;
158
158
  }
159
159
 
160
+ /** The red-set for the replan brief's copy of a step's exit gap: every non-blank
161
+ * line. It is `\S` and not the close stage's own `gapKeep` for the reason Layer R
162
+ * already pays at the step seam (see `createRoot` below) — a shipped `gapKeep` is
163
+ * `^`-anchored (`^BAREAGENT `, `^red`, `^FAILED`) and the exit evaluator wraps the
164
+ * stage's output in `check "x" red: …`, so the anchor no longer sits where the
165
+ * pattern expects it. A gap that reaches here is ALREADY a red-set: `judge` builds
166
+ * it out of the failing exits only, so every line in it is a red line and keeping
167
+ * all of them is the correct instrument, not a widened one. */
168
+ const REPLAN_GAP_KEEP = '\\S';
169
+
160
170
  /**
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.
171
+ * This step's own last EXIT results, rendered for the REPLAN brief.
172
+ *
173
+ * The label says exits and not "the close", and the distinction is the whole of one
174
+ * fix: `res.gap` is `lastGap`, the join of EVERY failing exit's detail in exit order.
175
+ * With `MAX_EXITS_PER_STEP` at 2 and the mandatory `tree-changed` pairing on a write
176
+ * step, a step that wrote nothing leads the block with the EVALUATOR's own prose
177
+ * (`0 files changed under src/** …`) — captured verbatim from a real replan prompt,
178
+ * under a label reading "the verification's own output". The close stage's output is
179
+ * one of the details here, never reliably all of them. The payload is left alone: "the
180
+ * step wrote nothing" is exactly what a replanner should be told, and stripping it to
181
+ * make an old label true would trade a real fact for a tidy sentence.
182
+ *
183
+ * u-mshcpdg4: a run one strict error from done, killed with $1.10 and 82 seconds
184
+ * unspent. The close named the remaining work exactly — `src/recurse.js(978,115)`
185
+ * — and the worker read that every attempt. The REPLANNER, which is the component
186
+ * that chooses which files the next plan targets, was handed only the trend line
187
+ * (`still progressing — typecheck 30 → 15 → 15 → 1`): converging, and silent about
188
+ * WHERE. It re-targeted `src/loop.js`, already at zero errors; that step wrote
189
+ * nothing and struck out. F85 gave the brief the trajectory; this gives it the
190
+ * artifact the trajectory is a summary OF.
191
+ *
192
+ * Handed over as TEXT, never as a parsed file list, and the trap is worth naming
193
+ * because the parse is the obvious-looking fix: line 1 of that very gap reads
194
+ * `reports 1 error(s) in src/recurse.js, src/loop.js` — every file in SCOPE — and
195
+ * only line 2 names the culprit. The `never wrote` advisory that used to sit here
196
+ * did exactly that parse, and on this gap it resolved to `src/loop.js` — the file
197
+ * already CLEAN — then said so as a directive beside the artifact that said the
198
+ * opposite. It was deleted with this change (hamr, 2026-08-05): two readers of one
199
+ * question, the parsed one wrong, is the same failure the variance meter just had.
200
+ * Nothing generic can tell a summary line from a detail line, and the
201
+ * shapes differ per close anyway (tsc file:line, pytest test ids, a count close
202
+ * that names no file at all). A model reading the artifact can make that
203
+ * distinction; a regex reproduces the bug. So: no extraction, no digest, no
204
+ * inference — the artifact, bounded and scrubbed.
205
+ *
206
+ * Two boundaries, both reused rather than respelled:
207
+ * - SCRUB. A prompt is an egress point and this repo has ONE secret inventory
208
+ * (`SECRET_PATTERNS` via `redactSecrets`). The gap arrives already scrubbed
209
+ * from `judge`, which makes this defense in depth rather than the only guard —
210
+ * and defense in depth at the egress is exactly where it belongs, because the
211
+ * next caller of this helper will not remember the upstream one.
212
+ * - BOUND, and it is a BACKSTOP rather than an envelope. What arrives here has
213
+ * ALREADY been bounded: `runClose` ran `boundGap` with the stage's own `gapKeep`,
214
+ * which deliberately rescues the `not ok`/`FAILED` names out of the elided middle
215
+ * — the mechanical gap F46's conversion mechanism feeds the worker. Re-bounding
216
+ * that with a fresh 400/1500 envelope deletes exactly those names a second time,
217
+ * which is F28 reintroduced; measured on a 120-red-line close output, the first
218
+ * bound rescued 78 names and this one dropped 12 of them. src/exits.js states the
219
+ * same rule for the same artifact one seam earlier, and this now uses ITS number:
220
+ * `CHECK_GAP_MAX`, imported rather than respelled, so the two seams cannot drift
221
+ * on how big a check gap may be. Under it the gap passes through VERBATIM.
222
+ *
223
+ * A backstop is still needed, and the headroom is why the ceiling is not smaller.
224
+ * `res.gap` is `lastGap`, the JOIN of up to `MAX_EXITS_PER_STEP` failing exit
225
+ * details, and the check-passes branch slices EACH at `CHECK_GAP_MAX` — so what
226
+ * arrives is bounded per detail, never in total. Measured, the real maximum is
227
+ * comfortably under: `runClose`'s own envelope tops out at ~10.1KB (400 head +
228
+ * 8192 keep + 1500 tail + labels), and joined with a `tree-changed` line that is
229
+ * ~10.2KB, so the shipped path passes through untouched with ~1.8KB to spare. The
230
+ * backstop therefore fires only where src/exits.js says it should: a seam that
231
+ * returned something never bounded at all.
172
232
  *
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
233
+ * Over the line `boundGap` runs with `REPLAN_GAP_KEEP`, the same envelope the
234
+ * close path uses, and every trim announces itself (F28). Note what that keep-set
235
+ * cannot do: `\S` matches every non-blank line, so the keep block degenerates into
236
+ * "the first 50 lines" and re-surfaces nothing from the middle. That is the honest
237
+ * limit of a red-set that cannot know the stage's own pattern (see
238
+ * `REPLAN_GAP_KEEP`), and it is affordable precisely because it now applies only
239
+ * to a gap nothing upstream ever bounded — on that gap ANY envelope loses names,
240
+ * and the choice is a bounded prompt over an unbounded one.
241
+ *
242
+ * No gap → the empty string, so the brief renders byte-identically to the pre-F86
243
+ * one. A labelled empty section would be an invitation to explain an absence the
244
+ * run never observed (a stall never judged its exits at all).
245
+ *
246
+ * @param {string | null | undefined} gap the step's last exit gap text
247
+ * @returns {string} the labelled block, or '' when there is nothing to show
176
248
  */
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;
249
+ export function closeGapBlock(gap) {
250
+ if (typeof gap !== 'string' || gap.trim() === '') return '';
251
+ const scrubbed = redactSecrets(gap);
252
+ // BACKSTOP, not an envelope: over the line only. Scrubbing can only grow a string
253
+ // (a mask is wider than most literals it replaces), so the length is measured
254
+ // AFTER it — bounding on the raw length would let a gap cross the ceiling on its
255
+ // way through the one transformation that runs unconditionally.
256
+ return '\nWhat this step\'s exits reported on its last attempt (their own output, verbatim):\n'
257
+ + (scrubbed.length > CHECK_GAP_MAX ? boundGap(scrubbed, REPLAN_GAP_KEEP) : scrubbed);
194
258
  }
195
259
 
196
260
  /**
@@ -257,7 +321,6 @@ strictly in array order. Each step (no other fields exist):
257
321
  - "tools": non-empty unique subset of ${JSON.stringify(ceiling)} (write/edit change the tree; recall/get/impact/related/recent search and navigate the repository index; compress/peek read cheaply; stash/remember/forget park and record notes across steps)
258
322
  - "rounds": integer 1..${maxStepRounds} — the step's per-attempt tool-round bound
259
323
  - "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
324
  - "scope" (optional): narrow this step's WRITE fence — copy one value from the offered scopes below.
262
325
  A narrowed "scope" is the ONLY ground this step can write: its "target" must sit
263
326
  inside it, and a "tree-changed" exit's scope must not be disjoint from it (one that
@@ -403,6 +466,40 @@ ${scoutBlob || '(no scout notes)'}`;
403
466
  * `flat` — "revise the goal" — on a run that was converging when its allowance ran
404
467
  * out. Baselines only (src/trend.js's THE SEED): it spends none of this leg's own
405
468
  * bounds and mints none of its strikes. Empty/omitted is the cold path.
469
+ * @param {{count?: number, grantUsed?: boolean}|null} [opts.resumeReplans] RESUME — the
470
+ * replan LEDGER the chain has already spent (`readResume`'s `restart.replans` /
471
+ * `restart.replanGrantUsed`), seeding the ceiling below. `resumeGrades`' sibling and
472
+ * deliberately NOT its twin: a grade seed feeds a READOUT, this one feeds a BOUND.
473
+ *
474
+ * Without it the ceiling is reborn on every call, and a resume is another call:
475
+ * measured, a leg that spent both replans and step-redded handed its checkpoint to a
476
+ * second leg that replanned twice more. Two became four by being killed once, which is
477
+ * the creep the latch exists to make impossible ("unlimited replanning launders thrash
478
+ * as adaptation", PRD v1.12).
479
+ *
480
+ * WHY THIS SEED SPENDS THE LEG'S BOUND WHERE `resumeGrades` DOES NOT. src/trend.js's
481
+ * THE SEED refuses to seed the fix loop's ITERATIONS from history "because the leg's
482
+ * own bounds must not be spent by history" — and that is right, because an attempt
483
+ * allowance is a LEG bound: a restarted leg buys its own attempts with its own money.
484
+ * The replan ceiling is a RUN bound by doctrine. It bounds how many times the WORKFLOW
485
+ * may be redrawn before redrawing it is thrash, and that question spans exactly the
486
+ * chain the halt readout already spans. The two seeds therefore disagree on purpose;
487
+ * they are not the same rule applied inconsistently.
488
+ *
489
+ * CHAIN SEMANTICS, and the one place this differs from the grade seed's mechanism.
490
+ * `readGradeSeed`'s documented KNOWN LIMIT is that it reads ONE spine, so a resume of a
491
+ * resume inherits leg 2's grades and not leg 1's — the chain shortens by one leg per
492
+ * resume, which is fail-safe for a READOUT (a shorter chain can only under-claim a
493
+ * direction). It is the DANGEROUS direction for a ceiling: an under-claimed ledger is a
494
+ * refilled allowance, and every kill would buy one. So this seed does NOT reuse that
495
+ * mechanism. It follows the MONEY fold instead (`priorSpentUsd`, `try-start`/
496
+ * `job-start`): each leg DECLARES the ledger it inherited on its own spine, and the
497
+ * next reader adds only that leg's own `replan` records to the declared number. Leg 3
498
+ * therefore inherits the whole chain, not leg 2's slice.
499
+ *
500
+ * `count` seeds `replans` (and, above zero, latches `replanned`); `grantUsed` seeds the
501
+ * F85-C variance latch, so a killed leg cannot re-earn an extra the arbiter has already
502
+ * granted. Null/omitted is the cold path and is byte-identical to a run nobody resumed.
406
503
  * @param {() => boolean} [opts.spendComplete] is the run's spend EXACT, or a floor? The
407
504
  * ledger one level up owns the answer (unpriced rounds, a self-healed stall, a mid-call
408
505
  * cut, an inherited resume floor); the money-halt readout states it beside the remaining
@@ -417,7 +514,7 @@ ${scoutBlob || '(no scout notes)'}`;
417
514
  * 'cap-halt' | 'wall-halt' | 'provider-red' | 'interpreter-red' | 'step-stalled' |
418
515
  * `step-red:<id>`
419
516
  */
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 = [] }) {
517
+ export async function runPlan(job, { workdir, provider, nativeProvider, providerFor, emit, remainingUsd, isUnpriced = () => false, spendComplete = () => true, capRuns = 3, strikeLimit = STRIKE_LIMIT, closeTimeoutMs, maxStepRounds = 40, layerRoot = false, scoutRounds = SCOUT_ROUNDS, bridge = null, now, priorWallMs = 0, resumeSeed = null, resumeGrades = [], resumeReplans = null }) {
421
518
  workdir = resolve(workdir);
422
519
  // ONE spelling of the redaction, housed next to the inventory it reads
423
520
  // (src/validate.js) — the same helper the isolate verbs scrub the litectx store
@@ -742,7 +839,25 @@ export async function runPlan(job, { workdir, provider, nativeProvider, provider
742
839
  });
743
840
  for (const m of menu) {
744
841
  const v = await runStages(m.run, scrub, closeOpts);
745
- if (v.verdict === 'needs_revision') seedRed.push(m.name);
842
+ if (v.verdict === 'needs_revision') {
843
+ seedRed.push(m.name);
844
+ // …and the preflight grade is a READING, for the same reason the precheck one
845
+ // above is: it is the seed the run's work is measured against. The precheck
846
+ // only ever reds at the close's FIRST failing stage (first-red-wins), so on
847
+ // every shipped close — each of which opens with `changed-from-seed` — the
848
+ // numeric stages have no baseline at all without it. A step's first grade
849
+ // would then have nothing to compare against and the whole run would read
850
+ // `unknown` on exactly the stop the readout exists for.
851
+ //
852
+ // ONCE PER STAGE, though. The precheck and this loop grade the SAME unchanged
853
+ // tree back to back with no work in between, so a stage both reach reports the
854
+ // same number twice — and a repeated baseline in a series reads as an attempt
855
+ // that achieved nothing (`verdict 2 → 2 → 1`). That is a phantom flat step
856
+ // handed to a human and to the replanner, which is the exact class of false
857
+ // story this change exists to remove. Asked of the reader's own books, so
858
+ // there is no second record of what has been graded.
859
+ if (!runTrend.report().stages.some((s) => s.stage === v.stage)) runTrend.record({ gap: v.gap });
860
+ }
746
861
  emit('check-preflight', { name: m.name, verdict: v.verdict, ...(m.run.length > 1 ? { chain: m.run.map((s2) => s2.name) } : {}) });
747
862
  const f = Object.hasOwn(CLOSE_FAULTS, v.verdict) ? CLOSE_FAULTS[v.verdict] : undefined;
748
863
  if (f) {
@@ -797,7 +912,24 @@ export async function runPlan(job, { workdir, provider, nativeProvider, provider
797
912
  const v = await runStages(chain, scrub, closeOpts);
798
913
  emit('check-run', { name, verdict: v.verdict, ...(v.stage && v.stage !== name ? { stage: v.stage } : {}), ...(v.exitCode !== undefined ? { exitCode: v.exitCode } : {}) });
799
914
  if (v.verdict === 'satisfied') return { pass: true };
800
- if (v.verdict === 'needs_revision') return { pass: false, gap: v.gap };
915
+ if (v.verdict === 'needs_revision') {
916
+ // The run trend is fed by EVERY grade the arbiter renders, and a check IS an
917
+ // arbiter-rendered close grade: the same `runStages`, the same chain, the same
918
+ // stage names, so it lands in the same per-stage buckets and the accuracy law
919
+ // holds unchanged. Without this the reader is blind to everything a STEP ever
920
+ // achieves — it sees only the precheck and the outer fix loop — which is why
921
+ // u-msh70zla's meter, firing mid-step, had nothing to report about a step that
922
+ // had just gone 24 → 15 → 14.
923
+ //
924
+ // Folded HERE, on `v.gap`, and the seam is load-bearing: `evalExits` wraps this
925
+ // gap as `check "<name>" red: …`, and THAT line carries the word "red" with no
926
+ // number on it, so `readGrade` reads the wrapper instead of the wall and every
927
+ // step grade donates a null. Measured on u-msh70zla's own archived gaps: raw
928
+ // reads `typecheck 24 → 15 → 14`, wrapped reads nothing at all. Same seam the
929
+ // Layer R note below already calls out for a check's `^`-anchored gapKeep.
930
+ runTrend.record({ gap: v.gap });
931
+ return { pass: false, gap: v.gap };
932
+ }
801
933
  return { pass: false, fault: v.verdict, gap: v.detail };
802
934
  };
803
935
 
@@ -1482,8 +1614,39 @@ export async function runPlan(job, { workdir, provider, nativeProvider, provider
1482
1614
  const artifacts = [];
1483
1615
  /** @type {{id: string, outcome: string}[]} */
1484
1616
  const stepOutcomes = [];
1485
- let replanned = false;
1486
- const planExecuted = () => emit('plan-executed', { steps: stepOutcomes, replanned });
1617
+ /** RESUME — the replan ledger the CHAIN has already spent, belted the way every
1618
+ * other declared fold entering the arbiter's arithmetic is (`priorSpentUsd`,
1619
+ * `priorWallMs`, `declaredRounds`): a garbage or negative seed reads as 0 rather
1620
+ * than as a NaN that would poison every later comparison, and a negative one must
1621
+ * never cancel a real replan and widen the ceiling (reuse's `> 0` belt, F-4). */
1622
+ const seededReplans = typeof resumeReplans?.count === 'number' && Number.isFinite(resumeReplans.count) && resumeReplans.count > 0
1623
+ ? Math.floor(resumeReplans.count) : 0;
1624
+ // Seeded, not reset. These three were locals reborn on every `runPlan` call — and a
1625
+ // resume IS another call, so the ceiling was refilled by every kill (see the
1626
+ // `resumeReplans` param doc for the measurement and for why this bound spans the
1627
+ // chain while the trend's iteration bound deliberately does not).
1628
+ let replanned = seededReplans > 0;
1629
+ /** F85 (C): how many replans this run has actually drafted, and whether the
1630
+ * arbiter has already spent its ONE extra grant. Two variables and not one
1631
+ * counter compared against a limit, deliberately — a latch cannot be widened by
1632
+ * arithmetic, and the ceiling is the thing this rung must not move (v1.12). */
1633
+ let replans = seededReplans;
1634
+ // the F85-C latch travels BESIDE the count and is not derived from it: a leg can
1635
+ // inherit a spent ordinary ceiling with the grant still unearned (count 1, false),
1636
+ // and deriving one from the other would collapse those two states into one.
1637
+ let varianceGrantUsed = resumeReplans?.grantUsed === true;
1638
+ // `replanned` stays the shipped boolean this record has always carried; `replans`
1639
+ // rides BESIDE it (append-only — a field's meaning is never repurposed).
1640
+ //
1641
+ // Both now count the CHAIN rather than the leg, and that IS a meaning change — made
1642
+ // rather than shadowed by a second field because `replans` shipped only on this
1643
+ // unreleased branch, so no archived spine carries the leg reading for a reader to
1644
+ // be confused by. The leg's own number is not lost by it: a leg emits one `replan`
1645
+ // record per replan it drafts, so `chain − records-in-this-window` is the fold it
1646
+ // inherited, and that is exactly the arithmetic `readResume` runs to rebuild the
1647
+ // ledger for the next leg. A second field stating a number already derivable from
1648
+ // the window would be a second reader of one question (F6's structural cousin).
1649
+ const planExecuted = () => emit('plan-executed', { steps: stepOutcomes, replanned, replans });
1487
1650
  /** @type {object|null} W-2 at the STEP site — set when the WALL refused to open a
1488
1651
  * step's attempt. It carries that stop's own record fields, and its presence is what
1489
1652
  * lets the step loop's terminal tell this reading apart from F64's mid-call one
@@ -1558,15 +1721,41 @@ export async function runPlan(job, { workdir, provider, nativeProvider, provider
1558
1721
  const moneyShare = stepStartUsd > 0 ? (stepStartUsd - remainingUsd()) / stepStartUsd : 0;
1559
1722
  const timeShare = clock.bounded && stepStartMs > 0 ? (stepStartMs - clock.remainingMs()) / stepStartMs : 0;
1560
1723
  if (moneyShare >= VARIANCE_THRESHOLD || timeShare >= VARIANCE_THRESHOLD) {
1724
+ // F85 — the meter REPORTS progress; it does not decide on it. The
1725
+ // condition above is the whole trigger and stays the whole trigger: a
1726
+ // converging step that is eating the run is still stopped, because this is
1727
+ // a governance instrument over an operator-owned allowance and a governor
1728
+ // that suppressed itself whenever the work looked promising would be
1729
+ // judging capability with the budget. What was missing was never a term in
1730
+ // the condition — it was the READING. u-msh70zla stopped a step whose close
1731
+ // had gone 24 → 15 → 14 and then told the replanner its exits were
1732
+ // "unmoved", so the replanner threw the progress away and re-targeted a
1733
+ // file that was already clean.
1734
+ //
1735
+ // The reading comes off `runTrend` — the SAME instance the money halt reads
1736
+ // (src/trend.js's ONE PER SERIES). A second reader for one question is how
1737
+ // two readers come to disagree, which is precisely the defect being fixed.
1738
+ // It is per-stage, numbers-and-stage-names only, and `unknown` stays
1739
+ // `unknown` — a close that reports no number donates nothing (F6).
1740
+ const t = runTrend.verdict();
1561
1741
  emit('variance', {
1562
1742
  step: step.id, iteration, threshold: VARIANCE_THRESHOLD,
1563
1743
  moneyShare: Number(moneyShare.toFixed(3)),
1564
1744
  timeShare: clock.bounded ? Number(timeShare.toFixed(3)) : null,
1565
1745
  axis: moneyShare >= VARIANCE_THRESHOLD ? 'money' : 'time',
1746
+ // ADDED fields, never repurposed ones (the spine is append-only forever).
1747
+ // Same four names and same shapes the money halt already emits, so one
1748
+ // reader parses a progress reading wherever it appears.
1749
+ trend: t.trend, motion: t.motion, reading: t.reading, series: t.series,
1566
1750
  });
1567
1751
  const err = /** @type {CategorizedError} */ (new Error(
1568
1752
  `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`));
1753
+ + `${moneyShare >= VARIANCE_THRESHOLD ? 'money' : 'time'} across ${iteration - 1} attempt(s) with its exits still red. `
1754
+ // The escalation `detail` is this message (ralph passes it through), and
1755
+ // the replan brief quotes the detail — so this sentence is what a human
1756
+ // AND the redrafting planner read. It states what the run achieved; it
1757
+ // never states what to do about it, which is the operator's lever list.
1758
+ + `Run progress: ${t.reading}.`));
1570
1759
  err.category = 'step-variance';
1571
1760
  throw err;
1572
1761
  }
@@ -1772,12 +1961,15 @@ export async function runPlan(job, { workdir, provider, nativeProvider, provider
1772
1961
  // pricing-red at the step-end guard above, so isUnpriced() is false here.)
1773
1962
  //
1774
1963
  // 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).
1964
+ // step that had eaten a declared share of the run. That is the case
1965
+ // exhaustion-only could never reach (F56: a replan has fired zero times in the
1966
+ // programme), and it is a genuine re-allocation rather than a stop. Both
1967
+ // triggers still need FUNDS LEFT, and both are bounded by the replan ceiling.
1780
1968
  const cat = lastEscalation?.category;
1969
+ /** F85 — the run's measured trajectory at the moment of this stop, read ONCE
1970
+ * here off the same instance the meter and the money halt read. Both consumers
1971
+ * below are the replanner's: the trigger sentence and the brief. */
1972
+ const stopTrend = runTrend.verdict();
1781
1973
  // F66 adds the THIRD trigger: `step-stalled` — the stall watchdog reissued the
1782
1974
  // model call MAX_STALLS times and never got a round back. hamr's ruling is that
1783
1975
  // a stall must self-heal rather than end the run ("killing and coming back is
@@ -1785,13 +1977,58 @@ export async function runPlan(job, { workdir, provider, nativeProvider, provider
1785
1977
  // demonstrably stopped working does the run adapt instead — by replanning, not
1786
1978
  // by stopping. Same ONE-replan ceiling and same FUNDS-LEFT condition as the
1787
1979
  // other two: this changes the trigger, never the ceiling (v1.12).
1980
+ //
1981
+ // F85 rewrote the variance branch. It used to be the FIXED string "…with its
1982
+ // exits unmoved", printed for every step-variance stop whatever had happened —
1983
+ // and on u-msh70zla it was simply false: the exits had moved 24 → 15 → 14. The
1984
+ // sentence now states the meter's fact (a share of the run) and then the trend
1985
+ // instrument's MEASURED reading, which is the same reading the `variance` record
1986
+ // and the money halt carry. Nothing here asserts a direction the reader did not
1987
+ // report: `unknown` says unknown.
1788
1988
  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'
1989
+ : cat === 'step-variance' ? `the meter stopped a step that was consuming the run — ${stopTrend.reading}`
1790
1990
  : cat === 'step-stalled' ? 'the model stopped producing rounds and reissuing the call did not recover it'
1791
1991
  : null;
1792
- if (!replanned && replanTrigger && remainingUsd() > MONEY_MIN) {
1992
+ // F85 (C) — the ARBITER's one additional replan. A second `step-variance` after
1993
+ // the ceiling is spent used to be a hard stop, and on a run that was still
1994
+ // converging that stop threw away work the run had already paid for. So the
1995
+ // arbiter may grant ONE more, and every clause of that sentence is a constraint:
1996
+ //
1997
+ // * ONE. Bounded, and bounded by a latch rather than a comparison, so the
1998
+ // ceiling cannot creep: unlimited replanning launders thrash as adaptation
1999
+ // (v1.12). A third variance stop is the stop, however well it is going.
2000
+ // * the ARBITER. Read mechanically off `runTrend` — the same instance the
2001
+ // meter and the money halt read. The agent never asks for it, never sees it
2002
+ // offered, and cannot influence it: no self-adjusted budgets, ever.
2003
+ // * `converging`, the trend instrument's OWN category. No new number is
2004
+ // invented here — threshold-setting is the operator's, and a fresh
2005
+ // comparison would be a second reader of the one question (src/trend.js).
2006
+ // `flat` and `unknown` are refusals; an unknown is never rounded up into a
2007
+ // reason to spend (F6).
2008
+ // * step-variance only. An exhaustion or a stall after the ceiling is
2009
+ // unchanged — this widens nothing but the case it was measured on.
2010
+ const grantExtraReplan = replanned
2011
+ && cat === 'step-variance'
2012
+ && !varianceGrantUsed
2013
+ && stopTrend.trend === 'converging';
2014
+ if ((!replanned || grantExtraReplan) && replanTrigger && remainingUsd() > MONEY_MIN) {
2015
+ if (grantExtraReplan) varianceGrantUsed = true;
1793
2016
  replanned = true;
1794
- emit('replan', { step: step.id, reason: replanTrigger, trigger: cat });
2017
+ replans += 1;
2018
+ emit('replan', {
2019
+ step: step.id, reason: replanTrigger, trigger: cat,
2020
+ // ADDED fields (append-only): which replan this is, and — only when the
2021
+ // arbiter granted one past the ordinary ceiling — the reading it granted it
2022
+ // on. Absent on the ordinary replan, so a reader can never mistake the
2023
+ // ceiling for a grant.
2024
+ //
2025
+ // The index is the RUN's, so on a resumed leg it opens above 1 — the ceiling
2026
+ // it is counted against spans the chain, and an index that restarted at 1 on
2027
+ // every leg would read as a fresh allowance on the one record that could have
2028
+ // shown it was not.
2029
+ replan: replans,
2030
+ ...(grantExtraReplan ? { granted: stopTrend.trend } : {}),
2031
+ });
1795
2032
  // The brief names the trigger HONESTLY, one branch per trigger: it is the
1796
2033
  // only channel the redrafting planner adapts to, so a wrong diagnosis here
1797
2034
  // is handed to the one component whose whole job is to respond to it. A
@@ -1809,28 +2046,45 @@ export async function runPlan(job, { workdir, provider, nativeProvider, provider
1809
2046
  const leftOver = `$${remainingUsd().toFixed(2)} and `
1810
2047
  + `${clock.bounded ? `${Math.round(clock.remainingMs() / 60_000)} minute(s)` : 'time UNBOUNDED'} `
1811
2048
  + 'of the run were still unspent when it stopped.';
2049
+ // F85: the meter's branch states the METER's fact and then the run's measured
2050
+ // one, in that order and as two separate claims — "it was stopped for eating
2051
+ // the run" and "here is what the run achieved" are different things, and the
2052
+ // old sentence quietly fused them into a verdict on the work.
1812
2053
  const why = cat === 'step-variance'
1813
- ? 'It was stopped by the run\'s meter for consuming too large a share of what was left.'
2054
+ ? 'It was stopped by the run\'s meter for consuming too large a share of what was left. '
2055
+ + `Its work so far, from the close's own numbers: ${stopTrend.reading}. ${leftOver}`
1814
2056
  : cat === 'step-stalled'
1815
2057
  ? 'It stalled: the model stopped producing rounds and reissuing the call did not recover it, so its exits were never judged.'
1816
2058
  : `${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
- : '';
2059
+ // F86 — this step's OWN last exit results, verbatim (u-mshcpdg4). Everything
2060
+ // above this line is the run's narration of the stop: the meter's share
2061
+ // sentence, the trend's direction, the never-wrote fact, the escalation detail.
2062
+ // None of them says WHERE the remaining work is, because none of them is the
2063
+ // close. The close's output is in HERE — as one of the failing exits' details,
2064
+ // beside the evaluator's own (a step that wrote nothing leads with
2065
+ // `tree-changed`), which is why the block is labelled by its exits rather than
2066
+ // by the close. It goes over as text — see `closeGapBlock` for why a parsed
2067
+ // file list would reproduce the exact bug it is fixing. Empty gap → empty
2068
+ // string → the brief is byte-identical to the pre-F86 one.
2069
+ const gapBlock = closeGapBlock(res?.gap);
1824
2070
  const failure = `Step "${step.id}" (${step.action}) did not reach its exits. `
1825
2071
  + `${why}\n`
1826
- + missedLine
1827
2072
  + `Last exit state:\n${lastEscalation?.detail ?? '(none)'}\n`
1828
- + `Steps completed so far: ${artifacts.map((a) => a.id).join(', ') || 'none'}.`;
2073
+ + `Steps completed so far: ${artifacts.map((a) => a.id).join(', ') || 'none'}.`
2074
+ + gapBlock;
1829
2075
  // The progress line IS the adaptation channel (addendum 2): the balance rides
1830
2076
  // in via obtainPlan's live read, and this says where the run got to. Both are
1831
2077
  // the planner re-allocating what remains across what is left — never a rate.
2078
+ //
2079
+ // F85 adds the second half. This line used to count STEPS only ("step 1 of 1
2080
+ // did not finish; 0 step(s) completed before it") — structurally true and
2081
+ // silent about outcome, so a replanner reading it could only conclude that
2082
+ // nothing had been achieved. On u-msh70zla it concluded exactly that and threw
2083
+ // three attempts of real convergence away. The structural sentence stays (a
2084
+ // plan's shape is what the planner re-allocates); the measured one joins it.
1832
2085
  const progress = `step ${idx + 1} of ${plan.steps.length} ("${step.id}") did not finish; `
1833
- + `${artifacts.length} step(s) completed before it`;
2086
+ + `${artifacts.length} step(s) completed before it; `
2087
+ + `close trend so far: ${stopTrend.reading}`;
1834
2088
  let pv;
1835
2089
  try {
1836
2090
  pv = await obtainPlan('replan', failure, progress);
@@ -1886,8 +2140,10 @@ export async function runPlan(job, { workdir, provider, nativeProvider, provider
1886
2140
  emitMoneyHalt({ phase: `step:${step.id}`, cutMidCall: false, stepsDone: idx, stepsPlanned: plan.steps.length });
1887
2141
  return 'cap-halt';
1888
2142
  }
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
2143
+ // A `step-variance` that earned no replan above is a STOP, and the stop is the
2144
+ // result. Reached in three ways now: the ordinary ceiling with the run reading
2145
+ // `flat` or `unknown`; the arbiter's one grant already spent (F85's bound); or
2146
+ // the wallet at dust. Each rides out as `step-red:<id>`, NOT as its own top-level
1891
2147
  // outcome. The category is a replan TRIGGER, never a run verdict: leaking it
1892
2148
  // upward would mint an outcome run.js and the ledger's class table do not know,
1893
2149
  // and an unmapped category is counted as a library bug (ledger.js) when this is a
package/src/ralph.js CHANGED
@@ -359,10 +359,17 @@ export const GAP_KEEP_TRIM_MARKER = 'more elided by the';
359
359
  * `keepPattern` is set (F28), lines of the WHOLE stream matching it are also
360
360
  * carried in a clearly-delimited block between head and tail — capped — so the
361
361
  * failing-test names buried in the middle still reach the worker.
362
+ * Exported for the ONE reason `GAP_KEEP_TRIM_MARKER` above is: a second consumer
363
+ * that needs a gap bounded must reuse THIS envelope, not spell its own. A private
364
+ * copy would be a second truncation scheme, and the two would drift on exactly the
365
+ * property that matters — whether the red lines survive, and whether the trim
366
+ * announces itself (F28: silent truncation is the disease this function cures).
367
+ * The close path is unchanged: `runClose` still calls it with the stage's own
368
+ * `gapKeep`, byte-identically.
362
369
  * @param {string} s the (redacted) close output
363
370
  * @param {string} [keepPattern] regex source; matching lines survive the elision
364
371
  */
365
- function boundGap(s, keepPattern) {
372
+ export function boundGap(s, keepPattern) {
366
373
  if (s.length <= GAP_HEAD + GAP_TAIL + 100) return s;
367
374
  const head = s.slice(0, GAP_HEAD);
368
375
  const tail = s.slice(-GAP_TAIL);