bareloop 0.45.0 → 0.46.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/CHANGELOG.md +22 -0
- package/bareloop.context.md +27 -2
- package/package.json +1 -1
- package/src/attemptdiff.js +116 -0
- package/src/authorreadout.js +8 -2
- package/src/authorscout.js +1 -1
- package/src/bundle.js +90 -6
- package/src/close-integrity.js +39 -5
- package/src/config.js +5 -2
- package/src/inputlook.js +1 -47
- package/src/judged.js +3 -3
- package/src/labelscan.js +13 -4
- package/src/ladder.js +2 -2
- package/src/learn.js +943 -0
- package/src/learnstore.js +154 -0
- package/src/modellist.js +1 -1
- package/src/pagedry.js +2 -3
- package/src/pagefetch.js +2 -1
- package/src/pagerules.js +27 -4
- package/src/panel/authorroutes.js +1 -1
- package/src/panel/index.html +96 -0
- package/src/panel/server.js +7 -125
- package/src/planrun.js +40 -6
- package/src/providerrows.js +3 -3
- package/src/run.js +4 -2
- package/src/schedulewords.js +1 -1
- package/src/source.js +1 -1
- package/src/stagewords.js +144 -0
- package/src/text.js +4 -2
- package/src/userrun.js +28 -0
- package/src/validate.js +10 -5
- package/src/worktree.js +8 -2
- package/types/attemptdiff.d.ts +38 -0
- package/types/config.d.ts +1 -1
- package/types/inputlook.d.ts +0 -23
- package/types/labelscan.d.ts +6 -0
- package/types/learn.d.ts +575 -0
- package/types/learnstore.d.ts +107 -0
- package/types/pagerules.d.ts +7 -1
- package/types/planrun.d.ts +13 -3
- package/types/reuse.d.ts +1 -1
- package/types/run.d.ts +8 -2
- package/types/stagewords.d.ts +90 -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
|
+
}
|