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
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
// The thin reader/writer around src/learn.js (cross-run learnings, ITEM33-BUILD.md rev 2). All file IO lives here and
|
|
2
|
+
// the pure rules live in learn.js; nothing in either file can reach a model.
|
|
3
|
+
//
|
|
4
|
+
// ONE ledger per user: `<keys home>/learn/ledger.json` (~/.config/bareloop/learn/ledger.json in production; tests inject a
|
|
5
|
+
// scratch home and never create the real directory). It lives outside every person's repo, is written atomically
|
|
6
|
+
// (temp file + rename) and mode 600, and every string in it passes the one secret inventory before it is written.
|
|
7
|
+
|
|
8
|
+
import { existsSync, mkdirSync, readFileSync, realpathSync, renameSync, writeFileSync, chmodSync } from 'node:fs';
|
|
9
|
+
import { join, dirname } from 'node:path';
|
|
10
|
+
import { keysHome } from './keysfile.js';
|
|
11
|
+
import { readConfig } from './config.js';
|
|
12
|
+
import { emptyLedger, redactDeep, countLedger, holdPending, releasePending, recordHealth, buildCards, placeOf, renderHints } from './learn.js';
|
|
13
|
+
import { stageQuestionsByName } from './stagewords.js';
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* `<home>/learn/ledger.json`.
|
|
17
|
+
* @param {string} [home] test seam; production never passes it
|
|
18
|
+
* @returns {string}
|
|
19
|
+
*/
|
|
20
|
+
export function ledgerPath(home) {
|
|
21
|
+
return join(keysHome(home), 'learn', 'ledger.json');
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Read the ledger. A missing file is an empty ledger; an unreadable or wrong-shaped one is reported as `problem` (and the
|
|
26
|
+
* writer then refuses to replace it: an append-only record is never silently reset).
|
|
27
|
+
* @param {string} [home]
|
|
28
|
+
* @returns {{ledger: import('./learn.js').Ledger, exists: boolean, problem: string|null}}
|
|
29
|
+
*/
|
|
30
|
+
export function readLedger(home) {
|
|
31
|
+
const file = ledgerPath(home);
|
|
32
|
+
if (!existsSync(file)) return { ledger: emptyLedger(), exists: false, problem: null };
|
|
33
|
+
try {
|
|
34
|
+
const d = JSON.parse(readFileSync(file, 'utf8'));
|
|
35
|
+
if (!d || typeof d !== 'object' || d.version !== 1 || !Array.isArray(d.rows) || !Array.isArray(d.pending)) {
|
|
36
|
+
return { ledger: emptyLedger(), exists: true, problem: `${file} is not a version-1 learnings ledger — fix or remove it` };
|
|
37
|
+
}
|
|
38
|
+
return { ledger: { ...emptyLedger(), ...d }, exists: true, problem: null };
|
|
39
|
+
} catch (/** @type {any} */ e) {
|
|
40
|
+
return { ledger: emptyLedger(), exists: true, problem: `${file} is not readable JSON (${e?.code ?? 'parse error'}) — fix or remove it` };
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Write the ledger atomically (temp + rename, mode 600), every string masked through the one secret inventory.
|
|
46
|
+
* @param {import('./learn.js').Ledger} ledger @param {string} [home]
|
|
47
|
+
* @returns {string} the file written
|
|
48
|
+
*/
|
|
49
|
+
export function writeLedger(ledger, home) {
|
|
50
|
+
const file = ledgerPath(home);
|
|
51
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
52
|
+
const tmp = `${file}.tmp-${process.pid}`;
|
|
53
|
+
writeFileSync(tmp, `${JSON.stringify(redactDeep(ledger), null, 1)}\n`, { mode: 0o600 });
|
|
54
|
+
chmodSync(tmp, 0o600);
|
|
55
|
+
renameSync(tmp, file);
|
|
56
|
+
return file;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Fold ONE finished run into the ledger on disk: a deterministic job's cards COUNT now; a rubric job's cards are HELD
|
|
61
|
+
* until a human Accept (they are written to `pending`, never counted, never readable). The run's health line is recorded
|
|
62
|
+
* either way. Never throws on a bad ledger file: it reports `problem` and writes nothing.
|
|
63
|
+
* @param {{home?: string, runid: string, build: import('./learn.js').CardBuild, workflow: string, place: string|null, specHash: string|null, trust: 'deterministic'|'rubric', at: string, now?: Date}} o
|
|
64
|
+
* @returns {{wrote: boolean, counted: number, held: number, problem: string|null}}
|
|
65
|
+
*/
|
|
66
|
+
export function foldRunIntoLedger({ home, runid, build, workflow, place, specHash, trust, at, now = new Date() }) {
|
|
67
|
+
const cur = readLedger(home);
|
|
68
|
+
if (cur.problem) return { wrote: false, counted: 0, held: 0, problem: cur.problem };
|
|
69
|
+
let L = cur.ledger;
|
|
70
|
+
let counted = 0;
|
|
71
|
+
let held = 0;
|
|
72
|
+
if (trust === 'rubric') {
|
|
73
|
+
L = holdPending(L, { runid, cards: build.cards, workflow, place, specHash, at }, now);
|
|
74
|
+
held = build.cards.length;
|
|
75
|
+
} else {
|
|
76
|
+
const before = L.rows.reduce((n, r) => n + r.runIds.filter((x) => x === runid).length, 0);
|
|
77
|
+
L = countLedger(L, { runid, cards: build.cards, workflow, place, specHash, trust, now });
|
|
78
|
+
counted = L.rows.reduce((n, r) => n + r.runIds.filter((x) => x === runid).length, 0) - before;
|
|
79
|
+
}
|
|
80
|
+
L = recordHealth(L, runid, build, at, now);
|
|
81
|
+
writeLedger(L, home);
|
|
82
|
+
return { wrote: true, counted, held, problem: null };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* The human Accept at the review door: release the run's held rubric cards so they count (only forward, only here).
|
|
87
|
+
* @param {{home?: string, runid: string, now?: Date}} o
|
|
88
|
+
* @returns {{released: number, problem: string|null}}
|
|
89
|
+
*/
|
|
90
|
+
export function releaseRun({ home, runid, now = new Date() }) {
|
|
91
|
+
const cur = readLedger(home);
|
|
92
|
+
if (cur.problem) return { released: 0, problem: cur.problem };
|
|
93
|
+
const r = releasePending(cur.ledger, runid, now);
|
|
94
|
+
if (r.ledger !== cur.ledger) writeLedger(r.ledger, home);
|
|
95
|
+
return { released: r.released, problem: null };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* The ONE setting: `learningHints` in config.json. Ships OFF (absent = off). OFF still writes the tally ($0) and the planner
|
|
100
|
+
* is simply handed nothing, so an ON/OFF contrast has data at once. An unreadable config reads as OFF.
|
|
101
|
+
* @param {string} [home]
|
|
102
|
+
* @returns {boolean}
|
|
103
|
+
*/
|
|
104
|
+
export function learningHintsOn(home) {
|
|
105
|
+
const c = readConfig({ home });
|
|
106
|
+
return c.problem === null && c.config.learningHints === true;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** @param {string} p @returns {any[]} the parsed rows of a JSONL file; missing or torn lines are skipped */
|
|
110
|
+
function readJsonl(p) {
|
|
111
|
+
if (!existsSync(p)) return [];
|
|
112
|
+
/** @type {any[]} */
|
|
113
|
+
const rows = [];
|
|
114
|
+
for (const l of readFileSync(p, 'utf8').split('\n')) {
|
|
115
|
+
if (!l) continue;
|
|
116
|
+
try { rows.push(JSON.parse(l)); } catch { /* a torn line is skipped, never a crash */ }
|
|
117
|
+
}
|
|
118
|
+
return rows;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** a path resolved to its real location when it exists, else as written @param {string} p */
|
|
122
|
+
const realOr = (p) => { try { return realpathSync(p); } catch { return p; } };
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* THE RUN-END TALLY ($0, code only, no model): turn the finished run's whole record into cards and fold them into the
|
|
126
|
+
* ledger (a rubric job's are held until Accept). Called once from the one engine's run-end block (src/userrun.js
|
|
127
|
+
* `execute`, shared by the panel, the CLI and the bundle runner), inside its own try/catch: it is a record, never a verdict,
|
|
128
|
+
* and can never change a run's outcome, exit code or spend. It runs whether the hints setting is on or off.
|
|
129
|
+
* @param {{home?: string, runid: string, events: any[], auditFiles?: string[], spec: any, specHash: string|null, workdir: string|null, trust: 'deterministic'|'rubric', now?: Date}} o
|
|
130
|
+
* @returns {{wrote: boolean, counted: number, held: number, problem: string|null, noise: string|null, cards: number}}
|
|
131
|
+
*/
|
|
132
|
+
export function learnAtRunEnd({ home, runid, events, auditFiles = [], spec, specHash, workdir, trust, now = new Date() }) {
|
|
133
|
+
const audit = auditFiles.flatMap(readJsonl);
|
|
134
|
+
const build = buildCards({ runid, events, audit, workdir, spec, questions: stageQuestionsByName(spec) });
|
|
135
|
+
const at = events.findLast((e) => e?.type === 'job-end')?.ts ?? now.toISOString();
|
|
136
|
+
const r = foldRunIntoLedger({
|
|
137
|
+
home, runid, build, workflow: String(spec.job), place: placeOf(spec, realOr), specHash, trust, at, now,
|
|
138
|
+
});
|
|
139
|
+
return { ...r, noise: build.noise, cards: build.cards.length };
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* The block the planner is handed for this job, made ONCE at run start from the ledger as it stood — empty when the
|
|
144
|
+
* setting is off, when there is no ledger, or when nothing is readable. The one `renderHints` render, never a second.
|
|
145
|
+
* @param {{home?: string, spec: any, specHash: string|null, now?: Date}} o
|
|
146
|
+
* @returns {{text: string, ids: string[]}|null}
|
|
147
|
+
*/
|
|
148
|
+
export function hintsForRun({ home, spec, specHash, now = new Date() }) {
|
|
149
|
+
if (!learningHintsOn(home)) return null;
|
|
150
|
+
const cur = readLedger(home);
|
|
151
|
+
if (cur.problem) return null;
|
|
152
|
+
const r = renderHints(cur.ledger, { workflow: String(spec.job), place: placeOf(spec, realOr), specHash, now });
|
|
153
|
+
return r.text ? r : null;
|
|
154
|
+
}
|
package/src/modellist.js
CHANGED
|
@@ -55,7 +55,7 @@ export function idsOf(shape, body) {
|
|
|
55
55
|
* @returns {Promise<ListResult>}
|
|
56
56
|
*/
|
|
57
57
|
export async function listModels({ shape, baseUrl, key, fetchImpl = fetch, timeoutMs = LIST_TIMEOUT_MS }) {
|
|
58
|
-
const base = typeof baseUrl === 'string' ? baseUrl.trim().replace(
|
|
58
|
+
const base = typeof baseUrl === 'string' ? baseUrl.trim().replace(/(?<!\/)\/+$/, '') : '';
|
|
59
59
|
if (base === '') return { ok: false, why: 'Type a Base URL.', status: 'no-base-url' };
|
|
60
60
|
const url = `${base}/models`;
|
|
61
61
|
const started = Date.now();
|
package/src/pagedry.js
CHANGED
|
@@ -7,14 +7,13 @@
|
|
|
7
7
|
|
|
8
8
|
import { createHash } from 'node:crypto';
|
|
9
9
|
import { gradeStep } from './rubricstep.js';
|
|
10
|
-
import { linesOf } from './pagerules.js';
|
|
10
|
+
import { linesOf, numberAtEnd } from './pagerules.js';
|
|
11
11
|
|
|
12
12
|
/** @typedef {import('./pagerules.js').KeptFact} KeptFact @typedef {import('./pagerules.js').Dropped} Dropped */
|
|
13
13
|
|
|
14
14
|
/** a number as the page writes it, thousands commas allowed */
|
|
15
15
|
const NUM = '\\d[\\d,]*(?:\\.\\d+)?';
|
|
16
16
|
const AFTER_LABEL = new RegExp(`^\\s*(?:[$€£]\\s*)?(${NUM})`);
|
|
17
|
-
const BEFORE_UNIT = new RegExp(`(${NUM})\\s*$`);
|
|
18
17
|
/** the label every dry-read claim is made under (the checker only needs one name; the fact's own is not read) */
|
|
19
18
|
const CLAIM = 'price';
|
|
20
19
|
const SOURCE = 'page';
|
|
@@ -39,7 +38,7 @@ export function moneyResultLines(text, k) {
|
|
|
39
38
|
}
|
|
40
39
|
} else {
|
|
41
40
|
for (const u of k.unit ?? []) {
|
|
42
|
-
for (let at = ln.indexOf(u); at !== -1 && amount === undefined; at = ln.indexOf(u, at + u.length)) amount =
|
|
41
|
+
for (let at = ln.indexOf(u); at !== -1 && amount === undefined; at = ln.indexOf(u, at + u.length)) amount = numberAtEnd(ln.slice(0, at));
|
|
43
42
|
if (amount !== undefined) break;
|
|
44
43
|
}
|
|
45
44
|
}
|
package/src/pagefetch.js
CHANGED
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
// fetch is a plain-words stop, never a silent empty input. Nothing here touches a model.
|
|
11
11
|
|
|
12
12
|
import { SECRET_PATTERNS } from './validate.js';
|
|
13
|
+
import { TAG } from './labelscan.js';
|
|
13
14
|
|
|
14
15
|
/** the session options (incognito: no auth ever enters; blockPrivateNetwork: SSRF guard); ONE frozen object so a test can pin them */
|
|
15
16
|
export const CONNECT_OPTIONS = Object.freeze({ incognito: true, blockPrivateNetwork: true });
|
|
@@ -59,7 +60,7 @@ export function pageContentChars(text) {
|
|
|
59
60
|
skipIndent = -1;
|
|
60
61
|
}
|
|
61
62
|
if (m && CHROME_ROLES.has(m[2])) { skipIndent = indent; continue; }
|
|
62
|
-
n += raw.replace(
|
|
63
|
+
n += raw.replace(TAG, ' ').replace(/^\s*-\s*/, '').replace(/\s+/g, ' ').trim().length;
|
|
63
64
|
}
|
|
64
65
|
return n;
|
|
65
66
|
}
|
package/src/pagerules.js
CHANGED
|
@@ -14,7 +14,7 @@ import { normLabel } from './labelscan.js';
|
|
|
14
14
|
* @typedef {{facts: MapFact[]}} PageMap
|
|
15
15
|
* @typedef {{line: number, name: string, shape: string, from: string, words: string[], unit?: string[], currency?: string[], forRule?: number, reading?: Reading}} KeptFact
|
|
16
16
|
* `words` = what the page proves: the labels of a money/number/time fact, or the alternative phrases of a phrase fact (a place is a phrase)
|
|
17
|
-
* @typedef {{line: number, rule?: number, fact: string, word?: string, reason: string, other?: string}} Dropped `other` = what the drop is compared to: the first label (R1), the primary form (R1L), the fact it would have repeated (S1)
|
|
17
|
+
* @typedef {{line: number, rule?: number, fact: string, word?: string, reason: string, other?: string}} Dropped `other` = what the drop is compared to: the first label (R1), the primary form (R1L), the fact it would have repeated (S1), the phrase it looked for (phrase-no-result)
|
|
18
18
|
*/
|
|
19
19
|
|
|
20
20
|
/** @param {unknown} v @returns {v is Record<string, any>} */
|
|
@@ -99,7 +99,30 @@ const sameSet = (a, b) => a.size === b.size && [...a].every((x) => b.has(x));
|
|
|
99
99
|
|
|
100
100
|
/** a number as the page writes it: digits, thousands commas, optional decimals (a currency sign next to it is not part of it) */
|
|
101
101
|
const NUM = '\\d[\\d,]*(?:\\.\\d+)?';
|
|
102
|
-
|
|
102
|
+
/**
|
|
103
|
+
* The number written at the very end of `s` (only whitespace after it): the leftmost match of NUM followed by `\s*$`. Shared with src/pagedry.js (one copy). Scans back from the end, so a
|
|
104
|
+
* long run of digits and commas costs O(n) where a backtracking regex is quadratic.
|
|
105
|
+
* @param {string} s @returns {string|undefined}
|
|
106
|
+
*/
|
|
107
|
+
export function numberAtEnd(s) {
|
|
108
|
+
let e = s.length;
|
|
109
|
+
while (e > 0 && /\s/.test(s[e - 1])) e--;
|
|
110
|
+
/** the first digit of the digit/comma run ending at `end`, or -1 if the run holds none; a start never follows a digit, so it is the run's first digit */
|
|
111
|
+
const firstDigit = (/** @type {number} */ end) => {
|
|
112
|
+
let l = end;
|
|
113
|
+
while (l > 0 && /[\d,]/.test(s[l - 1])) l--;
|
|
114
|
+
while (l < end && s[l] === ',') l++;
|
|
115
|
+
return l < end ? l : -1;
|
|
116
|
+
};
|
|
117
|
+
let d = e;
|
|
118
|
+
while (d > 0 && /\d/.test(s[d - 1])) d--;
|
|
119
|
+
if (d < e && s[d - 1] === '.') {
|
|
120
|
+
const p = firstDigit(d - 1);
|
|
121
|
+
if (p !== -1) return s.slice(p, e);
|
|
122
|
+
}
|
|
123
|
+
const p = firstDigit(e);
|
|
124
|
+
return p === -1 ? undefined : s.slice(p, e);
|
|
125
|
+
}
|
|
103
126
|
const AFTER = new RegExp(`^\\s*(?:[$€£]\\s*)?(${NUM})`);
|
|
104
127
|
const AFTER_CUR = new RegExp(`^\\s*(${NUM})`);
|
|
105
128
|
const stripCommas = (/** @type {string} */ s) => s.replace(/,/g, '');
|
|
@@ -121,13 +144,13 @@ function beforeAfter(lines, label) {
|
|
|
121
144
|
let B = 0;
|
|
122
145
|
let A = 0;
|
|
123
146
|
for (const [l, i] of occs(lines, label)) {
|
|
124
|
-
if (
|
|
147
|
+
if (numberAtEnd(l.slice(0, i)) !== undefined) B += 1;
|
|
125
148
|
if (AFTER.test(l.slice(i + label.length))) A += 1;
|
|
126
149
|
}
|
|
127
150
|
return { B, A };
|
|
128
151
|
}
|
|
129
152
|
/** R1L: the numbers a UNIT form reads - the one right before each occurrence @param {string[]} lines @param {string} u @returns {Set<string>} */
|
|
130
|
-
const unitSet = (lines, u) => new Set(occs(lines, u).map(([l, i]) =>
|
|
153
|
+
const unitSet = (lines, u) => new Set(occs(lines, u).map(([l, i]) => numberAtEnd(l.slice(0, i))).filter((v) => typeof v === 'string').map((v) => stripCommas(/** @type {string} */ (v))));
|
|
131
154
|
/** R1L: the numbers a CURRENCY form reads - the one right after each occurrence @param {string[]} lines @param {string} c @returns {Set<string>} */
|
|
132
155
|
const curSet = (lines, c) => new Set(occs(lines, c).map(([l, i]) => AFTER_CUR.exec(l.slice(i + c.length))?.[1]).filter((v) => typeof v === 'string').map((v) => stripCommas(/** @type {string} */ (v))));
|
|
133
156
|
|
|
@@ -422,7 +422,7 @@ export function createAuthorRoutes(opts) {
|
|
|
422
422
|
}
|
|
423
423
|
if (sub === 'sign') {
|
|
424
424
|
const claimedHash = String(body?.specHash ?? '');
|
|
425
|
-
const r = signRun(session, claimedHash, { env: envNow(), spawnFn, bareloopBin, sessionsRoot: opts.sessionsRoot, home: opts.home
|
|
425
|
+
const r = signRun(session, claimedHash, { env: envNow(), spawnFn, bareloopBin, sessionsRoot: opts.sessionsRoot, home: opts.home });
|
|
426
426
|
send(r.ok ? 200 : 400, r);
|
|
427
427
|
return true;
|
|
428
428
|
}
|
package/src/panel/index.html
CHANGED
|
@@ -926,6 +926,10 @@
|
|
|
926
926
|
<button class="chip" aria-pressed="false" data-view="flat" data-testid="audit-view-flat">Flat</button>
|
|
927
927
|
</div>
|
|
928
928
|
</div>
|
|
929
|
+
<div id="audit-learnings" class="audit-part" data-testid="audit-learnings" hidden>
|
|
930
|
+
<button type="button" id="audit-learnings-summary" class="btn audit-part-toggle" aria-expanded="false" data-testid="audit-learnings-summary">What the planner was told from earlier runs</button>
|
|
931
|
+
<div id="audit-learnings-list" class="audit-part-body" data-testid="audit-learnings-list" hidden></div>
|
|
932
|
+
</div>
|
|
929
933
|
<div id="audit-groups" data-testid="audit-groups"></div>
|
|
930
934
|
<div id="audit-flat-wrap" hidden>
|
|
931
935
|
<div id="audit-empty" class="hint" data-testid="audit-empty" style="font-size:13px;padding:24px 0;">select a run first</div>
|
|
@@ -2828,6 +2832,98 @@
|
|
|
2828
2832
|
// fetch, wired separately in selectRun below).
|
|
2829
2833
|
lastLegDividers = Array.isArray(detail.legDividers) ? detail.legDividers : [];
|
|
2830
2834
|
renderAuditGroups(detail);
|
|
2835
|
+
renderAuditLearnings(detail);
|
|
2836
|
+
}
|
|
2837
|
+
|
|
2838
|
+
// Cross-run learnings, READ-ONLY: what the planner was handed, one group per injection (the draft, the first replan). ONE fold for the
|
|
2839
|
+
// whole section (the <details> in the Audit tab); opened, every hint shows at once as a title line + labelled rows, no inner toggles.
|
|
2840
|
+
// The fields come from the server (learn.js parseHintBlock, the one reader of the block) - this page never splits the prose.
|
|
2841
|
+
// No edit, no Reject, no button. Absent (the setting was off, or nothing was readable) = the section stays hidden.
|
|
2842
|
+
// one row of a hint's table: the same label cell + value cell the schedule section body uses
|
|
2843
|
+
function learnRow(tbody, testid, label, lines){
|
|
2844
|
+
var tr = document.createElement("tr");
|
|
2845
|
+
tr.setAttribute("data-testid", testid);
|
|
2846
|
+
var lab = document.createElement("td");
|
|
2847
|
+
lab.className = "hint";
|
|
2848
|
+
lab.style.cssText = "white-space:nowrap";
|
|
2849
|
+
lab.textContent = label;
|
|
2850
|
+
var val = document.createElement("td");
|
|
2851
|
+
val.style.cssText = "overflow-wrap:anywhere;white-space:pre-wrap";
|
|
2852
|
+
val.textContent = lines.join("\n");
|
|
2853
|
+
tr.appendChild(lab);
|
|
2854
|
+
tr.appendChild(val);
|
|
2855
|
+
tbody.appendChild(tr);
|
|
2856
|
+
}
|
|
2857
|
+
function learnHintLabel(h){
|
|
2858
|
+
var bits = [h.n, h.level, "seen " + h.count + (h.count === 1 ? " time" : " times")];
|
|
2859
|
+
if(h.check) bits.push(h.check);
|
|
2860
|
+
if(h.kinds && h.kinds.length) bits.push(h.kinds.join(", "));
|
|
2861
|
+
bits.push(h.fixed ? "fixed" : "never fixed");
|
|
2862
|
+
return bits.join(" \u00B7 ");
|
|
2863
|
+
}
|
|
2864
|
+
function renderAuditLearnings(detail){
|
|
2865
|
+
var box = document.getElementById("audit-learnings");
|
|
2866
|
+
var list = document.getElementById("audit-learnings-list");
|
|
2867
|
+
var sum = document.getElementById("audit-learnings-summary");
|
|
2868
|
+
if(!box || !list) return;
|
|
2869
|
+
var items = detail && Array.isArray(detail.learnings) ? detail.learnings : [];
|
|
2870
|
+
list.innerHTML = "";
|
|
2871
|
+
var head = "What the planner was told from earlier runs";
|
|
2872
|
+
items.forEach(function(it, gi){
|
|
2873
|
+
var hints = Array.isArray(it.hints) ? it.hints : [];
|
|
2874
|
+
var when = it.phase === "replan" ? "first replan" : "draft";
|
|
2875
|
+
if(items.length > 1){
|
|
2876
|
+
var g = document.createElement("div");
|
|
2877
|
+
g.className = "hint";
|
|
2878
|
+
g.style.cssText = "margin-top:10px;font-weight:600;";
|
|
2879
|
+
g.setAttribute("data-testid", "audit-learnings-group-" + it.phase);
|
|
2880
|
+
g.textContent = it.phase === "replan" ? "At the first replan" : "At the draft";
|
|
2881
|
+
list.appendChild(g);
|
|
2882
|
+
}
|
|
2883
|
+
var note = document.createElement("div");
|
|
2884
|
+
note.className = "hint";
|
|
2885
|
+
note.style.cssText = "margin:6px 0;overflow-wrap:anywhere;";
|
|
2886
|
+
note.textContent = (gi === 0 ? "Read-only. " : "") + "Given to the planner at the " + when + " (" + Number(it.chars).toLocaleString("en-US") + " characters)." + (gi === 0 ? " No worker sees it." : "");
|
|
2887
|
+
list.appendChild(note);
|
|
2888
|
+
hints.forEach(function(h){
|
|
2889
|
+
var hint = document.createElement("div");
|
|
2890
|
+
hint.className = "audit-table-scroll";
|
|
2891
|
+
hint.setAttribute("data-testid", "audit-learnings-hint-" + it.phase + "-" + h.n);
|
|
2892
|
+
var table = document.createElement("table");
|
|
2893
|
+
table.className = "rounds-table";
|
|
2894
|
+
var tbody = document.createElement("tbody");
|
|
2895
|
+
var trT = document.createElement("tr");
|
|
2896
|
+
var title = document.createElement("td");
|
|
2897
|
+
title.setAttribute("colspan", "2");
|
|
2898
|
+
title.style.cssText = "font-weight:600;overflow-wrap:anywhere";
|
|
2899
|
+
title.setAttribute("data-testid", "audit-learnings-hint-" + it.phase + "-" + h.n + "-title");
|
|
2900
|
+
title.textContent = learnHintLabel(h);
|
|
2901
|
+
trT.appendChild(title);
|
|
2902
|
+
tbody.appendChild(trT);
|
|
2903
|
+
var t = "audit-learnings-hint-" + it.phase + "-" + h.n + "-";
|
|
2904
|
+
if(h.said) learnRow(tbody, t + "said", "It said:", [h.said]);
|
|
2905
|
+
if(h.touched) learnRow(tbody, t + "touched", "Fix touched:", [h.touched]);
|
|
2906
|
+
if(h.changed && h.changed.length) learnRow(tbody, t + "changed", "What changed:", h.changed.concat(h.moreChanged ? ["(+" + h.moreChanged + " more)"] : []));
|
|
2907
|
+
table.appendChild(tbody);
|
|
2908
|
+
hint.appendChild(table);
|
|
2909
|
+
list.appendChild(hint);
|
|
2910
|
+
});
|
|
2911
|
+
});
|
|
2912
|
+
if(sum){
|
|
2913
|
+
var first = items.length ? (Array.isArray(items[0].hints) ? items[0].hints.length : 0) : 0;
|
|
2914
|
+
var label = items.length ? head + " \u00B7 " + first + (first === 1 ? " hint" : " hints") : head;
|
|
2915
|
+
// no arrow (hamr 2026-10-23): the state chip leads; aria-expanded carries open/closed. The same `[✓] word` state markup the schedule/step headers wear (stateDotHTML), then the plain-words title
|
|
2916
|
+
var paint = function(){ sum.innerHTML = stateDotHTML(glyphClass("\u2713"), "learnings", "audit-learnings-state") + " " + escapeXml(label); };
|
|
2917
|
+
paint();
|
|
2918
|
+
// ONE fold, same as the other Audit sections: the header button toggles the body (wired once)
|
|
2919
|
+
sum.onclick = function(){
|
|
2920
|
+
var open = sum.getAttribute("aria-expanded") === "true";
|
|
2921
|
+
sum.setAttribute("aria-expanded", open ? "false" : "true");
|
|
2922
|
+
list.hidden = open;
|
|
2923
|
+
paint();
|
|
2924
|
+
};
|
|
2925
|
+
}
|
|
2926
|
+
box.hidden = items.length === 0;
|
|
2831
2927
|
}
|
|
2832
2928
|
|
|
2833
2929
|
// ONE spelling of a step's state: its dot + the bold state word — read by the Run tab's step cards AND the Audit tab's grouped rows
|
package/src/panel/server.js
CHANGED
|
@@ -47,6 +47,7 @@ import { chainSpend } from '../ledger.js';
|
|
|
47
47
|
import { runBehaviour } from '../behaviour.js';
|
|
48
48
|
import { SPEND_RECORD_TYPES, floorsFromRecords } from '../ledger.js';
|
|
49
49
|
import { jobSpecHash, workflowKey } from '../job.js';
|
|
50
|
+
import { parseHintBlock } from '../learn.js';
|
|
50
51
|
import { confirmProtections } from '../authorflow.js';
|
|
51
52
|
import { createScheduleDoor } from './schedules.js';
|
|
52
53
|
import { createStarter } from './schedulestart.js';
|
|
@@ -74,6 +75,7 @@ import { judgedFilesOf, lineRules, stepFileRel, proseNotCheckedItems } from '../
|
|
|
74
75
|
import { factPartOf, factSentence, ruleWords, moneyAs } from '../facts.js';
|
|
75
76
|
import { createSettingsRoutes } from './settingsroutes.js';
|
|
76
77
|
import { planFromSpec } from './jobplan.js';
|
|
78
|
+
import { stageQuestionsByName } from '../stagewords.js';
|
|
77
79
|
|
|
78
80
|
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
79
81
|
|
|
@@ -1849,6 +1851,9 @@ function getRunDetailBody(runid, opts) {
|
|
|
1849
1851
|
// carries no contamination risk); `null` when the spine carries no
|
|
1850
1852
|
// `memory-cache` record at all (never armed on this run).
|
|
1851
1853
|
behaviour: scopedBehaviour(row, rawSpineRecords),
|
|
1854
|
+
// Cross-run learnings, READ-ONLY (ITEM33 rev 2, RULING 8): the exact block the planner was handed, as the spine recorded it at
|
|
1855
|
+
// injection (the draft, and the first replan); `hints` = the same text split into fields by learn.js's parseHintBlock (the one reader). Empty when the setting was off or nothing was readable.
|
|
1856
|
+
learnings: rawSpineRecords.filter((e) => e?.type === 'learnings' && typeof e.text === 'string').map((e) => ({ phase: String(e.phase), text: e.text, chars: e.text.length, ids: Array.isArray(e.ids) ? e.ids : [], hints: parseHintBlock(e.text) })),
|
|
1852
1857
|
// PRD v1.106 Part 5 F: the two Run summary rows, worded here (the page prints them)
|
|
1853
1858
|
callsLine: callsLineOf(rawSpineRecords, scopedBehaviour(row, rawSpineRecords)),
|
|
1854
1859
|
checksLine: checksLineOf(rawSpineRecords, enrichedParts),
|
|
@@ -2593,116 +2598,6 @@ function resolveSpecForRow(row) {
|
|
|
2593
2598
|
return null;
|
|
2594
2599
|
}
|
|
2595
2600
|
|
|
2596
|
-
/**
|
|
2597
|
-
* THE PLAIN-QUESTION OWNER (plain-checks build, 2026-09-28, hamr's ruling:
|
|
2598
|
-
* the "checks N/M" headline is WITHDRAWN — "confusing, reads like 6 failed
|
|
2599
|
-
* when 5 never ran"). ONE pure function mapping a declared stage's own
|
|
2600
|
-
* signed `kind`+`params` (plus the close's own `genre`, also a signed field)
|
|
2601
|
-
* to a short plain-English question a person can read without knowing the
|
|
2602
|
-
* catalogue — never a second spelling on the client, and never anything the
|
|
2603
|
-
* model authored (the catalogue's kinds/params are the whole vocabulary this
|
|
2604
|
-
* reads).
|
|
2605
|
-
*
|
|
2606
|
-
* Every branch is traced straight off `src/kinds.js`'s runner semantics and
|
|
2607
|
-
* the TYPES genre template (`src/authoring.js` `TYPES_GENRE_TEMPLATE`,
|
|
2608
|
-
* `classGuards`'s `MECHANICAL_GUARDS`) — never guessed:
|
|
2609
|
-
* - `files-changed` is always the `changed-from-seed` guard: "did it
|
|
2610
|
-
* change any file?"
|
|
2611
|
-
* - `pattern-absent-in-diff` is always the `no-suppressions` guard: "no
|
|
2612
|
-
* casts or silencers added?"
|
|
2613
|
-
* - `rubric-step` (Part 1a) is one job line's output file: "line N — does its output pass the line's checks?"
|
|
2614
|
-
* - `command-exit` is the genre's `suite-green` stage (the only shipped use
|
|
2615
|
-
* of this kind): "does the test suite pass?"
|
|
2616
|
-
* - `count-not-worse` splits three ways on its own declared
|
|
2617
|
-
* `direction`/`baseline` (never the runtime number — see
|
|
2618
|
-
* {@link stageKindMetaFromSpec}'s own doc for why the baseline KIND must
|
|
2619
|
-
* travel separately from the measured figure):
|
|
2620
|
-
* - `higher-is-better` + `baseline: 'seed'` is the genre's `tests-kept`
|
|
2621
|
-
* floor: "did all the old tests still exist?"
|
|
2622
|
-
* - `lower-is-better` + `baseline: 0` is the genre's in-scope
|
|
2623
|
-
* `typecheck` stage — UNLESS its own `cmd`+`args` is the SAME
|
|
2624
|
-
* command a `command-exit` stage in the same close already runs
|
|
2625
|
-
* (`suiteCmdKeys`, below): the TYPES template's `suite-green` is "the
|
|
2626
|
-
* suite exits clean AND reports zero failing tests — TWO assertions"
|
|
2627
|
-
* over ONE population (the same `npm test`/`pytest` invocation), so a
|
|
2628
|
-
* `count-not-worse` stage sharing that exact command is the failing-
|
|
2629
|
-
* test-count half of that pair, never a typecheck stage: "no failing
|
|
2630
|
-
* tests?" — derived from the same cmd+args correlation (the stage
|
|
2631
|
-
* counts the suite's own failing tests), never from the stage's own
|
|
2632
|
-
* name string. The live check that found this (run mul5fofw's own
|
|
2633
|
-
* `suite-zero-failing-tests`, cmd `npm test`, same as its sibling
|
|
2634
|
-
* `suite-green` command-exit stage) would otherwise have read "test/
|
|
2635
|
-
* has 0 type errors?", which is false: its parser counts FAILING
|
|
2636
|
-
* TESTS (`^# fail (\d+)$`), not type-checker output. hamr's ruling:
|
|
2637
|
-
* name this half honestly rather than reusing "type errors" for a
|
|
2638
|
-
* population it was never about, or falling back to the stage's raw
|
|
2639
|
-
* name. When the command differs from every sibling `command-exit`
|
|
2640
|
-
* stage, the declared `scope.includePrefixes` naming exactly ONE path
|
|
2641
|
-
* is quoted verbatim (a signed param, never invented): "<path> has 0
|
|
2642
|
-
* type errors?" — otherwise the generic "type errors is 0?", never a
|
|
2643
|
-
* guessed filename.
|
|
2644
|
-
* - `lower-is-better` + `baseline: 'seed'` with a non-empty
|
|
2645
|
-
* `scope.excludePrefixes` is the genre's `typecheck-outside` ceiling:
|
|
2646
|
-
* "other files didn't get more type errors?"
|
|
2647
|
-
* The "type errors" wording is genre-owned (`GENRE_WHAT`), because TYPES is
|
|
2648
|
-
* the only genre this catalogue admits today (doc-genre kinds are not yet
|
|
2649
|
-
* built) — an unrecognised genre, or a `count-not-worse` shape none of the
|
|
2650
|
-
* three above matches, returns `null` rather than invent a fourth shape.
|
|
2651
|
-
*
|
|
2652
|
-
* `null` means "no honest plain question" — the caller (buildAttemptsList,
|
|
2653
|
-
* partLine1Text's `attemptChecksLine`) falls back to the stage's OWN name,
|
|
2654
|
-
* exactly as it already does for a stage with no resolvable kind at all.
|
|
2655
|
-
* @param {{kind: string|null, params: any, genre: string|null, suiteCmdKeys: Set<string>}} o
|
|
2656
|
-
* @returns {string|null}
|
|
2657
|
-
*/
|
|
2658
|
-
const GENRE_WHAT = Object.freeze({ TYPES: 'type errors' });
|
|
2659
|
-
|
|
2660
|
-
/** `cmd`+`args`, as a comparable key — the one signal (besides `kind`
|
|
2661
|
-
* itself) that two stages measure the SAME command's output, used to tell a
|
|
2662
|
-
* genuine typecheck stage apart from the TYPES genre's other `lower-is-
|
|
2663
|
-
* better`/`baseline: 0` stage (the failing-test-count half of `suite-green`'s
|
|
2664
|
-
* "two assertions"), which shares that stage's own shape but not its
|
|
2665
|
-
* population. `null` for a stage whose `cmd` isn't a non-empty string —
|
|
2666
|
-
* never matches anything, so it never falsely suppresses a real typecheck
|
|
2667
|
-
* question.
|
|
2668
|
-
* @param {any} params @returns {string|null} */
|
|
2669
|
-
function cmdKey(params) {
|
|
2670
|
-
if (!params || typeof params.cmd !== 'string' || params.cmd.length === 0) return null;
|
|
2671
|
-
const args = Array.isArray(params.args) ? params.args : [];
|
|
2672
|
-
return JSON.stringify([params.cmd, args]);
|
|
2673
|
-
}
|
|
2674
|
-
|
|
2675
|
-
function stageQuestionText({
|
|
2676
|
-
kind, params, genre, suiteCmdKeys,
|
|
2677
|
-
}) {
|
|
2678
|
-
if (kind === 'files-changed') return 'did it change any file?';
|
|
2679
|
-
if (kind === 'pattern-absent-in-diff') return 'no casts or silencers added?';
|
|
2680
|
-
if (kind === 'command-exit') return 'does the test suite pass?';
|
|
2681
|
-
// Part 1a: one rubric-step stage = one job line's output file
|
|
2682
|
-
if (kind === 'rubric-step') return params && Number.isInteger(params.step) ? `line ${params.step} — does its output pass the line's checks?` : null;
|
|
2683
|
-
if (kind !== 'count-not-worse' || !params || typeof params !== 'object') return null;
|
|
2684
|
-
const what = Object.hasOwn(GENRE_WHAT, String(genre)) ? GENRE_WHAT[String(genre)] : null;
|
|
2685
|
-
if (params.direction === 'higher-is-better' && params.baseline === 'seed') {
|
|
2686
|
-
return 'did all the old tests still exist?';
|
|
2687
|
-
}
|
|
2688
|
-
if (!what) return null;
|
|
2689
|
-
const scope = params.scope && typeof params.scope === 'object' ? params.scope : null;
|
|
2690
|
-
if (params.direction === 'lower-is-better' && params.baseline === 0) {
|
|
2691
|
-
const key = cmdKey(params);
|
|
2692
|
-
if (key !== null && suiteCmdKeys instanceof Set && suiteCmdKeys.has(key)) return 'no failing tests?';
|
|
2693
|
-
const include = scope && Array.isArray(scope.includePrefixes) ? scope.includePrefixes : null;
|
|
2694
|
-
if (include && include.length === 1 && typeof include[0] === 'string' && include[0].length > 0) {
|
|
2695
|
-
return `${include[0]} has 0 ${what}?`;
|
|
2696
|
-
}
|
|
2697
|
-
return `${what} is 0?`;
|
|
2698
|
-
}
|
|
2699
|
-
if (params.direction === 'lower-is-better' && params.baseline === 'seed') {
|
|
2700
|
-
const exclude = scope && Array.isArray(scope.excludePrefixes) ? scope.excludePrefixes : null;
|
|
2701
|
-
if (exclude && exclude.length > 0) return `other files didn't get more ${what}?`;
|
|
2702
|
-
return null;
|
|
2703
|
-
}
|
|
2704
|
-
return null;
|
|
2705
|
-
}
|
|
2706
2601
|
|
|
2707
2602
|
/**
|
|
2708
2603
|
* Per-stage `{kind, direction, baselineKind, question}`, keyed by stage NAME,
|
|
@@ -2724,18 +2619,7 @@ function stageQuestionText({
|
|
|
2724
2619
|
*/
|
|
2725
2620
|
function stageKindMetaFromSpec(spec) {
|
|
2726
2621
|
if (!spec || typeof spec !== 'object' || !spec.closeDecl || !Array.isArray(spec.closeDecl.stages)) return null;
|
|
2727
|
-
const
|
|
2728
|
-
// every command-exit stage's own cmd+args, gathered FIRST (a separate pass)
|
|
2729
|
-
// so stageQuestionText can tell the failing-test-count half of suite-green's
|
|
2730
|
-
// "two assertions" apart from a genuine typecheck stage — see its own doc.
|
|
2731
|
-
/** @type {Set<string>} */
|
|
2732
|
-
const suiteCmdKeys = new Set();
|
|
2733
|
-
for (const s of spec.closeDecl.stages) {
|
|
2734
|
-
if (s && s.kind === 'command-exit') {
|
|
2735
|
-
const key = cmdKey(s.params);
|
|
2736
|
-
if (key !== null) suiteCmdKeys.add(key);
|
|
2737
|
-
}
|
|
2738
|
-
}
|
|
2622
|
+
const questions = stageQuestionsByName(spec);
|
|
2739
2623
|
/** @type {Map<string, {kind: string|null, direction: string|null, baselineKind: 'seed'|0|null, question: string|null, fromLine: number[]}>} */
|
|
2740
2624
|
const map = new Map();
|
|
2741
2625
|
for (const s of spec.closeDecl.stages) {
|
|
@@ -2744,9 +2628,7 @@ function stageKindMetaFromSpec(spec) {
|
|
|
2744
2628
|
const params = s.params && typeof s.params === 'object' ? s.params : null;
|
|
2745
2629
|
const direction = params && typeof params.direction === 'string' ? params.direction : null;
|
|
2746
2630
|
const baselineKind = params && (params.baseline === 'seed' || params.baseline === 0) ? params.baseline : null;
|
|
2747
|
-
const question =
|
|
2748
|
-
kind, params, genre, suiteCmdKeys,
|
|
2749
|
-
});
|
|
2631
|
+
const question = questions.get(s.name) ?? null;
|
|
2750
2632
|
map.set(s.name, {
|
|
2751
2633
|
kind, direction, baselineKind, question, fromLine: Array.isArray(s.fromLine) ? s.fromLine.filter(Number.isInteger) : [],
|
|
2752
2634
|
});
|