bareloop 0.45.0 → 0.46.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/CHANGELOG.md +48 -0
  2. package/bareloop.context.md +34 -7
  3. package/package.json +1 -1
  4. package/src/attemptdiff.js +116 -0
  5. package/src/authorreadout.js +55 -14
  6. package/src/authorscout.js +1 -1
  7. package/src/bundle.js +90 -6
  8. package/src/close-integrity.js +39 -5
  9. package/src/config.js +5 -2
  10. package/src/inputlook.js +1 -47
  11. package/src/judged.js +3 -3
  12. package/src/labelscan.js +13 -4
  13. package/src/ladder.js +2 -2
  14. package/src/learn.js +943 -0
  15. package/src/learnstore.js +154 -0
  16. package/src/modellist.js +1 -1
  17. package/src/pagedry.js +2 -3
  18. package/src/pagefetch.js +2 -1
  19. package/src/pagerules.js +27 -4
  20. package/src/panel/authorroutes.js +4 -3
  21. package/src/panel/authorsession.js +5 -7
  22. package/src/panel/index.html +108 -0
  23. package/src/panel/schedulestart.js +5 -0
  24. package/src/panel/server.js +16 -134
  25. package/src/panel/status.js +11 -0
  26. package/src/planrun.js +40 -6
  27. package/src/providerrows.js +3 -3
  28. package/src/run.js +4 -2
  29. package/src/schedulewords.js +1 -1
  30. package/src/source.js +1 -1
  31. package/src/stagewords.js +145 -0
  32. package/src/text.js +4 -2
  33. package/src/userrun.js +28 -0
  34. package/src/validate.js +10 -5
  35. package/src/worktree.js +8 -2
  36. package/types/attemptdiff.d.ts +38 -0
  37. package/types/authorreadout.d.ts +7 -0
  38. package/types/config.d.ts +1 -1
  39. package/types/inputlook.d.ts +0 -23
  40. package/types/labelscan.d.ts +6 -0
  41. package/types/learn.d.ts +575 -0
  42. package/types/learnstore.d.ts +107 -0
  43. package/types/pagerules.d.ts +7 -1
  44. package/types/panel/status.d.ts +8 -0
  45. package/types/planrun.d.ts +13 -3
  46. package/types/reuse.d.ts +1 -1
  47. package/types/run.d.ts +8 -2
  48. package/types/stagewords.d.ts +91 -0
package/src/learn.js ADDED
@@ -0,0 +1,943 @@
1
+ // Cross-run learnings, CODE ONLY (ITEM33-BUILD.md "Addendum 2026-10-22 (rev 2)"; hamr's RULING 1: no model call
2
+ // anywhere in learnings — not to label, fold, rephrase, rank, judge or render). A lesson is a HINT made entirely by
3
+ // code from a run's own record: every field below is COPIED from the spine / gate audit, never written by a model.
4
+ //
5
+ // This module is PURE: no file IO, no clock reads (the caller injects `now`), and it imports nothing that can reach
6
+ // a provider (a static test walks its whole import closure). The thin reader/writer and the run-end call live in
7
+ // src/learnstore.js.
8
+ //
9
+ // Shape of the whole thing:
10
+ // buildCards one run's spine (+ gate audit) -> captioned cards (FIXED / NEVER FIXED), minus noise, suspect, thin
11
+ // foldCards when are two cards one lesson (same job + check + a shared error kind + 60% token overlap)
12
+ // countLedger fold a run's cards into the ONE ledger (+1 per run per row), climbing, decay
13
+ // renderHints the one function that turns the ledger into the block the PLANNER reads (2,000 chars, one render)
14
+ //
15
+ // Graduated from the proofs in .claude/scratch/poc-learn/ (pocA2 candidates, pocC cards, pocE2 fold) as a REWRITE.
16
+ // The 60% threshold is FITTED on known cards (a fit, not a proof; the unseen-card proof is OPEN 2).
17
+
18
+ import { redactSecrets } from './validate.js';
19
+
20
+ // ── constants (every number here is a ruling, not a tuning knob) ─────────────────────────────────
21
+ /** The block the planner reads is never longer than this many characters (RULING 7). */
22
+ export const HINT_CHARS_MAX = 2000;
23
+ /** MEMORY filter only (hamr 2026-10-23): a row untouched this long is not handed to the planner. The ledger keeps every row. */
24
+ export const DECAY_DAYS = 56;
25
+ /** A rubric lesson waits this long for the human Accept, then is dropped uncounted (ruling 7, = the door TTL). */
26
+ export const PENDING_DAYS = 60;
27
+ /** Fold threshold on flagged-text token overlap (FITTED on 87 known cards — see section 1 of the addendum). */
28
+ export const JACCARD_MIN = 0.6;
29
+ /** Climbing: this many distinct workflows on one place -> place; this many distinct places -> user. */
30
+ export const CLIMB_AT = 3;
31
+ /** How many "It said" lines a card keeps. */
32
+ export const SAID_LINES = 3;
33
+ /** CHECK-SUSPECT cards are listed (not folded); the list keeps this many newest. */
34
+ export const SUSPECT_KEEP = 100;
35
+
36
+ const DAY_MS = 86_400_000;
37
+
38
+ /**
39
+ * The no-suppressions tags an error kind may take (the closed list of `suppressions[].id` of the TYPES genre in
40
+ * src/authoring.js; a test fails if the two drift apart). Anything else in a bracket is NOT a kind.
41
+ */
42
+ export const ALLOWED_KINDS = Object.freeze([
43
+ 'ts-ignore', 'ts-expect-error', 'ts-nocheck', 'eslint-disable', 'any', 'any-star', 'cast', 'ignore', 'noqa', 'mypy-disable',
44
+ ]);
45
+
46
+ // ── small text helpers ───────────────────────────────────────────────────────────────────────────
47
+ /** @param {unknown} v @returns {v is string} */
48
+ const isStr = (v) => typeof v === 'string' && v !== '';
49
+ /** @template T @param {T[]} a @returns {T[]} */
50
+ const uniq = (a) => [...new Set(a)];
51
+
52
+ /** Path prefixes of a patient / worktree / hidden-git tree: stripped from every quote so a card names `src/a.js`. */
53
+ // The two leading lookarounds on the first two add no match and drop none. A start that directly follows a whole path segment ("/seg") is covered by the
54
+ // start at that segment's "/" (the chain reads "/seg" and goes on to the same end), so it is skipped - unless that "/" is the last character of a
55
+ // previous match's tail, which the global scan has already consumed (the inner lookbehind). Without them a long "/a/a/a/..." with no marker is quadratic.
56
+ const PREFIXES = [
57
+ /(?=\/)(?<!(?<!\/bareloop-patients\/[^/\s]+)\/[\w.@-]+)(?:\/[\w.@-]+)*\/bareloop-patients\/[^/\s]+\//g,
58
+ /(?:(?=\/)|(?=\.bareloop\/wt\/))(?<!(?<!\.bareloop\/wt\/[^/\s]+)\/[\w.@-]+)(?:\/[\w.@-]+)*\.bareloop\/wt\/[^/\s]+\//g,
59
+ /(?:\.\/)?out\/source-[^/\s]+\/tree\//g,
60
+ ];
61
+
62
+ /**
63
+ * Mask secrets and strip the working-tree prefix from a text. Pure.
64
+ * @param {string} text @param {string|null} workdir
65
+ * @returns {string}
66
+ */
67
+ export function cleanText(text, workdir) {
68
+ let s = redactSecrets(text);
69
+ if (workdir) s = s.split(`${workdir}/`).join('');
70
+ for (const r of PREFIXES) s = s.replace(r, '');
71
+ return s;
72
+ }
73
+
74
+ /**
75
+ * A card/audit path made relative to the run's tree, or null when it is not inside it. The workdir is the
76
+ * authority; the patient-prefix shapes are the fallback for a spine read without one.
77
+ * @param {string} abs @param {string|null} workdir
78
+ * @returns {string|null}
79
+ */
80
+ export function relPath(abs, workdir) {
81
+ if (typeof abs !== 'string') return null;
82
+ if (workdir && abs.startsWith(`${workdir}/`)) return abs.slice(workdir.length + 1);
83
+ let s = abs;
84
+ for (const r of PREFIXES) s = s.replace(r, '');
85
+ return s.startsWith('/') ? null : s;
86
+ }
87
+
88
+ /**
89
+ * Cut at a word / sentence edge, never mid-word where avoidable. Whitespace collapsed.
90
+ * @param {unknown} text @param {number} n @returns {string}
91
+ */
92
+ export function cutText(text, n) {
93
+ const s = String(text ?? '').replace(/\s+/g, ' ').trim();
94
+ if (s.length <= n) return s;
95
+ const sl = s.slice(0, n - 1);
96
+ const sp = sl.lastIndexOf(' ');
97
+ return `${(sp >= n * 0.5 ? sl.slice(0, sp) : sl).replace(/[\s,;:]+$/, '')}…`;
98
+ }
99
+
100
+ /**
101
+ * Deep-redact every string of a plain JSON value through the ONE secret inventory (a secret in a person's repo must
102
+ * not enter an append-only record). Pure; returns a new value.
103
+ * @template T @param {T} v @returns {T}
104
+ */
105
+ export function redactDeep(v) {
106
+ if (typeof v === 'string') return /** @type {any} */ (redactSecrets(v));
107
+ if (Array.isArray(v)) return /** @type {any} */ (v.map((x) => redactDeep(x)));
108
+ if (v && typeof v === 'object') {
109
+ /** @type {Record<string, any>} */
110
+ const o = {};
111
+ for (const [k, x] of Object.entries(v)) o[k] = redactDeep(x);
112
+ return /** @type {any} */ (o);
113
+ }
114
+ return v;
115
+ }
116
+
117
+ // ── error kinds, flagged text, tokens (pocE2 keyOf / stripFlag, ported) ─────────────────────────
118
+ /** `any` inside a `{...}` JSDoc type position: a real type use, not prose. */
119
+ function anyInBrace(/** @type {string} */ t) {
120
+ let d = 0;
121
+ const re = /[{}]|\bany\b/g;
122
+ let m;
123
+ while ((m = re.exec(t))) {
124
+ if (m[0] === '{') d += 1;
125
+ else if (m[0] === '}') d = Math.max(0, d - 1);
126
+ else if (d > 0) return true;
127
+ }
128
+ return false;
129
+ }
130
+
131
+ /**
132
+ * The flagged text of ONE quote line (wrapper stripped), or null when the line is not a flagged line.
133
+ * Flagged lines: `... added <id> - <text>` and `close: [<id>] <file>:<line>: <text>` (id in the allowed list), and
134
+ * lines with `error TS<digits>:` or `error:`.
135
+ * @param {string} line @returns {string|null}
136
+ */
137
+ export function flaggedText(line) {
138
+ let am = line.match(/: added ([a-z-]+) [—-] (.*)$/);
139
+ if (am && ALLOWED_KINDS.includes(am[1])) return am[2];
140
+ am = line.match(/^close:\s+\[([a-z-]+)\]\s+\S+?:\d+: (.*)$/);
141
+ if (am && ALLOWED_KINDS.includes(am[1])) return am[2];
142
+ if (/\berror TS\d+:|\berror:/.test(line)) {
143
+ return line.replace(/^.*?\berror TS\d+:/, '').replace(/^.*?\berror:/, '').replace(/\[[a-z]+(?:-[a-z]+)*\]\s*$/, '');
144
+ }
145
+ return null;
146
+ }
147
+
148
+ /**
149
+ * The error-kind set of a quote: TS codes; `[id]` of `added <id>` and `close: [id]` lines; a mypy trailing bracket
150
+ * code; empty -> `other`. Also the files named, the first symbol, and whether the card is CHECK-SUSPECT.
151
+ * @param {string} text the (path-stripped) quote @param {string} [fullGap] the whole gap, for the suspect read
152
+ * @returns {{kinds: string[], files: string[], symbol: string|null, suspect: boolean}}
153
+ */
154
+ export function errorKinds(text, fullGap = text) {
155
+ const kinds = [...text.matchAll(/\bTS\d+\b/g)].map((m) => m[0]);
156
+ for (const line of text.split('\n')) {
157
+ let am = line.match(/: added ([a-z-]+) [—-] (.*)$/);
158
+ if (!am) am = line.match(/^close:\s+\[([a-z-]+)\]\s+\S+?:\d+: (.*)$/);
159
+ if (am && ALLOWED_KINDS.includes(am[1])) kinds.push(`[${am[1]}]`);
160
+ if (/\berror:/.test(line)) {
161
+ const bm = line.match(/\[([a-z]+(?:-[a-z]+)*)\]\s*$/);
162
+ if (bm) kinds.push(`[${bm[1]}]`);
163
+ }
164
+ }
165
+ // CHECK-SUSPECT: a no-suppressions hit on `any`/`any-star` whose flagged text sits on a prose comment, not on a JSDoc
166
+ // type position. Read over the WHOLE gap (the quote is only a display cut), so a prose hit past the cut still counts.
167
+ let suspect = false;
168
+ for (const line of fullGap.split('\n')) {
169
+ let am = line.match(/: added ([a-z-]+) [—-] (.*)$/);
170
+ if (!am) am = line.match(/^close:\s+\[([a-z-]+)\]\s+\S+?:\d+: (.*)$/);
171
+ if (am && (am[1] === 'any' || am[1] === 'any-star') && /^(\/\/|\/\*|\*|#)/.test(am[2].trim()) && !anyInBrace(am[2])) suspect = true;
172
+ }
173
+ const kindSet = uniq(kinds).sort();
174
+ if (!kindSet.length) kindSet.push('other');
175
+ const files = uniq([...text.matchAll(PATH_RE)].map((m) => m[0].replace(/^\.\//, ''))).sort();
176
+ const sm = text.match(/(?:Variable|Parameter|Property) '([^']+)'/) || text.match(/Name "([^"]+)"/) || text.match(/'([^']+)' is of type/);
177
+ return { kinds: kindSet, files, symbol: sm ? sm[1] : null, suspect };
178
+ }
179
+
180
+ /**
181
+ * The token set of a quote's flagged lines: lowercase runs of `[a-z0-9_]` of length >= 2, union over flagged lines.
182
+ * File paths, line numbers, `added <id> -`, `error TS<n>:` and the trailing mypy code are already stripped.
183
+ * @param {string} text @returns {string[]} sorted
184
+ */
185
+ export function flaggedTokens(text) {
186
+ const s = new Set();
187
+ for (const line of text.split('\n')) {
188
+ const t = flaggedText(line);
189
+ if (t === null) continue;
190
+ for (const m of t.toLowerCase().matchAll(/[a-z0-9_]{2,}/g)) s.add(m[0]);
191
+ }
192
+ return [...s].sort();
193
+ }
194
+
195
+ // ── cards ────────────────────────────────────────────────────────────────────────────────────────
196
+ /**
197
+ * @typedef {object} Card
198
+ * @property {string} id `<runid>:<check>:<fixed|stuck>`
199
+ * @property {string} runid
200
+ * @property {string} at the run's end (ISO)
201
+ * @property {string} date YYYY-MM-DD of `at`
202
+ * @property {string} job the workflow key
203
+ * @property {string|null} model
204
+ * @property {string} check the check / close stage name
205
+ * @property {'FIXED'|'NEVER_FIXED'} kind
206
+ * @property {string} goal Job (as written), cut
207
+ * @property {string|null} stepId
208
+ * @property {string|null} stepAction Step (as planned), cut
209
+ * @property {string|null} meaning the check's own catalogue sentence (code-owned)
210
+ * @property {string[]} said It said: <= 3 lines, paths and prefixes stripped
211
+ * @property {number} saidMore lines left out
212
+ * @property {string[]} kinds error kinds, sorted; ['other'] when none
213
+ * @property {string[]} files files the quote names, sorted
214
+ * @property {string|null} symbol
215
+ * @property {string[]} tokens the flagged-text token set, sorted
216
+ * @property {string|null} nextCheck the next failing check after this moment, if any
217
+ * @property {string} ended how the run ended (job-end outcome, or its escalation word)
218
+ * @property {string[]} fixTouched files written by the passing try (the try that followed the red, for NEVER FIXED)
219
+ * @property {{files: {path: string, diff: string, truncated: boolean, cutLines?: number}[], binary: string[], truncated: boolean}|null} whatChanged
220
+ * the passing try's attempt-diff (FIXED only), as the spine capped it
221
+ * @property {string} key the one-line machine key
222
+ */
223
+
224
+ /**
225
+ * The one-line machine key of a card. Line numbers never appear in it.
226
+ * @param {{id: string, job: string, check: string, kinds: string[], files: string[], model: string|null, runid: string, date: string}} c
227
+ * @returns {string}
228
+ */
229
+ export function cardKey(c) {
230
+ return `card=${c.id} job=${c.job} check=${c.check} kinds=${c.kinds.join(',')} file=${c.files[0] ?? '-'} model=${c.model ?? '-'} run=${c.runid} date=${c.date}`;
231
+ }
232
+
233
+ /** the check name inside a gap text */
234
+ const nameOf = (/** @type {string} */ g) => (g.match(/check "([^"]+)"/)?.[1] ?? g.match(/close stage "([^"]+)"/)?.[1] ?? 'close');
235
+ const CODE = /\bTS\d+\b|\[[a-z][a-z-]+\]|\bE[A-Z]{2,}\b|\bERR_[A-Z_0-9]+\b/g;
236
+
237
+ /**
238
+ * The lines of a gap a card quotes: lines that carry a path:line or an error code come first, accumulated up to
239
+ * ~250 characters (the cut the fold was fitted on). Pure.
240
+ * @param {string} gap @returns {string[]}
241
+ */
242
+ export function quoteLines(gap) {
243
+ const ls = gap.split('\n').filter((l) => l.trim());
244
+ if (!ls.length) return [];
245
+ const pri = ls.filter((l) => /\.\w+[:(]\d+/.test(l) || (l.match(CODE) || []).length);
246
+ const pick = pri.length ? pri : ls;
247
+ /** @type {string[]} */
248
+ const out = [];
249
+ let len = 0;
250
+ for (const l of pick) {
251
+ if (len + l.length + 1 > 250) break;
252
+ out.push(l);
253
+ len += l.length + 1;
254
+ }
255
+ if (!out.length) out.push(pick[0].slice(0, 250));
256
+ return out;
257
+ }
258
+
259
+ /** terminal words that mean the run ended well */
260
+ const isGreen = (/** @type {unknown} */ o) => o === 'green' || o === 'already-green';
261
+ /** escalation categories that make a run NOISE: the instrument, not the work, failed */
262
+ const NOISE_CATEGORIES = new Set(['provider-red', 'close-crashed']);
263
+
264
+ /**
265
+ * @typedef {object} CardBuild
266
+ * @property {Card[]} cards foldable cards (FIXED and NEVER FIXED), suspect and too-thin removed
267
+ * @property {Card[]} suspect CHECK-SUSPECT cards: listed, never folded
268
+ * @property {number} thin TOO-THIN count: no error kind and no fix files
269
+ * @property {string|null} noise why the whole run was set aside (provider-red, close-crashed, worker-crash, no-close-spoke, no-job-end), else null
270
+ */
271
+
272
+ /**
273
+ * Turn one run's record into cards. Every field is COPIED from the spine / gate audit; nothing is generated.
274
+ *
275
+ * Candidates: FIXED = a check red, then the SAME check passes later in the same run; NEVER FIXED = a check still red
276
+ * at run end. At most one of each per check per run (the last red before the pass; the last red). Excluded, counted:
277
+ * noise runs, CHECK-SUSPECT, TOO-THIN.
278
+ *
279
+ * Written files come from `attempt-diff` events (rubric `output/steps/<n>.json` included: the diff force-adds the
280
+ * fence) and the REAL gate audit's allow write/edit rows relative to `workdir` — never `git status`, and never a
281
+ * reader that drops a path it cannot place under a known patient prefix (the POC defect: a rubric job's writes read
282
+ * as none).
283
+ * `questions` is the check catalogue (stage name -> plain question, src/stagewords.js) the caller resolved from the signed
284
+ * spec; a check with none reads `(not in the record)`.
285
+ * @param {{runid: string, events: any[], audit?: any[]|null, workdir?: string|null, spec?: any, model?: string|null, questions?: Map<string, string|null>}} o
286
+ * @returns {CardBuild}
287
+ */
288
+ export function buildCards({ runid, events, audit = null, workdir = null, spec = null, model = null, questions = new Map() }) {
289
+ const ev = (Array.isArray(events) ? events : []).filter((e) => e && typeof e === 'object');
290
+ /** @type {CardBuild} */
291
+ const out = { cards: [], suspect: [], thin: 0, noise: null };
292
+ const jobEnd = ev.findLast((e) => e.type === 'job-end') ?? null;
293
+ if (!jobEnd) { out.noise = 'no-job-end'; return out; }
294
+ const jobStart = ev.find((e) => e.type === 'job-start' && isStr(e.goal)) ?? ev.find((e) => e.type === 'job-start') ?? null;
295
+ const job = isStr(spec?.job) ? spec.job : (isStr(jobStart?.job) ? jobStart.job : null);
296
+ if (job === null) { out.noise = 'no-job-start'; return out; }
297
+ const runModel = model ?? (isStr(jobStart?.model) ? jobStart.model : null);
298
+ const at = isStr(jobEnd.ts) ? jobEnd.ts : '';
299
+
300
+ // ── the walk: stage verdicts, red moments, red->pass pairs (pocA2, indexes instead of timestamps) ──
301
+ /** @type {Map<string, string>} */ const verdict = new Map();
302
+ /** @type {Map<string, any>} */ const gaps = new Map();
303
+ /** @type {Map<string, any>} */ const pend = new Map();
304
+ /** @type {Map<string, {red: any, pass: {i: number, note: string|null}}>} */ const pairs = new Map();
305
+ /** @type {{i: number, check: string}[]} */ const reds = [];
306
+ /** @type {[number, any[]][]} */ const plans = [];
307
+ /** @type {number[]} */ const iters = [];
308
+ let curStep = null;
309
+ let inFix = false;
310
+ for (let i = 0; i < ev.length; i += 1) {
311
+ const e = ev[i];
312
+ if (e.type === 'plan-accepted' && Array.isArray(e.plan?.steps)) plans.push([i, e.plan.steps]);
313
+ if (e.type === 'step-start') { curStep = isStr(e.step) ? e.step : null; inFix = false; }
314
+ if (e.type === 'fix-loop') inFix = true;
315
+ if (e.type === 'iteration-start') iters.push(i);
316
+ const sat = (/** @type {string} */ name, /** @type {unknown} */ note) => {
317
+ const g0 = pend.get(name);
318
+ if (g0) { pairs.set(name, { red: g0, pass: { i, note: isStr(note) ? note : null } }); pend.delete(name); }
319
+ };
320
+ const red = (/** @type {string} */ name, /** @type {any} */ g) => { pend.set(name, { ...g, i }); reds.push({ i, check: name }); };
321
+ if (e.type === 'check-run' && isStr(e.name)) {
322
+ verdict.set(e.name, e.verdict);
323
+ if (e.verdict === 'satisfied') sat(e.name, null);
324
+ }
325
+ if (e.type === 'close-verdict' || e.type === 'outer-close') {
326
+ const stageScoped = e.type === 'outer-close' || inFix;
327
+ if (Array.isArray(e.stages)) {
328
+ for (const st of e.stages) {
329
+ if (st && isStr(st.name)) {
330
+ verdict.set(st.name, st.verdict);
331
+ if (st.verdict === 'satisfied') sat(st.name, st.notes);
332
+ }
333
+ }
334
+ }
335
+ if (isStr(e.gap)) {
336
+ const n = nameOf(e.gap);
337
+ if (n === 'close' && !Array.isArray(e.stages)) verdict.set('close', e.verdict);
338
+ const gg = { gap: e.gap, step: stageScoped ? null : curStep, seq: e.seq };
339
+ gaps.set(n, gg);
340
+ red(n, gg);
341
+ } else if (!Array.isArray(e.stages)) {
342
+ verdict.set('close', e.verdict);
343
+ if (e.verdict === 'satisfied') sat('close', null);
344
+ }
345
+ }
346
+ if (e.type === 'exit-eval') {
347
+ for (const r of e.results || []) {
348
+ if (!r || typeof r !== 'object') continue;
349
+ if (r.type === 'check-passes') {
350
+ if (r.pass === false && isStr(r.detail)) {
351
+ const gg = { gap: r.detail, step: isStr(e.step) ? e.step : curStep, seq: e.seq };
352
+ gaps.set(nameOf(r.detail), gg);
353
+ red(nameOf(r.detail), gg);
354
+ }
355
+ } else if (isStr(r.type)) {
356
+ if (r.pass === true) { verdict.set(r.type, 'satisfied'); sat(r.type, null); }
357
+ else if (r.pass === false) {
358
+ verdict.set(r.type, 'needs_revision');
359
+ if (isStr(r.detail)) {
360
+ const gg = { gap: r.detail, step: isStr(e.step) ? e.step : curStep, seq: e.seq };
361
+ gaps.set(r.type, gg);
362
+ red(r.type, gg);
363
+ }
364
+ }
365
+ }
366
+ }
367
+ }
368
+ }
369
+
370
+ // ── noise runs: the instrument failed, so no lesson about the work can be read off them ──
371
+ const lastEsc = ev.findLast((e) => e.type === 'escalation') ?? null;
372
+ const cat = lastEsc && isStr(lastEsc.category) ? lastEsc.category : null;
373
+ if (cat !== null && NOISE_CATEGORIES.has(cat)) { out.noise = cat; return out; }
374
+ if (verdict.size === 0) { out.noise = cat === 'worker-crash' ? 'worker-crash' : 'no-close-spoke'; return out; }
375
+
376
+ const ended = isGreen(jobEnd.outcome) ? String(jobEnd.outcome) : (cat ?? (isStr(jobEnd.outcome) ? jobEnd.outcome : 'ended'));
377
+ const goal = isStr(jobStart?.goal) ? cutText(cleanText(jobStart.goal, workdir), 200) : '(not in the record)';
378
+ const date = at.slice(0, 10);
379
+
380
+ const writtenIn = (/** @type {{fromI: number, toI: number}} */ w) => {
381
+ /** @type {string[]} */
382
+ const files = [];
383
+ let diff = null;
384
+ for (let k = w.fromI; k <= w.toI && k < ev.length; k += 1) {
385
+ const e = ev[k];
386
+ if (e.type === 'attempt-diff') {
387
+ diff = e;
388
+ for (const f of e.files || []) if (f && isStr(f.path)) files.push(f.path);
389
+ for (const b of e.binary || []) if (isStr(b)) files.push(b);
390
+ }
391
+ }
392
+ if (Array.isArray(audit)) {
393
+ const t0 = Date.parse(ev[w.fromI]?.ts ?? '');
394
+ const t1 = Date.parse(ev[Math.min(w.toI, ev.length - 1)]?.ts ?? '');
395
+ if (Number.isFinite(t0) && Number.isFinite(t1)) {
396
+ for (const a of audit) {
397
+ if (!a || a.phase !== 'gate' || a.decision !== 'allow') continue;
398
+ if (a.action?.type !== 'write' && a.action?.type !== 'edit') continue;
399
+ const t = Date.parse(a.ts ?? '');
400
+ if (!Number.isFinite(t) || t < t0 || t > t1 + 1000) continue;
401
+ const p = relPath(a.action.path, workdir);
402
+ if (p !== null) files.push(p);
403
+ }
404
+ }
405
+ }
406
+ return { files: uniq(files), diff };
407
+ };
408
+
409
+ /**
410
+ * @param {string} check @param {any} g the red moment @param {'FIXED'|'NEVER_FIXED'} kind
411
+ * @param {{i: number, note: string|null}|null} pass
412
+ */
413
+ const mk = (check, g, kind, pass) => {
414
+ const lines = quoteLines(String(g.gap)).map((l) => cleanText(l, workdir));
415
+ const quote = lines.join('\n');
416
+ const full = cleanText(String(g.gap), workdir);
417
+ const k = errorKinds(quote, full);
418
+ // Step (as planned): the failing step's action text in the plan live at that moment
419
+ const stepId = isStr(g.step) ? g.step : null;
420
+ const plan = plans.filter(([pi]) => pi <= g.i).at(-1) ?? plans.at(-1) ?? null;
421
+ const step = stepId && plan ? plan[1].find((x) => x?.id === stepId) : null;
422
+ const stepAction = isStr(step?.action) ? cutText(cleanText(step.action, workdir), 300) : null;
423
+ // the passing try (FIXED) / the try that followed the red (NEVER FIXED)
424
+ let w;
425
+ if (kind === 'FIXED' && pass) {
426
+ const start = Math.max(iters.filter((x) => x <= pass.i).at(-1) ?? 0, g.i + 1);
427
+ w = { fromI: start, toI: pass.i };
428
+ } else {
429
+ const start = iters.find((x) => x > g.i);
430
+ const end = start === undefined ? -1 : (iters.find((x) => x > start) ?? ev.length) - 1;
431
+ w = start === undefined ? { fromI: 0, toI: -1 } : { fromI: start, toI: end };
432
+ }
433
+ const wr = writtenIn(w);
434
+ const fixTouched = wr.files.map((p) => cleanText(p, workdir));
435
+ const diffEv = kind === 'FIXED' ? wr.diff : null;
436
+ const afterI = kind === 'FIXED' && pass ? pass.i : g.i;
437
+ const nextRed = reds.find((r) => r.i > afterI && r.check !== check);
438
+ const said = lines.map((l) => l.replace(/^\s*close:\s*/, '').replace(/^[A-Z][A-Z0-9_-]* \| /, '').trim())
439
+ .filter((l) => l && !/^(check ".*" red: )?close stage ".*" failed:$/.test(l));
440
+ const id = `${runid}:${check}:${kind === 'FIXED' ? 'fixed' : 'stuck'}`;
441
+ const base = {
442
+ id, runid, at, date, job, model: runModel, check, kind, goal,
443
+ stepId, stepAction,
444
+ meaning: questions.get(check) ?? (check === 'tree-changed' ? 'did the step change a file in its scope?' : check === 'close' ? "does the job's close pass?" : null),
445
+ said: said.slice(0, SAID_LINES).map((l) => cutText(l, 220)), saidMore: Math.max(0, said.length - SAID_LINES),
446
+ kinds: k.kinds, files: k.files, symbol: k.symbol, tokens: flaggedTokens(quote),
447
+ nextCheck: nextRed ? nextRed.check : null, ended,
448
+ fixTouched,
449
+ whatChanged: diffEv
450
+ ? {
451
+ files: (diffEv.files || []).map((/** @type {any} */ f) => ({ path: String(f.path), diff: String(f.diff ?? ''), truncated: f.truncated === true, ...(Number.isInteger(f.cutLines) ? { cutLines: f.cutLines } : {}) })),
452
+ binary: (diffEv.binary || []).map(String),
453
+ truncated: diffEv.truncated === true,
454
+ }
455
+ : null,
456
+ };
457
+ /** @type {Card} */
458
+ const card = redactDeep({ ...base, key: cardKey(base) });
459
+ return { card, suspect: k.suspect };
460
+ };
461
+
462
+ /** @type {Card[]} */ const all = [];
463
+ /** @type {Card[]} */ const sus = [];
464
+ const push = (/** @type {{card: Card, suspect: boolean}} */ r) => {
465
+ if (r.suspect) sus.push(r.card);
466
+ else if (r.card.kinds[0] === 'other' && r.card.fixTouched.length === 0) out.thin += 1;
467
+ else all.push(r.card);
468
+ };
469
+ for (const [check, p] of pairs) push(mk(check, p.red, 'FIXED', p.pass));
470
+ for (const [check, g] of gaps) {
471
+ const v = verdict.get(check);
472
+ if (v === 'satisfied') continue;
473
+ if (v !== undefined || check === 'close') push(mk(check, pend.get(check) ?? { ...g, i: reds.findLast((r) => r.check === check)?.i ?? 0 }, 'NEVER_FIXED', null));
474
+ }
475
+ out.cards = all;
476
+ out.suspect = sus;
477
+ return out;
478
+ }
479
+
480
+ // ── fold ─────────────────────────────────────────────────────────────────────────────────────────
481
+ /** @param {{kinds: string[]}} a @param {{kinds: string[]}} b */
482
+ export function sharedKind(a, b) {
483
+ return a.kinds[0] !== 'other' && b.kinds[0] !== 'other' && a.kinds.some((x) => b.kinds.includes(x));
484
+ }
485
+
486
+ /**
487
+ * Do two cards agree on kind? A shared error code / kind, OR (RULED 2026-10-23 R1) both carry NO code (`other`) and both
488
+ * have a non-empty flagged-text token set, so the text rule alone decides. A coded card never matches a code-less one.
489
+ * The 60% was fitted on cards WITH codes; the code-less use is UNTESTED on real data.
490
+ * @param {{kinds: string[], tokens: string[]}} a @param {{kinds: string[], tokens: string[]}} b
491
+ */
492
+ export function kindAgrees(a, b) {
493
+ if (sharedKind(a, b)) return true;
494
+ return a.kinds[0] === 'other' && b.kinds[0] === 'other' && a.tokens.length > 0 && b.tokens.length > 0;
495
+ }
496
+
497
+ /** Jaccard of two token arrays; an empty side scores 0. @param {string[]} a @param {string[]} b */
498
+ export function jaccard(a, b) {
499
+ if (!a.length || !b.length) return 0;
500
+ const sb = new Set(b);
501
+ let n = 0;
502
+ for (const x of a) if (sb.has(x)) n += 1;
503
+ return n / (a.length + b.length - n);
504
+ }
505
+
506
+ /**
507
+ * Are two cards one lesson? Same check, kinds that agree ({@link kindAgrees}: a shared code, or both code-less with text
508
+ * on both sides) and Jaccard >= 60% on flagged-text tokens. `sameJob: false` drops the job test (RULING 1: the cross-job rule for climbing).
509
+ * @param {Card} a @param {Card} b @param {{sameJob?: boolean}} [o]
510
+ * @returns {{match: boolean, score: number}}
511
+ */
512
+ export function cardsMatch(a, b, { sameJob = true } = {}) {
513
+ if ((sameJob && a.job !== b.job) || a.check !== b.check || !kindAgrees(a, b)) return { match: false, score: 0 };
514
+ const score = jaccard(a.tokens, b.tokens);
515
+ return { match: score >= JACCARD_MIN, score };
516
+ }
517
+
518
+ /**
519
+ * Group cards into lessons: single-link components over eligible pairs; every pair INSIDE a component is re-checked
520
+ * for a shared kind, and a component with any pair lacking one splits back to singletons.
521
+ * @param {Card[]} cards
522
+ * @returns {Card[][]} groups, in first-card order; each group in input order
523
+ */
524
+ export function foldCards(cards) {
525
+ const n = cards.length;
526
+ const par = cards.map((_, i) => i);
527
+ const find = (/** @type {number} */ x) => { let r = x; while (par[r] !== r) r = par[r]; par[x] = r; return r; };
528
+ for (let i = 0; i < n; i += 1) for (let j = i + 1; j < n; j += 1) if (cardsMatch(cards[i], cards[j]).match) par[find(i)] = find(j);
529
+ /** @type {Map<number, number[]>} */
530
+ const comps = new Map();
531
+ for (let i = 0; i < n; i += 1) { const r = find(i); comps.set(r, [...(comps.get(r) ?? []), i]); }
532
+ /** @type {Card[][]} */
533
+ const groups = [];
534
+ for (const m of comps.values()) {
535
+ let ok = true;
536
+ for (let a = 0; a < m.length && ok; a += 1) for (let b = a + 1; b < m.length; b += 1) if (!kindAgrees(cards[m[a]], cards[m[b]])) { ok = false; break; }
537
+ if (ok) groups.push(m.map((i) => cards[i]));
538
+ else for (const i of m) groups.push([cards[i]]);
539
+ }
540
+ return groups.sort((x, y) => cards.indexOf(x[0]) - cards.indexOf(y[0]));
541
+ }
542
+
543
+ // ── the ledger ───────────────────────────────────────────────────────────────────────────────────
544
+ /**
545
+ * @typedef {object} Row
546
+ * @property {string} id
547
+ * @property {string} lesson ONE sentence, template-only (see {@link lessonOf})
548
+ * @property {number} count runs seen; STARTS AT 1
549
+ * @property {Card} sample the LATEST card
550
+ * @property {Record<string, number>} models per-model counts (read-only information; never a filter)
551
+ * @property {string} workflow
552
+ * @property {string|null} place
553
+ * @property {'workflow'|'place'|'user'} level STORED as 'workflow' only; the level a reader sees is derived by {@link readableRows} from the rows memory still shows (an older file's stored value is ignored and reset on the next write)
554
+ * @property {'deterministic'|'rubric'} trust
555
+ * @property {string[]} runIds
556
+ * @property {string} firstSeen
557
+ * @property {string} lastSeen
558
+ * @property {string|null} specHash the signed hash the lesson was last seen under (a workflow row of another hash is never matched or read)
559
+ * @property {string} key check + sorted kinds (job-independent); information only since R2 removed the check-wide set-aside
560
+ */
561
+ /**
562
+ * @typedef {object} Pending
563
+ * @property {string} runid @property {string} at @property {string} workflow @property {string|null} place
564
+ * @property {string|null} specHash @property {Card[]} cards
565
+ */
566
+ /**
567
+ * @typedef {object} Ledger
568
+ * @property {1} version
569
+ * @property {Row[]} rows
570
+ * @property {Pending[]} pending rubric cards held until a human Accept (NOT counted, NOT readable)
571
+ * @property {Card[]} suspect CHECK-SUSPECT cards, listed, newest last
572
+ * @property {Record<string, {at: string, noise: string|null, cards: number, suspect: number, thin: number}>} health per run (idempotent by run id)
573
+ */
574
+
575
+ /** @returns {Ledger} */
576
+ export function emptyLedger() {
577
+ return { version: 1, rows: [], pending: [], suspect: [], health: {} };
578
+ }
579
+
580
+ /** The job-independent key of a card: check + sorted kinds. @param {{check: string, kinds: string[]}} c */
581
+ export const keyOfCard = (c) => `${c.check}|${[...c.kinds].sort().join(',')}`;
582
+
583
+ /**
584
+ * ONE sentence, rendered by code from the card's own fields (RULING 5, template-only).
585
+ * @param {Card} c @returns {string}
586
+ */
587
+ export function lessonOf(c) {
588
+ const what = c.meaning ? ` (${c.meaning.replace(/[?.]+$/, '')})` : '';
589
+ const kinds = c.kinds[0] === 'other' ? '' : ` on ${c.kinds.join(' ')}`;
590
+ const file = c.files[0] ? ` in ${c.files.slice(0, 3).join(', ')}${c.files.length > 3 ? ` +${c.files.length - 3} more` : ''}` : '';
591
+ const end = c.kind === 'FIXED' ? 'fixed later in the same run' : 'got stuck here, never fixed';
592
+ return `check "${c.check}"${what} failed${kinds}${file}; ${end}.`;
593
+ }
594
+
595
+ const stem = (/** @type {string|undefined} */ f) => (f ?? '').split('/').at(-1)?.replace(/\.[^.]+$/, '') ?? '';
596
+
597
+ /** Stable row id from the fold's shared fields: job + check + kinds + file stem. @param {Card} c */
598
+ function rowId(c) {
599
+ return [c.job, c.check, [...c.kinds].sort().join('+'), stem(c.files[0]) || '-'].join('~').replace(/[^\w~+.@-]+/g, '_');
600
+ }
601
+
602
+ /**
603
+ * Memory filter: is this row older than 56 days at `now`? Decided from `lastSeen` alone, read-time, never stored.
604
+ * @param {Row} r @param {Date} now
605
+ */
606
+ export const isDecayed = (r, now) => { const t = Date.parse(r.lastSeen); return Number.isFinite(t) && now.getTime() - t > DECAY_DAYS * DAY_MS; };
607
+
608
+ /**
609
+ * Is this workflow row from another job version (a different signed hash than this run's)? Decided from the stored
610
+ * `specHash`, read-time, never stored. Unknown hash on either side is the same version.
611
+ * @param {Row} r @param {string} workflow @param {string|null} specHash
612
+ */
613
+ export const otherJobVersion = (r, workflow, specHash) => r.level === 'workflow' && r.workflow === workflow && r.specHash !== null && specHash !== null && r.specHash !== specHash;
614
+
615
+ /** Drop the retired stored `status`/`expiredBy` fields an older ledger file may carry, and reset a stored `level` (never read; stripped on the next write). @param {Row} r */
616
+ const stripLegacy = (r) => { const { status: _s, expiredBy: _e, ...rest } = /** @type {Row & {status?: unknown, expiredBy?: unknown}} */ (r); return /** @type {Row} */ ({ ...rest, level: 'workflow' }); };
617
+
618
+ /** Rank order for matching and reading: highest count, most recent lastSeen, then id. */
619
+ const byRank = (/** @type {Row} */ a, /** @type {Row} */ b) => b.count - a.count || (a.lastSeen < b.lastSeen ? 1 : a.lastSeen > b.lastSeen ? -1 : 0) || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0);
620
+
621
+ /**
622
+ * Fold ONE run's cards into the ledger. Pure: returns a new ledger.
623
+ *
624
+ * +1 per RUN per row: the run's own cards are folded into lessons first, and a row whose `runIds` already holds the
625
+ * run id is never counted twice (a resumed run, one id, never double counts). A matching lesson replaces the row's
626
+ * sample, bumps the model count and appends the run id (a row last seen 56+ days ago counts +1 and is read again);
627
+ * otherwise it opens a new row at count 1. NEVER FIXED cards count like FIXED ones (RULING 3). A workflow row of
628
+ * another signed hash is never matched here: the new version opens its own rows. Nothing is ever deleted or stamped.
629
+ * @param {Ledger} ledger
630
+ * @param {{runid: string, cards: Card[], workflow: string, place: string|null, specHash: string|null, trust: 'deterministic'|'rubric', now: Date}} o
631
+ * @returns {Ledger}
632
+ */
633
+ export function countLedger(ledger, { runid, cards, workflow, place, specHash, trust, now }) {
634
+ const rows = ledger.rows.map((r) => ({ ...stripLegacy(r), runIds: [...r.runIds], models: { ...r.models } }));
635
+ for (const group of foldCards(cards)) {
636
+ const rep = group.reduce((a, c) => (c.at >= a.at ? c : a));
637
+ const hit = [...rows].sort(byRank).find((r) => r.workflow === workflow && !otherJobVersion(r, workflow, specHash) && cardsMatch(rep, r.sample).match);
638
+ if (hit) {
639
+ if (hit.runIds.includes(runid)) continue;
640
+ hit.count += 1;
641
+ hit.models[rep.model ?? 'unknown'] = (hit.models[rep.model ?? 'unknown'] ?? 0) + 1;
642
+ hit.runIds.push(runid);
643
+ hit.sample = rep;
644
+ hit.lesson = lessonOf(rep);
645
+ hit.key = keyOfCard(rep);
646
+ hit.lastSeen = rep.at;
647
+ hit.specHash = specHash;
648
+ hit.place = place ?? hit.place;
649
+ } else {
650
+ let id = rowId(rep);
651
+ for (let n = 2; rows.some((r) => r.id === id); n += 1) id = `${rowId(rep)}-${n}`;
652
+ rows.push({
653
+ id, lesson: lessonOf(rep), count: 1, sample: rep, models: { [rep.model ?? 'unknown']: 1 }, workflow, place, level: 'workflow',
654
+ trust, runIds: [runid], firstSeen: rep.at, lastSeen: rep.at, specHash, key: keyOfCard(rep),
655
+ });
656
+ }
657
+ }
658
+ return finish({ ...ledger, rows }, now);
659
+ }
660
+
661
+ /**
662
+ * Recompute the derived parts after any change: pending expiry. Ledger rows are never aged out or deleted, and carry no
663
+ * climbed level (that is decided at read time, see {@link readableRows}).
664
+ * @param {Ledger} L @param {Date} now @returns {Ledger}
665
+ */
666
+ function finish(L, now) {
667
+ const rows = L.rows.map(stripLegacy);
668
+ return {
669
+ ...L,
670
+ rows: rows.sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0)),
671
+ pending: L.pending.filter((p) => now.getTime() - Date.parse(p.at) <= PENDING_DAYS * DAY_MS),
672
+ };
673
+ }
674
+
675
+ const LEVEL_RANK = { workflow: 0, place: 1, user: 2 };
676
+
677
+ /**
678
+ * Climbing (RULING 1): the same lesson across workflows — the section-3 rule with "same job" dropped — seen in 3
679
+ * distinct workflows on one place climbs to `place`; in 3 distinct places, to `user`. Derived from the rows handed in
680
+ * alone (every row starts at `workflow`, a stored level is ignored): {@link readableRows} hands it only the rows memory
681
+ * still shows, so a level drops back when siblings age out. Returns copies; keeps every run id.
682
+ * @param {Row[]} rows @returns {Row[]}
683
+ */
684
+ export function climb(rows) {
685
+ const n = rows.length;
686
+ const par = rows.map((_, i) => i);
687
+ const find = (/** @type {number} */ x) => { let r = x; while (par[r] !== r) r = par[r]; par[x] = r; return r; };
688
+ for (let i = 0; i < n; i += 1) for (let j = i + 1; j < n; j += 1) if (cardsMatch(rows[i].sample, rows[j].sample, { sameJob: false }).match) par[find(i)] = find(j);
689
+ /** @type {Map<number, number[]>} */
690
+ const fam = new Map();
691
+ for (let i = 0; i < n; i += 1) { const r = find(i); fam.set(r, [...(fam.get(r) ?? []), i]); }
692
+ /** @type {Row[]} */
693
+ const out = rows.map((r) => ({ ...r, level: 'workflow' }));
694
+ for (const idx of fam.values()) {
695
+ if (idx.length < CLIMB_AT) continue;
696
+ /** @type {Map<string, Set<string>>} */
697
+ const perPlace = new Map();
698
+ for (const i of idx) {
699
+ const p = rows[i].place;
700
+ if (p !== null) perPlace.set(p, (perPlace.get(p) ?? new Set()).add(rows[i].workflow));
701
+ }
702
+ for (const i of idx) {
703
+ const p = rows[i].place;
704
+ /** @type {'workflow'|'place'|'user'} */
705
+ let level = out[i].level;
706
+ if (p !== null && (perPlace.get(p)?.size ?? 0) >= CLIMB_AT && LEVEL_RANK[level] < LEVEL_RANK.place) level = 'place';
707
+ if (perPlace.size >= CLIMB_AT && LEVEL_RANK[level] < LEVEL_RANK.user) level = 'user';
708
+ out[i].level = level;
709
+ }
710
+ }
711
+ return out;
712
+ }
713
+
714
+ /**
715
+ * A rubric run's cards are HELD, not counted, until a human Accept (the existing quarantine is the rule). Replaces
716
+ * an earlier hold for the same run id (a resumed run re-derives its cards). Pure.
717
+ * @param {Ledger} ledger
718
+ * @param {{runid: string, cards: Card[], workflow: string, place: string|null, specHash: string|null, at: string}} o
719
+ * @param {Date} now @returns {Ledger}
720
+ */
721
+ export function holdPending(ledger, { runid, cards, workflow, place, specHash, at }, now) {
722
+ const pending = ledger.pending.filter((p) => p.runid !== runid);
723
+ if (cards.length) pending.push({ runid, at, workflow, place, specHash, cards });
724
+ return finish({ ...ledger, pending }, now);
725
+ }
726
+
727
+ /**
728
+ * The human Accept releases a held rubric run: its cards count now (trust `rubric`). Only forward, only here. A hold
729
+ * older than 60 days is dropped uncounted. Idempotent (no hold = nothing to do; run ids stop a double count).
730
+ * @param {Ledger} ledger @param {string} runid @param {Date} now
731
+ * @returns {{ledger: Ledger, released: number}}
732
+ */
733
+ export function releasePending(ledger, runid, now) {
734
+ const p = ledger.pending.find((x) => x.runid === runid);
735
+ if (!p) return { ledger, released: 0 };
736
+ const rest = { ...ledger, pending: ledger.pending.filter((x) => x.runid !== runid) };
737
+ if (now.getTime() - Date.parse(p.at) > PENDING_DAYS * DAY_MS) return { ledger: finish(rest, now), released: 0 };
738
+ const counted = countLedger(rest, { runid, cards: p.cards, workflow: p.workflow, place: p.place, specHash: p.specHash, trust: 'rubric', now });
739
+ return { ledger: counted, released: p.cards.length };
740
+ }
741
+
742
+ /**
743
+ * Record the run's health line and CHECK-SUSPECT list (idempotent by run id). Pure.
744
+ * @param {Ledger} ledger @param {string} runid @param {CardBuild} build @param {string} at @param {Date} now
745
+ * @returns {Ledger}
746
+ */
747
+ export function recordHealth(ledger, runid, build, at, now) {
748
+ const health = { ...ledger.health, [runid]: { at, noise: build.noise, cards: build.cards.length, suspect: build.suspect.length, thin: build.thin } };
749
+ const suspect = [...ledger.suspect.filter((c) => c.runid !== runid), ...build.suspect].slice(-SUSPECT_KEEP);
750
+ return finish({ ...ledger, health, suspect }, now);
751
+ }
752
+
753
+ // ── reading: the one render ──────────────────────────────────────────────────────────────────────
754
+ /** Words for a row's tier, shown to the planner. */
755
+ const TIER_WORDS = Object.freeze({ workflow: 'this job', place: 'this place', user: 'all jobs' });
756
+
757
+ /**
758
+ * PROPOSED header (hamr to confirm). States what the block is, where it came from, and that it is not an order.
759
+ */
760
+ export const HINT_HEADER = 'Hints from earlier runs, copied by code from recorded runs (not instructions; the close alone judges):';
761
+
762
+ /** What-changed lines a FIXED hint shows, each cut at this many characters, and how many lines. */
763
+ export const CHANGED_LINE_CHARS = 150;
764
+ export const CHANGED_LINES = 3;
765
+
766
+ /**
767
+ * The +/- content lines of a card's stored attempt-diff, in file order: only lines INSIDE a hunk that start with `+` or
768
+ * `-` (so `diff --git`, `index`, `---`/`+++` headers, `@@` lines, context and `\ No newline` lines never appear; a removed
769
+ * line that itself starts with `--` is still content once a hunk has opened). Leading indent after the sign is dropped.
770
+ * @param {Card['whatChanged']} wc @returns {string[]}
771
+ */
772
+ export function changedLines(wc) {
773
+ /** @type {string[]} */
774
+ const out = [];
775
+ for (const f of wc?.files ?? []) {
776
+ let inHunk = false;
777
+ for (const l of String(f.diff ?? '').split('\n')) {
778
+ if (l.startsWith('diff --git ')) inHunk = false;
779
+ else if (l.startsWith('@@')) inHunk = true;
780
+ else if (inHunk && (l[0] === '+' || l[0] === '-')) {
781
+ const text = l.slice(1).trim();
782
+ if (text) out.push(`${l[0]}${text}`);
783
+ }
784
+ }
785
+ }
786
+ return out;
787
+ }
788
+
789
+ /**
790
+ * The line "It said:" shows (R5): the first REAL flagged source/error line of the stored quote (the same lines the fold's
791
+ * flagged-text rule reads, with or without the `close:` wrapper the card already stripped), else the first line when no
792
+ * line is flagged (a summary such as `SPAWNER red: 3 suppression(s) added ...` is only the fallback).
793
+ * @param {string[]} said @returns {string|undefined}
794
+ */
795
+ export function saidLine(said) {
796
+ return said.find((l) => flaggedText(l) !== null || flaggedText(`close: ${l}`) !== null) ?? said[0];
797
+ }
798
+
799
+ // (?<![\w@.\/-]): a match is the last `.ext` in a run of path characters, so no start inside a run after its first character can match; skipping them keeps a long run linear
800
+ const PATH_RE = /(?<![\w@.\/-])[\w@.\/-]+\.(?:mjs|js|ts|py)\b/g;
801
+ const baseName = (/** @type {string} */ p) => p.split('/').at(-1) ?? p;
802
+
803
+ /**
804
+ * Shorten file paths in a text (R5): a path already shown in full earlier in the SAME hint (`shown`, filled in reading
805
+ * order) is written by its basename; a first mention stays as written. Pure apart from filling `shown`.
806
+ * @param {string} text @param {Set<string>} shown @returns {string}
807
+ */
808
+ function shortenPaths(text, shown) {
809
+ return text.replace(PATH_RE, (p) => {
810
+ if (shown.has(p) && p.includes('/')) return baseName(p);
811
+ shown.add(p);
812
+ return p;
813
+ });
814
+ }
815
+
816
+ /** One hint, whole. @param {Row} r @param {number} n */
817
+ function hintText(r, n) {
818
+ const c = r.sample;
819
+ /** @type {Set<string>} */
820
+ const shown = new Set();
821
+ const lesson = `${n}. [${TIER_WORDS[r.level]}, seen ${r.count} time${r.count === 1 ? '' : 's'}] ${r.lesson}`;
822
+ for (const p of lesson.match(PATH_RE) ?? []) shown.add(p);
823
+ const L = [lesson];
824
+ const said = saidLine(c.said);
825
+ if (said) L.push(` It said: ${cutText(shortenPaths(said, shown), 150)}`);
826
+ if (c.kind === 'FIXED' && c.fixTouched.length) {
827
+ const touched = c.fixTouched.slice(0, 4).map((p) => { const out = shown.has(p) && p.includes('/') ? baseName(p) : p; shown.add(p); return out; });
828
+ L.push(` Fix touched: ${touched.join(', ')}${c.fixTouched.length > 4 ? ` +${c.fixTouched.length - 4} more` : ''}${c.fixTouched.length >= 2 && c.fixTouched.length <= 4 ? ' (changed together)' : ''}`);
829
+ }
830
+ if (c.kind === 'FIXED') {
831
+ const ch = changedLines(c.whatChanged);
832
+ if (ch.length) {
833
+ const pad = ' '.repeat(' What changed: '.length);
834
+ const show = ch.slice(0, CHANGED_LINES).map((l) => (l.length > CHANGED_LINE_CHARS ? `${l.slice(0, CHANGED_LINE_CHARS - 1)}…` : l));
835
+ if (ch.length > CHANGED_LINES) show.push(`(+${ch.length - CHANGED_LINES} more)`);
836
+ L.push(` What changed: ${show[0]}`, ...show.slice(1).map((l) => `${pad}${l}`));
837
+ }
838
+ }
839
+ return L.join('\n');
840
+ }
841
+
842
+ /**
843
+ * Which rows may be read for this job, in reading order (THE memory filter; the ledger itself keeps every row).
844
+ * Excluded: pending rows, any row last seen more than 56 days ago, any row with no run id, a workflow row of another
845
+ * job or of another signed hash, a place row of another place. The level (this job / this place / all jobs) is
846
+ * derived HERE by {@link climb} over the rows not decayed at `now` only; the stored level is never read.
847
+ * Order: this job's, then this place's, then all-jobs LAST (R2: climbing wins, so an all-jobs hint only uses leftover
848
+ * room); inside each level highest count first (ties: most recent lastSeen, then id), so the order is total and stable.
849
+ * @param {Ledger} ledger @param {{workflow: string, place: string|null, specHash?: string|null, now: Date}} o
850
+ * @returns {Row[]}
851
+ */
852
+ export function readableRows(ledger, { workflow, place, specHash = null, now }) {
853
+ return climb(ledger.rows.filter((r) => !isDecayed(r, now))).filter((r) => {
854
+ if (r.runIds.length === 0) return false;
855
+ if (r.level === 'workflow') return r.workflow === workflow && !otherJobVersion(r, workflow, specHash);
856
+ if (r.level === 'place') return place !== null && r.place === place;
857
+ return true;
858
+ }).sort((a, b) => LEVEL_RANK[a.level] - LEVEL_RANK[b.level] || byRank(a, b));
859
+ }
860
+
861
+ /**
862
+ * THE one render: the block the PLANNER reads at draft and at the first replan. Byte-for-byte reproducible from the
863
+ * ledger as it stood. Stops at 2,000 characters; a hint is added whole or not at all; nothing readable -> empty text.
864
+ * @param {Ledger} ledger @param {{workflow: string, place: string|null, specHash?: string|null, now: Date}} o
865
+ * @returns {{text: string, ids: string[]}}
866
+ */
867
+ export function renderHints(ledger, o) {
868
+ /** @type {string[]} */
869
+ const ids = [];
870
+ let text = HINT_HEADER;
871
+ for (const r of readableRows(ledger, o)) {
872
+ const next = `${text}\n${hintText(r, ids.length + 1)}`;
873
+ if (next.length > HINT_CHARS_MAX) break;
874
+ text = next;
875
+ ids.push(r.id);
876
+ }
877
+ return ids.length ? { text, ids } : { text: '', ids: [] };
878
+ }
879
+
880
+ /**
881
+ * @typedef {object} HintParts one hint of a rendered block, split back into its fields (for the panel's Audit view)
882
+ * @property {number} n the hint's number in the block
883
+ * @property {string} level 'this job' | 'this place' | 'all jobs' (the words {@link renderHints} wrote)
884
+ * @property {number} count how many runs saw it
885
+ * @property {string} check the check's name
886
+ * @property {string[]} kinds the error codes (empty when the hint names none)
887
+ * @property {boolean} fixed true = fixed later in the same run, false = never fixed
888
+ * @property {string|null} said the "It said" line
889
+ * @property {string|null} touched the "Fix touched" text
890
+ * @property {string[]} changed the shown What-changed lines (+/- lines), without the "(+N more)" line
891
+ * @property {number} moreChanged N of "(+N more)", 0 when absent
892
+ */
893
+
894
+ const HINT_HEAD_RE = /^(\d+)\. \[(this job|this place|all jobs), seen (\d+) times?\] (.*)$/;
895
+
896
+ /**
897
+ * Split a block {@link renderHints} wrote back into its hints. The ONLY reader of that text: it lives beside the
898
+ * writer ({@link hintText}, {@link lessonOf}) and a test renders real blocks and parses them back. A hint it cannot
899
+ * read is returned with what it could read (never thrown, never invented). Pure.
900
+ * @param {string} text @returns {HintParts[]}
901
+ */
902
+ export function parseHintBlock(text) {
903
+ /** @type {HintParts[]} */
904
+ const out = [];
905
+ /** @type {HintParts|null} */
906
+ let cur = null;
907
+ let inChanged = false;
908
+ for (const line of String(text).split('\n')) {
909
+ const m = HINT_HEAD_RE.exec(line);
910
+ if (m) {
911
+ const lesson = m[4];
912
+ const check = /^check "([^"]*)"/.exec(lesson)?.[1] ?? '';
913
+ const kinds = /(?:"|\)) failed on (.+?)(?: in |;)/.exec(lesson)?.[1]?.split(' ') ?? [];
914
+ cur = { n: Number(m[1]), level: m[2], count: Number(m[3]), check, kinds, fixed: /; fixed later in the same run\.$/.test(lesson), said: null, touched: null, changed: [], moreChanged: 0 };
915
+ out.push(cur);
916
+ inChanged = false;
917
+ } else if (cur && line.startsWith(' It said: ')) { cur.said = line.slice(' It said: '.length); inChanged = false; }
918
+ else if (cur && line.startsWith(' Fix touched: ')) { cur.touched = line.slice(' Fix touched: '.length); inChanged = false; }
919
+ else if (cur && line.startsWith(' What changed: ')) { inChanged = true; cur.changed.push(line.slice(' What changed: '.length)); }
920
+ else if (cur && inChanged && line.startsWith(' '.repeat(' What changed: '.length))) {
921
+ const t = line.slice(' What changed: '.length);
922
+ const more = /^\(\+(\d+) more\)$/.exec(t);
923
+ if (more) cur.moreChanged = Number(more[1]); else cur.changed.push(t);
924
+ }
925
+ }
926
+ return out;
927
+ }
928
+
929
+ // ── the place of a job ───────────────────────────────────────────────────────────────────────────
930
+ /**
931
+ * Where a job lives, derived by code from the signed spec's inputs line 1: a website job = the URL host; a repo or
932
+ * plain folder/file job = that path (resolved by the caller). Null when the spec carries no inputs.
933
+ * @param {any} spec @param {(p: string) => string} [resolve] path resolver (the caller passes `realpath`)
934
+ * @returns {string|null}
935
+ */
936
+ export function placeOf(spec, resolve = (p) => p) {
937
+ const v = Array.isArray(spec?.inputs) ? spec.inputs[0]?.value : null;
938
+ if (!isStr(v)) return null;
939
+ if (/^https?:\/\//i.test(v)) {
940
+ try { return new URL(v).host; } catch { return null; }
941
+ }
942
+ return resolve(v);
943
+ }