bareloop 0.6.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/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
+ }
@@ -0,0 +1,198 @@
1
+ /**
2
+ * D6's status ladder, walked in history order. Pure, total, and never throws —
3
+ * it is called on whatever a file happens to contain.
4
+ *
5
+ * - **candidate** — at least one green.
6
+ * - **proven** — greens on ≥2 DISTINCT patients (R2: the same SHAPE on a second
7
+ * instance; two greens on ONE patient is a lookup table, which the memorization
8
+ * audit kills).
9
+ * - a **red** on a PROVEN entry drops it to candidate, and re-promotion then needs
10
+ * greens on two distinct patients AGAIN — that is what "until a new green on a
11
+ * second distinct patient re-proves" means, so the demoting red clears the
12
+ * distinct-patient set rather than leaving the old count standing to re-promote
13
+ * the entry on the first repeat green.
14
+ * - a red on a CANDIDATE changes nothing: there is nothing below the entry bar, and
15
+ * the entry stays in the record with the red beside it.
16
+ * - a **CASUALTY is not a red.** Only the literal outcome `'red'` demotes; every
17
+ * other non-green outcome (`provider-red`, `wall-halt`, `close-crashed`, …) is a
18
+ * casualty, and casualties are never evidence in either direction.
19
+ *
20
+ * @param {unknown} history the bridge's per-run rows, oldest first
21
+ * @returns {'candidate'|'proven'|null} null = below the entry bar (no green yet)
22
+ */
23
+ export function deriveStatus(history: unknown): "candidate" | "proven" | null;
24
+ /**
25
+ * Validate a bridge entry (`schema: "bridge-v1"`). Never throws on JSON text or
26
+ * plain parsed data — the same ingest contract both config validators hold; every
27
+ * failure is a named red. Returns the parsed entry on ok, null on any red.
28
+ * @param {object|string|unknown} input parsed entry, or raw JSON text
29
+ * @returns {{ ok: boolean, reds: Red[], bridge: any }}
30
+ */
31
+ export function validateBridge(input: object | string | unknown): {
32
+ ok: boolean;
33
+ reds: Red[];
34
+ bridge: any;
35
+ };
36
+ /**
37
+ * FIRST GREEN MINTS: build a new registry entry from one green. The entry bar is
38
+ * a green (D6) — there is deliberately no way to create an entry without one, so
39
+ * a failed plan cannot enter the registry even by accident.
40
+ * @param {unknown} meta `{ name, goal, specHash, closeStageNames, toolsUsed }` —
41
+ * `specHash` may be an explicit null (one probe-era bridge on disk has none), and
42
+ * the KEY is required so an unknown hash has to be said rather than omitted.
43
+ * @param {unknown} record `{ runid, patient, at, plan, costUsd, spendComplete, wallMs, rounds, specHash? }`
44
+ * @returns {{ ok: boolean, reds: Red[], bridge: any }}
45
+ */
46
+ export function mintBridge(meta: unknown, record: unknown): {
47
+ ok: boolean;
48
+ reds: Red[];
49
+ bridge: any;
50
+ };
51
+ /**
52
+ * A green on an EXISTING bridge: the plan AS EXECUTED becomes its next version,
53
+ * and the run gets a history row. Pure — returns a NEW entry; the input is never
54
+ * mutated, so a caller that reds still holds the bridge it read from disk.
55
+ * @param {unknown} bridge a validated bridge entry
56
+ * @param {unknown} record `{ runid, patient, at, plan, costUsd, spendComplete, wallMs, rounds, specHash? }`
57
+ * @returns {{ ok: boolean, reds: Red[], bridge: any }}
58
+ */
59
+ export function appendGreen(bridge: unknown, record: unknown): {
60
+ ok: boolean;
61
+ reds: Red[];
62
+ bridge: any;
63
+ };
64
+ /**
65
+ * A red (or a casualty): a history row ONLY. R1 — the bridge file is never edited
66
+ * in place by a red; the recipe that greened stays exactly as it greened, and the
67
+ * status derivation reads the demotion off the history.
68
+ * @param {unknown} bridge a validated bridge entry
69
+ * @param {unknown} record `{ runid, patient, at, outcome, failingStage, costUsd, spendComplete, wallMs, rounds }`
70
+ * @returns {{ ok: boolean, reds: Red[], bridge: any }}
71
+ */
72
+ export function appendRed(bridge: unknown, record: unknown): {
73
+ ok: boolean;
74
+ reds: Red[];
75
+ bridge: any;
76
+ };
77
+ /**
78
+ * One row of D3's listing: what the user (and the selecting LLM) sees per bridge —
79
+ * name, the job sentence it greened, status, greens/reds, and the cost/time BAND of
80
+ * its greens. Deliberately no score (D6).
81
+ *
82
+ * The cost band SKIPS unknowns and says how many it skipped: an aggregate that
83
+ * treats a null as 0 reports a floor that reads as exact, which is F6 in an honest
84
+ * coat. With no priced green at all, every bound is null — never 0.
85
+ *
86
+ * Total and never throws: the listing is a display surface, and one malformed
87
+ * entry must not take the whole listing down (loadRegistry already reports it).
88
+ * @param {unknown} bridge
89
+ * @returns {{name: string|null, goal: string|null, status: 'candidate'|'proven'|null, greens: number, reds: number, lastOutcome: string|null, greenCost: {minUsd: number|null, maxUsd: number|null, minWallMs: number|null, maxWallMs: number|null, unpricedCount: number, untimedCount: number}}}
90
+ */
91
+ export function listingRow(bridge: unknown): {
92
+ name: string | null;
93
+ goal: string | null;
94
+ status: "candidate" | "proven" | null;
95
+ greens: number;
96
+ reds: number;
97
+ lastOutcome: string | null;
98
+ greenCost: {
99
+ minUsd: number | null;
100
+ maxUsd: number | null;
101
+ minWallMs: number | null;
102
+ maxWallMs: number | null;
103
+ unpricedCount: number;
104
+ untimedCount: number;
105
+ };
106
+ };
107
+ /**
108
+ * D2's LOAD-TIME gate, as SPLIT by the 2026-08-01 addendum. Exactly three checks —
109
+ * *"is this the right KIND of recipe?"*, asked at the box:
110
+ *
111
+ * 1. **verdict type matches.** v1 admits `green` only, and a bridge only ever
112
+ * exists because a green minted it, so the bridge side is `green` by
113
+ * construction and the check reads the JOB's declared type. **The day a second
114
+ * verdict class is admitted, the bridge entry must carry its own verdictType
115
+ * and this becomes a two-sided comparison** — recorded here so it cannot be
116
+ * quietly assumed to already be one.
117
+ * 2. **close-stage KINDS match.** v1 simplification: the same stage NAMES in the
118
+ * same ORDER is the definition of the same kinds. The probes' R2-legal pair
119
+ * matched on four identical stage names over different patients, which is the
120
+ * material this rule was ruled on; a real kind-comparison (what each stage
121
+ * INSPECTS, independent of what it was named) is a later rung's problem and is
122
+ * not pretended here.
123
+ * 3. **every stored verb fits THIS job's signed menu.** An omitted `tools` means
124
+ * the concrete current TOOL_MENU — the same predicate `resolveSpec` and
125
+ * `validatePlan` read (MED-1), so what the gate judges is what was signed.
126
+ *
127
+ * **NOTHING about paths, scopes or targets.** Those are instance-bound and are
128
+ * EXPECTED to red: every recipe from a different day names yesterday's bricks, and
129
+ * that is what a recipe IS, not a flaw. The pre-probe measured the drafter
130
+ * replacing them unaided (3/3 legal after tweak), while the gate as originally
131
+ * frozen would have refused all six of them at the door. The full `validatePlan`
132
+ * still judges the TWEAKED draft at draft time — nothing executes unvalidated, and
133
+ * no second, looser path exists for an inherited plan (D4).
134
+ *
135
+ * A failing gate is the **recipe-stale / wrong-kind** OUTCOME — a distinct result
136
+ * with cold drafting offered, never a throw.
137
+ * @param {unknown} bridge
138
+ * @param {unknown} job the validateJob-green spec this run is signed against
139
+ * @returns {{ ok: boolean, reds: Red[] }}
140
+ */
141
+ export function loadGate(bridge: unknown, job: unknown): {
142
+ ok: boolean;
143
+ reds: Red[];
144
+ };
145
+ /**
146
+ * Read one bridge file. Never throws — a missing/unreadable file and a malformed
147
+ * one are both named reds, because the registry is read at selection time and one
148
+ * bad file must not take the run down.
149
+ * @param {string} file
150
+ * @returns {{ ok: boolean, reds: Red[], bridge: any }}
151
+ */
152
+ export function loadBridge(file: string): {
153
+ ok: boolean;
154
+ reds: Red[];
155
+ bridge: any;
156
+ };
157
+ /**
158
+ * Read a whole registry directory. A malformed entry is SKIPPED AND REPORTED —
159
+ * one red per bad file, the good entries still returned, sorted by name for a
160
+ * stable listing. `ok` is false when anything was skipped, so a caller can surface
161
+ * the skip instead of silently listing fewer workflows than exist on disk.
162
+ * @param {string} dir the operator-supplied registry path (no default location)
163
+ * @returns {{ ok: boolean, reds: Red[], bridges: any[] }}
164
+ */
165
+ export function loadRegistry(dir: string): {
166
+ ok: boolean;
167
+ reds: Red[];
168
+ bridges: any[];
169
+ };
170
+ /**
171
+ * Write one bridge entry, ATOMICALLY (temp + rename): a registry read concurrent
172
+ * with a write must see the old entry or the new one, never a half-written file
173
+ * that then reads as a malformed skip. An INVALID entry is refused before anything
174
+ * touches the disk — the registry never holds a file its own validator rejects.
175
+ * @param {string} dir the operator-supplied registry directory (must exist)
176
+ * @param {unknown} bridge
177
+ * @returns {{ ok: boolean, reds: Red[], file: string|null }}
178
+ */
179
+ export function saveBridge(dir: string, bridge: unknown): {
180
+ ok: boolean;
181
+ reds: Red[];
182
+ file: string | null;
183
+ };
184
+ /**
185
+ * Create a registry directory. Separate from `saveBridge` ON PURPOSE: saving must
186
+ * never conjure a registry from a typo'd path, so making one is an explicit act.
187
+ * @param {string} dir
188
+ * @returns {string} the directory
189
+ */
190
+ export function makeRegistry(dir: string): string;
191
+ /** @param {string} dir @returns {boolean} does a registry directory exist */
192
+ export function registryExists(dir: string): boolean;
193
+ export const BRIDGE_SCHEMA: "bridge-v1";
194
+ export type Red = {
195
+ code: string;
196
+ path: string;
197
+ detail?: string;
198
+ };