bareloop 0.6.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/trend.js ADDED
@@ -0,0 +1,405 @@
1
+ // The close TREND — one instrument, two consumers (PRD v1.46 §2 and §4).
2
+ //
3
+ // It answers ONE question about a run's own close grades: **is this getting
4
+ // better?** The money halt's decision-ready readout asks it to pick the human's
5
+ // lever (a top-up finishes converging work; it is waste on a run that is out of
6
+ // ideas), and the close-fix loop's governor asks it whether to keep paying for
7
+ // another attempt. Both readings come from here so the two can never disagree.
8
+ //
9
+ // THE ACCURACY LAW, and it is the whole reason this module exists as its own
10
+ // file: **a series is PER STAGE, compared only against itself.** The operator's
11
+ // own $0 sweep instrument flattened a staged close's grades into one list and
12
+ // read `12, 2, 5, 0` as a regression — the 2 → 5 step. What actually happened
13
+ // (u-msew1uy5) was two axes falling at once: suppressions 12 → 5 while the type
14
+ // errors that scrub re-exposed went 2 → 0. Merging the axes did not make the read
15
+ // noisy, it made it WRONG in the opposite direction, and it would have told hamr
16
+ // to abandon a converging run. Here the stages are separate buckets by
17
+ // construction, so the mistake is inexpressible ACROSS stages rather than avoided
18
+ // by care — and expressible WITHIN one, which the second KNOWN LIMIT states.
19
+ //
20
+ // Two signals, both mechanical, both off the close's own output:
21
+ //
22
+ // count the first number on the first RED-marked line of the stage that
23
+ // rendered the verdict. Lower is better; the comparison is against the
24
+ // BEST that stage has ever reported, never the last (an A→B→A
25
+ // oscillator repays its own strike forever under last-only — the same
26
+ // reason src/ladder.js keeps a seen-set).
27
+ // stage WHERE in the ordered close the run now stops. `runStages` is
28
+ // first-red-wins, so reaching a LATER stage than ever before means the
29
+ // wall the run was stuck behind is gone — progress no per-stage number
30
+ // can see. Falling back to an EARLIER stage is the opposite, and it is
31
+ // a reading, not an unknown.
32
+ //
33
+ // F6 governs the third answer. A stage whose output carries no number donates
34
+ // NOTHING — not a zero, not a strike. A governor that struck on ignorance would
35
+ // be minting a verdict out of a blind instrument, which is the defect class this
36
+ // repo has shipped six times. What bounds the loop while the instrument is blind
37
+ // is `blindCap`: the fixed count this rule replaces, kept as the fallback for
38
+ // exactly the case the new instrument cannot read, and lifted the moment it can.
39
+ //
40
+ // It bounds the CONSECUTIVE RUN of unreadable gradings, not the run's opening. The
41
+ // first spelling asked "has this leg ever compared anything", a one-way latch — and
42
+ // a stage ADVANCE satisfies it on essentially every run that does any work (the
43
+ // verdict() comment below says so about itself). A 27-iteration repro: one advance
44
+ // flipped the latch, every later grading was a numberless red at the same stage
45
+ // (the shipped aurora TESTGEN `verdict` stage), and from there the loop accrued
46
+ // neither a strike nor a blind tick — bounded by money and the wall alone, where the
47
+ // retired count would have stopped it. "Lifted the moment it can read" is the rule,
48
+ // so it must re-arm the moment it cannot: a streak carries its own baseline and
49
+ // binds at exactly the length a blind-from-birth run does (pinned by test).
50
+ //
51
+ // MOTION — the third signal, and it is deliberately not a fourth verdict. W-2's
52
+ // wall halt used to answer "was it progressing" from its own instrument: byte
53
+ // equality of the last two close gaps, reading `stalled` / `moving` / `unknown`
54
+ // beside this module's `flat` / `converging` / `unknown`. Two instruments for one
55
+ // question is how the two come to disagree, so the byte read moved in here and the
56
+ // wall and the money halt now read ONE reader. It is strictly SUBORDINATE:
57
+ //
58
+ // * where a stage reported a comparable NUMBER, motion is not consulted at all —
59
+ // a number is a direction and bytes are not, and a second opinion next to a
60
+ // real reading is only ever a chance to contradict it.
61
+ // * where no number exists, motion rides INSIDE `unknown` as a reading, never as
62
+ // the headline. "The close's output changed" is not "the run got better": it
63
+ // is compatible with thrash, with an oscillation, and with a worker rewriting
64
+ // the same file two ways. Promoting it to a direction is exactly the F6 error
65
+ // in a trend's coat, and the old vocabulary (`moving`) invited it.
66
+ //
67
+ // The gaps it compares are held IN MEMORY for one comparison and for nothing else.
68
+ // Nothing this module emits or reports ever carries a close byte — the spine is
69
+ // append-only forever, so a gap that leaks into a record leaks for good.
70
+ //
71
+ // THE SEED (PRD v1.46 §3, the resume chain). A resumed leg is the same run
72
+ // continuing, so its readout must span the CHAIN rather than restart at its own
73
+ // first grade: a leg that halted after one reading would otherwise read `unknown`
74
+ // while the run it continues had a measured direction. `seed` folds the dead leg's
75
+ // recorded per-stage numbers into the BASELINES — `series` (so the readout shows
76
+ // the whole chain) and `best` (so this leg's first reading is a comparison rather
77
+ // than an unknown). It folds into nothing else, and each omission is load-bearing:
78
+ //
79
+ // * no ITERATION, because the leg's own bounds must not be spent by history.
80
+ // * no STRIKE, because a strike is a judgment on an attempt and the seed contains
81
+ // none of this leg's attempts.
82
+ // * no `comparableEver`, because that flag answers "can the instrument see
83
+ // anything THIS leg produced" — the blind cap must still govern a leg whose own
84
+ // readings never compare, and a seed that flipped it would disarm the backstop
85
+ // on a run the instrument cannot read.
86
+ // * no STAGE POSITION, because "further than ever before" is an ordering claim
87
+ // about one leg's walk through the close. Seeding it would make the resumed
88
+ // leg's first grade a stage REGRESSION and strike it for arriving.
89
+ //
90
+ // And it belongs to ONE of the two readers, never both — the "ONE PER SERIES" rule
91
+ // below, now with a second reason. The halt readout asks "was the RUN converging
92
+ // when its allowance cut it", which is a question about the chain; the fix loop's
93
+ // governor asks "is THIS loop out of ideas", which is leg-local by definition. A
94
+ // seeded governor was built and measured: a resumed loop that struck out dead flat
95
+ // rendered its own terminal as "still progressing", because the chain had fallen
96
+ // before the handover. One instance answering two questions, exactly as warned.
97
+ //
98
+ // KNOWN LIMIT, stated rather than discovered later. "Lower is better" is a
99
+ // direction this module cannot derive from prose, and a minority of close stages
100
+ // report a FLOOR instead of a fault count (`N tests executed, below the seed's
101
+ // M`) where a rising number is the improvement. Those read as "not improving",
102
+ // never as converging — the fail-safe direction, and deliberately so: a false
103
+ // "flat" costs one conservative stop, a false "converging" costs a top-up spent
104
+ // on a dead run. Never sharpened to chase it (the F49 precedent): the dangerous
105
+ // direction is the other one.
106
+ //
107
+ // THE LAW FOR CLOSE AUTHORS, which is where this module's boundary is enforced:
108
+ // **one POPULATION per stage.** A series is one bucket per stage NAME, and a
109
+ // stage name is not the same thing as an axis — nothing here can stop a close
110
+ // from redding on two structurally different measurements under one name, and
111
+ // the shipped ones once did. `typecheck` red either `reports N error(s) in
112
+ // <scope>` (the in-scope faults) or `the target files are clean but M strict
113
+ // error(s) exist outside them` (a different population entirely, reached only
114
+ // once the first is zero), and `suite-green` had the same shape (`N test(s) now
115
+ // fail` beside an executed-count floor). A run crossing that seam donated both
116
+ // genres to one series, so a 29 → 4 across it read CONVERGING and would have
117
+ // recommended a top-up on work that had only swapped which wall it was behind —
118
+ // the u-msew1uy5 error, in the one place the bucketing could not see it.
119
+ //
120
+ // That shipped instance is RETIRED (F84, hamr-signed 2026-08-05): every u-*-close
121
+ // stage that carried two populations was SPLIT in the close — `typecheck` keeps
122
+ // the in-scope faults with `typecheck-outside` immediately after it, and
123
+ // `tests-kept` carries the floor immediately before `suite-green` — each new
124
+ // stage sitting exactly where its branch already ran, so first-red-wins holds the
125
+ // same gate sequence. The record stays here because the fix belongs to the close
126
+ // and must keep belonging to it: a new close that mixes two populations under one
127
+ // name re-opens this, and the remedy is that close's stage list, never a detector
128
+ // taught to tell two prose shapes apart (the same F49 precedent as above — a
129
+ // per-close sharpening is how this reader stops being one reader).
130
+
131
+ /** The strikes that end the close-fix loop. Shell-owned, exactly where the fixed
132
+ * count was, and the SAME number the step ladder uses — hamr's 2. Validated by a
133
+ * $0 replay over all 8 archived fix loops: 0 greens harmed (every historical
134
+ * fix-loop green converted in <= 2 verdicts) and 1 real waste case caught
135
+ * (reuse-msc6w93z, dead flat at 2 errors for 7 verdicts until the wall). */
136
+ export const FIX_STRIKE_LIMIT = 2;
137
+
138
+ /** `runStages` prefixes every staged gap with its own header — the stage name is
139
+ * therefore already IN the gap and needs no second seam to travel beside it. */
140
+ const STAGE_HEADER = /^close stage "([^"]+)" failed:[ \t]*\r?\n?/;
141
+
142
+ /** A line the close itself marked as a judgment. Every shipped close spells it
143
+ * `red:`, `gate-red:`, `form-red:` or `clean-red:`, so `\bred\b` reads all four
144
+ * without a per-close pattern. Deliberately NOT a bare "first number anywhere":
145
+ * a path, a timestamp or an echoed tool line would donate a number that means
146
+ * nothing, and a wrong number is worse than none (F6 — the honest answer is a
147
+ * null, and it has one). */
148
+ const RED_LINE = /\bred\b/i;
149
+
150
+ /** the count on that line — the first STANDALONE number. No sign: a failure count
151
+ * is never negative, and a leading `-?` turns `foo-3` into -3. Decimals kept
152
+ * (`fault-detection rate 27.5%`). Standalone means not embedded in a word or a
153
+ * dotted token: the digit in a rule id (`D1a`) or a version (`v1.2`) is not a
154
+ * count, and reading one donated a constant wrong number to the governor (review
155
+ * F1 — the live repro was types-close's `D1a — … went 2 → 5` reading as 1). */
156
+ const COUNT = /(?<![\w.])\d+(?:\.\d+)?(?!\w)/g;
157
+
158
+ /** A before→after line (`… went 2 → 5`) states its own trend; the CURRENT value
159
+ * is the last number, and reading the first would grade every iteration with the
160
+ * constant seed baseline — flat by construction. Only such lines read last; every
161
+ * other line keeps first-number (the live fleet's reference-suffixed lines —
162
+ * `5 tests executed, below the seed's 12` — carry the truth up front).
163
+ * KNOWN LIMIT (same rule as the header's): an arrow line with a TRAILING reference
164
+ * number (`went 5 → 2 (seed was 30)`) would read the reference. No shipped close
165
+ * emits that shape; if one appears, the fix is that close's line format — this
166
+ * detector is never sharpened per-close (the F49 precedent). */
167
+ const ARROW = /→/;
168
+
169
+ /**
170
+ * Read one close gap into its stage and its count. Pure, and both halves are
171
+ * independently nullable: a gap with no header still yields its count, a gap
172
+ * with no red-marked line still yields its stage.
173
+ * @param {string} [gap] the close's gap, exactly as `runStages` built it
174
+ * @returns {{stage: string|null, value: number|null}}
175
+ */
176
+ export function readGrade(gap) {
177
+ const text = String(gap ?? '');
178
+ const h = STAGE_HEADER.exec(text);
179
+ // The header is REMOVED before the red scan, never merely skipped over: a stage
180
+ // whose NAME contains "red" would otherwise donate its own (numberless) header
181
+ // line as the reading and mask the real count two lines down.
182
+ const body = h ? text.slice(h[0].length) : text;
183
+ const line = body.split('\n').find((l) => RED_LINE.test(l));
184
+ const nums = line ? line.match(COUNT) : null;
185
+ const pick = nums && nums.length ? (ARROW.test(/** @type {string} */ (line)) ? nums[nums.length - 1] : nums[0]) : null;
186
+ const n = pick !== null ? Number(pick) : NaN;
187
+ return { stage: h ? h[1] : null, value: Number.isFinite(n) ? n : null };
188
+ }
189
+
190
+ /** the bucket key for a gap that named no stage — its own series, never merged
191
+ * into a named stage's (that would be the flattening this module forbids) */
192
+ const UNSTAGED = '(unstaged)';
193
+
194
+ /**
195
+ * Build one trend reader. ONE PER SERIES: the money readout wants the whole run's
196
+ * close grades (the precheck is the seed the work is measured against), while the
197
+ * fix governor wants only the grades its own loop produced. Two instances, one
198
+ * rule — never one instance with a phase field, which is how a shared reader comes
199
+ * to answer two questions with one number.
200
+ *
201
+ * @param {object} [opts]
202
+ * @param {string[]} [opts.stageOrder] the staged close's stage names IN ORDER, so
203
+ * "the run got further than it ever had" is readable. Empty disables that signal
204
+ * entirely (an unknown ordering is unknown, never assumed).
205
+ * @param {number} [opts.limit] consecutive no-progress readings that strike the
206
+ * loop out (default `FIX_STRIKE_LIMIT`).
207
+ * @param {number|null} [opts.blindCap] the RETIRED count, kept as the bound for
208
+ * the case the instrument cannot read at all. Counted in iterations PAST the
209
+ * baseline, so a blind loop runs exactly the number of attempts the old fixed
210
+ * count bought it. `null` = no fallback bound (money and the wall still apply).
211
+ * @param {{stage?: string|null, value?: number|null}[]} [opts.seed] RESUME — the
212
+ * grades a PREVIOUS leg of this same run already recorded, oldest first, read off
213
+ * its own spine. Baselines only (see THE SEED above). Empty/omitted is the cold
214
+ * path and is byte-identical to the pre-resume reader.
215
+ */
216
+ export function createTrend({ stageOrder = [], limit = FIX_STRIKE_LIMIT, blindCap = null, seed = [] } = {}) {
217
+ /** @type {Map<string, number[]>} stage → every count it reported, in order */
218
+ const series = new Map();
219
+ /** @type {Map<string, number>} stage → the BEST (lowest) count it ever reported */
220
+ const best = new Map();
221
+ /** the furthest stage index reached so far; null until a KNOWN stage is seen */
222
+ let bestStageIdx = null;
223
+ let iterations = 0;
224
+ let noProgress = 0;
225
+ let comparableEver = false;
226
+ /** consecutive readings the instrument could not compare, reset by any it could.
227
+ * CONSECUTIVE and not cumulative: a run that reads, goes blind for a stage, then
228
+ * reads again is a governed run, and spending its bound on a healed patch would be
229
+ * the retired count returning through the back door. */
230
+ let uncomparableRun = 0;
231
+ /** did the close ever get FURTHER through its stages than it had before? Kept
232
+ * separately from the counts because it is the one kind of progress no per-stage
233
+ * number can express — and it is the only evidence a run that never reported the
234
+ * same stage twice has. */
235
+ let advancedEver = false;
236
+ /** the last two gaps RECORDED, for the motion fallback and for nothing else. In
237
+ * memory only: never reported, never emitted, never returned (see MOTION above). */
238
+ const lastGaps = [];
239
+
240
+ const idxOf = (/** @type {string} */ stage) => stageOrder.indexOf(stage);
241
+
242
+ // ── the seed, folded in BEFORE any reading of this leg: baselines only ──
243
+ // An unreadable seed entry donates nothing, exactly as an unreadable gap does —
244
+ // the same F6 rule, applied one seam earlier.
245
+ for (const s of Array.isArray(seed) ? seed : []) {
246
+ const v = typeof s?.value === 'number' && Number.isFinite(s.value) ? s.value : null;
247
+ if (v === null) continue;
248
+ const st = s.stage ?? UNSTAGED;
249
+ if (!series.has(st)) series.set(st, []);
250
+ /** @type {number[]} */ (series.get(st)).push(v);
251
+ const b = best.get(st);
252
+ if (b === undefined || v < b) best.set(st, v);
253
+ }
254
+
255
+ return {
256
+ /**
257
+ * Read one RED close grade. A green never reaches here (both callers stop on
258
+ * satisfied), so every reading is a failing grade by construction.
259
+ * @param {{gap?: string, stage?: string|null, value?: number|null}} o either a
260
+ * raw gap (parsed here) or an already-read `{stage, value}` — never both
261
+ * spellings of the same reading in one call.
262
+ * @returns {{iteration: number, stage: string, value: number|null, improved: boolean,
263
+ * comparable: boolean, noProgress: number, limit: number, stageIndex: number}}
264
+ */
265
+ record({ gap, stage, value } = {}) {
266
+ const read = stage === undefined && value === undefined ? readGrade(gap) : { stage: stage ?? null, value: value ?? null };
267
+ // the motion fallback's only input, held for one comparison (MOTION above)
268
+ if (typeof gap === 'string') { lastGaps.push(gap); if (lastGaps.length > 2) lastGaps.shift(); }
269
+ const st = read.stage ?? UNSTAGED;
270
+ const v = typeof read.value === 'number' && Number.isFinite(read.value) ? read.value : null;
271
+ const idx = idxOf(st);
272
+
273
+ // ── the two comparisons, each against this stage's OWN history ──
274
+ const priorBest = best.get(st);
275
+ const countComparable = v !== null && priorBest !== undefined;
276
+ const countImproved = countComparable && v < /** @type {number} */ (priorBest);
277
+ // the ordering signal only exists between two KNOWN positions that DIFFER —
278
+ // an unnamed stage has no position, and standing still says nothing
279
+ const stageComparable = idx >= 0 && bestStageIdx !== null && idx !== bestStageIdx;
280
+ const stageImproved = stageComparable && idx > /** @type {number} */ (bestStageIdx);
281
+
282
+ const comparable = countComparable || stageComparable;
283
+ const improved = countImproved || stageImproved;
284
+
285
+ // ── fold the reading in AFTER the comparison (a reading never beats itself) ──
286
+ if (v !== null) {
287
+ if (!series.has(st)) series.set(st, []);
288
+ /** @type {number[]} */ (series.get(st)).push(v);
289
+ if (priorBest === undefined || v < priorBest) best.set(st, v);
290
+ }
291
+ if (idx >= 0 && (bestStageIdx === null || idx > bestStageIdx)) bestStageIdx = idx;
292
+ if (stageImproved) advancedEver = true;
293
+ iterations += 1;
294
+ if (comparable) {
295
+ comparableEver = true;
296
+ uncomparableRun = 0;
297
+ noProgress = improved ? 0 : noProgress + 1;
298
+ } else {
299
+ uncomparableRun += 1;
300
+ }
301
+ // an UNCOMPARABLE reading neither strikes nor repays: it is an absence of
302
+ // evidence, and the governor must not mint either verdict from one (F6). It
303
+ // does lengthen the blind streak, which is a count of what the instrument
304
+ // could not read — never a judgment on what the attempt did.
305
+
306
+ return { iteration: iterations, stage: st, value: v, improved, comparable, noProgress, limit, stageIndex: idx };
307
+ },
308
+
309
+ /** has the loop run out of ideas — or, while the instrument is blind, out of
310
+ * the count it replaced? Money and the wall are unchanged and bound it either way.
311
+ * The blind arm counts the streak PAST its own first reading, the same `- 1` the
312
+ * blind-from-birth path always used: a streak's opening grade is the baseline the
313
+ * blind iterations are measured from, so a never-comparable run binds on exactly
314
+ * the reading it always did. */
315
+ struckOut: () => noProgress >= limit
316
+ || (blindCap !== null && uncomparableRun - 1 >= blindCap),
317
+
318
+ /** the reader's own books. COUNTS AND STAGE NAMES ONLY — no close bytes ever
319
+ * enter a record from here, because the spine is append-only forever. */
320
+ report: () => ({
321
+ iterations, strikes: noProgress, limit,
322
+ // "the instrument has nothing comparable IN PLAY", which is the question the
323
+ // terminal prose branches on — is the blind cap what bounded this loop, or the
324
+ // strikes? `!comparableEver` alone answered it only for a run blind from birth;
325
+ // on a run that went blind after one advance it read `false` and the terminal
326
+ // would have offered "0/2 strikes" as the reason a loop had just been stopped.
327
+ // A live streak is the same blindness and says so; a leg with no readings yet
328
+ // is blind on the first clause, exactly as the seed rule requires.
329
+ blind: !comparableEver || uncomparableRun > 0,
330
+ stages: [...series.entries()].map(([name, vals]) => ({ stage: name, values: [...vals] })),
331
+ }),
332
+
333
+ /**
334
+ * The DECISION-READY readout (PRD v1.46 §2): which way the run was going when
335
+ * something cut it, in the three verdicts hamr named, and the lever each
336
+ * implies. The library only reports — it never adjusts a budget (hard line).
337
+ * @returns {{trend: 'converging'|'flat'|'unknown', motion: 'changed'|'unchanged'|null,
338
+ * reading: string, lever: string, series: {stage: string, values: number[]}[]}}
339
+ */
340
+ verdict() {
341
+ const rows = [...series.entries()].map(([name, vals]) => ({ stage: name, values: [...vals] }));
342
+ // The HEADLINE verdict rests on numbers only, and the reason is not purity: a
343
+ // stage advance is guaranteed on essentially every run that does any work at
344
+ // all (the precheck reds at stage 1, the plan fixes stage 1, the close walks
345
+ // on), so an ordering-only "converging" would be true of almost every run and
346
+ // could not discriminate the one thing hamr asked it to — whether another
347
+ // dollar finishes this. It stays a real signal for the STRIKE rule, where the
348
+ // question is the different one of whether the LAST attempt helped.
349
+ const numeric = rows.filter((r) => r.values.length > 1);
350
+ const render = numeric.map((r) => `${r.stage} ${r.values.join(' → ')}`).join('; ');
351
+ // convergence is defined PER STAGE and per stage only: some axis is lower than
352
+ // where it started. First-vs-last, because the question a halt asks is "did
353
+ // this run make net progress", not "did the last attempt help".
354
+ const fell = numeric.some((r) => r.values[r.values.length - 1] < r.values[0]);
355
+
356
+ // No stage ever reported a comparable number: the honest answer, stated rather
357
+ // than rounded up to either direction. A "flat" here would recommend rewriting
358
+ // a goal on no evidence at all; a "converging" would recommend spending on the
359
+ // same. The ordering movement, where there was any, rides in the READING as
360
+ // context — it just does not get to decide (F6: an unknown stays an unknown,
361
+ // even when something adjacent to it is known).
362
+ if (numeric.length === 0) {
363
+ // MOTION rides here and ONLY here: inside the unknown, as a reading. Two
364
+ // gaps are the minimum a comparison needs — one grade matching nothing is
365
+ // an absent comparison, and reporting that as "unchanged" would be the
366
+ // same rounding-up of an absence this branch exists to refuse.
367
+ const motion = lastGaps.length < 2 ? null : (lastGaps[0] === lastGaps[1] ? 'unchanged' : 'changed');
368
+ const base = advancedEver
369
+ ? 'no stage reported a comparable number twice — nothing the instrument can compare, though the close did reach a later stage than it had before'
370
+ : 'no stage reported a comparable number twice — nothing the instrument can compare, so the direction is unknown rather than assumed';
371
+ return {
372
+ trend: 'unknown',
373
+ motion,
374
+ reading: motion === 'unchanged'
375
+ ? `${base}; the close's output was byte-identical the last 2 times, so the previous attempt moved nothing the close can see`
376
+ : motion === 'changed'
377
+ ? `${base}; the close's output was still changing between grades, so something moved that the numbers cannot see`
378
+ : base,
379
+ lever: motion === 'unchanged'
380
+ ? 'a frozen close output strongly suggests the goal, not the allowance: revise the goal/spec first (a spec edit, so the new hash needs re-approval)'
381
+ : 'read the last close output before choosing between a top-up and a different goal',
382
+ series: rows,
383
+ };
384
+ }
385
+ // Past this point a real number decided it, so motion is not consulted and is
386
+ // not reported: a byte comparison beside a measured direction can only ever
387
+ // contradict it, and the contradiction would be the weaker signal winning.
388
+ return fell
389
+ ? {
390
+ trend: 'converging',
391
+ motion: null,
392
+ reading: `still progressing — ${render}`,
393
+ lever: 'top up budgetUsd and resume; the work was still converging when the money ran out (a spec edit, so the new hash needs re-approval)',
394
+ series: rows,
395
+ }
396
+ : {
397
+ trend: 'flat',
398
+ motion: null,
399
+ reading: `no stage improved — ${render}`,
400
+ lever: 'more money is unlikely to help; revise the goal/spec first (same re-approval)',
401
+ series: rows,
402
+ };
403
+ },
404
+ };
405
+ }
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
+ }