bareloop 0.6.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/clock.js CHANGED
@@ -68,6 +68,14 @@ export const MIN_CALL_TIMEOUT_MS = 30_000;
68
68
  * it as transport. */
69
69
  export const TIMEOUT_CODE = 'ETIMEDOUT';
70
70
 
71
+ /** bare-agent's rejection code when its TOTAL-duration deadline trips (BA-19,
72
+ * `provider-http.js` → `applyRequestDeadline`). A DIFFERENT code from the idle
73
+ * bound's on purpose — upstream splits them precisely so a consumer routing
74
+ * governance stops apart from transport casualties can tell which timer fired. Like
75
+ * `TIMEOUT_CODE` it carries no `category`, so every default-to-transport catch site
76
+ * would file it as a casualty. */
77
+ export const DEADLINE_CODE = 'EDEADLINE';
78
+
71
79
  /**
72
80
  * F64 — is this throw the run's OWN deadline coming back dressed as a provider
73
81
  * error? `callTimeoutMs()` hands the provider a bound derived from the wall, so
@@ -90,12 +98,20 @@ export const TIMEOUT_CODE = 'ETIMEDOUT';
90
98
  * the same (raise `maxWallMs`), and the alternative — calling a governance stop
91
99
  * transport on the chance that it was — is the failure this closes.
92
100
  *
101
+ * BOTH derived bounds land here, because both are derived from the same wall:
102
+ * BA-18's idle timeout (`callTimeoutMs`) and BA-19's total-duration deadline
103
+ * (`callDeadlineMs`). Adding the second CODE does not widen the rule — the
104
+ * discriminator is still `clock.expired()`, never the code. That matters for the
105
+ * deadline specifically: a provider someone else constructed with its own
106
+ * `deadlineMs` could throw this code on a run with time left, and that is transport,
107
+ * exactly as it was before.
108
+ *
93
109
  * @param {any} err the throw as caught
94
110
  * @param {Clock} clock the run's clock
95
111
  * @returns {boolean}
96
112
  */
97
113
  export function isWallTimeout(err, clock) {
98
- return !!err && err.code === TIMEOUT_CODE && clock.expired();
114
+ return !!err && (err.code === TIMEOUT_CODE || err.code === DEADLINE_CODE) && clock.expired();
99
115
  }
100
116
 
101
117
  /**
@@ -108,21 +124,37 @@ export function isWallTimeout(err, clock) {
108
124
  * @property {() => boolean} expired whether the deadline has passed (always false when unbounded)
109
125
  * @property {(closeTimeoutMs: number) => number|null} enforcedMs the HONEST worst case
110
126
  * @property {(providerDefaultMs?: number) => number} callTimeoutMs the derived per-call bound
127
+ * @property {() => number|null} callDeadlineMs the derived per-call TOTAL-duration ceiling; `null` when unbounded
111
128
  * @property {(closeTimeoutMs: number) => object} report one spine-safe shape carrying both numbers
112
129
  */
113
130
 
114
131
  /**
115
132
  * Start a wall clock for one run.
116
- * @param {{ maxWallMs?: number|null, closeStages?: number, now?: () => number }} [opts]
133
+ * @param {{ maxWallMs?: number|null, closeStages?: number, now?: () => number, priorElapsedMs?: number }} [opts]
117
134
  * `maxWallMs`: the signed operator cap, or absent/null for an honestly unbounded
118
135
  * run. `closeStages`: how many stages the job's close runs (W5) — each one gets
119
136
  * the FULL `closeTimeoutMs`, so this is the multiplier on the overshoot, not a
120
137
  * decoration. Defaults to 1, the single-predicate close. `now`: injected for
121
- * tests — the default is the real clock.
138
+ * tests — the default is the real clock. `priorElapsedMs`: time this attempt's
139
+ * PREDECESSOR already consumed (see below).
122
140
  * @returns {Clock}
123
141
  */
124
- export function createClock({ maxWallMs = null, closeStages = 1, now = () => Date.now() } = {}) {
125
- const startedAt = now();
142
+ export function createClock({ maxWallMs = null, closeStages = 1, now = () => Date.now(), priorElapsedMs = 0 } = {}) {
143
+ // RESUME (module C) — hamr's checkpoint ruling in a time coat: a run killed
144
+ // mid-try restarts THAT try under the REMAINDER of its signed per-try numbers,
145
+ // because "a budget ceiling folds in prior spend so re-invoking cannot silently
146
+ // widen it". The fold happens HERE rather than by editing `maxWallMs`, and that
147
+ // is the whole point: the cap the operator signed stays the number reported and
148
+ // the number hashed (a rewritten cap is a new spec version needing a new
149
+ // signature), while the clock simply starts already partly spent. A fresh
150
+ // allotment on every kill would let a resumed run outlive the worst case the
151
+ // operator signed, one kill at a time.
152
+ //
153
+ // Belted like `maxWallMs` above: a non-finite, negative or garbage fold is 0. A
154
+ // NaN here would make every comparison false and silently unbound the run, which
155
+ // is the inverse of what the fold exists to do.
156
+ const prior = typeof priorElapsedMs === 'number' && Number.isFinite(priorElapsedMs) && priorElapsedMs > 0 ? priorElapsedMs : 0;
157
+ const startedAt = now() - prior;
126
158
  // A cap is only a cap if it is a usable positive number. validateJob already
127
159
  // reds anything else, so this is the belt (a caller that hand-builds a spec
128
160
  // must not be able to turn a garbage value into a silent unbounded run OR a
@@ -162,6 +194,26 @@ export function createClock({ maxWallMs = null, closeStages = 1, now = () => Dat
162
194
  // would restore the ~2h OS-TCP hang on the last round of a tight budget.
163
195
  return Math.min(providerDefaultMs, Math.max(MIN_CALL_TIMEOUT_MS, remainingMs()));
164
196
  },
197
+ // BA-19 (F72 park D) — the TOTAL-duration ceiling beside the idle one. The idle
198
+ // bound resets on every byte, so a response that trickles forever never trips it
199
+ // (F66's 274-minute call, and F67's 81.5-minute freeze happened above it); this
200
+ // one is a plain timer that no byte resets.
201
+ //
202
+ // Three properties, each a rule this repo already paid for:
203
+ // - NULL when unbounded. An operator who set no wall gets no ceiling — a
204
+ // defaulted cap is a silent second ceiling (F45, and the reason `maxWallMs`
205
+ // has no default). The caller must OMIT the knob on null, not pass a number.
206
+ // - REMAINING, read at call time. A constant would bound the last call by the
207
+ // first call's budget, which is the thing `callTimeoutMs` already refuses.
208
+ // - NOT clamped to the provider default. That upper clamp exists on the idle
209
+ // bound because a value above the provider's own default is inert; a
210
+ // total-duration ceiling above it is the entire point of BA-19.
211
+ // The lower floor is shared with `callTimeoutMs` and for the same two reasons: a
212
+ // bound certain to trip manufactures a stop out of arithmetic, and `0` DISABLES
213
+ // the bound upstream (`if (!(deadlineMs > 0)) return`) — the exact inverse of the
214
+ // intent. A trip past the floor is still honest: the wall has expired by then, so
215
+ // `isWallTimeout` reads it as the governance stop it is.
216
+ callDeadlineMs: () => (cap === null ? null : Math.max(MIN_CALL_TIMEOUT_MS, remainingMs())),
165
217
  report: (closeTimeoutMs) => ({
166
218
  bounded,
167
219
  requestedMs: cap,
package/src/index.js CHANGED
@@ -32,4 +32,33 @@ export { validatePlan, stageClose, EXIT_TYPES, MAX_EXITS_PER_STEP, MAX_PLAN_STEP
32
32
  export { snapshotScope, evalExits } from './exits.js';
33
33
  export { runPlan } from './planrun.js';
34
34
  export { runJob } from './run.js';
35
+ // Layer 3 — the REUSE registry (bridge-v1) and D3's display half. The registry is a
36
+ // directory of plain files at an OPERATOR-supplied path (never a default location), the
37
+ // load gate is the code half of D2's split, and `renderListing`/`selectionPrompt` are
38
+ // pure text: they send nothing, parse nothing and decide nothing. The selection CALL and
39
+ // the pin/shortlist/force-cold flow are the adopter's, which is why the pieces they need
40
+ // are exported rather than kept internal to the runner.
41
+ export {
42
+ BRIDGE_SCHEMA, validateBridge, deriveStatus, listingRow, loadGate,
43
+ mintBridge, appendGreen, appendRed,
44
+ loadBridge, loadRegistry, saveBridge, makeRegistry, registryExists,
45
+ } from './bridges.js';
46
+ export { renderListing, selectionPrompt } from './selection.js';
47
+ // Layer 3 modules 4+5 — the D7 envelope and the reuse runner. `runReuse` composes
48
+ // `runJob` under an operator-signed envelope (three explicit numbers, tighten-only) and
49
+ // is the only thing in the library that WRITES the registry — minting is a graded
50
+ // green's privilege (R1), never a caller's. `validateEnvelope`/`resolveTrySpec` are
51
+ // exported because an operator runner has to show the resolved per-try spec's HASH at
52
+ // its approval gate: a tightened envelope is a new spec version, and the human signs it.
53
+ // `resolveReuse`/`reuseSpecHash` are the pair that gate must actually PRINT: the try
54
+ // count multiplies the worst case, so a signature over the per-try spec alone leaves it
55
+ // unsigned — a runner showing `jobSpecHash(resolveTrySpec(...))` prints the same hash for
56
+ // 0 tries and for 9.
57
+ // Module C (resume after a kill) exports its two operator-side halves for the same
58
+ // reason: `readResume` is what turns a dead run's spine back into the state a resume
59
+ // continues from (which tries completed, which one restarts, on what remainder), and
60
+ // `resumeTreeGate` is the ruling that a resumed patient is continued dirty and never
61
+ // reset. Both are read by a runner BEFORE the approval gate, so neither can live behind
62
+ // `runReuse`'s own entry.
63
+ export { validateEnvelope, resolveTrySpec, resolveReuse, reuseSpecHash, selectBridge, runReuse, REUSE_GRADED_RED, readResume, resumeTreeGate } from './reuse.js';
35
64
  export { classifyIncidents, foldLedger, ledgerDeltas, updateLedger, LEDGER_CLASSES } from './ledger.js';
package/src/ladder.js ADDED
@@ -0,0 +1,196 @@
1
+ // The step ladder's governor — PROGRESS, not a count (design 2026-08-03).
2
+ //
3
+ // MEASURED reason this exists. The step loop was bounded by a fixed number of
4
+ // check-iterations (`capRuns`, 4 in the operator's runner). Two real spines show
5
+ // what that cost: u-msd916dh went 30 → 22 → 17 → 11 scope errors across its
6
+ // iterations and was cut with $1.30 and ~6 minutes still unspent; u-mscxuziw went
7
+ // 13 → 1 and was cut the same way. The count could not tell converging work from
8
+ // thrash, so it stopped both at the same place. And the one lever the plan could
9
+ // pull — a step's `attempts` field — could only ever TIGHTEN below the shell's
10
+ // number, so the correct heal was inexpressible: drafters measurably tightened to
11
+ // 3, then to 2 on replan.
12
+ //
13
+ // The replacement asks the question the count could not: **is this iteration
14
+ // making progress?** Two signals, read per RED iteration:
15
+ //
16
+ // gap-new the failing exits' text, normalized and hashed, against every gap
17
+ // hash seen so far in this step's ladder. A SEEN-SET, never last-only:
18
+ // a real A→B→A oscillator exists in the archive and last-only reads it
19
+ // as progress.
20
+ // wrote did the worker make real write-class actions THIS iteration — read
21
+ // from the gate audit's own record COUNT (the F32 instrument:
22
+ // run-scoped, allow-decision, write AND edit). Never git status, never
23
+ // a tree diff. It must be a COUNT and not the path SET, because a plan
24
+ // step rewrites its one target every attempt — the set is constant
25
+ // after iteration 1 and would read every later iteration as idle (the
26
+ // same Finding-3 trap Layer R documents one module over).
27
+ //
28
+ // A **strike** is a red iteration that is a repeat OR wrote nothing. Strikes are
29
+ // STICKY — never reset, never repaid by a good iteration in between — because the
30
+ // question the ceiling answers is "has this step spent enough of the run failing
31
+ // to make progress", and a counter that a single good iteration zeroes can be held
32
+ // open forever by alternating.
33
+ //
34
+ // What still bounds a converging step, so this cannot run away: the wallet (the
35
+ // worker's Gate carries the run's remaining budget), the run's wall clock (W-2, at
36
+ // the head of every attempt), the variance meter (A), and the stall fuse. Those
37
+ // are unchanged and each still ends the ladder on its own authority. This module
38
+ // replaces exactly ONE of the old bounds: exhaustion-by-count.
39
+
40
+ import { createHash } from 'node:crypto';
41
+
42
+ /**
43
+ * The strike ceiling. Shell-owned, exactly where the fixed count was: the runner
44
+ * sets it, the agent cannot express it, and it is arbiter territory — a step's own
45
+ * plan may not tighten it and may not raise it. Two is hamr's number.
46
+ */
47
+ export const STRIKE_LIMIT = 2;
48
+
49
+ /**
50
+ * The volatile bytes, stripped before hashing. COMPARISON-ONLY: the gap the worker
51
+ * sees is never rewritten (the `normalizeRedLine` rule, one module over).
52
+ *
53
+ * Every entry is a byte a re-run changes while the failure does not. Without them
54
+ * a `node --test` gap is never byte-stable (its duration stamps move every run),
55
+ * so every repeat would read as a novel gap and the repeat signal would be dead —
56
+ * an instrument that cannot see the variable.
57
+ * @type {[RegExp, string][]}
58
+ */
59
+ const VOLATILE = [
60
+ // TAP's own timing line, dropped WHOLE: its value is the only thing on it
61
+ [/^[ \t]*#?[ \t]*duration_ms[ \t]*[:=].*$/gm, ''],
62
+ // ISO-8601 stamps, wherever they appear in a close's output
63
+ [/\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:?\d{2})?/g, '<ts>'],
64
+ // any millisecond figure (`(1.738ms)`, `took 12 ms`)
65
+ [/\d+(?:\.\d+)?[ \t]*ms\b/g, '<ms>'],
66
+ ];
67
+
68
+ /**
69
+ * Normalize a gap for comparison. Deliberately NARROW: only bytes a re-run changes
70
+ * on its own are removed. Error COUNTS, file names and line numbers all survive —
71
+ * a trajectory like "30 errors" → "22 errors" must read as two different gaps, and
72
+ * a normalizer that scrubbed numbers would turn the converging case this module
73
+ * exists to protect into an instant strike.
74
+ * @param {string} text @returns {string}
75
+ */
76
+ export function normalizeGap(text) {
77
+ let s = String(text ?? '');
78
+ for (const [re, to] of VOLATILE) s = s.replace(re, to);
79
+ // runs of spaces/tabs collapse (a re-run can re-pad a column); newlines do NOT —
80
+ // line structure is real content
81
+ return s.replace(/[ \t]+/g, ' ').replace(/[ \t]+$/gm, '').trim();
82
+ }
83
+
84
+ /**
85
+ * The comparable key for a gap: the normalized text, hashed. Hashed rather than
86
+ * kept whole because the seen-set lives for the whole step and a close's output
87
+ * can be tens of KB — and because the set holds no content, nothing here can leak
88
+ * model or file bytes into a record.
89
+ * @param {string} text @returns {string}
90
+ */
91
+ export function gapKey(text) {
92
+ return createHash('sha256').update(normalizeGap(text), 'utf8').digest('hex');
93
+ }
94
+
95
+ /**
96
+ * Build one ladder. ONE PER STEP: the seen-set and the strike count are the step's
97
+ * own history, and a ladder shared across steps would strike a fresh step for a gap
98
+ * an earlier one had already seen.
99
+ *
100
+ * @param {object} opts
101
+ * @param {number} [opts.limit] strikes that end the step (default `STRIKE_LIMIT`)
102
+ * @param {() => number} opts.writeCount the CUMULATIVE count of the worker's
103
+ * allow-decision write/edit records so far this step (planrun reads it off the
104
+ * gate audit). Required, and a throw when it is missing rather than a silent
105
+ * degradation to repeats-only: a strike rule running on one of its two signals
106
+ * with nothing on the record saying so is the blind-instrument class, and this
107
+ * is the BA-4 param-guard class where a throw is the right answer.
108
+ */
109
+ export function createLadder({ limit = STRIKE_LIMIT, writeCount }) {
110
+ if (typeof writeCount !== 'function') {
111
+ throw new TypeError('createLadder requires a writeCount seam — a strike rule with one signal wired is prose, not a bound');
112
+ }
113
+ /** @type {Map<string, number>} gap key → the iteration it was FIRST seen at */
114
+ const firstSeen = new Map();
115
+ /** @type {{at: number, firstAt: number}[]} */
116
+ const repeats = [];
117
+ /** @type {number[]} iterations in which the worker wrote nothing */
118
+ const idle = [];
119
+ let strikes = 0;
120
+ let iterations = 0;
121
+ let priorWrites = 0;
122
+
123
+ return {
124
+ /**
125
+ * Read one RED iteration. Called by ralph after the judge has rendered a
126
+ * non-green, non-fault verdict — a green never reaches here, so every reading
127
+ * is a failing iteration by construction.
128
+ * @param {{iteration: number, gap?: string}} o
129
+ * @returns {{governor: string, iteration: number, strike: boolean, strikes: number,
130
+ * limit: number, wrote: boolean, repeatOf: number|null, distinctGaps: number}}
131
+ */
132
+ record({ iteration, gap }) {
133
+ iterations = iteration;
134
+ const key = gapKey(gap ?? '');
135
+ const firstAt = firstSeen.get(key);
136
+ const repeated = firstAt !== undefined;
137
+ if (repeated) repeats.push({ at: iteration, firstAt: /** @type {number} */ (firstAt) });
138
+ else firstSeen.set(key, iteration);
139
+
140
+ // the DELTA, not the total: the audit is append-only across the whole step
141
+ const total = writeCount();
142
+ const wrote = total > priorWrites;
143
+ priorWrites = total;
144
+ if (!wrote) idle.push(iteration);
145
+
146
+ const strike = repeated || !wrote;
147
+ if (strike) strikes += 1; // sticky: nothing anywhere decrements this
148
+ return {
149
+ // WHICH governor produced this reading. Two of them emit under the one
150
+ // `ladder` spine type now (this one, and the close-fix loop's per-stage trend
151
+ // reader), and their fields differ — a reader that cannot tell them apart
152
+ // would average two different instruments into one number.
153
+ governor: 'step-ladder',
154
+ iteration, strike, strikes, limit, wrote,
155
+ repeatOf: repeated ? /** @type {number} */ (firstAt) : null,
156
+ distinctGaps: firstSeen.size,
157
+ };
158
+ },
159
+ /** has the step run out of strikes? */
160
+ struckOut: () => strikes >= limit,
161
+ /** the ladder's own books — counts and iteration numbers, never gap content */
162
+ report: () => ({
163
+ iterations, strikes, limit, distinctGaps: firstSeen.size,
164
+ repeats: repeats.map((r) => ({ ...r })),
165
+ idle: [...idle],
166
+ }),
167
+ /**
168
+ * The mechanism, in one sentence, for the replan brief. It is the only channel
169
+ * the redrafting planner adapts to, so it states WHICH shape ended the step and
170
+ * the evidence for it — never a label on its own, and never a culprit file
171
+ * beyond what the close itself reported (F28): the inputs here are iteration
172
+ * numbers and counts, so naming one is inexpressible.
173
+ * @returns {string}
174
+ */
175
+ brief() {
176
+ const parts = [];
177
+ if (repeats.length) {
178
+ const last = repeats[repeats.length - 1];
179
+ parts.push(`it repeated itself — the exit output at iteration ${last.at} had already been seen at iteration ${last.firstAt}`);
180
+ }
181
+ if (idle.length) parts.push(`it stalled — no file was written in iteration(s) ${idle.join(', ')}`);
182
+ const trajectory = `${firstSeen.size} distinct exit output(s) over ${iterations} iteration(s)`;
183
+ // BOTH signals gate it, because "converging" is a claim about the WORK and a
184
+ // moving exit output is only half the evidence for one. Gated on repeats alone
185
+ // it fired on an idle strike-out — "no file was written in iteration(s) 1, 2"
186
+ // and "the step was converging" in the same paragraph, handed to the one
187
+ // channel the redrafting planner adapts to. A step that wrote nothing was not
188
+ // converging whatever its exit bytes did (a close re-run moves its own).
189
+ const converging = repeats.length === 0 && idle.length === 0
190
+ ? ' Every attempt moved the exit output, so the step was converging when it struck out.'
191
+ : '';
192
+ return `The step ladder ended it on ${strikes} strike(s) of ${limit}: ${parts.join('; ')}. `
193
+ + `Gap trajectory: ${trajectory}.${converging}`;
194
+ },
195
+ };
196
+ }
package/src/ledger.js CHANGED
@@ -79,6 +79,33 @@ const EXCLUDED_ESCALATIONS = new Set([
79
79
  'smoke-red', // already counted via primitive-smoke
80
80
  'hitl-close', // by design: a human is the close
81
81
  'close-unsupported', // honest refusal, by design
82
+ // Layer 3 (D2 split): the bridge load gate refused a wrong-KIND recipe at the door.
83
+ // That is the mechanism WORKING — every recipe from a different day is a candidate for
84
+ // refusal, and the run stopped having spent nothing. Filing it upstream would aim a bug
85
+ // report at a library for our own gate doing its job (the step-stalled lesson).
86
+ 'recipe-stale',
87
+ // Layer 3 (D7/D3) — the reuse runner's own three stops. Each is the ENVELOPE or the
88
+ // OPERATOR speaking, never a library failing: `reuse-exhausted` is the pre-authorized
89
+ // number of tries being spent (the money/time story, exactly like cap-halt);
90
+ // `selection-refused` is the model declining a pinned workflow, which D3 requires it to
91
+ // be able to do; `selection-red` is a model answer that was not the one JSON object it
92
+ // was asked for — a behaviour, not a transport fault (a real transport fault out of the
93
+ // selection call is classified as `provider-red` and is NOT excluded).
94
+ 'reuse-exhausted',
95
+ 'selection-refused',
96
+ 'selection-red',
97
+ // Layer 3 (module C) — a RESUME that refuses to continue: the seed was signed under a
98
+ // different envelope, or the workflow the killed run was part-way through is no longer
99
+ // in the registry. Both are the operator's own state speaking (a stale signature, an
100
+ // edited registry), and both stop having run nothing.
101
+ 'resume-red',
102
+ 'registry-red', // the operator-supplied registry path does not exist — a typo, not a bug
103
+ 'envelope-red', // the envelope does not compose with the signed spec — operator input
104
+ // the reuse runner also STOPS on the three answers no further try could change, and
105
+ // escalates each under its own name. Both are operator-input stories: an unsigned spec
106
+ // version and a spec that will not validate. (`smoke-red` is already excluded above.)
107
+ 'unapproved-spec',
108
+ 'job-red',
82
109
  'close-timeout', // close-verdict reds: the arbiter's own named terminals
83
110
  'close-killed', // (F17) — operator/environment stories, never a suite lib
84
111
  'close-crashed',
package/src/plan.js CHANGED
@@ -246,7 +246,7 @@ export function stageClose(close) {
246
246
  * job spec. Never throws on JSON text or plain parsed data; every failure is
247
247
  * a named red. Returns the parsed plan on ok (single parse), null on any red.
248
248
  * @param {object|string} input parsed plan, or raw JSON text (parse failures are a red)
249
- * @param {{ job?: any, maxStepRounds?: number, scopes?: string[], capRuns?: number }} [opts] `job`: the
249
+ * @param {{ job?: any, maxStepRounds?: number, scopes?: string[], seedRed?: string[], priorChecks?: string[] }} [opts] `job`: the
250
250
  * validateJob-GREEN four-field spec (the ceiling, the fence, and the checks
251
251
  * menu all come from it — a missing or non-plan-shape job fails CLOSED);
252
252
  * `maxStepRounds`: the shell's per-step rounds ceiling (interpret's
@@ -254,9 +254,23 @@ export function stageClose(close) {
254
254
  * `scopes`: the offered `tree-changed` menu from `legalScopes` (the SAME array
255
255
  * the drafting prompt enumerated). Omitted, it derives from the signed
256
256
  * writeScope — never a free-text fallback (F50).
257
+ *
258
+ * The two shape-lottery gate rules (2026-08-04) — both keyed on RECORDED
259
+ * facts, never an LLM judgment of "is this job small", and both inactive
260
+ * when their fact is omitted (resume/reuse callers stay byte-identical):
261
+ * `seedRed`: check names whose PREFLIGHT verdict was needs_revision. Rule
262
+ * A-v2 — a seed-red check is the goal itself, not a milestone; it may only
263
+ * sit on the plan's FINAL write step (`check-placement`). The $0 archive
264
+ * sweep: seed-red-check-on-an-early-step has 0 honest greens ever; the one
265
+ * wide closing step greens 7/7. Green-at-seed checks stay free mid-plan
266
+ * (the 20 TESTGEN mid-plan greens — a guard, not a goal).
267
+ * `priorChecks`: the check names the PREDECESSOR plan carried (replan gate
268
+ * only). Rule B — a redraft may not drop one (`check-shed`): exits without
269
+ * the check verify FORM alone, so a shed lets the run "green" unearned
270
+ * (u-msdsmkid mechanism b; the 3 archived sheds were all on step-red runs).
257
271
  * @returns {{ ok: boolean, reds: Red[], plan: object|null }}
258
272
  */
259
- export function validatePlan(input, { job, maxStepRounds = 40, scopes, capRuns = 3 } = {}) {
273
+ export function validatePlan(input, { job, maxStepRounds = 40, scopes, seedRed, priorChecks } = {}) {
260
274
  /** @type {Red[]} */
261
275
  const reds = [];
262
276
  /** @type {(code: string, path: string, detail?: string) => void} */
@@ -319,6 +333,14 @@ export function validatePlan(input, { job, maxStepRounds = 40, scopes, capRuns =
319
333
  } else if (plan.steps.length > MAX_PLAN_STEPS) {
320
334
  red('bounds', 'steps', `max ${MAX_PLAN_STEPS} steps — per-step rounds bound the spend, the step count bounds the claim on the wallet`);
321
335
  } else {
336
+ // Rule A-v2's anchor: the index of the plan's FINAL write-granted step. Read
337
+ // off the RAW steps (a step whose grant didn't parse counts as non-write —
338
+ // its own missing-required/invalid-value red already fired; deriving a
339
+ // placement charge from the false default would be a second red for one
340
+ // defect). -1 when no step writes: every check then reds as a mailbox anyway.
341
+ const lastWriteIdx = plan.steps.reduce((/** @type {number} */ acc, /** @type {any} */ s, /** @type {number} */ i) => (
342
+ isObj(s) && Array.isArray(s.tools) && s.tools.some((/** @type {unknown} */ t) => WRITE_VERBS.includes(/** @type {string} */ (t))) ? i : acc), -1);
343
+ const seedRedNames = Array.isArray(seedRed) ? seedRed.filter(isNonEmptyString) : [];
322
344
  const seen = new Set();
323
345
  plan.steps.forEach((/** @type {any} */ s, /** @type {number} */ i) => {
324
346
  const at = `steps.${i}`;
@@ -370,8 +392,24 @@ export function validatePlan(input, { job, maxStepRounds = 40, scopes, capRuns =
370
392
  if (s.model !== undefined && !STEP_MODELS.includes(s.model)) {
371
393
  red('invalid-value', `${at}.model`, `menu: ${STEP_MODELS.join('|')} — the tier menu is signed; the agent picks a tier, never names a model`);
372
394
  }
373
- if (s.attempts !== undefined && !(Number.isInteger(s.attempts) && s.attempts >= 1 && s.attempts <= capRuns)) {
374
- red('bounds', `${at}.attempts`, `integer 1..${capRuns} — a step may tighten the shell's attempt cap, never exceed it`);
395
+ // `attempts` is TOLERATED-INERT (step ladder, 2026-08-03). It once tightened a
396
+ // fixed per-step attempt count; that count is gone — a step's iterations are
397
+ // governed by the strike ladder, and a field that could only ever TIGHTEN is
398
+ // the wrong shape for a progress rule (drafters measurably tightened it to 3,
399
+ // then to 2 on replan, which is the opposite of the heal a converging step
400
+ // needs). It is no longer OFFERED in the drafting prompt, and the runner
401
+ // ignores it.
402
+ //
403
+ // It is not REJECTED, and that is the load-bearing half: the frozen bridge
404
+ // registry's stored plans carry `attempts`, and a bridge rides into the drafter
405
+ // as a starting draft after passing the ordinary validator. Redding it here
406
+ // would refuse every stored recipe minted before today and ruin a frozen
407
+ // contrast experiment. The SHAPE is still checked — a known field holding
408
+ // garbage stays a named red — but the upper bound is gone with the cap it
409
+ // named: keeping `<= capRuns` would make a plan stored under one runner's
410
+ // number invalid under another's, for a field that decides nothing.
411
+ if (s.attempts !== undefined && !(Number.isInteger(s.attempts) && s.attempts >= 1)) {
412
+ red('bounds', `${at}.attempts`, 'integer >= 1 — the field is accepted for stored-plan compatibility and bounds nothing (the step ladder governs iterations)');
375
413
  }
376
414
  if (s.scope !== undefined && !scopeMenu.includes(s.scope)) {
377
415
  red('invalid-value', `${at}.scope`, `menu: [${scopeMenu.join(', ')}] — a per-step write scope narrows the fence from the same menu tree-changed uses`);
@@ -435,8 +473,26 @@ export function validatePlan(input, { job, maxStepRounds = 40, scopes, capRuns =
435
473
  red('step-scope-escape', `${at}.target`, `"${s.target}" is inside the signed fence but outside this step's own scope "${stepScope}" — the step's gate is built from that narrowed prefix, so every write to this target would be denied`);
436
474
  }
437
475
 
438
- validateExit(s, at, red, { checkNames, fence: spec.writeScope, insideFence, writeStep, toolsParsed, scopeMenu, stepScope, overlapsStepScope });
476
+ validateExit(s, at, red, { checkNames, fence: spec.writeScope, insideFence, writeStep, toolsParsed, scopeMenu, stepScope, overlapsStepScope, seedRedNames, isFinalWrite: i === lastWriteIdx });
439
477
  });
478
+
479
+ // Rule B (`check-shed`, replan gate): the caller hands over the check names
480
+ // the PREDECESSOR plan carried; a redraft that drops one is refused. Exits
481
+ // without the check verify FORM alone (tree-changed/artifact-written), so a
482
+ // shed lets a step "green" with the truth unjudged — u-msdsmkid's replan
483
+ // dropped check-passes from BOTH steps and greened unearned on form. Set
484
+ // semantics on NAMES: where the check sits may move (A-v2 forces it to the
485
+ // final write step); that it is judged at all may not. One red listing every
486
+ // dropped name — one defect (the shed), one red.
487
+ if (Array.isArray(priorChecks)) {
488
+ const carried = new Set(plan.steps.flatMap((/** @type {any} */ s) => (isObj(s) && Array.isArray(s.exit)
489
+ ? s.exit.filter((/** @type {any} */ e) => isObj(e) && e.type === 'check-passes' && isNonEmptyString(e.name)).map((/** @type {any} */ e) => e.name)
490
+ : [])));
491
+ const dropped = priorChecks.filter(isNonEmptyString).filter((n) => !carried.has(n));
492
+ if (dropped.length) {
493
+ red('check-shed', 'steps', `the predecessor plan carried check-passes(${dropped.join(', ')}) and this redraft drops ${dropped.length === 1 ? 'it' : 'them'} — a replan may move a check, never shed it (the remaining exits verify form only, so a shed green would be unearned)`);
494
+ }
495
+ }
440
496
  }
441
497
 
442
498
  // 3. secrets sweep — the agent-authored document is the riskier entry point
@@ -452,13 +508,15 @@ export function validatePlan(input, { job, maxStepRounds = 40, scopes, capRuns =
452
508
  * @param {Record<string, any>} s the step
453
509
  * @param {string} at step path prefix
454
510
  * @param {(code: string, path: string, detail?: string) => void} red
455
- * @param {{ checkNames: string[], fence: string[], insideFence: (p: string) => boolean, writeStep: boolean, toolsParsed: boolean, scopeMenu: string[], stepScope: string|null, overlapsStepScope: (p: string) => boolean }} ctx
511
+ * @param {{ checkNames: string[], fence: string[], insideFence: (p: string) => boolean, writeStep: boolean, toolsParsed: boolean, scopeMenu: string[], stepScope: string|null, overlapsStepScope: (p: string) => boolean, seedRedNames: string[], isFinalWrite: boolean }} ctx
456
512
  * `stepScope`/`overlapsStepScope` (W3): the step's OWN narrowed fence when it
457
513
  * declared a legal `scope`, null/always-true otherwise. Exits are OBSERVATIONS,
458
514
  * not writes, so only `tree-changed` is constrained by it and only against
459
515
  * DISJOINTness — see the arm for the measured semantics.
516
+ * `seedRedNames`/`isFinalWrite` (Rule A-v2): the preflight-red check names and
517
+ * whether THIS step is the plan's final write step — see the check-passes arm.
460
518
  */
461
- function validateExit(s, at, red, { checkNames, fence, insideFence, writeStep, toolsParsed, scopeMenu, stepScope, overlapsStepScope }) {
519
+ function validateExit(s, at, red, { checkNames, fence, insideFence, writeStep, toolsParsed, scopeMenu, stepScope, overlapsStepScope, seedRedNames, isFinalWrite }) {
462
520
  if (!Array.isArray(s.exit) || s.exit.length === 0) {
463
521
  red('missing-required', `${at}.exit`, `non-empty array from the closed menu ${EXIT_TYPES.join('|')} — ALL listed exits must pass (AND-only); a step without an exit has no progress gate`);
464
522
  return;
@@ -485,6 +543,16 @@ function validateExit(s, at, red, { checkNames, fence, insideFence, writeStep, t
485
543
  // decision 1: a check the spec doesn't sign DOES NOT EXIST — and the
486
544
  // detail names the signed menu so the replan can aim, not guess
487
545
  red('check-unknown', eAt, `"${e.name}" is not an offered close stage — the agent references stages of the operator's close, never authors a ruler; offered: [${checkNames.join(', ') || 'none'}]`);
546
+ } else if (seedRedNames.includes(e.name) && writeStep && !isFinalWrite) {
547
+ // Rule A-v2 (`check-placement`): a check that was RED at preflight is
548
+ // the GOAL itself, not a milestone — it judges the whole territory, so
549
+ // an earlier step scoped to a slice can never satisfy it without
550
+ // crossing its own boundary (the bareagent-u death shape: 0 honest
551
+ // greens in the whole archive; the wide closing step greens 7/7). Only
552
+ // on a WRITE step and only when the anchor exists elsewhere: a
553
+ // read-only step already redded as a mailbox (one defect, one red),
554
+ // and a green-at-seed check is a guard, free to sit anywhere.
555
+ red('check-placement', eAt, `"${e.name}" was RED at preflight — a failing check is the goal itself and may only gate the plan's FINAL write step (make that step wide: free to edit any file it reports, iterating until it passes); an earlier step scoped to a slice can never satisfy a whole-goal check`);
488
556
  }
489
557
  } else if (e.type === 'tree-changed') {
490
558
  hasTreeChanged = true;