bareloop 0.5.0 → 0.6.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/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'],
@@ -49,6 +112,133 @@ const EXIT_FIELDS = {
49
112
  };
50
113
  const SLUG_RE = /^[a-z0-9][a-z0-9-]*$/;
51
114
 
115
+ /** Length of an UNBOUNDED quantifier token at `src[i]` (`*`, `+`, or `{n,}`,
116
+ * plus an optional trailing lazy `?`), or 0. Bounded forms (`?`, `{n}`,
117
+ * `{n,m}`) return 0: they cannot drive exponential backtracking (a bounded
118
+ * outer repeat is polynomial at worst, and the body is self-authored — F49).
119
+ * @param {string} src @param {number} i */
120
+ function unboundedQuantLen(src, i) {
121
+ let len = src[i] === '*' || src[i] === '+' ? 1 : 0;
122
+ if (!len && src[i] === '{') { const m = /^\{\d+,\}/.exec(src.slice(i)); if (m) len = m[0].length; }
123
+ if (len && src[i + len] === '?') len++; // the lazy modifier is part of the same token
124
+ return len;
125
+ }
126
+
127
+ /**
128
+ * F49 — a heuristic reject for the catastrophic-backtracking footgun: an
129
+ * unbounded quantifier applied to a group whose body itself repeats unboundedly
130
+ * (`(a+)+`, `(\d*)*`, `([a-z]+){1,}`) — including through a redundant wrapping
131
+ * group (`((a+))+`, `(?:(a+))*`), which is the SAME exponential class as `(a+)+`
132
+ * and is caught by propagating the inner repeat up through the wrapper. Such a
133
+ * pattern can hang `RegExp.test` for
134
+ * seconds on a short crafted body, and `evalExits` runs the agent-authored
135
+ * `artifact-written` pattern with NO timeout. This is self-DoS only — the agent
136
+ * authors both the pattern and (via the worker) the artifact, so a hang burns
137
+ * only its own run's wall-clock; there is NO arbiter compromise (it cannot
138
+ * escape the fence, forge a green, or leak a secret). So the reject targets the
139
+ * dominant exponential class and fails it as a mechanical gap at the validation
140
+ * gate, before any tokens burn. A full ReDoS analyzer needs a real regex engine
141
+ * (an external native dep we do not take for a LOW issue); exotic
142
+ * overlapping-alternation blowup is out of scope by decision.
143
+ *
144
+ * SCOPE IS ASYMMETRIC, both directions named on purpose:
145
+ * - false NEGATIVE: overlapping-alternation blowup (`(a|ab)+`-class) is not
146
+ * detected — out of scope (self-DoS only, no arbiter compromise).
147
+ * - false POSITIVE: a group whose repetitions are disambiguated by a literal
148
+ * anchor/delimiter (`(?:^- .+$\n?)+`, `(?:CHANGELOG:.+\n)+`) is FLAGGED even
149
+ * though a real engine runs it linearly — the scan sees the nested-quantifier
150
+ * SHAPE, not the disambiguation. Rejecting it is the FAIL-SAFE direction (it
151
+ * never admits an unsafe pattern), and the cost is bounded: the plan drafter
152
+ * gets a mechanical gap and rewrites (drop the outer `+`, or match once).
153
+ * Detecting "safe because anchored" needs the same real engine we declined,
154
+ * and guessing it wrong would ADMIT an exponential pattern — so the shape
155
+ * reject stands, and the over-rejection is a named, accepted limitation.
156
+ *
157
+ * The input is guaranteed to compile as a RegExp (checked first by the caller),
158
+ * so this scan assumes valid, balanced JS regex syntax.
159
+ * @param {string} src a compiled regex source string
160
+ * @returns {boolean} true iff a nested unbounded quantifier is present
161
+ */
162
+ export function hasNestedQuantifier(src) {
163
+ /** @type {{ quant: boolean }[]} */
164
+ const stack = [];
165
+ for (let i = 0; i < src.length; i++) {
166
+ const c = src[i];
167
+ if (c === '\\') { i++; continue; } // escaped atom — the next char is a literal
168
+ if (c === '[') { // character class: quantifier chars inside are literals
169
+ i++;
170
+ if (src[i] === '^') i++;
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".
187
+ while (i < src.length && src[i] !== ']') { if (src[i] === '\\') i++; i++; }
188
+ continue;
189
+ }
190
+ if (c === '(') { stack.push({ quant: false }); continue; }
191
+ if (c === ')') {
192
+ const g = stack.pop();
193
+ const qlen = unboundedQuantLen(src, i + 1);
194
+ if (g && g.quant && qlen) return true; // group repeats unboundedly AND its body did too
195
+ // The group is an unbounded-repeated atom of its parent when it is directly
196
+ // re-quantified (qlen) OR its own body already repeats unboundedly (g.quant):
197
+ // a redundant wrapper — ((a+))+ , (?:(a+))* , (((\d*)))+ — is the SAME
198
+ // exponential class as (a+)+, so an inner repeat must propagate THROUGH the
199
+ // enclosing group or the outer quantifier's close never sees it. Propagation
200
+ // is MONOTONIC (it only ever SETS quant=true → only ever adds rejections): it
201
+ // can widen over-rejection — the named FAIL-SAFE direction — but can never
202
+ // introduce a false negative, which is the dangerous one (F49).
203
+ if ((qlen || (g && g.quant)) && stack.length) stack[stack.length - 1].quant = true;
204
+ i += qlen;
205
+ continue;
206
+ }
207
+ const qlen = unboundedQuantLen(src, i); // a quantifier applying to the preceding atom, inside the current group
208
+ if (qlen) { if (stack.length) stack[stack.length - 1].quant = true; i += qlen - 1; }
209
+ }
210
+ return false;
211
+ }
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
+
52
242
  /** @typedef {{code: string, path: string, detail?: string, verb?: string}} Red */
53
243
 
54
244
  /**
@@ -56,14 +246,17 @@ const SLUG_RE = /^[a-z0-9][a-z0-9-]*$/;
56
246
  * job spec. Never throws on JSON text or plain parsed data; every failure is
57
247
  * a named red. Returns the parsed plan on ok (single parse), null on any red.
58
248
  * @param {object|string} input parsed plan, or raw JSON text (parse failures are a red)
59
- * @param {{ job?: any, maxStepRounds?: number }} [opts] `job`: the
249
+ * @param {{ job?: any, maxStepRounds?: number, scopes?: string[], capRuns?: number }} [opts] `job`: the
60
250
  * validateJob-GREEN four-field spec (the ceiling, the fence, and the checks
61
251
  * menu all come from it — a missing or non-plan-shape job fails CLOSED);
62
252
  * `maxStepRounds`: the shell's per-step rounds ceiling (interpret's
63
- * 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).
64
257
  * @returns {{ ok: boolean, reds: Red[], plan: object|null }}
65
258
  */
66
- export function validatePlan(input, { job, maxStepRounds = 40 } = {}) {
259
+ export function validatePlan(input, { job, maxStepRounds = 40, scopes, capRuns = 3 } = {}) {
67
260
  /** @type {Red[]} */
68
261
  const reds = [];
69
262
  /** @type {(code: string, path: string, detail?: string) => void} */
@@ -72,15 +265,36 @@ export function validatePlan(input, { job, maxStepRounds = 40 } = {}) {
72
265
  // The signed side, fail-CLOSED (the validate.js fence-invalid pattern): the
73
266
  // ceiling/fence/menu are meaningless without a plan-shape spec, and an open
74
267
  // gate on a malformed one would validate a plan against nothing.
75
- if (!isObj(job) || !isNonEmptyString(/** @type {any} */ (job).goal) || !Array.isArray(/** @type {any} */ (job).writeScope)) {
76
- 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 };
268
+ // The writeScope's MEMBERS are checked here too, not just its arrayness:
269
+ // `globToPrefix` below calls `.replace` on each one, so a non-string member
270
+ // threw `TypeError: scope.replace is not a function` out of a function whose
271
+ // contract is that plain data only ever yields a named red. Same
272
+ // `isNonEmptyString` question `legalScopes` already asks of the same field.
273
+ if (!isObj(job) || !isNonEmptyString(/** @type {any} */ (job).goal) || !Array.isArray(/** @type {any} */ (job).writeScope)
274
+ || !(/** @type {any[]} */ (/** @type {any} */ (job).writeScope)).every(isNonEmptyString)) {
275
+ 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 };
77
276
  }
78
277
  const spec = /** @type {Record<string, any>} */ (job);
79
278
  /** the signed tool ceiling — validateJob permits omission, meaning the full menu */
80
279
  const ceiling = Array.isArray(spec.tools) ? spec.tools : [...TOOL_MENU];
81
280
  const fence = spec.writeScope.map(globToPrefix);
82
281
  const insideFence = (/** @type {string} */ p) => fence.some((f) => p === f || p.startsWith(f + '/'));
83
- const checkNames = Array.isArray(spec.checks) ? spec.checks.map((/** @type {any} */ c) => c?.name).filter(isNonEmptyString) : [];
282
+ // The check menu DERIVES from the close's stages (PRD v1.28) — the agent
283
+ // references a piece of the operator's own inspection, never a ruler someone
284
+ // hand-carved beside it. One derivation, shared with the runner, so what the
285
+ // prompt offers and what the validator accepts cannot drift apart — which means
286
+ // reading the close through `stageClose`, the same staging the runner EXECUTES
287
+ // (W4: reading the raw field here made the legacy object form's stage invisible
288
+ // to the validator and the prompt while the runner ran it).
289
+ // `?? []` is the null case spelled out: a close that names no command to run
290
+ // (gold/rubric/hitl) stages nothing, so it offers nothing — the runner
291
+ // escalates `close-unsupported` on the same null, and an empty menu is already
292
+ // the honest reading here (every check-passes becomes a check-unknown).
293
+ const checkNames = checkMenu(stageClose(spec.close) ?? []).map((m) => m.name);
294
+ // The offered scope menu. A caller-supplied menu must be the same one the
295
+ // prompt enumerated; with none, derive from the signed fence — fail-CLOSED,
296
+ // so omitting the option narrows the agent's choices and never widens them.
297
+ const scopeMenu = Array.isArray(scopes) && scopes.length ? scopes : legalScopes(spec.writeScope);
84
298
 
85
299
  let p = input;
86
300
  if (typeof p === 'string') {
@@ -122,6 +336,7 @@ export function validatePlan(input, { job, maxStepRounds = 40 } = {}) {
122
336
  // verb as structured data (the ledger counts overreach per verb) — an
123
337
  // unknown string stays a typo (invalid-value), never an escape
124
338
  let writeStep = false;
339
+ let toolsParsed = false;
125
340
  if (s.tools === undefined) red('missing-required', `${at}.tools`, 'every step declares its grant — the narrowed menu is the step boundary');
126
341
  else if (!(Array.isArray(s.tools) && s.tools.length > 0
127
342
  && s.tools.every((/** @type {unknown} */ t) => typeof t === 'string')
@@ -141,6 +356,7 @@ export function validatePlan(input, { job, maxStepRounds = 40 } = {}) {
141
356
  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` });
142
357
  }
143
358
  writeStep = s.tools.some((/** @type {string} */ t) => WRITE_VERBS.includes(t));
359
+ toolsParsed = true;
144
360
  }
145
361
 
146
362
  // rounds ≤ the shell cap (cap-not-estimate; the step bound IS maxTurns)
@@ -148,6 +364,65 @@ export function validatePlan(input, { job, maxStepRounds = 40 } = {}) {
148
364
  red('bounds', `${at}.rounds`, `integer 1..${maxStepRounds} — the step bound is the Gate's maxTurns, it may tighten the shell cap, never exceed it`);
149
365
  }
150
366
 
367
+ // P (design record 2026-07-28): the widened vocabulary — every field
368
+ // optional, every field tighten-only, every legal set handed over as a
369
+ // MENU (choose-don't-describe: illegal is inexpressible, never described)
370
+ if (s.model !== undefined && !STEP_MODELS.includes(s.model)) {
371
+ red('invalid-value', `${at}.model`, `menu: ${STEP_MODELS.join('|')} — the tier menu is signed; the agent picks a tier, never names a model`);
372
+ }
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`);
375
+ }
376
+ if (s.scope !== undefined && !scopeMenu.includes(s.scope)) {
377
+ red('invalid-value', `${at}.scope`, `menu: [${scopeMenu.join(', ')}] — a per-step write scope narrows the fence from the same menu tree-changed uses`);
378
+ }
379
+ // W3 — a step that NARROWS the fence is gated by its own scope, not the
380
+ // signed one: planrun's mkWorker builds this step's Gate writeScope from
381
+ // `globToPrefix(scope)`. So a TARGET that is in-fence but OUTSIDE the step's
382
+ // own scope is a write the gate denies on every attempt — the step burns its
383
+ // whole attempt budget on refusals and no red is raised anywhere, because
384
+ // each half was individually legal and nothing checked the PAIR. Checking it
385
+ // here makes the incoherent pair inexpressible at the validation gate rather
386
+ // than discovered at the runtime one.
387
+ //
388
+ // The rule binds WRITES, not OBSERVATIONS. An exit is evaluated by the shell
389
+ // (src/exits.js), never written by the worker, so the step's gate constrains
390
+ // it only where the evaluator's own semantics say so — measured, not assumed:
391
+ // - `tree-changed` passes iff ANY file under ITS scope changed, so a scope
392
+ // that CONTAINS the step's scope contains the step's writable ground and
393
+ // fires exactly when the narrow one does. Only a DISJOINT scope is
394
+ // unsatisfiable: it can pass only if ground this gate refuses changed.
395
+ // Hence overlap, not containment (and the menu is shallowest-first while
396
+ // the prompt says copy one value, so the widest scope is the LIKELIEST
397
+ // draft — containment here would tax the natural plan, the exact F60
398
+ // class `legalScopes` exists to eliminate).
399
+ // - `artifact-written`/`json-valid` read and parse the named file and ask
400
+ // nothing about who wrote it, so a path outside the step's scope naming a
401
+ // PRIOR step's artifact is legal AND satisfiable (v1 is strictly
402
+ // sequential — that composition is the natural shape). No step-scope red
403
+ // belongs on those arms: it would claim a constraint the evaluator does
404
+ // not impose. A path nothing ever produces still fails at runtime, with
405
+ // the mechanical "<path> was not written (does not exist)" gap.
406
+ //
407
+ // Fence FIRST, narrowed scope SECOND (see the target/scope arms): the menu is
408
+ // fence-filtered by `legalScopes`, so containment in the scope normally
409
+ // implies containment in the fence — but a caller-supplied menu is data, and
410
+ // deriving the fence check FROM it would let a bad menu widen what is
411
+ // accepted. Kept as two ordered checks, the narrowed one can only tighten.
412
+ // An OFF-MENU scope carries no narrowing (it already redded above): one
413
+ // defect, one red — a second red derived from a value the agent cannot use
414
+ // would charge the ledger twice for the same mistake.
415
+ const stepScope = s.scope !== undefined && scopeMenu.includes(s.scope) ? s.scope : null;
416
+ const stepPrefix = stepScope === null ? null : globToPrefix(stepScope);
417
+ /** contained by the step's OWN scope — vacuously true on an unscoped step,
418
+ * which is what keeps every pre-W3 plan byte-identical. The WRITE test. */
419
+ const insideStepScope = (/** @type {string} */ p) => stepPrefix === null
420
+ || p === stepPrefix || p.startsWith(stepPrefix + '/');
421
+ /** shares ground with the step's own scope — either prefix contains the
422
+ * other. The OBSERVATION test: what `tree-changed` needs to be satisfiable. */
423
+ const overlapsStepScope = (/** @type {string} */ p) => stepPrefix === null
424
+ || p === stepPrefix || p.startsWith(stepPrefix + '/') || stepPrefix.startsWith(p + '/');
425
+
151
426
  // target (v1.18): the per-step deliverable — required on write steps,
152
427
  // always inside the signed fence when present
153
428
  if (s.target === undefined || s.target === '') {
@@ -156,9 +431,11 @@ export function validatePlan(input, { job, maxStepRounds = 40 } = {}) {
156
431
  red('invalid-value', `${at}.target`, 'a relative path inside the run dir — no absolute paths, no ".." segments');
157
432
  } else if (!insideFence(globToPrefix(s.target))) {
158
433
  red('scope-escape', `${at}.target`, `"${s.target}" is outside the signed fence [${spec.writeScope.join(', ')}]`);
434
+ } else if (!insideStepScope(globToPrefix(s.target))) {
435
+ 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`);
159
436
  }
160
437
 
161
- validateExit(s, at, red, { checkNames, fence: spec.writeScope, insideFence, writeStep });
438
+ validateExit(s, at, red, { checkNames, fence: spec.writeScope, insideFence, writeStep, toolsParsed, scopeMenu, stepScope, overlapsStepScope });
162
439
  });
163
440
  }
164
441
 
@@ -175,9 +452,13 @@ export function validatePlan(input, { job, maxStepRounds = 40 } = {}) {
175
452
  * @param {Record<string, any>} s the step
176
453
  * @param {string} at step path prefix
177
454
  * @param {(code: string, path: string, detail?: string) => void} red
178
- * @param {{ checkNames: string[], fence: string[], insideFence: (p: string) => boolean, writeStep: boolean }} ctx
455
+ * @param {{ checkNames: string[], fence: string[], insideFence: (p: string) => boolean, writeStep: boolean, toolsParsed: boolean, scopeMenu: string[], stepScope: string|null, overlapsStepScope: (p: string) => boolean }} ctx
456
+ * `stepScope`/`overlapsStepScope` (W3): the step's OWN narrowed fence when it
457
+ * declared a legal `scope`, null/always-true otherwise. Exits are OBSERVATIONS,
458
+ * not writes, so only `tree-changed` is constrained by it and only against
459
+ * DISJOINTness — see the arm for the measured semantics.
179
460
  */
180
- function validateExit(s, at, red, { checkNames, fence, insideFence, writeStep }) {
461
+ function validateExit(s, at, red, { checkNames, fence, insideFence, writeStep, toolsParsed, scopeMenu, stepScope, overlapsStepScope }) {
181
462
  if (!Array.isArray(s.exit) || s.exit.length === 0) {
182
463
  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`);
183
464
  return;
@@ -203,16 +484,50 @@ function validateExit(s, at, red, { checkNames, fence, insideFence, writeStep })
203
484
  else if (!checkNames.includes(e.name)) {
204
485
  // decision 1: a check the spec doesn't sign DOES NOT EXIST — and the
205
486
  // detail names the signed menu so the replan can aim, not guess
206
- red('check-unknown', eAt, `"${e.name}" is not a signed check — the agent references checks, never authors them; signed menu: [${checkNames.join(', ') || 'none'}]`);
487
+ 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'}]`);
207
488
  }
208
489
  } else if (e.type === 'tree-changed') {
209
490
  hasTreeChanged = true;
210
- if (!isNonEmptyString(e.scope) || !scopeContained(e.scope) || globToPrefix(e.scope).includes('*')) {
211
- red('invalid-value', `${eAt}.scope`, 'a contained scope (trailing /** or /* only, no ".." or absolute)');
212
- } else if (!insideFence(globToPrefix(e.scope))) {
213
- red('scope-escape', `${eAt}.scope`, `"${e.scope}" is outside the signed fence [${fence.join(', ')}]`);
491
+ // choose-don't-describe: membership of the enumerated menu, NOT a shape
492
+ // grammar the agent has to guess (§4; F60's 72%-of-all-reds class). The
493
+ // menu derives from the signed writeScope when the caller passes none, so
494
+ // omitting the option can never restore free-text containment — a
495
+ // silently-ignored optional param is the F50 blind-instrument class. Every
496
+ // menu comes from `legalScopes`, which filters by the fence, so
497
+ // scope-escape here is inexpressible rather than checked.
498
+ // An off-menu scope splits by CAUSE, because the two carry different
499
+ // meaning to the ledger's class table: reaching OUTSIDE the signed fence is
500
+ // a behaviour signal about the agent (`scope-escape`, same code the target
501
+ // and path arms raise), while an in-fence value that simply is not offered
502
+ // is drafting friction (`invalid-value`, and the detail carries the menu so
503
+ // the redraft can choose instead of guess). Collapsing them would launder an
504
+ // attempted fence escape into a typo class.
505
+ if (!isNonEmptyString(e.scope) || !scopeMenu.includes(e.scope)) {
506
+ const contained = isNonEmptyString(e.scope) && scopeContained(e.scope);
507
+ if (contained && !insideFence(globToPrefix(e.scope))) {
508
+ red('scope-escape', `${eAt}.scope`, `"${e.scope}" is outside the signed fence [${fence.join(', ')}]`);
509
+ } else {
510
+ red('invalid-value', `${eAt}.scope`, `one of the offered scopes: [${scopeMenu.join(', ')}]`);
511
+ }
512
+ } else if (!overlapsStepScope(globToPrefix(e.scope))) {
513
+ // W3: an offered, in-fence scope can still be DISJOINT from the step's own
514
+ // narrowed fence. Measured against src/exits.js: `tree-changed` passes iff
515
+ // some file under ITS scope changed against the pre-step snapshot, and that
516
+ // snapshot already holds every earlier step's bytes — so a disjoint scope
517
+ // can only pass if ground this step's gate refuses changed. Unsatisfiable on
518
+ // every attempt, which is exactly the burn W3 closes.
519
+ // A WIDER (containing) scope is NOT redded: it contains the step's writable
520
+ // ground and fires exactly when the narrow one does.
521
+ 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)`);
214
522
  }
215
523
  } else { // artifact-written | json-valid — a named file path inside the fence
524
+ // NO step-scope check here, deliberately (W3): src/exits.js reads and parses
525
+ // the named file and asks nothing about who wrote it, so a path outside the
526
+ // step's own scope naming a PRIOR step's artifact is legal AND satisfiable —
527
+ // and v1 is strictly sequential, so that composition is the natural shape.
528
+ // A red would claim a constraint the evaluator does not impose. A path nothing
529
+ // ever produces still fails at runtime with the mechanical "was not written"
530
+ // gap — the honest direction, versus taxing a legal draft with a redraft.
216
531
  if (!isNonEmptyString(e.path) || !scopeContained(e.path)) {
217
532
  red('invalid-value', `${eAt}.path`, 'a relative file path inside the run dir — no absolute paths, no ".." segments');
218
533
  } else if (!insideFence(globToPrefix(e.path))) {
@@ -221,8 +536,15 @@ function validateExit(s, at, red, { checkNames, fence, insideFence, writeStep })
221
536
  if (e.type === 'artifact-written' && e.pattern !== undefined) {
222
537
  if (!isNonEmptyString(e.pattern)) red('invalid-value', `${eAt}.pattern`, 'regex source string');
223
538
  else {
224
- try { new RegExp(e.pattern, 'm'); }
539
+ let compiled = false;
540
+ try { new RegExp(e.pattern, 'm'); compiled = true; }
225
541
  catch { red('invalid-value', `${eAt}.pattern`, 'must compile as a RegExp'); }
542
+ // F49: a compiled-but-catastrophic pattern (nested unbounded
543
+ // quantifier) can hang the untimed exit evaluator — reject it here as
544
+ // a mechanical gap so the replan rewrites it, before any tokens burn.
545
+ if (compiled && hasNestedQuantifier(e.pattern)) {
546
+ red('invalid-value', `${eAt}.pattern`, `nested unbounded quantifier (e.g. (a+)+ , (\\d*)* , (x+){1,}) — a catastrophic-backtracking footgun that can hang the exit evaluator (F49); rewrite without a repeated group inside a repeat`);
547
+ }
226
548
  }
227
549
  }
228
550
  }
@@ -234,4 +556,18 @@ function validateExit(s, at, red, { checkNames, fence, insideFence, writeStep })
234
556
  if (writeStep && hasCheck && !hasTreeChanged) {
235
557
  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)');
236
558
  }
559
+ // The inverse trap, measured (runs ms4l5p6w/ms57zr7c: 4 of 4 drafted plans):
560
+ // a failing check's gap is re-delivered to THIS step's own worker, so a step
561
+ // holding a check must be able to act on it. A read-only "verify" step is a
562
+ // mailbox with no hands — the gap arrives, nothing can edit, the loop stalls
563
+ // to cap on a byte-identical gap. The outer close is the run's verification;
564
+ // forbidding this shape pushes the check onto the step that fixes (the one
565
+ // shape that has ever greened this job).
566
+ // …and only when the grant PARSED: with `tools` missing/malformed the step's
567
+ // hands are unknowable, and a mailbox red derived from the false default would
568
+ // charge the ledger with a violation the agent never committed (the real
569
+ // defect already redded as missing-required/invalid-value). One defect, one red.
570
+ if (toolsParsed && !writeStep && hasCheck) {
571
+ 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)`);
572
+ }
237
573
  }