bareloop 0.5.1 → 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/ledger.js CHANGED
@@ -22,7 +22,6 @@
22
22
  import { readFileSync, appendFileSync, existsSync } from 'node:fs';
23
23
  import { basename } from 'node:path';
24
24
  import { createHash } from 'node:crypto';
25
- import { VERBS } from './validate.js';
26
25
 
27
26
  /** severity order, worst-first (the fold's display order; frequency ranks within) */
28
27
  export const LEDGER_CLASSES = Object.freeze([
@@ -50,7 +49,14 @@ const normalizeDetail = (/** @type {unknown} */ s) => String(s ?? '')
50
49
  .slice(0, DETAIL_MAX);
51
50
  const sigOf = (/** @type {string} */ normalized) => createHash('sha256').update(normalized).digest('hex').slice(0, 8);
52
51
 
53
- const VERB_RE = new RegExp(`\\b(${VERBS.join('|')})\\b`);
52
+ // The prose fallback for attributing a store failure to a verb. This list came
53
+ // from config-v1's hook vocabulary, which was deleted with that path (PRD
54
+ // v1.32); it is relocated here VERBATIM rather than re-derived, so classification
55
+ // behaviour is byte-identical across the deletion. Re-aiming it at litectx's own
56
+ // verb names would be a behavioural change and belongs in its own commit — two
57
+ // levers in one diff make the delta unattributable to either.
58
+ const SNIFF_VERBS = Object.freeze(['recall', 'compress', 'stash', 'remember']);
59
+ const VERB_RE = new RegExp(`\\b(${SNIFF_VERBS.join('|')})\\b`);
54
60
  const QUOTED_VERB_RE = /"([a-z0-9-]+)"/;
55
61
 
56
62
  /** The deliberate escalation exclusions as a RUNTIME set, not prose: anything
@@ -59,10 +65,47 @@ const QUOTED_VERB_RE = /"([a-z0-9-]+)"/;
59
65
  * explains WHY each one classifies to nothing. */
60
66
  const EXCLUDED_ESCALATIONS = new Set([
61
67
  'cap-halt', // a budget story, not a lib bug
68
+ 'wall-halt', // the TIME budget's version of the same story (T, PRD v1.27)
69
+ // A stall is the ABSENCE of beats, not an observed provider failure: nothing
70
+ // saw the transport fail, so the typed-lib route rendered a live ask reading
71
+ // "bare-agent: the provider path failed — worker stalled…" and aimed a bug at
72
+ // the wrong package. Excluded on the wall-halt shape (operator ruling
73
+ // 2026-07-31): the F66 fuse firing is OUR governance working, and the stall
74
+ // evidence still rides the spine's own escalation event, which is where a
75
+ // real upstream ask would be sourced from anyway.
76
+ 'step-stalled',
77
+ 'step-variance', // A's replan trigger: a planning story, never a lib bug
62
78
  'gate-red', // governance working as intended
63
79
  'smoke-red', // already counted via primitive-smoke
64
80
  'hitl-close', // by design: a human is the close
65
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',
66
109
  'close-timeout', // close-verdict reds: the arbiter's own named terminals
67
110
  'close-killed', // (F17) — operator/environment stories, never a suite lib
68
111
  'close-crashed',
@@ -86,7 +129,9 @@ const ASKS = /** @type {Record<string, (o: {lib: string, verb: string, detail: s
86
129
  * bareloop vocabulary). Deliberate exclusions — bare cap-halt (a budget story),
87
130
  * close-verdict reds and artifact-red (worker stories, the §5b line), gate-red
88
131
  * (governance working as intended), pr-red (operator environment, not a suite
89
- * lib), smoke-red escalations (already counted via primitive-smoke), hitl-close
132
+ * lib), smoke-red escalations (already counted via primitive-smoke), step-stalled
133
+ * (our own fuse firing on an ABSENCE of beats — no observed lib failure to file),
134
+ * hitl-close
90
135
  * and close-unsupported (by design) — classify to nothing.
91
136
  * @param {any[]} events one spine's parsed events, in seq order
92
137
  * @param {{spine?: string}} [opts] spine id stamped into samples
@@ -127,6 +172,13 @@ export function classifyIncidents(events, { spine = 'spine' } = {}) {
127
172
  } else if (ev.category === 'pricing-red') {
128
173
  add(ev, 'pricing-red', 'bare-agent', 'pricing', ev.decision ?? ev.detail);
129
174
  } else if (ev.category === 'interpreter-red') {
175
+ // `step-stalled` (the F66 stall fuse) used to ride this branch on a
176
+ // typed-lib argument. It is now a named EXCLUSION: StallError stamps
177
+ // `lib:'bare-agent'` at the throw site, so the ask rendered as
178
+ // "bare-agent: the provider path failed — worker stalled…" — but a stall
179
+ // is the ABSENCE of beats; nothing observed the provider path fail, and
180
+ // the upstream package got aimed at for our own governance firing.
181
+ //
130
182
  // the design split: a store verb in the detail → runtime-red (verb→lib
131
183
  // map); a worker-loop/provider failure → provider-red; neither is
132
184
  // still counted (runtime-red, lib unknown) — never silently dropped
package/src/plan.js CHANGED
@@ -9,16 +9,22 @@
9
9
  // `request-red`, which is admission demand against the product menu)
10
10
  // bounds ≤ the shell caps → `bounds`
11
11
  // scopes/targets inside the signed fence → `scope-escape` / `invalid-value`
12
+ // …and, on a step that NARROWED the fence with `scope`, the step's WRITES are
13
+ // judged against that narrowed prefix too → `step-scope-escape` (the runner
14
+ // gates the step from it, so an in-fence-but-out-of-scope target is a write
15
+ // denied on every attempt with no red anywhere — W3). Exits are observations,
16
+ // not writes: only a `tree-changed` scope DISJOINT from the step's scope is
17
+ // unsatisfiable, and the artifact paths are not constrained at all
12
18
  // exits from the closed menu only → `exit-illegal` (arbiter
13
19
  // inexpressibility: an exit is declarative data the shell evaluates with
14
20
  // its own fixed code — `run` cannot be laundered through it)
15
- // check references resolve against the SIGNED checks menu → `check-unknown`
21
+ // check references resolve against the menu DERIVED from the signed close → `check-unknown`
16
22
  // (decision 1: a check the spec doesn't sign does not exist)
17
23
  //
18
24
  // Like its siblings (validate.js, job.js) it never throws on JSON text or
19
25
  // plain parsed data; every failure is a named {code, path, detail} red.
20
26
 
21
- import { TOOL_MENU, LOCKED_TOOLS } from './job.js';
27
+ import { TOOL_MENU, LOCKED_TOOLS, WRITE_VERBS, checkMenu } from './job.js';
22
28
  import { globToPrefix, scopeContained, isObj, isNonEmptyString, sweepSecretLiterals } from './validate.js';
23
29
 
24
30
  /** the closed exit menu (PRD v1.12 §3 + decision 1's `check-passes`): the
@@ -33,14 +39,71 @@ export const MAX_EXITS_PER_STEP = 2;
33
39
  export const MAX_PLAN_STEPS = 8;
34
40
  /** the write-class verbs (BA-13: `edit` is judged by the same fence as
35
41
  * `write`) — a step granting one is a WRITE step for the F17 pairing rule
36
- * and the v1.18 target requirement */
37
- export const WRITE_VERBS = Object.freeze(['write', 'edit']);
42
+ * and the v1.18 target requirement. RE-EXPORTED from job.js, never re-declared:
43
+ * ONE inventory (the SECRET_PATTERNS precedent) — a second copy lets a third
44
+ * write-class verb land in one list and stay invisible to the other. */
45
+ export { WRITE_VERBS };
46
+ /** scope-menu bound: the menu is a PROMPT ingredient on every draft and
47
+ * redraft, so an unbounded repo would price the drafting call by its directory
48
+ * count. Shallow entries survive the cap (see `legalScopes`) because they are
49
+ * the useful ones — a deep leaf is reachable via its parent. */
50
+ export const MAX_SCOPE_MENU = 24;
51
+
52
+ /**
53
+ * The MENU of legal `tree-changed` scopes — choose-don't-describe (design record
54
+ * §4, hamr's correction: *"did agent choose from a list or not?"*).
55
+ *
56
+ * F60 measured 13 of 18 validator reds as ONE class: the agent writes
57
+ * `scope: "src/*.js"`, the grammar accepts only a trailing `/**` or `/*`, and
58
+ * each red costs a redraft call. Teaching the rule in the prompt or widening the
59
+ * grammar both leave the agent authoring a free-text string and guessing its
60
+ * shape. Enumerating instead makes an illegal scope INEXPRESSIBLE.
61
+ *
62
+ * This is the only producer of scope menus, and it filters by the signed fence —
63
+ * so a menu entry that escapes containment cannot be constructed, rather than
64
+ * being caught by a downstream check. `globToPrefix` is untouched: this changes
65
+ * what the agent is OFFERED, never what the fence ENFORCES.
66
+ *
67
+ * @param {string[]} writeScope the signed fence entries (always offerable — they
68
+ * cover directories a step has not created yet, which `snapshotScope` treats
69
+ * as an empty snapshot)
70
+ * @param {string[]} [dirs] workdir-relative directories discovered under the
71
+ * fence; each becomes `<dir>/**`
72
+ * @param {number} [cap] the menu bound; `Infinity` asks for the UNCAPPED set, so
73
+ * a caller can report how many entries the cap dropped (no silent caps — a menu
74
+ * that reads as complete when it is not is the blind-instrument class)
75
+ * @returns {string[]} the offerable scopes, deduped, shallowest-first, capped
76
+ */
77
+ export function legalScopes(writeScope, dirs = [], cap = MAX_SCOPE_MENU) {
78
+ const fence = writeScope.filter(isNonEmptyString).map(globToPrefix).filter((p) => p !== '');
79
+ const inside = (/** @type {string} */ p) => fence.some((f) => p === f || p.startsWith(f + '/'));
80
+ /** @type {string[]} */
81
+ const prefixes = [];
82
+ for (const p of [...fence, ...dirs.filter(isNonEmptyString).map(globToPrefix)]) {
83
+ // scopeContained rejects absolutes, drive letters, backslashes and any ".."
84
+ // segment; `inside` rejects a contained-but-out-of-fence dir. Both, in that
85
+ // order, on the NORMALIZED prefix.
86
+ if (!scopeContained(p) || !inside(p) || prefixes.includes(p)) continue;
87
+ prefixes.push(p);
88
+ }
89
+ // depth first, then lexical — a stable order so the same repo yields the same
90
+ // menu (and the same prompt) across drafts, and so the cap drops leaves
91
+ // rather than roots.
92
+ prefixes.sort((a, b) => a.split('/').length - b.split('/').length || a.localeCompare(b));
93
+ return prefixes.slice(0, cap).map((p) => `${p}/**`);
94
+ }
38
95
  const PLAN_FIELDS = ['schema', 'steps'];
39
96
  // decision 7: NO dependsOn — v1 is strictly sequential, array order IS the
40
97
  // order; a field nothing consumes is a live-looking knob with zero effect
41
98
  // (the F16 inert-op class). The arbiter's own vocabulary (close, budget,
42
99
  // fence) is absent by construction — unknown-field at every depth.
43
- const STEP_FIELDS = ['id', 'action', 'tools', 'rounds', 'target', 'exit'];
100
+ const STEP_FIELDS = ['id', 'action', 'tools', 'rounds', 'target', 'exit', 'model', 'attempts', 'scope'];
101
+ /** P (design record 2026-07-28): the signed per-step model-tier menu. Closed by
102
+ * construction — the agent picks a tier, never names an arbitrary model string
103
+ * (the tier→model mapping and any effort params are the RUNNER's, per provider).
104
+ * opus is deliberately absent: reserved for fresh builds hamr explicitly assigns,
105
+ * never plan-selectable. */
106
+ export const STEP_MODELS = Object.freeze(['sonnet', 'haiku']);
44
107
  const EXIT_FIELDS = {
45
108
  'artifact-written': ['type', 'path', 'pattern'],
46
109
  'tree-changed': ['type', 'scope'],
@@ -105,7 +168,22 @@ export function hasNestedQuantifier(src) {
105
168
  if (c === '[') { // character class: quantifier chars inside are literals
106
169
  i++;
107
170
  if (src[i] === '^') i++;
108
- if (src[i] === ']') i++; // a leading ] is a literal member, not the close
171
+ // NO POSIX leading-`]`-is-a-literal rule: JS is not POSIX here. `[]` is the
172
+ // EMPTY class (matches nothing) and `[^]` the any-char idiom — both close at
173
+ // that first `]`. Skipping it ran the scan past the class's real end and
174
+ // swallowed every quantifier after it, so `x[^](a+)+$` read as safe while
175
+ // RegExp.test on it does not finish in 15s on a 31-char body. A false
176
+ // NEGATIVE is the dangerous direction (F49), so the scan follows JS.
177
+ //
178
+ // Honest limit on the monotonicity claim: this is a PARSE correction, not
179
+ // the "add rejections only" tightening F49's rule contemplates, and it is
180
+ // not strictly rejection-monotonic. Measured over 14,862 compilable
181
+ // generated patterns (2,369 previously rejected): 2 rejections disappear,
182
+ // both pathological `[]`-bearing shapes whose old `true` came from the very
183
+ // misparse being fixed, and both verified to finish RegExp.test in ~0ms.
184
+ // Every entry of the checked-in REDOS_BAD/GOOD/OVERREJECTED corpus is
185
+ // unchanged. So no real hazard was un-rejected — but the claim is
186
+ // "measured, no hazard lost", never "monotonic by construction".
109
187
  while (i < src.length && src[i] !== ']') { if (src[i] === '\\') i++; i++; }
110
188
  continue;
111
189
  }
@@ -132,6 +210,35 @@ export function hasNestedQuantifier(src) {
132
210
  return false;
133
211
  }
134
212
 
213
+ /**
214
+ * The close, as the ordered STAGE LIST the plan flow actually executes (PRD
215
+ * v1.28). A staged close is already that list; the legacy object form is the
216
+ * ONE-stage list it stands for, named `close`. Anything else — a gold, rubric or
217
+ * hitl close, which names no command to run — is null, and the runner escalates
218
+ * `close-unsupported` before any tokens.
219
+ *
220
+ * W4 — this is the ONE staging, and it exists because there used to be two. The
221
+ * runner synthesized the single stage and derived its check menu from THAT, while
222
+ * `planPrompt` and `validatePlan` called `checkMenu` on the RAW spec — where an
223
+ * object close is not an array, so the menu came back empty. The result was a
224
+ * runner announcing and preflighting a stage the drafter was never offered and the
225
+ * validator would have redded `check-unknown`. Same class as the two-transforms
226
+ * defects before it: the fix is one derivation every consumer calls, not three
227
+ * copies kept in step by comment.
228
+ *
229
+ * Lives here rather than beside `checkMenu` in job.js only because job.js owns the
230
+ * SIGNED vocabulary and this is the consumer-side reading of it; `plan.js` is the
231
+ * lowest module every check-menu consumer already imports (planrun imports plan,
232
+ * never the reverse), so it is the one place all three can share without a cycle.
233
+ * @param {any} close the spec's `close` field, any shape
234
+ * @returns {any[]|null} the stage list, or null when the close names no command
235
+ */
236
+ export function stageClose(close) {
237
+ if (Array.isArray(close)) return close;
238
+ if (!isObj(close) || close.type !== 'predicate') return null;
239
+ return [{ name: 'close', cmd: close.cmd, expect: close.expect, judged: close.judged, gapKeep: close.gapKeep }];
240
+ }
241
+
135
242
  /** @typedef {{code: string, path: string, detail?: string, verb?: string}} Red */
136
243
 
137
244
  /**
@@ -139,14 +246,31 @@ export function hasNestedQuantifier(src) {
139
246
  * job spec. Never throws on JSON text or plain parsed data; every failure is
140
247
  * a named red. Returns the parsed plan on ok (single parse), null on any red.
141
248
  * @param {object|string} input parsed plan, or raw JSON text (parse failures are a red)
142
- * @param {{ job?: any, maxStepRounds?: number }} [opts] `job`: the
249
+ * @param {{ job?: any, maxStepRounds?: number, scopes?: string[], seedRed?: string[], priorChecks?: string[] }} [opts] `job`: the
143
250
  * validateJob-GREEN four-field spec (the ceiling, the fence, and the checks
144
251
  * menu all come from it — a missing or non-plan-shape job fails CLOSED);
145
252
  * `maxStepRounds`: the shell's per-step rounds ceiling (interpret's
146
- * tool-mode TURNS_PER_ATTEMPT) — a step may tighten it, never exceed it.
253
+ * tool-mode TURNS_PER_ATTEMPT) — a step may tighten it, never exceed it;
254
+ * `scopes`: the offered `tree-changed` menu from `legalScopes` (the SAME array
255
+ * the drafting prompt enumerated). Omitted, it derives from the signed
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).
147
271
  * @returns {{ ok: boolean, reds: Red[], plan: object|null }}
148
272
  */
149
- export function validatePlan(input, { job, maxStepRounds = 40 } = {}) {
273
+ export function validatePlan(input, { job, maxStepRounds = 40, scopes, seedRed, priorChecks } = {}) {
150
274
  /** @type {Red[]} */
151
275
  const reds = [];
152
276
  /** @type {(code: string, path: string, detail?: string) => void} */
@@ -155,15 +279,36 @@ export function validatePlan(input, { job, maxStepRounds = 40 } = {}) {
155
279
  // The signed side, fail-CLOSED (the validate.js fence-invalid pattern): the
156
280
  // ceiling/fence/menu are meaningless without a plan-shape spec, and an open
157
281
  // gate on a malformed one would validate a plan against nothing.
158
- if (!isObj(job) || !isNonEmptyString(/** @type {any} */ (job).goal) || !Array.isArray(/** @type {any} */ (job).writeScope)) {
159
- return { ok: false, reds: [{ code: 'job-invalid', path: 'job', detail: 'a plan validates only against a validateJob-green plan shape spec (goal/verdictType/close/checks[]) — validate the job first' }], plan: null };
282
+ // The writeScope's MEMBERS are checked here too, not just its arrayness:
283
+ // `globToPrefix` below calls `.replace` on each one, so a non-string member
284
+ // threw `TypeError: scope.replace is not a function` out of a function whose
285
+ // contract is that plain data only ever yields a named red. Same
286
+ // `isNonEmptyString` question `legalScopes` already asks of the same field.
287
+ if (!isObj(job) || !isNonEmptyString(/** @type {any} */ (job).goal) || !Array.isArray(/** @type {any} */ (job).writeScope)
288
+ || !(/** @type {any[]} */ (/** @type {any} */ (job).writeScope)).every(isNonEmptyString)) {
289
+ return { ok: false, reds: [{ code: 'job-invalid', path: 'job', detail: 'a plan validates only against a validateJob-green plan shape spec (goal/verdictType/close) — validate the job first' }], plan: null };
160
290
  }
161
291
  const spec = /** @type {Record<string, any>} */ (job);
162
292
  /** the signed tool ceiling — validateJob permits omission, meaning the full menu */
163
293
  const ceiling = Array.isArray(spec.tools) ? spec.tools : [...TOOL_MENU];
164
294
  const fence = spec.writeScope.map(globToPrefix);
165
295
  const insideFence = (/** @type {string} */ p) => fence.some((f) => p === f || p.startsWith(f + '/'));
166
- const checkNames = Array.isArray(spec.checks) ? spec.checks.map((/** @type {any} */ c) => c?.name).filter(isNonEmptyString) : [];
296
+ // The check menu DERIVES from the close's stages (PRD v1.28) — the agent
297
+ // references a piece of the operator's own inspection, never a ruler someone
298
+ // hand-carved beside it. One derivation, shared with the runner, so what the
299
+ // prompt offers and what the validator accepts cannot drift apart — which means
300
+ // reading the close through `stageClose`, the same staging the runner EXECUTES
301
+ // (W4: reading the raw field here made the legacy object form's stage invisible
302
+ // to the validator and the prompt while the runner ran it).
303
+ // `?? []` is the null case spelled out: a close that names no command to run
304
+ // (gold/rubric/hitl) stages nothing, so it offers nothing — the runner
305
+ // escalates `close-unsupported` on the same null, and an empty menu is already
306
+ // the honest reading here (every check-passes becomes a check-unknown).
307
+ const checkNames = checkMenu(stageClose(spec.close) ?? []).map((m) => m.name);
308
+ // The offered scope menu. A caller-supplied menu must be the same one the
309
+ // prompt enumerated; with none, derive from the signed fence — fail-CLOSED,
310
+ // so omitting the option narrows the agent's choices and never widens them.
311
+ const scopeMenu = Array.isArray(scopes) && scopes.length ? scopes : legalScopes(spec.writeScope);
167
312
 
168
313
  let p = input;
169
314
  if (typeof p === 'string') {
@@ -188,6 +333,14 @@ export function validatePlan(input, { job, maxStepRounds = 40 } = {}) {
188
333
  } else if (plan.steps.length > MAX_PLAN_STEPS) {
189
334
  red('bounds', 'steps', `max ${MAX_PLAN_STEPS} steps — per-step rounds bound the spend, the step count bounds the claim on the wallet`);
190
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) : [];
191
344
  const seen = new Set();
192
345
  plan.steps.forEach((/** @type {any} */ s, /** @type {number} */ i) => {
193
346
  const at = `steps.${i}`;
@@ -205,6 +358,7 @@ export function validatePlan(input, { job, maxStepRounds = 40 } = {}) {
205
358
  // verb as structured data (the ledger counts overreach per verb) — an
206
359
  // unknown string stays a typo (invalid-value), never an escape
207
360
  let writeStep = false;
361
+ let toolsParsed = false;
208
362
  if (s.tools === undefined) red('missing-required', `${at}.tools`, 'every step declares its grant — the narrowed menu is the step boundary');
209
363
  else if (!(Array.isArray(s.tools) && s.tools.length > 0
210
364
  && s.tools.every((/** @type {unknown} */ t) => typeof t === 'string')
@@ -224,6 +378,7 @@ export function validatePlan(input, { job, maxStepRounds = 40 } = {}) {
224
378
  reds.push({ code: 'verb-escape', path: `${at}.tools`, verb: t, detail: `"${t}" is outside the signed ceiling [${ceiling.join(', ')}] — the plan may narrow the grant, never widen it` });
225
379
  }
226
380
  writeStep = s.tools.some((/** @type {string} */ t) => WRITE_VERBS.includes(t));
381
+ toolsParsed = true;
227
382
  }
228
383
 
229
384
  // rounds ≤ the shell cap (cap-not-estimate; the step bound IS maxTurns)
@@ -231,6 +386,81 @@ export function validatePlan(input, { job, maxStepRounds = 40 } = {}) {
231
386
  red('bounds', `${at}.rounds`, `integer 1..${maxStepRounds} — the step bound is the Gate's maxTurns, it may tighten the shell cap, never exceed it`);
232
387
  }
233
388
 
389
+ // P (design record 2026-07-28): the widened vocabulary — every field
390
+ // optional, every field tighten-only, every legal set handed over as a
391
+ // MENU (choose-don't-describe: illegal is inexpressible, never described)
392
+ if (s.model !== undefined && !STEP_MODELS.includes(s.model)) {
393
+ red('invalid-value', `${at}.model`, `menu: ${STEP_MODELS.join('|')} — the tier menu is signed; the agent picks a tier, never names a model`);
394
+ }
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)');
413
+ }
414
+ if (s.scope !== undefined && !scopeMenu.includes(s.scope)) {
415
+ red('invalid-value', `${at}.scope`, `menu: [${scopeMenu.join(', ')}] — a per-step write scope narrows the fence from the same menu tree-changed uses`);
416
+ }
417
+ // W3 — a step that NARROWS the fence is gated by its own scope, not the
418
+ // signed one: planrun's mkWorker builds this step's Gate writeScope from
419
+ // `globToPrefix(scope)`. So a TARGET that is in-fence but OUTSIDE the step's
420
+ // own scope is a write the gate denies on every attempt — the step burns its
421
+ // whole attempt budget on refusals and no red is raised anywhere, because
422
+ // each half was individually legal and nothing checked the PAIR. Checking it
423
+ // here makes the incoherent pair inexpressible at the validation gate rather
424
+ // than discovered at the runtime one.
425
+ //
426
+ // The rule binds WRITES, not OBSERVATIONS. An exit is evaluated by the shell
427
+ // (src/exits.js), never written by the worker, so the step's gate constrains
428
+ // it only where the evaluator's own semantics say so — measured, not assumed:
429
+ // - `tree-changed` passes iff ANY file under ITS scope changed, so a scope
430
+ // that CONTAINS the step's scope contains the step's writable ground and
431
+ // fires exactly when the narrow one does. Only a DISJOINT scope is
432
+ // unsatisfiable: it can pass only if ground this gate refuses changed.
433
+ // Hence overlap, not containment (and the menu is shallowest-first while
434
+ // the prompt says copy one value, so the widest scope is the LIKELIEST
435
+ // draft — containment here would tax the natural plan, the exact F60
436
+ // class `legalScopes` exists to eliminate).
437
+ // - `artifact-written`/`json-valid` read and parse the named file and ask
438
+ // nothing about who wrote it, so a path outside the step's scope naming a
439
+ // PRIOR step's artifact is legal AND satisfiable (v1 is strictly
440
+ // sequential — that composition is the natural shape). No step-scope red
441
+ // belongs on those arms: it would claim a constraint the evaluator does
442
+ // not impose. A path nothing ever produces still fails at runtime, with
443
+ // the mechanical "<path> was not written (does not exist)" gap.
444
+ //
445
+ // Fence FIRST, narrowed scope SECOND (see the target/scope arms): the menu is
446
+ // fence-filtered by `legalScopes`, so containment in the scope normally
447
+ // implies containment in the fence — but a caller-supplied menu is data, and
448
+ // deriving the fence check FROM it would let a bad menu widen what is
449
+ // accepted. Kept as two ordered checks, the narrowed one can only tighten.
450
+ // An OFF-MENU scope carries no narrowing (it already redded above): one
451
+ // defect, one red — a second red derived from a value the agent cannot use
452
+ // would charge the ledger twice for the same mistake.
453
+ const stepScope = s.scope !== undefined && scopeMenu.includes(s.scope) ? s.scope : null;
454
+ const stepPrefix = stepScope === null ? null : globToPrefix(stepScope);
455
+ /** contained by the step's OWN scope — vacuously true on an unscoped step,
456
+ * which is what keeps every pre-W3 plan byte-identical. The WRITE test. */
457
+ const insideStepScope = (/** @type {string} */ p) => stepPrefix === null
458
+ || p === stepPrefix || p.startsWith(stepPrefix + '/');
459
+ /** shares ground with the step's own scope — either prefix contains the
460
+ * other. The OBSERVATION test: what `tree-changed` needs to be satisfiable. */
461
+ const overlapsStepScope = (/** @type {string} */ p) => stepPrefix === null
462
+ || p === stepPrefix || p.startsWith(stepPrefix + '/') || stepPrefix.startsWith(p + '/');
463
+
234
464
  // target (v1.18): the per-step deliverable — required on write steps,
235
465
  // always inside the signed fence when present
236
466
  if (s.target === undefined || s.target === '') {
@@ -239,10 +469,30 @@ export function validatePlan(input, { job, maxStepRounds = 40 } = {}) {
239
469
  red('invalid-value', `${at}.target`, 'a relative path inside the run dir — no absolute paths, no ".." segments');
240
470
  } else if (!insideFence(globToPrefix(s.target))) {
241
471
  red('scope-escape', `${at}.target`, `"${s.target}" is outside the signed fence [${spec.writeScope.join(', ')}]`);
472
+ } else if (!insideStepScope(globToPrefix(s.target))) {
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`);
242
474
  }
243
475
 
244
- validateExit(s, at, red, { checkNames, fence: spec.writeScope, insideFence, writeStep });
476
+ validateExit(s, at, red, { checkNames, fence: spec.writeScope, insideFence, writeStep, toolsParsed, scopeMenu, stepScope, overlapsStepScope, seedRedNames, isFinalWrite: i === lastWriteIdx });
245
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
+ }
246
496
  }
247
497
 
248
498
  // 3. secrets sweep — the agent-authored document is the riskier entry point
@@ -258,9 +508,15 @@ export function validatePlan(input, { job, maxStepRounds = 40 } = {}) {
258
508
  * @param {Record<string, any>} s the step
259
509
  * @param {string} at step path prefix
260
510
  * @param {(code: string, path: string, detail?: string) => void} red
261
- * @param {{ checkNames: string[], fence: string[], insideFence: (p: string) => boolean, writeStep: 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
512
+ * `stepScope`/`overlapsStepScope` (W3): the step's OWN narrowed fence when it
513
+ * declared a legal `scope`, null/always-true otherwise. Exits are OBSERVATIONS,
514
+ * not writes, so only `tree-changed` is constrained by it and only against
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.
262
518
  */
263
- function validateExit(s, at, red, { checkNames, fence, insideFence, writeStep }) {
519
+ function validateExit(s, at, red, { checkNames, fence, insideFence, writeStep, toolsParsed, scopeMenu, stepScope, overlapsStepScope, seedRedNames, isFinalWrite }) {
264
520
  if (!Array.isArray(s.exit) || s.exit.length === 0) {
265
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`);
266
522
  return;
@@ -286,16 +542,60 @@ function validateExit(s, at, red, { checkNames, fence, insideFence, writeStep })
286
542
  else if (!checkNames.includes(e.name)) {
287
543
  // decision 1: a check the spec doesn't sign DOES NOT EXIST — and the
288
544
  // detail names the signed menu so the replan can aim, not guess
289
- red('check-unknown', eAt, `"${e.name}" is not a signed check — the agent references checks, never authors them; signed menu: [${checkNames.join(', ') || 'none'}]`);
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`);
290
556
  }
291
557
  } else if (e.type === 'tree-changed') {
292
558
  hasTreeChanged = true;
293
- if (!isNonEmptyString(e.scope) || !scopeContained(e.scope) || globToPrefix(e.scope).includes('*')) {
294
- red('invalid-value', `${eAt}.scope`, 'a contained scope (trailing /** or /* only, no ".." or absolute)');
295
- } else if (!insideFence(globToPrefix(e.scope))) {
296
- red('scope-escape', `${eAt}.scope`, `"${e.scope}" is outside the signed fence [${fence.join(', ')}]`);
559
+ // choose-don't-describe: membership of the enumerated menu, NOT a shape
560
+ // grammar the agent has to guess (§4; F60's 72%-of-all-reds class). The
561
+ // menu derives from the signed writeScope when the caller passes none, so
562
+ // omitting the option can never restore free-text containment — a
563
+ // silently-ignored optional param is the F50 blind-instrument class. Every
564
+ // menu comes from `legalScopes`, which filters by the fence, so
565
+ // scope-escape here is inexpressible rather than checked.
566
+ // An off-menu scope splits by CAUSE, because the two carry different
567
+ // meaning to the ledger's class table: reaching OUTSIDE the signed fence is
568
+ // a behaviour signal about the agent (`scope-escape`, same code the target
569
+ // and path arms raise), while an in-fence value that simply is not offered
570
+ // is drafting friction (`invalid-value`, and the detail carries the menu so
571
+ // the redraft can choose instead of guess). Collapsing them would launder an
572
+ // attempted fence escape into a typo class.
573
+ if (!isNonEmptyString(e.scope) || !scopeMenu.includes(e.scope)) {
574
+ const contained = isNonEmptyString(e.scope) && scopeContained(e.scope);
575
+ if (contained && !insideFence(globToPrefix(e.scope))) {
576
+ red('scope-escape', `${eAt}.scope`, `"${e.scope}" is outside the signed fence [${fence.join(', ')}]`);
577
+ } else {
578
+ red('invalid-value', `${eAt}.scope`, `one of the offered scopes: [${scopeMenu.join(', ')}]`);
579
+ }
580
+ } else if (!overlapsStepScope(globToPrefix(e.scope))) {
581
+ // W3: an offered, in-fence scope can still be DISJOINT from the step's own
582
+ // narrowed fence. Measured against src/exits.js: `tree-changed` passes iff
583
+ // some file under ITS scope changed against the pre-step snapshot, and that
584
+ // snapshot already holds every earlier step's bytes — so a disjoint scope
585
+ // can only pass if ground this step's gate refuses changed. Unsatisfiable on
586
+ // every attempt, which is exactly the burn W3 closes.
587
+ // A WIDER (containing) scope is NOT redded: it contains the step's writable
588
+ // ground and fires exactly when the narrow one does.
589
+ red('step-scope-escape', `${eAt}.scope`, `"${e.scope}" is disjoint from this step's own scope "${stepScope}" — the step's gate allows writes only under "${stepScope}", and this exit passes only if a file under "${e.scope}" changed, so no write this step can make would ever satisfy it (a scope that contains "${stepScope}" is fine)`);
297
590
  }
298
591
  } else { // artifact-written | json-valid — a named file path inside the fence
592
+ // NO step-scope check here, deliberately (W3): src/exits.js reads and parses
593
+ // the named file and asks nothing about who wrote it, so a path outside the
594
+ // step's own scope naming a PRIOR step's artifact is legal AND satisfiable —
595
+ // and v1 is strictly sequential, so that composition is the natural shape.
596
+ // A red would claim a constraint the evaluator does not impose. A path nothing
597
+ // ever produces still fails at runtime with the mechanical "was not written"
598
+ // gap — the honest direction, versus taxing a legal draft with a redraft.
299
599
  if (!isNonEmptyString(e.path) || !scopeContained(e.path)) {
300
600
  red('invalid-value', `${eAt}.path`, 'a relative file path inside the run dir — no absolute paths, no ".." segments');
301
601
  } else if (!insideFence(globToPrefix(e.path))) {
@@ -324,4 +624,18 @@ function validateExit(s, at, red, { checkNames, fence, insideFence, writeStep })
324
624
  if (writeStep && hasCheck && !hasTreeChanged) {
325
625
  red('exit-illegal', `${at}.exit`, 'check-passes on a write-granted step requires the tree-changed conjunct — the seed tree is green, so a lone check would pass on the untouched repo (F17/F46 already-green trap)');
326
626
  }
627
+ // The inverse trap, measured (runs ms4l5p6w/ms57zr7c: 4 of 4 drafted plans):
628
+ // a failing check's gap is re-delivered to THIS step's own worker, so a step
629
+ // holding a check must be able to act on it. A read-only "verify" step is a
630
+ // mailbox with no hands — the gap arrives, nothing can edit, the loop stalls
631
+ // to cap on a byte-identical gap. The outer close is the run's verification;
632
+ // forbidding this shape pushes the check onto the step that fixes (the one
633
+ // shape that has ever greened this job).
634
+ // …and only when the grant PARSED: with `tools` missing/malformed the step's
635
+ // hands are unknowable, and a mailbox red derived from the false default would
636
+ // charge the ledger with a violation the agent never committed (the real
637
+ // defect already redded as missing-required/invalid-value). One defect, one red.
638
+ if (toolsParsed && !writeStep && hasCheck) {
639
+ red('exit-illegal', `${at}.exit`, `check-passes on a step with no write-class tool (${WRITE_VERBS.join('|')}) — the check's failure gap comes back to this step's own worker, which cannot edit; attach the check to the step that fixes (the operator's close already verifies the finished run)`);
640
+ }
327
641
  }