bareloop 0.5.0 → 0.5.1

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/CHANGELOG.md CHANGED
@@ -7,6 +7,30 @@ feature lands, **patch** = docs, fixes, scaffolding.
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.5.1] — 2026-07-24
11
+
12
+ ### Added
13
+ - **Layer R wired into the plan-v1 flow** (F50). The within-run ratchet (`layerRoot`) now
14
+ engages in `runPlan` — one root per EXECUTE step (red-set = the exit evaluator's own gap)
15
+ AND in the outer close-fix loop (red-set = the close's own `gapKeep`) — not only on the
16
+ legacy `steps[]` path. So a plan-shape job can emit `root-injected` and the pre-registered
17
+ ON-vs-OFF default-flip read becomes possible on the accepted surface. The write-tee is wired
18
+ so same-path target rewrites are visible to the detector. Still OFF by default (`layerRoot:
19
+ false`, F41). Excluded on native/clipipe (no `onToolResult` seam — F48 fallback surface).
20
+
21
+ ### Fixed
22
+ - **F49 (security-hardening): the agent-authored `artifact-written` regex can no longer hang
23
+ the exit evaluator.** `plan.js` rejects nested-quantifier ReDoS patterns (`(a+)+`, `(\d*)*`,
24
+ `(x+){1,}`, and redundant-wrapper forms like `((a+))+` / `(?:(a+))*` / `(((a+)))+`) at the
25
+ validation gate, before any tokens burn. The wrapper class was a review-caught false negative
26
+ in the first (flat-only) scan — each measured to hang `RegExp.test` >8s on ~29 chars — closed
27
+ by propagating an inner repeat up through the enclosing group; the change is MONOTONIC (it only
28
+ ever adds rejections, the fail-safe direction, and cannot introduce a new false negative). LOW
29
+ self-DoS, no arbiter compromise; input-bounding was rejected as theater (a 33-char body hangs)
30
+ and a JS regex timeout as disproportionate (worker/RE2 dep).
31
+ - **F50: `runJob` no longer silently ignores `layerRoot` for plan-shape jobs** (was accepted
32
+ and dropped — an advertised no-op).
33
+
10
34
  ## [0.5.0] — 2026-07-23
11
35
 
12
36
  ### Added
@@ -173,7 +173,7 @@ middle writes; `validatePlan` gates it against the SIGNED job spec before tokens
173
173
  | `steps[].tools` | non-empty unique subset of the SPEC ceiling | a verb beyond the ceiling reds `verb-escape` with the verb as structured data (overreach, distinct from the operator-side `request-red`); `run` is `verb-escape` at every layer |
174
174
  | `steps[].rounds` | int 1..shell cap (default 40) | the step's per-attempt tool-round bound (the Gate's `maxTurns` natively) |
175
175
  | `steps[].target` | path inside the fence | v1.18 deliverable; REQUIRED on write-granted steps |
176
- | `steps[].exit` | 1..2 items (`MAX_EXITS_PER_STEP`), ALL must pass (AND-only, no OR/NOT) | closed menu (`EXIT_TYPES`): `artifact-written(path, pattern?)` · `tree-changed(scope)` · `json-valid(path)` · `check-passes(name)`. `check-passes` must name a SIGNED check (`check-unknown` red names the signed menu); on a write-granted step it must be paired with `tree-changed` (`exit-illegal` — the seed tree is green, a lone check would pass untouched, F17/F46). Exits verify FORM, not truth — progress gates; the operator's close stays the one arbiter |
176
+ | `steps[].exit` | 1..2 items (`MAX_EXITS_PER_STEP`), ALL must pass (AND-only, no OR/NOT) | closed menu (`EXIT_TYPES`): `artifact-written(path, pattern?)` · `tree-changed(scope)` · `json-valid(path)` · `check-passes(name)`. `check-passes` must name a SIGNED check (`check-unknown` red names the signed menu); on a write-granted step it must be paired with `tree-changed` (`exit-illegal` — the seed tree is green, a lone check would pass untouched, F17/F46). `artifact-written.pattern` must compile AND survive a ReDoS shape check — an unbounded quantifier over a group that itself repeats unboundedly (`(a+)+`, `(\d*)*`, even wrapped: `((a+))+`) is an `invalid-value` red (F49, catastrophic-backtracking footgun; rewrite without a repeated group inside a repeat). Exits verify FORM, not truth — progress gates; the operator's close stays the one arbiter |
177
177
 
178
178
  Red vocabulary (all three validators): `parse-error`, `unknown-field`, `missing-required`,
179
179
  `invalid-value`, `bounds`, `duplicate-id`, `close-type`, `close-hierarchy`,
@@ -376,14 +376,19 @@ through `Loop`'s `onToolResult`). So a repeat that never applied is told its anc
376
376
  missed, and only content actually in the file is ever described as having landed. Spine event `root-injected` carries
377
377
  `{stage, mode, streak, paths, redSetSize}` — counts and paths only, NEVER content (the
378
378
  spine is append-only). State dies with the run: this is within-run scratch, not
379
- across-run memory. `layerRoot: true` (on `interpret` and `runJob`) is the ON/experimental
380
- arm; the default is OFF. Status (F41): the ratchet is MEASURED inert on every current
381
- job — two frozen probes + an archive sweep read 0 fixated pairs in 14 (the F21 repetition
382
- disease was a broken-loop symptom, since cured). Because ON has therefore never won its
383
- own A/B, it ships **OFF by default (decided 2026-07-21)** — armed and correct, but not
384
- default-enabled until a stuck job (Layer 2, or a manufactured-fixation probe) shows ON
385
- beats OFF. Flip the default to `true` the day that evidence lands; a `root-injected` event
386
- on your spine (when you pass `layerRoot: true`) is a signal worth reading, not noise.
379
+ across-run memory. `layerRoot: true` is the ON/experimental arm; the default is OFF. It is
380
+ wired on BOTH surfaces (F50): the legacy `steps[]` path (`interpret`) and the accepted
381
+ plan-v1 flow (`runPlan`, one root per EXECUTE step plus one in the outer close-fix loop). On
382
+ the plan flow the per-step red-set is the exit evaluator's OWN gap (the whole normalized
383
+ complaint) rather than a single close's gapKeep, while the fix loop uses the close's own
384
+ gapKeep (raw close output); the write-tee makes same-path target rewrites visible to the
385
+ detector. It is NOT wired on the native/clipipe worker (no `onToolResult` seam — F48 fallback). Status (F41):
386
+ the ratchet is MEASURED inert on every current job — two frozen probes + an archive sweep
387
+ read 0 fixated pairs in 14 (the F21 repetition disease was a broken-loop symptom, since
388
+ cured). Because ON has therefore never won its own A/B, it ships **OFF by default (decided
389
+ 2026-07-21)** — armed and correct, but not default-enabled until a stuck job shows ON beats
390
+ OFF. Flip the default to `true` the day that evidence lands; a `root-injected` event on your
391
+ spine (when you pass `layerRoot: true`) is a signal worth reading, not noise.
387
392
 
388
393
  ### `runJob(spec, { approvals, workdir, provider, nativeProvider?, emit, target?, capRuns?, shellCapUsd?, closeTimeoutMs?, execCmd?, layerRoot? })` → outcome — `src/run.js`
389
394
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bareloop",
3
- "version": "0.5.0",
3
+ "version": "0.5.1",
4
4
  "description": "Workflows that earn their own design, with receipts — agent-authored workflows for repeated, long, verifiable jobs that improve across runs under an un-gameable gate.",
5
5
  "type": "module",
6
6
  "main": "./src/index.js",
package/src/plan.js CHANGED
@@ -49,6 +49,89 @@ const EXIT_FIELDS = {
49
49
  };
50
50
  const SLUG_RE = /^[a-z0-9][a-z0-9-]*$/;
51
51
 
52
+ /** Length of an UNBOUNDED quantifier token at `src[i]` (`*`, `+`, or `{n,}`,
53
+ * plus an optional trailing lazy `?`), or 0. Bounded forms (`?`, `{n}`,
54
+ * `{n,m}`) return 0: they cannot drive exponential backtracking (a bounded
55
+ * outer repeat is polynomial at worst, and the body is self-authored — F49).
56
+ * @param {string} src @param {number} i */
57
+ function unboundedQuantLen(src, i) {
58
+ let len = src[i] === '*' || src[i] === '+' ? 1 : 0;
59
+ if (!len && src[i] === '{') { const m = /^\{\d+,\}/.exec(src.slice(i)); if (m) len = m[0].length; }
60
+ if (len && src[i + len] === '?') len++; // the lazy modifier is part of the same token
61
+ return len;
62
+ }
63
+
64
+ /**
65
+ * F49 — a heuristic reject for the catastrophic-backtracking footgun: an
66
+ * unbounded quantifier applied to a group whose body itself repeats unboundedly
67
+ * (`(a+)+`, `(\d*)*`, `([a-z]+){1,}`) — including through a redundant wrapping
68
+ * group (`((a+))+`, `(?:(a+))*`), which is the SAME exponential class as `(a+)+`
69
+ * and is caught by propagating the inner repeat up through the wrapper. Such a
70
+ * pattern can hang `RegExp.test` for
71
+ * seconds on a short crafted body, and `evalExits` runs the agent-authored
72
+ * `artifact-written` pattern with NO timeout. This is self-DoS only — the agent
73
+ * authors both the pattern and (via the worker) the artifact, so a hang burns
74
+ * only its own run's wall-clock; there is NO arbiter compromise (it cannot
75
+ * escape the fence, forge a green, or leak a secret). So the reject targets the
76
+ * dominant exponential class and fails it as a mechanical gap at the validation
77
+ * gate, before any tokens burn. A full ReDoS analyzer needs a real regex engine
78
+ * (an external native dep we do not take for a LOW issue); exotic
79
+ * overlapping-alternation blowup is out of scope by decision.
80
+ *
81
+ * SCOPE IS ASYMMETRIC, both directions named on purpose:
82
+ * - false NEGATIVE: overlapping-alternation blowup (`(a|ab)+`-class) is not
83
+ * detected — out of scope (self-DoS only, no arbiter compromise).
84
+ * - false POSITIVE: a group whose repetitions are disambiguated by a literal
85
+ * anchor/delimiter (`(?:^- .+$\n?)+`, `(?:CHANGELOG:.+\n)+`) is FLAGGED even
86
+ * though a real engine runs it linearly — the scan sees the nested-quantifier
87
+ * SHAPE, not the disambiguation. Rejecting it is the FAIL-SAFE direction (it
88
+ * never admits an unsafe pattern), and the cost is bounded: the plan drafter
89
+ * gets a mechanical gap and rewrites (drop the outer `+`, or match once).
90
+ * Detecting "safe because anchored" needs the same real engine we declined,
91
+ * and guessing it wrong would ADMIT an exponential pattern — so the shape
92
+ * reject stands, and the over-rejection is a named, accepted limitation.
93
+ *
94
+ * The input is guaranteed to compile as a RegExp (checked first by the caller),
95
+ * so this scan assumes valid, balanced JS regex syntax.
96
+ * @param {string} src a compiled regex source string
97
+ * @returns {boolean} true iff a nested unbounded quantifier is present
98
+ */
99
+ export function hasNestedQuantifier(src) {
100
+ /** @type {{ quant: boolean }[]} */
101
+ const stack = [];
102
+ for (let i = 0; i < src.length; i++) {
103
+ const c = src[i];
104
+ if (c === '\\') { i++; continue; } // escaped atom — the next char is a literal
105
+ if (c === '[') { // character class: quantifier chars inside are literals
106
+ i++;
107
+ if (src[i] === '^') i++;
108
+ if (src[i] === ']') i++; // a leading ] is a literal member, not the close
109
+ while (i < src.length && src[i] !== ']') { if (src[i] === '\\') i++; i++; }
110
+ continue;
111
+ }
112
+ if (c === '(') { stack.push({ quant: false }); continue; }
113
+ if (c === ')') {
114
+ const g = stack.pop();
115
+ const qlen = unboundedQuantLen(src, i + 1);
116
+ if (g && g.quant && qlen) return true; // group repeats unboundedly AND its body did too
117
+ // The group is an unbounded-repeated atom of its parent when it is directly
118
+ // re-quantified (qlen) OR its own body already repeats unboundedly (g.quant):
119
+ // a redundant wrapper — ((a+))+ , (?:(a+))* , (((\d*)))+ — is the SAME
120
+ // exponential class as (a+)+, so an inner repeat must propagate THROUGH the
121
+ // enclosing group or the outer quantifier's close never sees it. Propagation
122
+ // is MONOTONIC (it only ever SETS quant=true → only ever adds rejections): it
123
+ // can widen over-rejection — the named FAIL-SAFE direction — but can never
124
+ // introduce a false negative, which is the dangerous one (F49).
125
+ if ((qlen || (g && g.quant)) && stack.length) stack[stack.length - 1].quant = true;
126
+ i += qlen;
127
+ continue;
128
+ }
129
+ const qlen = unboundedQuantLen(src, i); // a quantifier applying to the preceding atom, inside the current group
130
+ if (qlen) { if (stack.length) stack[stack.length - 1].quant = true; i += qlen - 1; }
131
+ }
132
+ return false;
133
+ }
134
+
52
135
  /** @typedef {{code: string, path: string, detail?: string, verb?: string}} Red */
53
136
 
54
137
  /**
@@ -221,8 +304,15 @@ function validateExit(s, at, red, { checkNames, fence, insideFence, writeStep })
221
304
  if (e.type === 'artifact-written' && e.pattern !== undefined) {
222
305
  if (!isNonEmptyString(e.pattern)) red('invalid-value', `${eAt}.pattern`, 'regex source string');
223
306
  else {
224
- try { new RegExp(e.pattern, 'm'); }
307
+ let compiled = false;
308
+ try { new RegExp(e.pattern, 'm'); compiled = true; }
225
309
  catch { red('invalid-value', `${eAt}.pattern`, 'must compile as a RegExp'); }
310
+ // F49: a compiled-but-catastrophic pattern (nested unbounded
311
+ // quantifier) can hang the untimed exit evaluator — reject it here as
312
+ // a mechanical gap so the replan rewrites it, before any tokens burn.
313
+ if (compiled && hasNestedQuantifier(e.pattern)) {
314
+ 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`);
315
+ }
226
316
  }
227
317
  }
228
318
  }
package/src/planrun.js CHANGED
@@ -15,6 +15,7 @@
15
15
 
16
16
  import { createRequire } from 'node:module';
17
17
  import { readFileSync } from 'node:fs';
18
+ import { createHash } from 'node:crypto';
18
19
  import { join, resolve } from 'node:path';
19
20
  import { Gate, redact } from 'bareguard';
20
21
  import { LiteCtx } from 'litectx';
@@ -22,6 +23,7 @@ import { runClose, ralph, CLOSE_FAULTS } from './ralph.js';
22
23
  import { validatePlan } from './plan.js';
23
24
  import { WRITE_VERBS, EXIT_TYPES, MAX_EXITS_PER_STEP, MAX_PLAN_STEPS } from './plan.js';
24
25
  import { snapshotScope, evalExits } from './exits.js';
26
+ import { createRoot } from './root.js';
25
27
  import { TOOL_MENU } from './job.js';
26
28
  import { TOOL_BY_VERB, CTX_TOOLS, createCtxTools, toolAction, PERSONA_TOOLS, RETRIEVAL_STRATEGY, EDIT_STRATEGY } from './interpret.js';
27
29
  import { globToPrefix, SECRET_PATTERNS } from './validate.js';
@@ -127,11 +129,20 @@ ${scoutBlob || '(no scout notes)'}`;
127
129
  * @param {number} [opts.capRuns] shell-owned per-step attempt cap
128
130
  * @param {number} [opts.closeTimeoutMs] close/check wall-clock cap (shell territory)
129
131
  * @param {number} [opts.maxStepRounds] the shell's per-step rounds ceiling (validatePlan's bound)
132
+ * @param {boolean} [opts.layerRoot=false] Layer R — the within-run ratchet (src/root.js),
133
+ * scoped PER STEP's ralph loop (each micro-wheel is the Layer-1 atom). Shell-assembled from
134
+ * the step's own books: per-attempt write-sets from the F32 workerWrites audit (teed for
135
+ * same-path rewrites, which the cumulative audit alone cannot see) and the red-set from the
136
+ * exit evaluator's own gap. Defaults OFF (decided 2026-07-21, F41): fixation is extinct on
137
+ * every current job, so ON has never won its A/B; `true` is the ON/experimental arm, and the
138
+ * first plan-flow job to emit `root-injected` runs the pre-registered ON-vs-OFF acceptance
139
+ * read (the Layer R default-flip, LAYERS.md ⚠). Excluded on native (clipipe): the native
140
+ * worker has no onToolResult seam, so the tee cannot settle and same-path rewrites are blind.
130
141
  * @returns {Promise<string>} 'green' | 'already-green' | 'escalated' | 'plan-red' |
131
142
  * 'check-red' | 'close-red' | 'close-unsupported' | 'pricing-red' | 'cap-halt' |
132
143
  * 'provider-red' | 'interpreter-red' | `step-red:<id>`
133
144
  */
134
- export async function runPlan(job, { workdir, provider, nativeProvider, emit, remainingUsd, isUnpriced = () => false, capRuns = 3, closeTimeoutMs, maxStepRounds = 40 }) {
145
+ export async function runPlan(job, { workdir, provider, nativeProvider, emit, remainingUsd, isUnpriced = () => false, capRuns = 3, closeTimeoutMs, maxStepRounds = 40, layerRoot = false }) {
135
146
  workdir = resolve(workdir);
136
147
  const scrub = (/** @type {string} */ s) => redact(s, { patterns: SECRET_PATTERNS });
137
148
 
@@ -228,9 +239,13 @@ export async function runPlan(job, { workdir, provider, nativeProvider, emit, re
228
239
  * denied, the wallet as its budget), granted tools only (the menu IS the
229
240
  * grant), per-attempt round bound via loop.stop() (F20), every round metered
230
241
  * with a phase label (F12).
231
- * @param {{granted: string[], phase: string, attemptRounds: number, attempts: number, writable: boolean}} o
242
+ * @param {{granted: string[], phase: string, attemptRounds: number, attempts: number, writable: boolean, root?: ReturnType<typeof createRoot>|null}} o
243
+ * `root` (Layer R): when present, the worker's write-class actions are teed
244
+ * for the within-run ratchet — staged before the gate decides, discarded on
245
+ * deny/halt, settled to landed-or-not after execution (the two axes, F43/F7).
246
+ * Wired ONLY on the Loop path: the native session exposes no onToolResult seam.
232
247
  */
233
- async function mkWorker({ granted, phase, attemptRounds, attempts, writable }) {
248
+ async function mkWorker({ granted, phase, attemptRounds, attempts, writable, root = null }) {
234
249
  const gate = new Gate({
235
250
  fs: {
236
251
  writeScope: writable ? fencePrefixes : [],
@@ -259,7 +274,69 @@ export async function runPlan(job, { workdir, provider, nativeProvider, emit, re
259
274
  return [...paths];
260
275
  } catch { return []; }
261
276
  };
262
- const { policy, onLlmResult } = wireGate(gate, { actionTranslator: (/** @type {string} */ n, /** @type {any} */ a) => toolAction(n, a, workdir) });
277
+ // Layer R tee (design record 2026-07-19; created per-step by executeStep).
278
+ // The translator STAGES write/edit content and snapshots the pre-write hash
279
+ // BEFORE the gate decides (Finding 6); the policy wrapper DISCARDS on a deny
280
+ // or a halt (the tool never runs); onToolOutcome SETTLES landed-vs-not after
281
+ // execution (Finding 7 — the gate's allow is intent, the file is outcome).
282
+ // All no-ops when root is null: every scout/drafter and every native worker.
283
+ const fileHash = (/** @type {string} */ p) => {
284
+ try { return createHash('sha256').update(readFileSync(p)).digest('hex'); } catch { return null; }
285
+ };
286
+ /** @type {{path: string, content: string, type: string, before: string|null}|null} the write-class action awaiting its OUTCOME */
287
+ let pendingProbe = null;
288
+ const teeingTranslator = (/** @type {string} */ n, /** @type {any} */ a) => {
289
+ // classify by action TYPE, never a name list (the workerWrites filter uses
290
+ // the same write|edit test — a third enumeration would let a future
291
+ // write-class verb bypass the tee, the blind-instrument class)
292
+ const act = toolAction(n, a, workdir);
293
+ if (root && (act.type === 'write' || act.type === 'edit')) {
294
+ const path = /** @type {string} */ (act.path);
295
+ const content = String((act.type === 'edit' ? a?.newText : a?.content) ?? '');
296
+ root.stageWrite(path, content); // content read off RAW args, never onto the action (the audit stays bytes-only)
297
+ pendingProbe = { path, content, type: act.type, before: fileHash(path) };
298
+ }
299
+ return act;
300
+ };
301
+ const { policy: gatePolicy, onLlmResult } = wireGate(gate, { actionTranslator: teeingTranslator });
302
+ // Settle the stage on the verdict. A DENY/HALT means the tool never runs, so
303
+ // the outcome seam below never fires for it — discard here (an allow is NOT
304
+ // settled here: the bytes are not written yet; settling on the verdict is the
305
+ // exact Finding 7 defect). The catch RE-THROWS so cap-halt routing still reads
306
+ // the throw. Non-write actions stage nothing, so both settlements are no-ops.
307
+ const policy = root
308
+ ? async (/** @type {string} */ n, /** @type {any} */ a, /** @type {any} */ c) => {
309
+ try {
310
+ const verdict = await gatePolicy(n, a, c);
311
+ if (verdict !== true) { root.discardWrite(); pendingProbe = null; }
312
+ return verdict;
313
+ } catch (e) { root.discardWrite(); pendingProbe = null; throw e; }
314
+ }
315
+ : gatePolicy;
316
+ /**
317
+ * Finding 7 — settle the staged write on what the tool ACTUALLY did. Fires
318
+ * after every tool.execute (bare-agent loop.js), success or error, and tool
319
+ * calls run strictly sequentially, so exactly one probe is ever in flight. A
320
+ * probe that never reaches here (a halt out of a tool body) leaves the stage
321
+ * unsettled — correct: nothing landed, and the attempt boundary drops it.
322
+ * @param {{result?: any}} [info] the tool's return value
323
+ */
324
+ const onToolOutcome = async (info) => {
325
+ if (!root || !pendingProbe) return;
326
+ const { path: p, content, type, before } = pendingProbe;
327
+ pendingProbe = null;
328
+ let after = null;
329
+ try { after = readFileSync(p, 'utf8'); } catch { /* absent → after stays null */ }
330
+ let landed = after !== null && createHash('sha256').update(after, 'utf8').digest('hex') !== before;
331
+ if (!landed && after !== null) {
332
+ // no-byte-change: a write always writes (identical bytes ⇒ content IS the
333
+ // file, exact-equal never substring); a no-op edit is an idempotent apply
334
+ // OR a missed anchor — the tool RESULT is the only honest signal (F2)
335
+ if (type === 'write') landed = after === content;
336
+ else landed = typeof info?.result === 'string' && info.result.startsWith('edited ');
337
+ }
338
+ root.settleWrite(landed);
339
+ };
263
340
  /** @type {number|string|undefined} */
264
341
  let roundIteration;
265
342
  let roundsThisAttempt = 0;
@@ -383,7 +460,7 @@ export async function runPlan(job, { workdir, provider, nativeProvider, emit, re
383
460
  }
384
461
  return onLlmResult(arg);
385
462
  };
386
- const loop = new Loop({ provider: loopProvider, system, policy, onLlmResult: metered });
463
+ const loop = new Loop({ provider: loopProvider, system, policy, onLlmResult: metered, onToolResult: onToolOutcome });
387
464
  /** @param {string} prompt @param {typeof toolDefs} [defs] */
388
465
  const ask = async (prompt, defs = toolDefs) => {
389
466
  let r;
@@ -507,13 +584,33 @@ export async function runPlan(job, { workdir, provider, nativeProvider, emit, re
507
584
  for (const e of step.exit) {
508
585
  if (e.type === 'tree-changed') for (const [k, v] of await snapshotScope(workdir, e.scope)) snapshot.set(k, v);
509
586
  }
510
- const w = await mkWorker({ granted: step.tools, phase: `step:${step.id}`, attemptRounds: step.rounds, attempts: capRuns, writable: true });
587
+ // Layer R — one root per step's ralph loop (each micro-wheel is the Layer-1
588
+ // atom, LAYERS.md). The red-set is the exit evaluator's OWN gap: fixation
589
+ // here means "the worker rewrote the same file(s) AND the exit evaluator's
590
+ // complaint is byte-identical" — so the whole normalized gap is the
591
+ // comparable set (gapKeep `\S` = every non-blank line). That is more robust
592
+ // than reusing a check's `^`-anchored gapKeep, which the exit wrapper
593
+ // (`check "x" red: …`) would break. writesInformative is true (tool mode,
594
+ // always) but never fires on write-overlap ALONE — a plan step rewrites its
595
+ // one target every attempt, so only a KNOWN-unmoved red-set can distinguish
596
+ // repetition from progress (Finding 3). Native is excluded (no onToolResult
597
+ // seam → the tee cannot settle → same-path rewrites are blind).
598
+ const root = layerRoot && !native
599
+ ? createRoot({ gapKeep: '\\S', redact: scrub, writesInformative: true })
600
+ : null;
601
+ const w = await mkWorker({ granted: step.tools, phase: `step:${step.id}`, attemptRounds: step.rounds, attempts: capRuns, writable: true, root });
511
602
  let lastText = '';
512
603
  let iterationNow = 0;
513
604
  /** @param {number} iteration @param {string} [gap] */
514
605
  const middle = async (iteration, gap) => {
515
606
  w.setIteration(iteration);
516
607
  iterationNow = iteration;
608
+ // Layer R observe: finalize the prior attempt from the books (workerWrites
609
+ // audit + teed same-path rewrites), run the fixation detector, and inject
610
+ // its escalating note into THIS attempt's prompt (null = inert). The event
611
+ // carries counts and paths only — never content (the spine is forever).
612
+ const rootInj = root ? root.observe({ iteration, gap, writes: w.workerWrites() }) : null;
613
+ if (rootInj) emit('root-injected', { step: step.id, ...rootInj.event });
517
614
  const r = await w.ask([
518
615
  step.action,
519
616
  `Repository root (absolute): ${workdir}\nEvery path you pass to a tool MUST be absolute and inside this root — a relative path resolves against a different directory and will be denied by the gate.`,
@@ -522,6 +619,7 @@ export async function runPlan(job, { workdir, provider, nativeProvider, emit, re
522
619
  gap && `Previous attempt failed this step's checks:\n${gap}`,
523
620
  w.wasBounded() === iteration - 1
524
621
  && `Your previous attempt was CUT OFF after ${step.rounds} tool rounds. Reading is bounded; writing is not. Form a hypothesis EARLY and make the change.`,
622
+ rootInj && rootInj.note,
525
623
  ].filter(Boolean).join('\n\n'));
526
624
  lastText = scrub(r.text ?? '').slice(0, ARTIFACT_MAX);
527
625
  };
@@ -651,16 +749,29 @@ export async function runPlan(job, { workdir, provider, nativeProvider, emit, re
651
749
  emit('fix-loop', { gapBytes: Buffer.byteLength(post.gap ?? '') });
652
750
  let fixOutcome;
653
751
  try {
654
- const w = await mkWorker({ granted: ceiling, phase: 'fix', attemptRounds: maxStepRounds, attempts: capRuns, writable: true });
752
+ // Layer R for the close-fix loop — the plan flow's single ralph loop judged
753
+ // by the REAL close (the closest analog to interpret.js's loop, and the
754
+ // likeliest place fixation manifests: the fix worker has the full menu and
755
+ // is judged by a command, not a form-only exit). The red-set is the CLOSE's
756
+ // own gapKeep (the gap here is the raw close output, unwrapped — so the
757
+ // `^`-anchored pattern matches, unlike the exec steps' exit-eval gap). Same
758
+ // native exclusion (no onToolResult seam ⇒ the tee cannot settle).
759
+ const fixRoot = layerRoot && !native
760
+ ? createRoot({ gapKeep: job.close.gapKeep, redact: scrub, writesInformative: true })
761
+ : null;
762
+ const w = await mkWorker({ granted: ceiling, phase: 'fix', attemptRounds: maxStepRounds, attempts: capRuns, writable: true, root: fixRoot });
655
763
  /** @param {number} iteration @param {string} [gap] */
656
764
  const middle = async (iteration, gap) => {
657
765
  w.setIteration(iteration);
766
+ const rootInj = fixRoot ? fixRoot.observe({ iteration, gap, writes: w.workerWrites() }) : null;
767
+ if (rootInj) emit('root-injected', { phase: 'fix', ...rootInj.event });
658
768
  await w.ask([
659
769
  'The job\'s final verification is failing. Fix the repository so it passes.',
660
770
  `Repository root (absolute): ${workdir}\nEvery path you pass to a tool MUST be absolute and inside this root.`,
661
771
  artifacts.length > 0 && `Working context (read-only) — the plan's steps produced:\n${artifacts.map((a) => `[${a.id}] ${a.text}`).join('\n\n')}`,
662
772
  !gap && post.gap && `The verification's output on the tree as it stands (not an attempt of yours):\n${post.gap}`,
663
773
  gap && `Previous attempt failed the verification:\n${gap}`,
774
+ rootInj && rootInj.note,
664
775
  ].filter(Boolean).join('\n\n'));
665
776
  };
666
777
  fixOutcome = await ralph({
package/src/run.js CHANGED
@@ -303,7 +303,7 @@ export async function runJob(rawSpec, { approvals, workdir, target, provider, na
303
303
  // money contract is identical to the legacy path.
304
304
  if (planShape) {
305
305
  const outcome = await runPlan(job, {
306
- workdir, provider, nativeProvider, emit: meter, capRuns, closeTimeoutMs,
306
+ workdir, provider, nativeProvider, emit: meter, capRuns, closeTimeoutMs, layerRoot,
307
307
  remainingUsd: () => Math.min(shellCapUsd, job.budgetUsd - spentUsd),
308
308
  isUnpriced: () => unpriced, // F6: let the plan flow bail in-flight, not just after it returns
309
309
  });
package/types/plan.d.ts CHANGED
@@ -1,3 +1,39 @@
1
+ /**
2
+ * F49 — a heuristic reject for the catastrophic-backtracking footgun: an
3
+ * unbounded quantifier applied to a group whose body itself repeats unboundedly
4
+ * (`(a+)+`, `(\d*)*`, `([a-z]+){1,}`) — including through a redundant wrapping
5
+ * group (`((a+))+`, `(?:(a+))*`), which is the SAME exponential class as `(a+)+`
6
+ * and is caught by propagating the inner repeat up through the wrapper. Such a
7
+ * pattern can hang `RegExp.test` for
8
+ * seconds on a short crafted body, and `evalExits` runs the agent-authored
9
+ * `artifact-written` pattern with NO timeout. This is self-DoS only — the agent
10
+ * authors both the pattern and (via the worker) the artifact, so a hang burns
11
+ * only its own run's wall-clock; there is NO arbiter compromise (it cannot
12
+ * escape the fence, forge a green, or leak a secret). So the reject targets the
13
+ * dominant exponential class and fails it as a mechanical gap at the validation
14
+ * gate, before any tokens burn. A full ReDoS analyzer needs a real regex engine
15
+ * (an external native dep we do not take for a LOW issue); exotic
16
+ * overlapping-alternation blowup is out of scope by decision.
17
+ *
18
+ * SCOPE IS ASYMMETRIC, both directions named on purpose:
19
+ * - false NEGATIVE: overlapping-alternation blowup (`(a|ab)+`-class) is not
20
+ * detected — out of scope (self-DoS only, no arbiter compromise).
21
+ * - false POSITIVE: a group whose repetitions are disambiguated by a literal
22
+ * anchor/delimiter (`(?:^- .+$\n?)+`, `(?:CHANGELOG:.+\n)+`) is FLAGGED even
23
+ * though a real engine runs it linearly — the scan sees the nested-quantifier
24
+ * SHAPE, not the disambiguation. Rejecting it is the FAIL-SAFE direction (it
25
+ * never admits an unsafe pattern), and the cost is bounded: the plan drafter
26
+ * gets a mechanical gap and rewrites (drop the outer `+`, or match once).
27
+ * Detecting "safe because anchored" needs the same real engine we declined,
28
+ * and guessing it wrong would ADMIT an exponential pattern — so the shape
29
+ * reject stands, and the over-rejection is a named, accepted limitation.
30
+ *
31
+ * The input is guaranteed to compile as a RegExp (checked first by the caller),
32
+ * so this scan assumes valid, balanced JS regex syntax.
33
+ * @param {string} src a compiled regex source string
34
+ * @returns {boolean} true iff a nested unbounded quantifier is present
35
+ */
36
+ export function hasNestedQuantifier(src: string): boolean;
1
37
  /** @typedef {{code: string, path: string, detail?: string, verb?: string}} Red */
2
38
  /**
3
39
  * Validate an agent-authored plan (`schema: "plan-v1"`) against the SIGNED
@@ -23,11 +23,20 @@
23
23
  * @param {number} [opts.capRuns] shell-owned per-step attempt cap
24
24
  * @param {number} [opts.closeTimeoutMs] close/check wall-clock cap (shell territory)
25
25
  * @param {number} [opts.maxStepRounds] the shell's per-step rounds ceiling (validatePlan's bound)
26
+ * @param {boolean} [opts.layerRoot=false] Layer R — the within-run ratchet (src/root.js),
27
+ * scoped PER STEP's ralph loop (each micro-wheel is the Layer-1 atom). Shell-assembled from
28
+ * the step's own books: per-attempt write-sets from the F32 workerWrites audit (teed for
29
+ * same-path rewrites, which the cumulative audit alone cannot see) and the red-set from the
30
+ * exit evaluator's own gap. Defaults OFF (decided 2026-07-21, F41): fixation is extinct on
31
+ * every current job, so ON has never won its A/B; `true` is the ON/experimental arm, and the
32
+ * first plan-flow job to emit `root-injected` runs the pre-registered ON-vs-OFF acceptance
33
+ * read (the Layer R default-flip, LAYERS.md ⚠). Excluded on native (clipipe): the native
34
+ * worker has no onToolResult seam, so the tee cannot settle and same-path rewrites are blind.
26
35
  * @returns {Promise<string>} 'green' | 'already-green' | 'escalated' | 'plan-red' |
27
36
  * 'check-red' | 'close-red' | 'close-unsupported' | 'pricing-red' | 'cap-halt' |
28
37
  * 'provider-red' | 'interpreter-red' | `step-red:<id>`
29
38
  */
30
- export function runPlan(job: any, { workdir, provider, nativeProvider, emit, remainingUsd, isUnpriced, capRuns, closeTimeoutMs, maxStepRounds }: {
39
+ export function runPlan(job: any, { workdir, provider, nativeProvider, emit, remainingUsd, isUnpriced, capRuns, closeTimeoutMs, maxStepRounds, layerRoot }: {
31
40
  workdir: string;
32
41
  provider: any;
33
42
  nativeProvider?: ((o: {
@@ -42,6 +51,7 @@ export function runPlan(job: any, { workdir, provider, nativeProvider, emit, rem
42
51
  capRuns?: number | undefined;
43
52
  closeTimeoutMs?: number | undefined;
44
53
  maxStepRounds?: number | undefined;
54
+ layerRoot?: boolean | undefined;
45
55
  }): Promise<string>;
46
56
  export type CategorizedError = Error & {
47
57
  category?: string;