bareloop 0.44.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.
Files changed (61) hide show
  1. package/CHANGELOG.md +40 -0
  2. package/bareloop.context.md +31 -5
  3. package/package.json +1 -1
  4. package/src/attemptdiff.js +116 -0
  5. package/src/authorflow.js +1 -1
  6. package/src/authorjob.js +25 -1
  7. package/src/authorreadout.js +13 -2
  8. package/src/authorrun.js +8 -2
  9. package/src/authorscout.js +1 -1
  10. package/src/bundle.js +90 -6
  11. package/src/close-integrity.js +39 -5
  12. package/src/config.js +5 -2
  13. package/src/fitcheck.js +166 -0
  14. package/src/inputlook.js +1 -47
  15. package/src/judged.js +3 -3
  16. package/src/labelscan.js +13 -4
  17. package/src/ladder.js +2 -2
  18. package/src/learn.js +943 -0
  19. package/src/learnstore.js +154 -0
  20. package/src/modellist.js +1 -1
  21. package/src/pagedropwords.js +47 -1
  22. package/src/pagedry.js +124 -0
  23. package/src/pagefetch.js +2 -1
  24. package/src/pagemap.js +7 -2
  25. package/src/pagerules.js +35 -13
  26. package/src/panel/authorroutes.js +1 -1
  27. package/src/panel/authorsession.js +37 -6
  28. package/src/panel/index.html +100 -0
  29. package/src/panel/jobplan.js +12 -7
  30. package/src/panel/server.js +8 -125
  31. package/src/planrun.js +40 -6
  32. package/src/promptregisters.js +4 -0
  33. package/src/providerrows.js +3 -3
  34. package/src/rubricstep.js +36 -6
  35. package/src/run.js +4 -2
  36. package/src/schedulewords.js +1 -1
  37. package/src/source.js +1 -1
  38. package/src/stagewords.js +144 -0
  39. package/src/text.js +4 -2
  40. package/src/userrun.js +28 -0
  41. package/src/validate.js +10 -5
  42. package/src/worktree.js +8 -2
  43. package/types/attemptdiff.d.ts +38 -0
  44. package/types/authorjob.d.ts +10 -2
  45. package/types/config.d.ts +1 -1
  46. package/types/fitcheck.d.ts +113 -0
  47. package/types/inputlook.d.ts +0 -23
  48. package/types/labelscan.d.ts +6 -0
  49. package/types/learn.d.ts +575 -0
  50. package/types/learnstore.d.ts +107 -0
  51. package/types/pagedropwords.d.ts +13 -0
  52. package/types/pagedry.d.ts +37 -0
  53. package/types/pagerules.d.ts +7 -1
  54. package/types/panel/authorsession.d.ts +4 -2
  55. package/types/panel/jobplan.d.ts +5 -2
  56. package/types/panel/server.d.ts +1 -0
  57. package/types/planrun.d.ts +13 -3
  58. package/types/reuse.d.ts +1 -1
  59. package/types/rubricstep.d.ts +5 -0
  60. package/types/run.d.ts +8 -2
  61. 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();
@@ -21,7 +21,8 @@ const WORDS = /** @type {Record<string, (d: Dropped) => string>} */ ({
21
21
  'limit-currency': (d) => `Only the first 3 currencies are used; "${d.word}" was left out.`,
22
22
  'limit-unit': (d) => `Only the first 10 units are used; "${d.word}" was left out.`,
23
23
  'unit-too-long': (d) => `"${d.word}" is longer than 100 characters, so it was not used.`,
24
- 'currency-not-symbol': (d) => `"${d.word}" is not a currency sign ($, € or £); a word the page writes after the amount is read as a unit.`,
24
+ 'reads-no-result': () => "the price rule could not read a price in the page's results, so it is not checked.",
25
+ 'phrase-no-result': (d) => `"${d.other}" is on the page but in none of its results, so it is not checked.`,
25
26
  'person-left': () => 'You chose to leave it Not checked.',
26
27
  'bad-shape': (d) => `The AI named a kind of value for "${d.fact}" that a check cannot read.`,
27
28
  });
@@ -62,3 +63,48 @@ export function dropLine(row, jobLines, rulesOf) {
62
63
  const rule = row.rule === undefined || !jl ? undefined : rulesOf(jl)[row.rule - 1];
63
64
  return `line ${row.line}: "${rule ?? row.fact}" — ${row.sentences.join(' ')}`;
64
65
  }
66
+
67
+ // HOW THE PAGE WAS READ (run mvij7kuk, flight-c1): a drop of ONE WORD whose fact is still checked is a NOTE about the form code did not use, not
68
+ // something left unchecked. The sentences for those reasons are WORD-level; every other sentence (a whole fact left out) keeps the line under
69
+ // Not checked. Classified from the line's own words, so it holds for a signed spec already on disk.
70
+ const WORD_REASONS = ['not-on-page', 'no-value', 'n2', 'r1', 'sign-twice', 'r1l', 'limit-currency', 'limit-unit', 'unit-too-long'];
71
+ /** @param {string} s */
72
+ const esc = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
73
+ const SENTENCE_RE = (() => {
74
+ const [W, O] = ['\u0001w\u0001', '\u0001o\u0001'];
75
+ /** @type {string[]} */
76
+ const alts = [];
77
+ for (const reason of WORD_REASONS) {
78
+ const probes = reason === 'r1l' ? [{ word: W, other: W }, { word: W, other: O }] : [{ word: W, other: O }];
79
+ for (const p of probes) alts.push(esc(WORDS[reason](/** @type {any} */ ({ ...p, reason, line: 0, fact: 'f' }))).split(esc(W)).join('[^"]+').split(esc(O)).join('[^"]+'));
80
+ }
81
+ return new RegExp(`^(?:(?:${alts.join('|')})(?: |$))+$`);
82
+ })();
83
+
84
+ /** the s1 sentence as a pattern whose one capture is the OTHER fact's name (the sentence is built from the one WORDS table) */
85
+ const S1_RE = (() => {
86
+ const [F, O] = ['\u0001f\u0001', '\u0001o\u0001'];
87
+ return new RegExp(esc(WORDS['s1'](/** @type {any} */ ({ fact: F, other: O, reason: 's1', line: 0 }))).split(esc(F)).join('[^"]+').split(esc(O)).join('([^"]+)'), 'g');
88
+ })();
89
+
90
+ /**
91
+ * An `s1` drop (a second fact that would read the same value as one already kept) is a NOTE when that OTHER fact is still signed: the guardrail
92
+ * is checked by it. With the other fact not signed, or no signed list given, the sentence stays Not checked. Every sentence of a row must qualify.
93
+ * @param {string} text one Not-checked line @param {string[]|null|undefined} [signed] the names of the facts the plan signs
94
+ * @returns {boolean} true when the line only says which written forms of a still-checked fact were not used
95
+ */
96
+ export function isReadNote(text, signed) {
97
+ const m = /^line \d+: "[^"]*" \u2014 (.+)$/s.exec(String(text));
98
+ if (m === null) return false;
99
+ const names = new Set(Array.isArray(signed) ? signed : []);
100
+ let found = 0;
101
+ const rest = m[1].replace(S1_RE, (all, other) => { if (!names.has(other)) return all; found += 1; return ''; }).replace(/\s+/g, ' ').trim();
102
+ return rest === '' ? found > 0 : SENTENCE_RE.test(rest);
103
+ }
104
+
105
+ /** @param {string[]|null|undefined} items @param {string[]|null|undefined} [signed] names of the signed facts (see isReadNote)
106
+ * @returns {{notChecked: string[], readNotes: string[]}} the lines split: what is left unchecked, and how the page was read */
107
+ export function splitReadNotes(items, signed) {
108
+ const all = Array.isArray(items) ? items : [];
109
+ return { notChecked: all.filter((t) => !isReadNote(t, signed)), readNotes: all.filter((t) => isReadNote(t, signed)) };
110
+ }
package/src/pagedry.js ADDED
@@ -0,0 +1,124 @@
1
+ // MONEY BUILD v2 (docs/product/ITEM33-BUILD.md "Addendum 2026-10-21 — money build spec v2", part 2): "CAN IT READ". After the page rules, a $0 dry
2
+ // read asks of each kept money fact whether the REAL run-time checker can read a price on the page's own result lines. For every result line it
3
+ // grades an HONEST claim (quote = that line, value = the line's amount) with `gradeStep` (src/rubricstep.js), the one reader the run itself
4
+ // uses - no copy of its logic here. A fact that reads no result line is dropped as `reads-no-result` and shows under Not checked.
5
+ // No limits, no counts, no pass bar: only "can it read". Async (gradeStep is), so it sits beside applyPageRules, not inside it: the caller is
6
+ // src/pagemap.js `runPageMap`. No I/O, no clock, no model.
7
+
8
+ import { createHash } from 'node:crypto';
9
+ import { gradeStep } from './rubricstep.js';
10
+ import { linesOf, numberAtEnd } from './pagerules.js';
11
+
12
+ /** @typedef {import('./pagerules.js').KeptFact} KeptFact @typedef {import('./pagerules.js').Dropped} Dropped */
13
+
14
+ /** a number as the page writes it, thousands commas allowed */
15
+ const NUM = '\\d[\\d,]*(?:\\.\\d+)?';
16
+ const AFTER_LABEL = new RegExp(`^\\s*(?:[$€£]\\s*)?(${NUM})`);
17
+ /** the label every dry-read claim is made under (the checker only needs one name; the fact's own is not read) */
18
+ const CLAIM = 'price';
19
+ const SOURCE = 'page';
20
+
21
+ /**
22
+ * The result lines of a kept money fact: with a label, every page line where a kept label is followed by a number (one sign between allowed);
23
+ * with no label (U1 moved it to a unit, e.g. "€533 total"), every line where a number is followed by a kept unit.
24
+ * @param {string} text @param {KeptFact} k @returns {{quote: string, value: string}[]}
25
+ */
26
+ export function moneyResultLines(text, k) {
27
+ const raw = text.split('\n');
28
+ const squashed = linesOf(text);
29
+ /** @type {{quote: string, value: string}[]} */
30
+ const out = [];
31
+ squashed.forEach((ln, i) => {
32
+ /** @type {string|undefined} */
33
+ let amount;
34
+ if (k.words.length) {
35
+ for (const w of k.words) {
36
+ for (let at = ln.indexOf(w); at !== -1 && amount === undefined; at = ln.indexOf(w, at + w.length)) amount = AFTER_LABEL.exec(ln.slice(at + w.length))?.[1];
37
+ if (amount !== undefined) break;
38
+ }
39
+ } else {
40
+ for (const u of k.unit ?? []) {
41
+ for (let at = ln.indexOf(u); at !== -1 && amount === undefined; at = ln.indexOf(u, at + u.length)) amount = numberAtEnd(ln.slice(0, at));
42
+ if (amount !== undefined) break;
43
+ }
44
+ }
45
+ if (amount !== undefined) out.push({ quote: raw[i].trim(), value: amount.replace(/,/g, '') });
46
+ });
47
+ return out;
48
+ }
49
+
50
+ /**
51
+ * The fact as a run would sign it, for the dry read: the primary label is the anchor (none when U1 moved every label), then the kept units and currencies.
52
+ * @param {KeptFact} k @returns {Record<string, any>}
53
+ */
54
+ const signedOf = (k) => ({ name: CLAIM, shape: 'money', ...(k.words.length ? { anchor: k.words[0] } : {}), ...(k.currency ? { currency: k.currency } : {}), ...(k.unit ? { unit: k.unit } : {}) });
55
+
56
+ /**
57
+ * Dry-read every kept money fact; drop (as `reads-no-result`) the ones whose forms read no result line. Other facts pass through untouched.
58
+ * @param {string} text the frozen page text @param {KeptFact[]} facts what applyPageRules kept
59
+ * @returns {Promise<{facts: KeptFact[], dropped: Dropped[], reads: Record<string, number>, results: Record<string, number>}>}
60
+ * `reads` / `results` = result lines the checker read / lines found, per fact name (for tests and reports; no verdict derives from the numbers)
61
+ */
62
+ export async function dryReadMoney(text, facts) {
63
+ const sha = createHash('sha256').update(text).digest('hex');
64
+ /** @type {KeptFact[]} */
65
+ const kept = [];
66
+ /** @type {Dropped[]} */
67
+ const dropped = [];
68
+ /** @type {Record<string, number>} */
69
+ const reads = {};
70
+ /** @type {Record<string, number>} */
71
+ const results = {};
72
+ for (const k of facts) {
73
+ if (k.shape !== 'money') { kept.push(k); continue; }
74
+ const fact = signedOf(k);
75
+ const lines = moneyResultLines(text, k);
76
+ let n = 0;
77
+ for (const r of lines) {
78
+ const params = { step: 1, parts: [{ text: 'dry read', class: 'fact', source: SOURCE, covers: [1], facts: [fact] }] };
79
+ const g = await gradeStep({ params, bytes: JSON.stringify({ results: [{ quote: r.quote, values: { [CLAIM]: r.value } }] }), outputBytes: null, inputs: { [SOURCE]: text } }, { [SOURCE]: sha });
80
+ if (g.verdict === 'green') n += 1;
81
+ }
82
+ reads[k.name] = n;
83
+ results[k.name] = lines.length;
84
+ if (n > 0) { kept.push(k); continue; }
85
+ dropped.push({ line: k.line, ...(k.forRule === undefined ? {} : { rule: k.forRule }), fact: k.name, reason: 'reads-no-result' });
86
+ }
87
+ return { facts: kept, dropped, reads, results };
88
+ }
89
+
90
+ /**
91
+ * The same "can it read" for PHRASE facts (hamr ruled 3a, run m10-walk-flights-3: "Rome" was on the page only in "Track prices from Madrid to
92
+ * Rome" switches, in none of the 18 result lines, so no result could ever say it). A kept phrase fact is read against the page's RESULT LINES =
93
+ * the lines the job's kept money facts read (`moneyResultLines`), each graded as an honest claim by the real `gradeStep` (quote = that line;
94
+ * a phrase has no value to write). A phrase that reads no result line is dropped whole as `phrase-no-result` and shows under Not checked.
95
+ * With no kept money fact the result lines are unknown, so phrase facts pass through untouched (nothing is guessed about what a result is).
96
+ * @param {string} text the frozen page text @param {KeptFact[]} facts what the money dry read kept
97
+ * @returns {Promise<{facts: KeptFact[], dropped: Dropped[], reads: Record<string, number>}>} `reads` = result lines that said the phrase, per fact name
98
+ */
99
+ export async function dryReadPhrases(text, facts) {
100
+ const sha = createHash('sha256').update(text).digest('hex');
101
+ /** @type {Map<string, true>} */
102
+ const quotes = new Map();
103
+ for (const k of facts) if (k.shape === 'money') for (const r of moneyResultLines(text, k)) quotes.set(r.quote, true);
104
+ /** @type {KeptFact[]} */
105
+ const kept = [];
106
+ /** @type {Dropped[]} */
107
+ const dropped = [];
108
+ /** @type {Record<string, number>} */
109
+ const reads = {};
110
+ for (const k of facts) {
111
+ if (k.shape !== 'phrase' || quotes.size === 0 || !k.words.length) { kept.push(k); continue; }
112
+ const fact = { name: k.name, shape: 'phrase', phrase: k.words[0] };
113
+ let n = 0;
114
+ for (const quote of quotes.keys()) {
115
+ const params = { step: 1, parts: [{ text: 'dry read', class: 'fact', source: SOURCE, covers: [1], facts: [fact] }] };
116
+ const g = await gradeStep({ params, bytes: JSON.stringify({ results: [{ quote, values: {} }] }), outputBytes: null, inputs: { [SOURCE]: text } }, { [SOURCE]: sha });
117
+ if (g.verdict === 'green') n += 1;
118
+ }
119
+ reads[k.name] = n;
120
+ if (n > 0) { kept.push(k); continue; }
121
+ dropped.push({ line: k.line, ...(k.forRule === undefined ? {} : { rule: k.forRule }), fact: k.name, other: k.words[0], reason: 'phrase-no-result' });
122
+ }
123
+ return { facts: kept, dropped, reads };
124
+ }
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(/\[[A-Za-z]+=[^\]\s]*\]/g, ' ').replace(/^\s*-\s*/, '').replace(/\s+/g, ' ').trim().length;
63
+ n += raw.replace(TAG, ' ').replace(/^\s*-\s*/, '').replace(/\s+/g, ' ').trim().length;
63
64
  }
64
65
  return n;
65
66
  }
package/src/pagemap.js CHANGED
@@ -15,6 +15,7 @@ import { isOutputCutOff } from './authorflow.js';
15
15
  import { AUTHOR_CALL_TIMEOUT_MS, AUTHOR_SCOUT_MAX_TOKENS, SURVEY_CAUSES } from './authorscout.js';
16
16
  import { lineRules, boundInWords, wordsSaidOn } from './rubricstep.js';
17
17
  import { applyPageRules, validReading } from './pagerules.js';
18
+ import { dryReadMoney, dryReadPhrases } from './pagedry.js';
18
19
  import { ruleWords } from './facts.js';
19
20
  import { lookFactOf } from './inputlook.js';
20
21
 
@@ -164,8 +165,12 @@ export async function runPageMap({
164
165
  if (malformed >= 2) return absent(MAP_UNREADABLE, SURVEY_CAUSES.UNPARSEABLE);
165
166
  }
166
167
  const kept = applyPageRules(texts[label], mapping, jobLines);
167
- for (const f of kept.facts) facts.push(lookFactOf(f, label));
168
- for (const d of kept.dropped) dropped.push({ ...d, source: label });
168
+ // money build v2: "can it read" - a money fact whose forms read no result line of the page, by the run's own checker, is Not checked
169
+ const read = await dryReadMoney(texts[label], kept.facts);
170
+ // hamr 3a: a phrase fact is read against the same result lines (only when the job keeps a money fact)
171
+ const phr = await dryReadPhrases(texts[label], read.facts);
172
+ for (const f of phr.facts) facts.push(lookFactOf(f, label));
173
+ for (const d of [...kept.dropped, ...read.dropped, ...phr.dropped]) dropped.push({ ...d, source: label });
169
174
  maps.push({ source: label, facts: mapping.facts.length });
170
175
  looks.push(label);
171
176
  }
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
- const BEFORE = new RegExp(`(${NUM})\\s*$`);
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 (BEFORE.test(l.slice(0, i))) B += 1;
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]) => BEFORE.exec(l.slice(0, i))?.[1]).filter((v) => typeof v === 'string').map((v) => stripCommas(/** @type {string} */ (v))));
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
 
@@ -200,7 +223,9 @@ export function applyPageRules(text, mapping, jobLines) {
200
223
  /** @type {string[]} */ let uni = [];
201
224
  /** @type {string[]} */ let cur = [];
202
225
  if (f.shape === 'money') {
203
- // the forms: units first (the U1-moved words, then the AI's listed units), then currency signs
226
+ // the forms: units first (the U1-moved words, then the AI's listed units), then currency signs. SORT, never drop for the box (money
227
+ // build v2): the box is position only - a sign (before the number) is a currency, any other form (a word after it) is a unit - so every
228
+ // form the AI listed in either box is pooled and moved to the box its own shape belongs to
204
229
  const listedCurrencies = (Array.isArray(f.currency) ? f.currency : []).map(String);
205
230
  // a currency SIGN listed in BOTH unit and currency is said once: the unit copy goes (fwd F71 drafts)
206
231
  const listedUnits = (Array.isArray(f.unit) ? f.unit : []).map(String).filter((u) => {
@@ -208,12 +233,15 @@ export function applyPageRules(text, mapping, jobLines) {
208
233
  drop(f, u, 'sign-twice');
209
234
  return false;
210
235
  });
211
- const units = [...new Set([...moved, ...listedUnits])].filter((u) => {
236
+ // a WORD listed in both boxes is one form, said once (a record of the double listing is kept, as R1L always said it)
237
+ for (const w of new Set(listedCurrencies.filter((c) => !CURRENCY_SYMBOLS.includes(c) && listedUnits.includes(c)))) drop(f, w, 'r1l', w);
238
+ const pool = [...moved, ...listedUnits, ...listedCurrencies];
239
+ const units = [...new Set(pool.filter((w) => !CURRENCY_SYMBOLS.includes(w)))].filter((u) => {
212
240
  if (countPhrase(text, u) >= 1) return true;
213
241
  drop(f, u, 'not-on-page');
214
242
  return false;
215
243
  });
216
- const curs = listedCurrencies.filter((c) => {
244
+ const curs = [...new Set(pool.filter((w) => CURRENCY_SYMBOLS.includes(w)))].filter((c) => {
217
245
  if (countExact(text, c) >= 1) return true;
218
246
  drop(f, c, 'not-on-page');
219
247
  return false;
@@ -230,12 +258,6 @@ export function applyPageRules(text, mapping, jobLines) {
230
258
  uni = keep.filter((x) => x.kind === 'unit').map((x) => x.w);
231
259
  cur = keep.filter((x) => x.kind === 'currency').map((x) => x.w);
232
260
  }
233
- // a currency that is a word ("US dollars") is read after the number, so it is a unit, never a currency sign
234
- cur = cur.filter((c) => {
235
- if (CURRENCY_SYMBOLS.includes(c)) return true;
236
- drop(f, c, 'currency-not-symbol');
237
- return false;
238
- });
239
261
  // the guard's list limits (src/facts.js), applied here so a longer list is cut by code and SAID, never refused later: the first forms stay
240
262
  uni = uni.filter((u, i) => {
241
263
  if (u.length > MAX_UNIT_CHARS) { drop(f, u, 'unit-too-long'); return false; }
@@ -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, ...(opts.schedules ? { schedules: opts.schedules } : {}) });
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
  }
@@ -51,9 +51,10 @@ import { resolveJobJudge, defaultJudgeLoop } from '../judged.js';
51
51
  import { assertThinkingMode, thinkingPlanFor, thinkingRowText } from '../thinking.js';
52
52
  import { closeJudges } from '../kinds.js';
53
53
  import { RUBRIC_LANG } from '../authoring.js';
54
- import { rubricGoal, reconcileRubricPlan } from '../rubricstep.js';
54
+ import { rubricGoal, reconcileRubricPlan, signedFactNames } from '../rubricstep.js';
55
55
  import { redactSecrets } from '../validate.js';
56
56
  import { isDropIt, isAgree } from '../pagemap.js';
57
+ import { FIT_ABSENT_NOTE } from '../fitcheck.js';
57
58
  import { tallyCalls } from '../text.js';
58
59
  import { writeDraftSpend, appendDraftLog } from '../draftspend.js';
59
60
  import { PLAIN_PROPOSAL_STOPS, proposalStopText, signingStopText, draftStopText, redsRecord, declarationRecord, calibrationSummary, tryRefusedText } from '../authorreadout.js';
@@ -118,6 +119,9 @@ export const PHASE_STEP = Object.freeze({
118
119
  'scout-done': 'scout',
119
120
  look: 'look',
120
121
  'look-done': 'look',
122
+ // F243: the fit check's one call is counted in the look's line, like the look
123
+ 'fit-check': 'look',
124
+ 'fit-check-done': 'look',
121
125
  confirm: 'confirm',
122
126
  'confirm-turn-done': 'confirm',
123
127
  listing: 'listing',
@@ -166,7 +170,7 @@ export const PLAN_FOOTER = 'Sign & run to confirm this plan, or Revise to edit t
166
170
  * The confirm turn's plan as a chat message (P7): a STRUCTURED `{role:'bot', kind:'plan', plan}` the page draws with the
167
171
  * same renderer the Job tab uses — each numbered job line, its `~` rule, its drafted checks folded; the AI's own "not
168
172
  * checked"; the always-on guards. `questions` (what the plan still asks) ride inside the plan. One owner of the shape.
169
- * @param {{goal?: string, checks?: string[], checkItems?: {text: string, fromLine: number[]|null}[], notChecked?: string[], questions?: string[], protections?: string[]}} p the confirm turn's plan
173
+ * @param {{goal?: string, checks?: string[], checkItems?: {text: string, fromLine: number[]|null}[], notChecked?: string[], questions?: string[], protections?: string[], signedFacts?: string[]}} p the confirm turn's plan
170
174
  * @param {{n: number, text: string, rule: string}[]|null} jobLines
171
175
  * @returns {{role: 'bot', kind: 'plan', text: string, plan: ReturnType<typeof planFromConfirm> & {goal: string, questions: string[]}}}
172
176
  */
@@ -404,7 +408,7 @@ export function validateJobCard(card, opts = {}) {
404
408
  * @param {any} card the validated job card (see {@link validateJobCard})
405
409
  * @param {{env?: Record<string,string|undefined>, sessionsRoot?: string, home?: string, timeoutMs?: number,
406
410
  * scout?: any, generate?: Function, confirmGenerate?: Function, authorFn?: Function,
407
- * prepareSigningFn?: Function, npmCiFn?: Function, lookFn?: Function|null, fetchUrl?: (url: string) => Promise<{ok: true, text: string, url: string}|{ok: false, error: string}>, draftThinking?: "on"|"off", reuse?: {spec: any, workflowKey: string}}} [deps] `scout`..`prepareSigningFn` are
411
+ * prepareSigningFn?: Function, npmCiFn?: Function, lookFn?: Function|null, fitFn?: Function|null, fetchUrl?: (url: string) => Promise<{ok: true, text: string, url: string}|{ok: false, error: string}>, draftThinking?: "on"|"off", reuse?: {spec: any, workflowKey: string}}} [deps] `scout`..`prepareSigningFn` are
408
412
  * TEST SEAMS ONLY — see the note just above where each is read, below. `reuse` (Reuse workflow) is NOT a seam: the
409
413
  * caller (`/api/author/start`) sets it only when the server's own same-job rule held.
410
414
  * @returns {any} the session object
@@ -560,7 +564,8 @@ export function createSession(card, deps = {}) {
560
564
  if (step.kind === 'language') say('bot', `${step.field?.prompt ?? 'Which language is this job about?'} (${(step.candidates ?? []).join(', ')})`);
561
565
  else if (step.kind === 'menu') {
562
566
  sayPlan(step.plan ?? {});
563
- } else if (step.kind === 'answer') say('bot', `A question the plan raised (${step.index} of ${step.total})${Number.isInteger(step.line) ? `, about job line ${step.line}` : ''}: ${step.question}`);
567
+ } else if (step.kind === 'answer' && step.source === 'fit-check') say('bot', step.question);
568
+ else if (step.kind === 'answer') say('bot', `A question the plan raised (${step.index} of ${step.total})${Number.isInteger(step.line) ? `, about job line ${step.line}` : ''}: ${step.question}`);
564
569
  else if (step.kind === 'goal') say('bot', 'Type the goal sentence yourself.');
565
570
  });
566
571
 
@@ -575,6 +580,19 @@ export function createSession(card, deps = {}) {
575
580
  };
576
581
 
577
582
  let lastTryRefused = false;
583
+ // F243: the fit check's result is one drafting-log row (the asks, what was found, what the quote proved); an `absent` check says so in chat
584
+ // (a seam of its own - onPhase never posts a chat message)
585
+ const onFit = (/** @type {any} */ data) => {
586
+ const running = state.steps.findLastIndex((/** @type {any} */ x) => x.status === 'running');
587
+ appendDraftLog(outDir, {
588
+ kind: 'fit-check', no: running === -1 ? Math.max(0, state.steps.length - 1) : running, state: String(data.state ?? 'absent'),
589
+ asks: (Array.isArray(data.asks) ? data.asks : []).map((/** @type {any} */ a) => ({
590
+ line: a.line, ask: redactSecrets(String(a.ask)), found: a.found === true, quote: typeof a.quote === 'string' ? redactSecrets(a.quote) : null, madeUp: a.madeUp === true, ...(a.ignored === 'number' ? { ignored: 'number' } : {}),
591
+ })),
592
+ notFound: (Array.isArray(data.notFound) ? data.notFound : []).map((/** @type {any} */ x) => ({ line: x.line, ask: redactSecrets(String(x.ask)) })), at: new Date().toISOString(),
593
+ });
594
+ if (data.state === 'absent') say('bot', FIT_ABSENT_NOTE);
595
+ };
578
596
  const onPhase = (name, data = {}) => {
579
597
  // COLLAPSED INTO THE PROGRESS INDICATOR ONLY (build item 3) — this used
580
598
  // to also `say('system', `… ${name}`)`, one jargon chat bubble per
@@ -1068,6 +1086,16 @@ export function createSession(card, deps = {}) {
1068
1086
  return { jobLines, answers, left };
1069
1087
  };
1070
1088
 
1089
+ // F243: the fit check's one question, through the same `ask` (the chat box + Send; a blank answer asks again, there is no skip)
1090
+ const fitAsk = async (/** @type {string} */ question) => {
1091
+ for (;;) {
1092
+ const a = await ask({ kind: 'answer', source: 'fit-check', index: 1, total: 1, question });
1093
+ if (a === null) return null;
1094
+ if (String(a).trim()) return String(a);
1095
+ say('bot', 'An answer is needed here — there is no skip. Type it in the box below and press Send.');
1096
+ }
1097
+ };
1098
+
1071
1099
  state.phase = 'drafting';
1072
1100
  // no early "drafting" line: the real author phase (PHASE_STEP) starts it, carrying draftDetail (see onPhase)
1073
1101
  // build item 4 — the DISPLAY id (card.model, e.g. "deepseek-flash"), not
@@ -1087,6 +1115,9 @@ export function createSession(card, deps = {}) {
1087
1115
  ...(deps.scout ? { scout: deps.scout } : {}),
1088
1116
  // PART 1c: a test that replaces the model boundary (`generate`) must not reach the real paid look; `deps.lookFn` brings its own
1089
1117
  ...(deps.lookFn !== undefined ? { lookFn: deps.lookFn } : (deps.generate ? { lookFn: null } : {})),
1118
+ // F243: the fit check asks through the ordinary ask path; like the look, a test that replaces the model boundary never reaches the real paid call
1119
+ fitAsk, onFit,
1120
+ ...(deps.fitFn !== undefined ? { fitFn: deps.fitFn } : (deps.generate ? { fitFn: null } : {})),
1090
1121
  ...(authorFnOverride ? { authorFn: authorFnOverride } : {}),
1091
1122
  });
1092
1123
 
@@ -1135,7 +1166,7 @@ export function createSession(card, deps = {}) {
1135
1166
  const rec = reconcileRubricPlan({
1136
1167
  confirmed: authored.confirmed?.checkItems ?? [], notChecked: authored.confirmed?.notChecked ?? [], closeDecl: spec.closeDecl, jobLines,
1137
1168
  });
1138
- state.finalPlan = { goal: String(draft.goal ?? ''), checkItems: rec.checkItems, notChecked: rec.notChecked, protections: authored.confirmed?.protections ?? [], moved: rec.moved };
1169
+ state.finalPlan = { goal: String(draft.goal ?? ''), checkItems: rec.checkItems, notChecked: rec.notChecked, protections: authored.confirmed?.protections ?? [], moved: rec.moved, signedFacts: signedFactNames(spec.closeDecl) };
1139
1170
  }
1140
1171
  await finish(spec, authored.seedRef);
1141
1172
  if (state.finalPlan && state.phase === 'prepared') {
@@ -1144,7 +1175,7 @@ export function createSession(card, deps = {}) {
1144
1175
  // dropped, so drafter prose can never sit above (and be read instead of) the code's sentences (run mv5e1ktb).
1145
1176
  state.messages = state.messages.filter((/** @type {any} */ m) => m.kind !== 'plan');
1146
1177
  state.messages.push({
1147
- ...planMessage({ checkItems: state.finalPlan.checkItems, checks: state.finalPlan.checkItems.map((/** @type {any} */ c) => c.text), notChecked: state.finalPlan.notChecked, protections: state.finalPlan.protections, questions: [] }, jobLines),
1178
+ ...planMessage({ checkItems: state.finalPlan.checkItems, checks: state.finalPlan.checkItems.map((/** @type {any} */ c) => c.text), notChecked: state.finalPlan.notChecked, protections: state.finalPlan.protections, signedFacts: state.finalPlan.signedFacts, questions: [] }, jobLines),
1148
1179
  text: 'These are the checks the signed close will run, and what it will not check. Sign & run to accept them, or Revise to edit the boxes and draft again.',
1149
1180
  });
1150
1181
  }