bareloop 0.7.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/reuse.js CHANGED
@@ -594,7 +594,11 @@ function readGradeSeed(seen) {
594
594
  * **The declared fold, not a re-derivation.** A restarted try's own `try-start`
595
595
  * carries the fold it inherited (`priorSpentUsd`/`priorWallMs`), so a resume OF a
596
596
  * resume adds only its own new rounds. Re-deriving from the whole file would bill an
597
- * abandoned attempt twice.
597
+ * abandoned attempt twice. The REPLAN ledger (`priorReplans`/`priorReplanGrantUsed`)
598
+ * folds on this same mechanism and deliberately not on `readGradeSeed`'s: a grade seed
599
+ * feeds a readout, so its documented one-leg-per-resume shortening can only under-claim
600
+ * a direction, while an under-claimed CEILING is a refilled allowance and every kill
601
+ * would buy one (PRD v1.12 — the replan bound is the RUN's).
598
602
  *
599
603
  * **KNOWN LIMIT, stated rather than discovered later.** A call that was killed BEFORE it
600
604
  * returned left no event at all — no `worker-round`, no `selection-result` — so money it
@@ -688,6 +692,19 @@ export function readResume(events, { deathAt = null, direct = false, resumableOu
688
692
  // declaration existed, or a try nobody resumed — both are honestly zero.
689
693
  declaredRounds: !('priorRounds' in ev) ? 0
690
694
  : (typeof ev.priorRounds === 'number' && Number.isFinite(ev.priorRounds) && ev.priorRounds >= 0 ? ev.priorRounds : null),
695
+ // the REPLAN ledger this attempt was already carrying, on the same declared-fold
696
+ // mechanism and with the same `> 0` belt as the money. It has to fold this way
697
+ // rather than the grade seed's way: `readGradeSeed`'s chain shortens by one leg
698
+ // per resume, which under-claims — harmless for a readout, and for a CEILING an
699
+ // under-claim IS a refilled allowance, one per kill (PRD v1.12).
700
+ declaredReplans: typeof ev.priorReplans === 'number' && Number.isFinite(ev.priorReplans) && ev.priorReplans > 0 ? Math.floor(ev.priorReplans) : 0,
701
+ // the F85-C latch rides BESIDE the count, never derived from it: a chain can have
702
+ // spent the ordinary ceiling with the arbiter's one extra still unearned, and
703
+ // deriving one from the other would collapse those two states into one
704
+ declaredGrantUsed: ev.priorReplanGrantUsed === true,
705
+ // this window's OWN replans, counted from the records the runner emits — the same
706
+ // `replan` event a human reads, so the ledger and the log cannot disagree
707
+ windowReplans: 0, windowGrantUsed: false,
691
708
  roundsUsd: 0, roundsComplete: true, r1Written: false, jobEnd: null,
692
709
  /** @type {any[]} the window's own events, so the SAME `readTry` the live loop uses
693
710
  * reads a graded-but-unrecorded try — never a second reader spelling it differently */
@@ -745,6 +762,16 @@ export function readResume(events, { deathAt = null, direct = false, resumableOu
745
762
  open.jobEnd = ev;
746
763
  } else if (ev.type === 'bridge-write') {
747
764
  open.r1Written = true;
765
+ } else if (ev.type === 'replan') {
766
+ // ONE record per replan the runner drafts, so counting them IS the leg's ledger
767
+ // — never re-derived from `plan-accepted` (a resumed leg emits one of those for a
768
+ // plan it RELOADED, which would read as a redraft nobody paid for).
769
+ open.windowReplans += 1;
770
+ // the arbiter stamps `granted` only on the extra it granted past the ordinary
771
+ // ceiling, and stamps nothing on the ordinary one — so PRESENCE is the whole
772
+ // signal, and reading it by presence keeps this reader honest about a reading it
773
+ // does not itself make (never re-deciding whether the trend deserved it).
774
+ if (ev.granted !== undefined) open.windowGrantUsed = true;
748
775
  } else if (ev.type === 'try-end') {
749
776
  completed.push({ ...ev, seq: undefined, ts: undefined, inherited: true });
750
777
  if (open.bridge) tried.push(open.bridge);
@@ -815,6 +842,19 @@ export function readResume(events, { deathAt = null, direct = false, resumableOu
815
842
  // restart already re-graded that tree. Counting both would enter the same
816
843
  // reading twice and flatten a chain that moved.
817
844
  grades: readGradeSeed(open.seen),
845
+ // the REPLAN ledger, folded like the money one line below and deliberately
846
+ // NOT like the grades one line above. A grade seed feeds a READOUT and its
847
+ // documented shortening chain can only under-claim a direction; this feeds a
848
+ // BOUND, where under-claiming is a refilled allowance and every kill would buy
849
+ // one (PRD v1.12: unlimited replanning launders thrash as adaptation). So it
850
+ // reads the declared fold plus THIS window's own records — the same arithmetic
851
+ // as `priorSpentUsd`, which is what makes a resume of a resume inherit leg 1.
852
+ //
853
+ // The abandoned windows are not read, for the money's exact reason: the
854
+ // restart DECLARED what it inherited, so counting the window it abandoned
855
+ // would charge the chain twice for one leg's replans.
856
+ replans: open.declaredReplans + open.windowReplans,
857
+ replanGrantUsed: open.declaredGrantUsed || open.windowGrantUsed,
818
858
  priorSpentUsd: open.declaredSpentUsd + open.roundsUsd,
819
859
  // F83 — was that fold EXACT? Three sources, because `runJob` decides the same
820
860
  // question from more than this window can see (src/run.js: `!unpriced &&
@@ -1017,7 +1057,7 @@ export async function runReuse(opts) {
1017
1057
  const runid = opts.runid ?? Date.now().toString(36);
1018
1058
  const resume = isObj(opts.resume) ? /** @type {any} */ (opts.resume) : null;
1019
1059
  /** a try that inherits nothing: the ordinary, non-resumed attempt */
1020
- const NO_PRIOR = { spentUsd: 0, wallMs: 0, spendComplete: true, rounds: /** @type {number|null} */ (0), grades: /** @type {any[]} */ ([]) };
1060
+ const NO_PRIOR = { spentUsd: 0, wallMs: 0, spendComplete: true, rounds: /** @type {number|null} */ (0), grades: /** @type {any[]} */ ([]), replans: 0, replanGrantUsed: false };
1021
1061
 
1022
1062
  /** @type {any[]} */
1023
1063
  const tries = [];
@@ -1113,7 +1153,7 @@ export async function runReuse(opts) {
1113
1153
 
1114
1154
  /** the shared per-try execution: run the job, read the spine, write the box (R1).
1115
1155
  * @param {any|null} bridge @param {number} n
1116
- * @param {{spentUsd: number, wallMs: number, spendComplete: boolean, rounds: number|null, grades: any[]}} [prior]
1156
+ * @param {{spentUsd: number, wallMs: number, spendComplete: boolean, rounds: number|null, grades: any[], replans: number, replanGrantUsed: boolean}} [prior]
1117
1157
  * RESUME — what a KILLED attempt of this same try already consumed, and what it
1118
1158
  * already measured. Money and wall are FOLDED into the attempt (`runJob`'s own ledger
1119
1159
  * and clock start partly spent) rather than shrinking the caps, so the signed per-try
@@ -1121,7 +1161,10 @@ export async function runReuse(opts) {
1121
1161
  * way but on the ROW alone (nothing enforces a round bound here), and `null` — an
1122
1162
  * attempt whose turns could not be counted — is reported, never folded. `grades` are
1123
1163
  * the close readings that leg recorded, handed on as the run trend's BASELINES: they
1124
- * feed the HALT READOUT alone and spend none of this attempt's bounds.
1164
+ * feed the HALT READOUT alone and spend none of this attempt's bounds. `replans` /
1165
+ * `replanGrantUsed` are the opposite kind of inheritance and sit here for that
1166
+ * contrast: they SPEND a bound (the plan flow's replan ceiling), because that ceiling
1167
+ * is the RUN's by doctrine and a fresh one per kill is the creep v1.12 forbids.
1125
1168
  * @param {any} [seed] RESUME (v2) — WHERE that attempt was killed (`readResume`'s
1126
1169
  * `restart.seed`): the plan it accepted and the steps it finished. The fold says how
1127
1170
  * much of the try's allowance is gone; this says how much of its WORK is done, so the
@@ -1142,6 +1185,15 @@ export async function runReuse(opts) {
1142
1185
  // means "a spine older than this field", and a reader that cannot tell that from a
1143
1186
  // predecessor's honest unknown would launder the unknown into a zero (F6).
1144
1187
  ...(resumed ? { priorSpentUsd: prior.spentUsd, priorWallMs: prior.wallMs, priorSpendComplete: prior.spendComplete, priorRounds: prior.rounds, resumedFrom: resume?.fromRunid ?? runid } : {}),
1188
+ // the REPLAN ledger, declared on the same record and for the same reason as the
1189
+ // money: the next reader adds only its own window's `replan` records to this
1190
+ // number, so a chain of resumes folds once instead of restarting the ceiling at
1191
+ // whichever leg it happens to be reading (PRD v1.12 — the bound is the RUN's).
1192
+ // Declared independently of `resumed`, which is a MONEY/WALL test: a leg can
1193
+ // replan without buying a round the fold would notice, and gating the ledger on
1194
+ // the wallet would drop it exactly there. Only when there is one to state — a
1195
+ // decorative zero is indistinguishable from a leg nobody folded.
1196
+ ...(prior.replans > 0 ? { priorReplans: prior.replans, priorReplanGrantUsed: prior.replanGrantUsed } : {}),
1145
1197
  // WHERE it picks up, on the row's own record: a try that restarts at step 3 of 4 is
1146
1198
  // a materially different attempt from one that restarts at its beginning, and a
1147
1199
  // reader must not have to infer which happened from the absence of a scout.
@@ -1173,6 +1225,11 @@ export async function runReuse(opts) {
1173
1225
  // restarted leg would report `flat` on a run that was converging. Only when there
1174
1226
  // ARE grades: an empty array is the cold path, and the cold path stays identical.
1175
1227
  ...(prior.grades.length ? { resumeGrades: prior.grades } : {}),
1228
+ // and the replan LEDGER. Same omission class as the two above, with a sharper
1229
+ // cost: a grade seam that never arrives makes a readout say `flat`, while a
1230
+ // ceiling that never arrives is spent by the dead leg and fresh in this one —
1231
+ // a bound the operator signed, widened by a kill (PRD v1.12).
1232
+ ...(prior.replans > 0 ? { resumeReplans: { count: prior.replans, grantUsed: prior.replanGrantUsed } } : {}),
1176
1233
  ...(seed ? { resumeSeed: seed } : {}),
1177
1234
  bridge,
1178
1235
  });
@@ -1446,6 +1503,12 @@ export async function runReuse(opts) {
1446
1503
  // this run make progress" and "is this loop out of ideas" are two questions,
1447
1504
  // and one instrument answering both is how they come to disagree.
1448
1505
  grades: Array.isArray(rs.grades) ? rs.grades : [],
1506
+ // the replan LEDGER the chain has spent — belted like the money and NOT like
1507
+ // the grades, because this one seeds a BOUND: a garbage or negative figure
1508
+ // that read through as a widening is the ceiling being refilled by a corrupt
1509
+ // spine (PRD v1.12). The latch rides beside the count, never derived from it.
1510
+ replans: typeof rs.replans === 'number' && Number.isFinite(rs.replans) && rs.replans > 0 ? Math.floor(rs.replans) : 0,
1511
+ replanGrantUsed: rs.replanGrantUsed === true,
1449
1512
  }
1450
1513
  : NO_PRIOR;
1451
1514
  const remainingCapUsd = envelope.perTryBudgetUsd - prior.spentUsd;
package/src/run.js CHANGED
@@ -116,6 +116,15 @@ async function primitiveSmoke(workdir) {
116
116
  * chain, not just the leg: without them a leg that re-grades an unchanged tree reports
117
117
  * `flat` — "revise the goal" — on a run that was converging when the money ran out.
118
118
  * Counts and stage names only; no close bytes cross this seam. Absent is the cold path.
119
+ * @param {{count?: number, grantUsed?: boolean}|null} [opts.resumeReplans] RESUME — the
120
+ * replan LEDGER the chain has already spent (`readResume`'s `restart.replans` /
121
+ * `restart.replanGrantUsed`), forwarded to the plan flow, which seeds its ceiling with
122
+ * it. Its sibling above feeds a READOUT; this one feeds a BOUND, and that is the whole
123
+ * difference: `replanned`/`varianceGrantUsed` are locals in `runPlan`, so without this
124
+ * every kill hands the next leg a fresh allowance of an allowance PRD v1.12 makes the
125
+ * RUN's ("unlimited replanning launders thrash as adaptation"). It also rides onto this
126
+ * run's `job-start` as `priorReplans`, the declared-fold precedent `priorSpentUsd`
127
+ * already sets, so a resume OF a resume folds once. Absent is the cold path.
119
128
  * @param {boolean} [opts.layerRoot=false] Layer R (within-run ratchet) — shell
120
129
  * territory, threaded to the plan flow. Defaults OFF
121
130
  * (decided 2026-07-21): fixation is extinct on every current job (F41), so
@@ -128,7 +137,7 @@ async function primitiveSmoke(workdir) {
128
137
  * 'close-red' | 'close-unsupported' | 'recipe-stale' | 'pricing-red' | 'provider-red' |
129
138
  * 'interpreter-red' | 'cap-halt' | 'wall-halt' | 'step-stalled' | `step-red:<id>`
130
139
  */
131
- export async function runJob(rawSpec, { approvals, workdir, provider, nativeProvider, providerFor, emit, capRuns = 3, strikeLimit, shellCapUsd = 2, closeTimeoutMs, layerRoot = false, bridge = null, priorSpentUsd = 0, priorSpendComplete = true, priorWallMs = 0, resumeSeed = null, resumeGrades = [] }) {
140
+ export async function runJob(rawSpec, { approvals, workdir, provider, nativeProvider, providerFor, emit, capRuns = 3, strikeLimit, shellCapUsd = 2, closeTimeoutMs, layerRoot = false, bridge = null, priorSpentUsd = 0, priorSpendComplete = true, priorWallMs = 0, resumeSeed = null, resumeGrades = [], resumeReplans = null }) {
132
141
  // 0. the ledger's counters, declared FIRST so that every job-end — including
133
142
  // the pre-token reds below — can state a real figure. An omitted `spentUsd` is
134
143
  // not a zero: a consumer reads `undefined` and either crashes or launders it
@@ -204,6 +213,16 @@ export async function runJob(rawSpec, { approvals, workdir, provider, nativeProv
204
213
  // it inherited, and it is the only field that can carry it forward.
205
214
  ...(spentUsd > 0 || priorFloor ? { priorSpentUsd: spentUsd, priorSpendComplete: !priorFloor } : {}),
206
215
  ...(typeof priorWallMs === 'number' && Number.isFinite(priorWallMs) && priorWallMs > 0 ? { priorWallMs } : {}),
216
+ // the REPLAN ledger, on the same declaration and for the same reason — a chain of
217
+ // resumes must fold once. This is the DIRECT-spine half of it: `readResume`'s
218
+ // `direct` mode opens its one implicit window on `job-start` and reads the fold off
219
+ // these very fields, so a U-path run resumed twice inherits leg 1's replans rather
220
+ // than restarting the ceiling at leg 2's window (PRD v1.12). Emitted only when there
221
+ // is one — a decorative `0` is indistinguishable from a run nobody folded, and the
222
+ // grant latch travels beside the count rather than being derived from it.
223
+ ...(typeof resumeReplans?.count === 'number' && Number.isFinite(resumeReplans.count) && resumeReplans.count > 0
224
+ ? { priorReplans: Math.floor(resumeReplans.count), priorReplanGrantUsed: resumeReplans.grantUsed === true }
225
+ : {}),
207
226
  });
208
227
 
209
228
  // 2. known-answer smoke before tokens (A3: silent degradation throws nothing)
@@ -256,7 +275,7 @@ export async function runJob(rawSpec, { approvals, workdir, provider, nativeProv
256
275
  // accounts it natively (F12) and the job-end money contract is unchanged.
257
276
  {
258
277
  const outcome = await runPlan(job, {
259
- workdir, provider, nativeProvider, providerFor, emit: meter, capRuns, ...(strikeLimit !== undefined ? { strikeLimit } : {}), closeTimeoutMs, layerRoot, bridge, priorWallMs, resumeSeed, resumeGrades,
278
+ workdir, provider, nativeProvider, providerFor, emit: meter, capRuns, ...(strikeLimit !== undefined ? { strikeLimit } : {}), closeTimeoutMs, layerRoot, bridge, priorWallMs, resumeSeed, resumeGrades, resumeReplans,
260
279
  remainingUsd: () => Math.min(shellCapUsd, job.budgetUsd - spentUsd),
261
280
  isUnpriced: () => unpriced, // F6: let the plan flow bail in-flight, not just after it returns
262
281
  spendComplete, // …and let its money-halt readout say whether the remaining it quotes is exact
package/src/validate.js CHANGED
@@ -155,3 +155,124 @@ export function sweepSecretLiterals(root, red) {
155
155
  }
156
156
  })(root, '');
157
157
  }
158
+
159
+ // ── the catastrophic-backtracking reject (F49) ────────────────────────────────
160
+ // Housed here, with the other primitives BOTH validators share, because THREE
161
+ // regex fields reach an untimed evaluator and they are split across the two
162
+ // documents: the agent's `artifact-written.pattern` (plan.js) and the
163
+ // operator's `judged.pattern` + `gapKeep` (job.js). plan.js already imports
164
+ // job.js, so job.js cannot import plan.js — and a second copy here would be the
165
+ // drift class SECRET_PATTERNS exists to prevent: a shape one side rejects and
166
+ // the other admits. ONE inventory, imported by both.
167
+
168
+ /** Length of an UNBOUNDED quantifier token at `src[i]` (`*`, `+`, or `{n,}`,
169
+ * plus an optional trailing lazy `?`), or 0. Bounded forms (`?`, `{n}`,
170
+ * `{n,m}`) return 0: they cannot drive exponential backtracking (a bounded
171
+ * outer repeat is polynomial at worst, and the body is self-authored — F49).
172
+ * @param {string} src @param {number} i */
173
+ function unboundedQuantLen(src, i) {
174
+ let len = src[i] === '*' || src[i] === '+' ? 1 : 0;
175
+ if (!len && src[i] === '{') { const m = /^\{\d+,\}/.exec(src.slice(i)); if (m) len = m[0].length; }
176
+ if (len && src[i + len] === '?') len++; // the lazy modifier is part of the same token
177
+ return len;
178
+ }
179
+
180
+ /**
181
+ * F49 — a heuristic reject for the catastrophic-backtracking footgun: an
182
+ * unbounded quantifier applied to a group whose body itself repeats unboundedly
183
+ * (`(a+)+`, `(\d*)*`, `([a-z]+){1,}`) — including through a redundant wrapping
184
+ * group (`((a+))+`, `(?:(a+))*`), which is the SAME exponential class as `(a+)+`
185
+ * and is caught by propagating the inner repeat up through the wrapper. Such a
186
+ * pattern can hang `RegExp.test` for seconds on a short crafted body, and THREE
187
+ * fields feed it to an evaluator with NO timeout — one per document:
188
+ * - the AGENT's `artifact-written.pattern`, run by `evalExits` (F49, the
189
+ * original site). Self-DoS only: the agent authors both the pattern and
190
+ * (via the worker) the artifact, so a hang burns only its own run's
191
+ * wall-clock, and there is no arbiter compromise — it cannot escape the
192
+ * fence, forge a green, or leak a secret.
193
+ * - the OPERATOR's `judged.pattern` (`runClose`) and `gapKeep` (`boundGap`),
194
+ * compiled and run against close output in BARELOOP'S OWN PROCESS. These
195
+ * are worse than an operator self-DoS, and the reason is F67's lesson: the
196
+ * regex blocks the MAIN EVENT LOOP, so the in-process stall fuse — a timer
197
+ * in that same loop — cannot fire. The run dies to the OUTSIDE watchdog and
198
+ * is read as a model stall, so a bad regex in a SIGNED spec presents as a
199
+ * provider problem. MEASURED: `boundGap(stream, '(a+)+$')` on a 40-char
200
+ * body does not finish in 20s.
201
+ * So the reject targets the dominant exponential class and fails it as a
202
+ * mechanical gap at the validation gate, before any tokens burn. A full ReDoS
203
+ * analyzer needs a real regex engine (an external native dep we do not take for
204
+ * a LOW issue); exotic overlapping-alternation blowup is out of scope by
205
+ * decision (PARKED to the close-authoring rung — PRD v1.55).
206
+ *
207
+ * SCOPE IS ASYMMETRIC, both directions named on purpose:
208
+ * - false NEGATIVE: overlapping-alternation blowup (`(a|ab)+`-class) is not
209
+ * detected — there is no inner QUANTIFIER to find, so the shape scan cannot
210
+ * see it; the blowup comes from overlapping alternation BRANCHES under a
211
+ * repeat. A genuine limit of the approach, not a bug in it. Widening the
212
+ * detector changes which SIGNED specs are admissible, which is
213
+ * arbiter-adjacent — parked, not chased (PRD v1.55).
214
+ * - false POSITIVE: a group whose repetitions are disambiguated by a literal
215
+ * anchor/delimiter (`(?:^- .+$\n?)+`, `(?:CHANGELOG:.+\n)+`) is FLAGGED even
216
+ * though a real engine runs it linearly — the scan sees the nested-quantifier
217
+ * SHAPE, not the disambiguation. Rejecting it is the FAIL-SAFE direction (it
218
+ * never admits an unsafe pattern), and the cost is bounded: the plan drafter
219
+ * gets a mechanical gap and rewrites (drop the outer `+`, or match once).
220
+ * Detecting "safe because anchored" needs the same real engine we declined,
221
+ * and guessing it wrong would ADMIT an exponential pattern — so the shape
222
+ * reject stands, and the over-rejection is a named, accepted limitation.
223
+ *
224
+ * The input is guaranteed to compile as a RegExp (checked first by the caller),
225
+ * so this scan assumes valid, balanced JS regex syntax.
226
+ * @param {string} src a compiled regex source string
227
+ * @returns {boolean} true iff a nested unbounded quantifier is present
228
+ */
229
+ export function hasNestedQuantifier(src) {
230
+ /** @type {{ quant: boolean }[]} */
231
+ const stack = [];
232
+ for (let i = 0; i < src.length; i++) {
233
+ const c = src[i];
234
+ if (c === '\\') { i++; continue; } // escaped atom — the next char is a literal
235
+ if (c === '[') { // character class: quantifier chars inside are literals
236
+ i++;
237
+ if (src[i] === '^') i++;
238
+ // NO POSIX leading-`]`-is-a-literal rule: JS is not POSIX here. `[]` is the
239
+ // EMPTY class (matches nothing) and `[^]` the any-char idiom — both close at
240
+ // that first `]`. Skipping it ran the scan past the class's real end and
241
+ // swallowed every quantifier after it, so `x[^](a+)+$` read as safe while
242
+ // RegExp.test on it does not finish in 15s on a 31-char body. A false
243
+ // NEGATIVE is the dangerous direction (F49), so the scan follows JS.
244
+ //
245
+ // Honest limit on the monotonicity claim: this is a PARSE correction, not
246
+ // the "add rejections only" tightening F49's rule contemplates, and it is
247
+ // not strictly rejection-monotonic. Measured over 14,862 compilable
248
+ // generated patterns (2,369 previously rejected): 2 rejections disappear,
249
+ // both pathological `[]`-bearing shapes whose old `true` came from the very
250
+ // misparse being fixed, and both verified to finish RegExp.test in ~0ms.
251
+ // Every entry of the checked-in REDOS_BAD/GOOD/OVERREJECTED corpus is
252
+ // unchanged. So no real hazard was un-rejected — but the claim is
253
+ // "measured, no hazard lost", never "monotonic by construction".
254
+ while (i < src.length && src[i] !== ']') { if (src[i] === '\\') i++; i++; }
255
+ continue;
256
+ }
257
+ if (c === '(') { stack.push({ quant: false }); continue; }
258
+ if (c === ')') {
259
+ const g = stack.pop();
260
+ const qlen = unboundedQuantLen(src, i + 1);
261
+ if (g && g.quant && qlen) return true; // group repeats unboundedly AND its body did too
262
+ // The group is an unbounded-repeated atom of its parent when it is directly
263
+ // re-quantified (qlen) OR its own body already repeats unboundedly (g.quant):
264
+ // a redundant wrapper — ((a+))+ , (?:(a+))* , (((\d*)))+ — is the SAME
265
+ // exponential class as (a+)+, so an inner repeat must propagate THROUGH the
266
+ // enclosing group or the outer quantifier's close never sees it. Propagation
267
+ // is MONOTONIC (it only ever SETS quant=true → only ever adds rejections): it
268
+ // can widen over-rejection — the named FAIL-SAFE direction — but can never
269
+ // introduce a false negative, which is the dangerous one (F49).
270
+ if ((qlen || (g && g.quant)) && stack.length) stack[stack.length - 1].quant = true;
271
+ i += qlen;
272
+ continue;
273
+ }
274
+ const qlen = unboundedQuantLen(src, i); // a quantifier applying to the preceding atom, inside the current group
275
+ if (qlen) { if (stack.length) stack[stack.length - 1].quant = true; i += qlen - 1; }
276
+ }
277
+ return false;
278
+ }
package/types/exits.d.ts CHANGED
@@ -36,6 +36,20 @@ export function evalExits(exits: Array<Record<string, any>>, { dir, snapshot, ru
36
36
  pass: boolean;
37
37
  results: ExitResult[];
38
38
  }>;
39
+ /** The check gap is ALREADY bounded upstream by runClose's boundGap (head +
40
+ * gapKeep block ≤ 8192 + tail ≈ 10KB, with the failing-test NAMES preserved).
41
+ * The evaluator must NOT re-truncate it: a 400-char clamp deletes exactly those
42
+ * `not ok`/`FAILED` names — the mechanical gap the F46 conversion mechanism
43
+ * feeds the worker (F28 reintroduced). This cap is a defensive backstop above
44
+ * boundGap's own envelope: a normal bounded gap passes through intact, and only
45
+ * a pathological seam that returned an unbounded gap would ever be trimmed.
46
+ *
47
+ * Exported for the ONE reason `boundGap`/`GAP_KEEP_TRIM_MARKER` are (src/ralph.js):
48
+ * the replan brief re-bounds this same artifact one seam later (`closeGapBlock`), and
49
+ * a second hand-spelled ceiling there would be a second answer to "how big may a
50
+ * check gap be" — the two would drift on exactly the property that matters, whether
51
+ * the `not ok`/`FAILED` names survive. */
52
+ export const CHECK_GAP_MAX: 12000;
39
53
  /**
40
54
  * `fault` present means the INSTRUMENT could not judge (unwired snapshot/seam,
41
55
  * a check in runClose's forbidden zone) — it carries the runClose verdict name
package/types/job.d.ts CHANGED
@@ -1,4 +1,9 @@
1
- /** @typedef {{code: string, path: string, detail?: string, verb?: string}} Red — `verb` rides request-reds as structured data (the ledger keys on it, never on prose) */
1
+ /** @typedef {{code: string, path: string, detail?: string, verb?: string, lib?: string}} Red — `verb` and `lib` ride request-reds as structured data (the ledger keys on them, never on prose).
2
+ * `lib` is the TERRITORY the demand lands against, stamped HERE at the emit site
3
+ * (the typed-lib rule): one code, two catalogues — a locked TOOL verb is demand
4
+ * against the worker-surface package, a locked VERDICT type is demand against
5
+ * bareloop's OWN menu. Inferring it downstream is how a bareloop refusal gets
6
+ * filed as an upstream bug (the BA-2 misattribution class). */
2
7
  /**
3
8
  * Validate an operator-owned job spec (`schema: "job-v1"`). Never throws on
4
9
  * JSON text or plain parsed data — the ingest contract; a live object with a
@@ -165,11 +170,17 @@ export const PROVIDERS: readonly string[];
165
170
  * not duplicated here). */
166
171
  export const CONDITION_KEYS: readonly string[];
167
172
  /**
168
- * — `verb` rides request-reds as structured data (the ledger keys on it, never on prose)
173
+ * — `verb` and `lib` ride request-reds as structured data (the ledger keys on them, never on prose).
174
+ * `lib` is the TERRITORY the demand lands against, stamped HERE at the emit site
175
+ * (the typed-lib rule): one code, two catalogues — a locked TOOL verb is demand
176
+ * against the worker-surface package, a locked VERDICT type is demand against
177
+ * bareloop's OWN menu. Inferring it downstream is how a bareloop refusal gets
178
+ * filed as an upstream bug (the BA-2 misattribution class).
169
179
  */
170
180
  export type Red = {
171
181
  code: string;
172
182
  path: string;
173
183
  detail?: string;
174
184
  verb?: string;
185
+ lib?: string;
175
186
  };
package/types/plan.d.ts CHANGED
@@ -24,42 +24,6 @@
24
24
  * @returns {string[]} the offerable scopes, deduped, shallowest-first, capped
25
25
  */
26
26
  export function legalScopes(writeScope: string[], dirs?: string[], cap?: number): string[];
27
- /**
28
- * F49 — a heuristic reject for the catastrophic-backtracking footgun: an
29
- * unbounded quantifier applied to a group whose body itself repeats unboundedly
30
- * (`(a+)+`, `(\d*)*`, `([a-z]+){1,}`) — including through a redundant wrapping
31
- * group (`((a+))+`, `(?:(a+))*`), which is the SAME exponential class as `(a+)+`
32
- * and is caught by propagating the inner repeat up through the wrapper. Such a
33
- * pattern can hang `RegExp.test` for
34
- * seconds on a short crafted body, and `evalExits` runs the agent-authored
35
- * `artifact-written` pattern with NO timeout. This is self-DoS only — the agent
36
- * authors both the pattern and (via the worker) the artifact, so a hang burns
37
- * only its own run's wall-clock; there is NO arbiter compromise (it cannot
38
- * escape the fence, forge a green, or leak a secret). So the reject targets the
39
- * dominant exponential class and fails it as a mechanical gap at the validation
40
- * gate, before any tokens burn. A full ReDoS analyzer needs a real regex engine
41
- * (an external native dep we do not take for a LOW issue); exotic
42
- * overlapping-alternation blowup is out of scope by decision.
43
- *
44
- * SCOPE IS ASYMMETRIC, both directions named on purpose:
45
- * - false NEGATIVE: overlapping-alternation blowup (`(a|ab)+`-class) is not
46
- * detected — out of scope (self-DoS only, no arbiter compromise).
47
- * - false POSITIVE: a group whose repetitions are disambiguated by a literal
48
- * anchor/delimiter (`(?:^- .+$\n?)+`, `(?:CHANGELOG:.+\n)+`) is FLAGGED even
49
- * though a real engine runs it linearly — the scan sees the nested-quantifier
50
- * SHAPE, not the disambiguation. Rejecting it is the FAIL-SAFE direction (it
51
- * never admits an unsafe pattern), and the cost is bounded: the plan drafter
52
- * gets a mechanical gap and rewrites (drop the outer `+`, or match once).
53
- * Detecting "safe because anchored" needs the same real engine we declined,
54
- * and guessing it wrong would ADMIT an exponential pattern — so the shape
55
- * reject stands, and the over-rejection is a named, accepted limitation.
56
- *
57
- * The input is guaranteed to compile as a RegExp (checked first by the caller),
58
- * so this scan assumes valid, balanced JS regex syntax.
59
- * @param {string} src a compiled regex source string
60
- * @returns {boolean} true iff a nested unbounded quantifier is present
61
- */
62
- export function hasNestedQuantifier(src: string): boolean;
63
27
  /**
64
28
  * The close, as the ordered STAGE LIST the plan flow actually executes (PRD
65
29
  * v1.28). A staged close is already that list; the legacy object form is the
@@ -135,7 +99,6 @@ export const MAX_EXITS_PER_STEP: 2;
135
99
  /** plan size bound — per-step rounds bound the spend, but an unbounded step
136
100
  * COUNT is still an unbounded claim on the wallet; same 8 as loop.maxIterations */
137
101
  export const MAX_PLAN_STEPS: 8;
138
- export { WRITE_VERBS };
139
102
  /** scope-menu bound: the menu is a PROMPT ingredient on every draft and
140
103
  * redraft, so an unbounded repo would price the drafting call by its directory
141
104
  * count. Shallow entries survive the cap (see `legalScopes`) because they are
@@ -145,7 +108,27 @@ export const MAX_SCOPE_MENU: 24;
145
108
  * construction — the agent picks a tier, never names an arbitrary model string
146
109
  * (the tier→model mapping and any effort params are the RUNNER's, per provider).
147
110
  * opus is deliberately absent: reserved for fresh builds hamr explicitly assigns,
148
- * never plan-selectable. */
111
+ * never plan-selectable.
112
+ *
113
+ * 2026-08-06 — haiku dropped, on hamr's order: "haiku should be dropped to see if
114
+ * it passes, if it's agent poor work or harness, like always." This is a REVERSIBLE
115
+ * ATTRIBUTION PROBE, not a finding that haiku is incapable. What the $0 archive read
116
+ * (186 spines / 71 accepted plans / 255 steps) actually shows is a CONFOUND, in both
117
+ * directions: only 20 steps ever declared a tier at all — haiku 12 (9 of them on a
118
+ * REPLANNED plan, 6 the LAST step of their plan, bounds 3–12 rounds, 2 ever green)
119
+ * against explicitly-declared sonnet 8 (bounds 14–32, 5 green). The planner picks
120
+ * haiku for steps it judges mechanical, and replanned steps sit on runs already in
121
+ * trouble, so neither column is a controlled read. All three bareagent-u runs of
122
+ * 2026-08-06 tiered the REPLANNED step down (haiku/6, haiku/8, haiku/12) and all
123
+ * three failed, against sonnet initial steps at 30–40 rounds — a confound sitting
124
+ * on the programme's most recent capability read. Removing the tier is how it gets
125
+ * attributed: with only one tier expressible, a still-failing replan is agent or
126
+ * harness, not the tier.
127
+ *
128
+ * The `model` FIELD and its validator branch stay: a one-entry menu means a plan (or
129
+ * a stored bridge) declaring `model:"sonnet"` still validates, and restoring haiku is
130
+ * a one-token edit. This narrows only what the AGENT may express — `--model haiku`
131
+ * remains the OPERATOR's probe knob (PRD v1.36), untouched in the runner scripts. */
149
132
  export const STEP_MODELS: readonly string[];
150
133
  export type Red = {
151
134
  code: string;
@@ -154,3 +137,5 @@ export type Red = {
154
137
  verb?: string;
155
138
  };
156
139
  import { WRITE_VERBS } from './job.js';
140
+ import { hasNestedQuantifier } from './validate.js';
141
+ export { WRITE_VERBS, hasNestedQuantifier };
@@ -1,21 +1,83 @@
1
1
  /**
2
- * Files named in an exit gap that the step's worker never wrote — the mechanical
3
- * fact behind the prose-prohibition trap (u-msdpuaej, 2026-08-03): a drafter wrote
4
- * "do not modify any file outside X" into a step whose exit check judges the whole
5
- * goal, the worker obeyed, and the replanner — handed the gap AND the stall note —
6
- * rebuilt the same shape, because inferring "the exit reds on files this step never
7
- * touched" is exactly the semantic leap that fails (F38: mechanical gaps convert,
8
- * semantic ones stall; 3/3 across the mailbox class). This computes that fact from
9
- * the two books that already exist: path-like tokens in the gap text (extension
10
- * required — a bare `step/two` is not a file claim) suffix-matched against the F32
11
- * write audit's absolute paths. Advisory-only and fail-safe: extraction that finds
12
- * nothing yields no line, never a guess.
2
+ * This step's own last EXIT results, rendered for the REPLAN brief.
13
3
  *
14
- * @param {string | undefined} gap the step's last exit gap text
15
- * @param {string[] | undefined} writes the step's cumulative write-audit paths
16
- * @returns {string[]} gap-named files with no matching write, in gap order
4
+ * The label says exits and not "the close", and the distinction is the whole of one
5
+ * fix: `res.gap` is `lastGap`, the join of EVERY failing exit's detail in exit order.
6
+ * With `MAX_EXITS_PER_STEP` at 2 and the mandatory `tree-changed` pairing on a write
7
+ * step, a step that wrote nothing leads the block with the EVALUATOR's own prose
8
+ * (`0 files changed under src/** …`) — captured verbatim from a real replan prompt,
9
+ * under a label reading "the verification's own output". The close stage's output is
10
+ * one of the details here, never reliably all of them. The payload is left alone: "the
11
+ * step wrote nothing" is exactly what a replanner should be told, and stripping it to
12
+ * make an old label true would trade a real fact for a tidy sentence.
13
+ *
14
+ * u-mshcpdg4: a run one strict error from done, killed with $1.10 and 82 seconds
15
+ * unspent. The close named the remaining work exactly — `src/recurse.js(978,115)`
16
+ * — and the worker read that every attempt. The REPLANNER, which is the component
17
+ * that chooses which files the next plan targets, was handed only the trend line
18
+ * (`still progressing — typecheck 30 → 15 → 15 → 1`): converging, and silent about
19
+ * WHERE. It re-targeted `src/loop.js`, already at zero errors; that step wrote
20
+ * nothing and struck out. F85 gave the brief the trajectory; this gives it the
21
+ * artifact the trajectory is a summary OF.
22
+ *
23
+ * Handed over as TEXT, never as a parsed file list, and the trap is worth naming
24
+ * because the parse is the obvious-looking fix: line 1 of that very gap reads
25
+ * `reports 1 error(s) in src/recurse.js, src/loop.js` — every file in SCOPE — and
26
+ * only line 2 names the culprit. The `never wrote` advisory that used to sit here
27
+ * did exactly that parse, and on this gap it resolved to `src/loop.js` — the file
28
+ * already CLEAN — then said so as a directive beside the artifact that said the
29
+ * opposite. It was deleted with this change (hamr, 2026-08-05): two readers of one
30
+ * question, the parsed one wrong, is the same failure the variance meter just had.
31
+ * Nothing generic can tell a summary line from a detail line, and the
32
+ * shapes differ per close anyway (tsc file:line, pytest test ids, a count close
33
+ * that names no file at all). A model reading the artifact can make that
34
+ * distinction; a regex reproduces the bug. So: no extraction, no digest, no
35
+ * inference — the artifact, bounded and scrubbed.
36
+ *
37
+ * Two boundaries, both reused rather than respelled:
38
+ * - SCRUB. A prompt is an egress point and this repo has ONE secret inventory
39
+ * (`SECRET_PATTERNS` via `redactSecrets`). The gap arrives already scrubbed
40
+ * from `judge`, which makes this defense in depth rather than the only guard —
41
+ * and defense in depth at the egress is exactly where it belongs, because the
42
+ * next caller of this helper will not remember the upstream one.
43
+ * - BOUND, and it is a BACKSTOP rather than an envelope. What arrives here has
44
+ * ALREADY been bounded: `runClose` ran `boundGap` with the stage's own `gapKeep`,
45
+ * which deliberately rescues the `not ok`/`FAILED` names out of the elided middle
46
+ * — the mechanical gap F46's conversion mechanism feeds the worker. Re-bounding
47
+ * that with a fresh 400/1500 envelope deletes exactly those names a second time,
48
+ * which is F28 reintroduced; measured on a 120-red-line close output, the first
49
+ * bound rescued 78 names and this one dropped 12 of them. src/exits.js states the
50
+ * same rule for the same artifact one seam earlier, and this now uses ITS number:
51
+ * `CHECK_GAP_MAX`, imported rather than respelled, so the two seams cannot drift
52
+ * on how big a check gap may be. Under it the gap passes through VERBATIM.
53
+ *
54
+ * A backstop is still needed, and the headroom is why the ceiling is not smaller.
55
+ * `res.gap` is `lastGap`, the JOIN of up to `MAX_EXITS_PER_STEP` failing exit
56
+ * details, and the check-passes branch slices EACH at `CHECK_GAP_MAX` — so what
57
+ * arrives is bounded per detail, never in total. Measured, the real maximum is
58
+ * comfortably under: `runClose`'s own envelope tops out at ~10.1KB (400 head +
59
+ * 8192 keep + 1500 tail + labels), and joined with a `tree-changed` line that is
60
+ * ~10.2KB, so the shipped path passes through untouched with ~1.8KB to spare. The
61
+ * backstop therefore fires only where src/exits.js says it should: a seam that
62
+ * returned something never bounded at all.
63
+ *
64
+ * Over the line `boundGap` runs with `REPLAN_GAP_KEEP`, the same envelope the
65
+ * close path uses, and every trim announces itself (F28). Note what that keep-set
66
+ * cannot do: `\S` matches every non-blank line, so the keep block degenerates into
67
+ * "the first 50 lines" and re-surfaces nothing from the middle. That is the honest
68
+ * limit of a red-set that cannot know the stage's own pattern (see
69
+ * `REPLAN_GAP_KEEP`), and it is affordable precisely because it now applies only
70
+ * to a gap nothing upstream ever bounded — on that gap ANY envelope loses names,
71
+ * and the choice is a bounded prompt over an unbounded one.
72
+ *
73
+ * No gap → the empty string, so the brief renders byte-identically to the pre-F86
74
+ * one. A labelled empty section would be an invitation to explain an absence the
75
+ * run never observed (a stall never judged its exits at all).
76
+ *
77
+ * @param {string | null | undefined} gap the step's last exit gap text
78
+ * @returns {string} the labelled block, or '' when there is nothing to show
17
79
  */
18
- export function gapFilesNeverWritten(gap: string | undefined, writes: string[] | undefined): string[];
80
+ export function closeGapBlock(gap: string | null | undefined): string;
19
81
  /**
20
82
  * The plan-drafting prompt: a schema DESCRIPTION built from the live validator
21
83
  * menus — never a copyable example (the drafter must author, not echo; the
@@ -147,6 +209,40 @@ export function planPrompt(job: any, scoutBlob: string, reds: any[] | null, maxS
147
209
  * `flat` — "revise the goal" — on a run that was converging when its allowance ran
148
210
  * out. Baselines only (src/trend.js's THE SEED): it spends none of this leg's own
149
211
  * bounds and mints none of its strikes. Empty/omitted is the cold path.
212
+ * @param {{count?: number, grantUsed?: boolean}|null} [opts.resumeReplans] RESUME — the
213
+ * replan LEDGER the chain has already spent (`readResume`'s `restart.replans` /
214
+ * `restart.replanGrantUsed`), seeding the ceiling below. `resumeGrades`' sibling and
215
+ * deliberately NOT its twin: a grade seed feeds a READOUT, this one feeds a BOUND.
216
+ *
217
+ * Without it the ceiling is reborn on every call, and a resume is another call:
218
+ * measured, a leg that spent both replans and step-redded handed its checkpoint to a
219
+ * second leg that replanned twice more. Two became four by being killed once, which is
220
+ * the creep the latch exists to make impossible ("unlimited replanning launders thrash
221
+ * as adaptation", PRD v1.12).
222
+ *
223
+ * WHY THIS SEED SPENDS THE LEG'S BOUND WHERE `resumeGrades` DOES NOT. src/trend.js's
224
+ * THE SEED refuses to seed the fix loop's ITERATIONS from history "because the leg's
225
+ * own bounds must not be spent by history" — and that is right, because an attempt
226
+ * allowance is a LEG bound: a restarted leg buys its own attempts with its own money.
227
+ * The replan ceiling is a RUN bound by doctrine. It bounds how many times the WORKFLOW
228
+ * may be redrawn before redrawing it is thrash, and that question spans exactly the
229
+ * chain the halt readout already spans. The two seeds therefore disagree on purpose;
230
+ * they are not the same rule applied inconsistently.
231
+ *
232
+ * CHAIN SEMANTICS, and the one place this differs from the grade seed's mechanism.
233
+ * `readGradeSeed`'s documented KNOWN LIMIT is that it reads ONE spine, so a resume of a
234
+ * resume inherits leg 2's grades and not leg 1's — the chain shortens by one leg per
235
+ * resume, which is fail-safe for a READOUT (a shorter chain can only under-claim a
236
+ * direction). It is the DANGEROUS direction for a ceiling: an under-claimed ledger is a
237
+ * refilled allowance, and every kill would buy one. So this seed does NOT reuse that
238
+ * mechanism. It follows the MONEY fold instead (`priorSpentUsd`, `try-start`/
239
+ * `job-start`): each leg DECLARES the ledger it inherited on its own spine, and the
240
+ * next reader adds only that leg's own `replan` records to the declared number. Leg 3
241
+ * therefore inherits the whole chain, not leg 2's slice.
242
+ *
243
+ * `count` seeds `replans` (and, above zero, latches `replanned`); `grantUsed` seeds the
244
+ * F85-C variance latch, so a killed leg cannot re-earn an extra the arbiter has already
245
+ * granted. Null/omitted is the cold path and is byte-identical to a run nobody resumed.
150
246
  * @param {() => boolean} [opts.spendComplete] is the run's spend EXACT, or a floor? The
151
247
  * ledger one level up owns the answer (unpriced rounds, a self-healed stall, a mid-call
152
248
  * cut, an inherited resume floor); the money-halt readout states it beside the remaining
@@ -161,7 +257,7 @@ export function planPrompt(job: any, scoutBlob: string, reds: any[] | null, maxS
161
257
  * 'cap-halt' | 'wall-halt' | 'provider-red' | 'interpreter-red' | 'step-stalled' |
162
258
  * `step-red:<id>`
163
259
  */
164
- export function runPlan(job: any, { workdir, provider, nativeProvider, providerFor, emit, remainingUsd, isUnpriced, spendComplete, capRuns, strikeLimit, closeTimeoutMs, maxStepRounds, layerRoot, scoutRounds, bridge, now, priorWallMs, resumeSeed, resumeGrades }: {
260
+ export function runPlan(job: any, { workdir, provider, nativeProvider, providerFor, emit, remainingUsd, isUnpriced, spendComplete, capRuns, strikeLimit, closeTimeoutMs, maxStepRounds, layerRoot, scoutRounds, bridge, now, priorWallMs, resumeSeed, resumeGrades, resumeReplans }: {
165
261
  workdir: string;
166
262
  provider: any;
167
263
  nativeProvider?: ((o: {
@@ -187,6 +283,10 @@ export function runPlan(job: any, { workdir, provider, nativeProvider, providerF
187
283
  stage?: string | null;
188
284
  value?: number | null;
189
285
  }[] | undefined;
286
+ resumeReplans?: {
287
+ count?: number;
288
+ grantUsed?: boolean;
289
+ } | null | undefined;
190
290
  spendComplete?: (() => boolean) | undefined;
191
291
  now?: (() => number) | undefined;
192
292
  }): Promise<string>;
package/types/ralph.d.ts CHANGED
@@ -111,6 +111,22 @@ export function runStages(stages: any[], redact?: (s: string) => string, { timeo
111
111
  timeoutMs?: number;
112
112
  cwd?: string;
113
113
  }): Promise<any>;
114
+ /**
115
+ * Bound an over-long close stream to a head sample + elided middle + tail. When
116
+ * `keepPattern` is set (F28), lines of the WHOLE stream matching it are also
117
+ * carried in a clearly-delimited block between head and tail — capped — so the
118
+ * failing-test names buried in the middle still reach the worker.
119
+ * Exported for the ONE reason `GAP_KEEP_TRIM_MARKER` above is: a second consumer
120
+ * that needs a gap bounded must reuse THIS envelope, not spell its own. A private
121
+ * copy would be a second truncation scheme, and the two would drift on exactly the
122
+ * property that matters — whether the red lines survive, and whether the trim
123
+ * announces itself (F28: silent truncation is the disease this function cures).
124
+ * The close path is unchanged: `runClose` still calls it with the stage's own
125
+ * `gapKeep`, byte-identically.
126
+ * @param {string} s the (redacted) close output
127
+ * @param {string} [keepPattern] regex source; matching lines survive the elision
128
+ */
129
+ export function boundGap(s: string, keepPattern?: string): string;
114
130
  /**
115
131
  * The exhaustion-by-PROGRESS seam, as a contract rather than "whatever one module
116
132
  * happens to return". Two governors implement it and they read different signals —