bareloop 0.43.0 → 0.44.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 (57) hide show
  1. package/CHANGELOG.md +42 -0
  2. package/bareloop.context.md +52 -13
  3. package/package.json +2 -2
  4. package/src/authorflow.js +26 -11
  5. package/src/authoring.js +1 -1
  6. package/src/authorjob.js +27 -15
  7. package/src/authorreadout.js +7 -6
  8. package/src/declaredclose.js +4 -1
  9. package/src/draftspend.js +20 -2
  10. package/src/facts.js +54 -9
  11. package/src/factwords.js +58 -6
  12. package/src/inputlook.js +38 -3
  13. package/src/job.js +1 -1
  14. package/src/kinds.js +5 -0
  15. package/src/pagedropwords.js +64 -0
  16. package/src/pagemap.js +264 -0
  17. package/src/pagerules.js +319 -0
  18. package/src/panel/authorroutes.js +34 -30
  19. package/src/panel/authorsession.js +29 -5
  20. package/src/panel/index.html +465 -65
  21. package/src/panel/jobplan.js +4 -2
  22. package/src/panel/runroutes.js +46 -22
  23. package/src/panel/server.js +791 -28
  24. package/src/panel/status.js +33 -0
  25. package/src/panelmoney.js +29 -0
  26. package/src/planrun.js +53 -4
  27. package/src/promptregisters.js +4 -0
  28. package/src/replay.js +118 -7
  29. package/src/rubricstep.js +123 -11
  30. package/src/runlist.js +2 -1
  31. package/src/schedulewords.js +6 -3
  32. package/src/source.js +18 -0
  33. package/src/thinking.js +63 -2
  34. package/src/userrun.js +28 -14
  35. package/types/authorflow.d.ts +42 -6
  36. package/types/authorjob.d.ts +10 -2
  37. package/types/declaredclose.d.ts +2 -0
  38. package/types/draftspend.d.ts +27 -1
  39. package/types/facts.d.ts +15 -0
  40. package/types/factwords.d.ts +26 -3
  41. package/types/inputlook.d.ts +15 -0
  42. package/types/kinds.d.ts +5 -0
  43. package/types/pagedropwords.d.ts +26 -0
  44. package/types/pagemap.d.ts +154 -0
  45. package/types/pagerules.d.ts +86 -0
  46. package/types/panel/authorroutes.d.ts +17 -14
  47. package/types/panel/runroutes.d.ts +2 -2
  48. package/types/panel/server.d.ts +155 -2
  49. package/types/panel/status.d.ts +51 -0
  50. package/types/panelmoney.d.ts +5 -0
  51. package/types/replay.d.ts +21 -1
  52. package/types/rubricstep.d.ts +57 -1
  53. package/types/runlist.d.ts +4 -1
  54. package/types/schedulewords.d.ts +4 -2
  55. package/types/source.d.ts +11 -0
  56. package/types/thinking.d.ts +39 -0
  57. package/types/userrun.d.ts +4 -0
package/src/draftspend.js CHANGED
@@ -25,6 +25,8 @@ export const DRAFT_LOG_FILE = 'draft-log.jsonl';
25
25
  * - `step-start` {no, id, label, at}
26
26
  * - `step-end` {no, status: 'done'|'failed', label, at} (a re-opened step writes `step-reopen` {no, at} first)
27
27
  * - `step-label` {no, label, at} (the step reads `label` from now on, status and times untouched: the thinking row's "the model thought anyway" / "can't change it" / "not confirmed")
28
+ * - `human` {no, source: 'confirm'|'try-3'|'look', question, answer, line?, at}: one question the person was asked and what they answered (the answer is secret-masked
29
+ * before it is written; the row is written only when BOTH exist, so a question nobody answered leaves no row)
28
30
  * - `call` {no, step, label, model, thinking, thinkingSupport, thinkingStatus?, costUsd (null stays null), unpricedRounds, at}: one METERED model call
29
31
  * (`thinking` = the tick's mode: off | on; `thinkingSupport` = bare-agent's lookup for the model: true | false | 'unverified';
30
32
  * `thinkingStatus` = bare-agent's own verdict for the call's run: applied | unsupported | ignored | unknown, present only when thinkingMode was sent)
@@ -126,12 +128,26 @@ export function sessionDirForRow(row, opts = {}) {
126
128
  return null;
127
129
  }
128
130
 
131
+ /**
132
+ * PRD v1.106 Part 5 Q1: one answered question as the two lines the Audit's drafting table shows under its step. ONE place owns the words.
133
+ * @param {{question: string, answer: string, source?: string, line?: number, at?: string}} h
134
+ * @returns {{question: string, answer: string, source: string, line?: number, at?: string, askedText: string, saidText: string}}
135
+ */
136
+ export function humanRowWords(h) {
137
+ return {
138
+ question: h.question, answer: h.answer, source: typeof h.source === 'string' ? h.source : 'confirm',
139
+ ...(Number.isInteger(h.line) ? { line: h.line } : {}), ...(h.at ? { at: h.at } : {}),
140
+ askedText: `you were asked: ${h.question}`, saidText: `you said: ${h.answer}`,
141
+ };
142
+ }
143
+
129
144
  /**
130
145
  * Fold `<dir>/draft-log.jsonl` into one record per drafting step, each with its calls. A line that cannot be parsed is
131
146
  * skipped (a torn last line must not hide the rest).
132
147
  * @param {string} dir
133
148
  * @returns {{no: number, id: string, label: string, status: 'running'|'done'|'failed', startedAt: string, endedAt: string|null, wallMs: number|null,
134
- * calls: {label: string, model: string|null, costUsd: number|null, unpricedRounds: number, at: string}[]}[]}
149
+ * calls: {label: string, model: string|null, costUsd: number|null, unpricedRounds: number, at: string}[],
150
+ * humans: ReturnType<typeof humanRowWords>[]}[]}
135
151
  */
136
152
  export function readDraftSteps(dir) {
137
153
  /** @type {string} */
@@ -145,7 +161,7 @@ export function readDraftSteps(dir) {
145
161
  try { ev = JSON.parse(line); } catch { continue; }
146
162
  if (!ev || typeof ev !== 'object' || !Number.isInteger(ev.no)) continue;
147
163
  if (ev.kind === 'step-start') {
148
- byNo.set(ev.no, { no: ev.no, id: String(ev.id), label: String(ev.label), status: 'running', startedAt: ev.at, endedAt: null, wallMs: null, calls: [] });
164
+ byNo.set(ev.no, { no: ev.no, id: String(ev.id), label: String(ev.label), status: 'running', startedAt: ev.at, endedAt: null, wallMs: null, calls: [], humans: [] });
149
165
  } else if (ev.kind === 'step-end' && byNo.has(ev.no)) {
150
166
  const x = byNo.get(ev.no);
151
167
  x.status = ev.status === 'failed' ? 'failed' : 'done';
@@ -157,6 +173,8 @@ export function readDraftSteps(dir) {
157
173
  const x = byNo.get(ev.no);
158
174
  x.status = 'running';
159
175
  x.endedAt = null;
176
+ } else if (ev.kind === 'human' && byNo.has(ev.no) && typeof ev.question === 'string' && typeof ev.answer === 'string') {
177
+ byNo.get(ev.no).humans.push(humanRowWords({ question: ev.question, answer: ev.answer, source: ev.source, ...(Number.isInteger(ev.line) ? { line: ev.line } : {}), at: ev.at }));
160
178
  } else if (ev.kind === 'call') {
161
179
  const c = { label: String(ev.label), model: typeof ev.model === 'string' ? ev.model : null, costUsd: typeof ev.costUsd === 'number' ? ev.costUsd : null, unpricedRounds: Number(ev.unpricedRounds) || 0, at: String(ev.at) };
162
180
  byNo.get(ev.no)?.calls.push(c);
package/src/facts.js CHANGED
@@ -24,6 +24,22 @@ export const FACT_PART_FIELDS = Object.freeze(['source', 'facts', 'count']);
24
24
  /** a fact's name appears in the claim text bareguard scans for numbers, so it carries letters only (no digit can red the claim) */
25
25
  const NAME_RE = /^[A-Za-z][A-Za-z _-]{0,63}$/;
26
26
 
27
+ /** the limits bareguard 0.24 puts on a money fact's lists (ruling #65), enforced here first so the red is ours and plain */
28
+ export const MAX_CURRENCIES = 3;
29
+ export const MAX_UNITS = 10;
30
+ export const MAX_UNIT_CHARS = 100;
31
+ const CURRENCY_SIGNS = ['$', '€', '£'];
32
+
33
+ /**
34
+ * A money fact's `unit` / `currency` is ONE string (every spec signed before lists) or a non-empty list of them; a string is read exactly as a
35
+ * one-element list. Nothing is rewritten, so an old spec's bytes and hash stay as they were.
36
+ * @param {unknown} v @returns {string[]}
37
+ */
38
+ export const asList = (v) => (Array.isArray(v) ? v.map(String) : (typeof v === 'string' ? [v] : []));
39
+
40
+ /** @param {any} f a money fact @returns {string[]} every form the page writes the amount in: the units first (as R1L reads them), then the currency signs */
41
+ export const moneyForms = (f) => [...asList(f.unit), ...asList(f.currency)];
42
+
27
43
  /** @param {unknown} v @returns {v is Record<string, any>} */
28
44
  const isRec = (v) => v !== null && typeof v === 'object' && !Array.isArray(v);
29
45
  /** @param {unknown} v @returns {v is string} */
@@ -36,7 +52,7 @@ export function factPartOf(params) {
36
52
  }
37
53
 
38
54
  /** @param {any} v @returns {boolean} a rule bound has the type its shape needs: a number for number/money, a string for date/time */
39
- const boundOk = (/** @type {string} */ shape, /** @type {unknown} */ v) => (shape === 'number' || shape === 'money' ? typeof v === 'number' && Number.isFinite(v) : typeof v === 'string' && v !== '');
55
+ export const boundOk = (/** @type {string} */ shape, /** @type {unknown} */ v) => (shape === 'number' || shape === 'money' ? typeof v === 'number' && Number.isFinite(v) : typeof v === 'string' && v !== '');
40
56
 
41
57
  /**
42
58
  * The SHAPE of a `fact` part (what it may carry), pushed as reds in the same form `rubricStepShapeReds` uses. Whether the
@@ -72,10 +88,27 @@ export function factShapeReds(part, w, red) {
72
88
  if (Object.hasOwn(f, 'aiProposal') && typeof f.aiProposal !== 'boolean') red('invalid-value', `${fw}.aiProposal`, 'true when the AI turned the person\'s words into this number');
73
89
  const has = (/** @type {string} */ k) => Object.hasOwn(f, k);
74
90
  if (f.shape === 'money') {
75
- if (has('unit') === has('currency')) red('invalid-value', `${fw}.unit`, 'money names exactly one of unit (words the input writes after the amount, e.g. "US dollars") or currency ("$", "€" or "£")');
91
+ if (!has('unit') && !has('currency')) red('invalid-value', `${fw}.unit`, 'money names at least one of unit (words the input writes after the amount, e.g. "US dollars") or currency ("$", "€" or "£"); each may be one value or a list');
76
92
  } else if (has('unit') || has('currency')) red('invalid-value', `${fw}.${has('unit') ? 'unit' : 'currency'}`, 'only money has a unit or a currency');
77
- if (has('unit') && !nonEmpty(f.unit)) red('invalid-value', `${fw}.unit`, 'a non-empty string copied from the input');
78
- if (has('currency') && !['$', '€', '£'].includes(f.currency)) red('invalid-value', `${fw}.currency`, 'one of $ | € | £');
93
+ if (has('unit')) {
94
+ const us = f.unit;
95
+ if (Array.isArray(us) ? us.length === 0 || us.some((/** @type {unknown} */ u) => !nonEmpty(u)) : !nonEmpty(us)) red('invalid-value', `${fw}.unit`, 'a non-empty string copied from the input, or a non-empty list of them');
96
+ else {
97
+ const list = asList(us);
98
+ if (list.length > MAX_UNITS) red('invalid-value', `${fw}.unit`, `at most ${MAX_UNITS} units (${list.length} given)`);
99
+ else if (list.some((u) => u.length > MAX_UNIT_CHARS)) red('invalid-value', `${fw}.unit`, `a unit is at most ${MAX_UNIT_CHARS} characters`);
100
+ else if (new Set(list).size !== list.length) red('invalid-value', `${fw}.unit`, 'the units are not repeated');
101
+ }
102
+ }
103
+ if (has('currency')) {
104
+ const cs = f.currency;
105
+ if (Array.isArray(cs) ? cs.length === 0 || cs.some((/** @type {unknown} */ c) => !CURRENCY_SIGNS.includes(/** @type {string} */ (c))) : !CURRENCY_SIGNS.includes(cs)) red('invalid-value', `${fw}.currency`, 'one of $ | € | £, or a list of them');
106
+ else {
107
+ const list = asList(cs);
108
+ if (list.length > MAX_CURRENCIES) red('invalid-value', `${fw}.currency`, `at most ${MAX_CURRENCIES} currencies (${list.length} given)`);
109
+ else if (new Set(list).size !== list.length) red('invalid-value', `${fw}.currency`, 'the currencies are not repeated');
110
+ }
111
+ }
79
112
  if (f.shape === 'phrase') {
80
113
  if (!nonEmpty(f.phrase)) red('invalid-value', `${fw}.phrase`, 'the fixed words the quote must contain, copied from the input');
81
114
  for (const k of ['anchor', 'until', 'rule']) if (has(k)) red('invalid-value', `${fw}.${k}`, 'a phrase is fixed words the quote must contain; it has no anchor, until or rule');
@@ -112,7 +145,7 @@ export function factDecides(part) {
112
145
  if (f.shape === 'phrase') return { phrases: [f.phrase] };
113
146
  /** @type {Record<string, any>} */
114
147
  const d = { valueKind: f.shape };
115
- if (f.shape === 'money') { if (f.unit !== undefined) d.unit = f.unit; else d.currency = f.currency; }
148
+ if (f.shape === 'money') { if (f.unit !== undefined) d.unit = f.unit; if (f.currency !== undefined) d.currency = f.currency; }
116
149
  if (f.anchor !== undefined) d.anchor = f.anchor;
117
150
  if (f.until !== undefined) d.until = f.until;
118
151
  if (f.rule) {
@@ -151,12 +184,24 @@ export function factClaims(part, results) {
151
184
 
152
185
  /** @param {unknown} n @param {string} shape @param {any} f @returns {string} one bound, as the person would say it */
153
186
  function sayBound(n, shape, f) {
154
- if (shape === 'money') return f.unit !== undefined ? `${n} ${f.unit}` : `${f.currency}${n}`;
187
+ if (shape === 'money') return moneyAs(f, /** @type {string|number} */ (n), false);
155
188
  return String(n);
156
189
  }
157
190
 
191
+ /**
192
+ * One amount of a money fact in the fact's OWN signed forms, never a hardcoded sign: `symbolFirst` puts the currency sign before the amount
193
+ * ("$144") when the fact has one, else the unit after it ("144 US dollars"); not `symbolFirst` (a rule's bound) prefers the unit.
194
+ * @param {any} f a money fact @param {string|number} n @param {boolean} symbolFirst @returns {string}
195
+ */
196
+ export function moneyAs(f, n, symbolFirst) {
197
+ const cur = asList(f.currency)[0];
198
+ const uni = asList(f.unit)[0];
199
+ if (symbolFirst) return cur !== undefined ? `${cur}${n}` : `${n} ${uni}`;
200
+ return uni !== undefined ? `${n} ${uni}` : `${cur}${n}`;
201
+ }
202
+
158
203
  /** @param {any} f a fact with a rule @returns {string} the rule in words, e.g. "at most 450 US dollars" */
159
- function ruleWords(f) {
204
+ export function ruleWords(f) {
160
205
  const r = f.rule;
161
206
  return r.op === 'between' ? `between ${sayBound(r.lo, f.shape, f)} and ${sayBound(r.hi, f.shape, f)}`
162
207
  : `${{ lt: 'under', lte: 'at most', gt: 'over', gte: 'at least' }[/** @type {'lt'|'lte'|'gt'|'gte'} */ (r.op)]} ${sayBound(r.value, f.shape, f)}`;
@@ -173,7 +218,7 @@ export function factSentence(f, source) {
173
218
  const bits = [];
174
219
  if (f.shape === 'phrase') bits.push('phrase', `must say "${f.phrase}"`);
175
220
  else {
176
- bits.push(f.shape === 'money' ? `money in ${f.unit !== undefined ? f.unit : f.currency}` : f.shape);
221
+ bits.push(f.shape === 'money' ? `money in ${moneyForms(f).join(' or ')}` : f.shape);
177
222
  if (f.rule) bits.push(ruleWords(f));
178
223
  else if (f.anchor === undefined) bits.push('as the quote writes it');
179
224
  if (f.anchor !== undefined) bits.push(f.until !== undefined ? `the words after "${f.anchor}" up to "${f.until}"` : `after "${f.anchor}"`);
@@ -190,7 +235,7 @@ export function factSentence(f, source) {
190
235
  export function factBrief(f) {
191
236
  if (f.shape === 'phrase') return `${f.name} — no value to write; the quote you copy must say "${f.phrase}"`;
192
237
  /** @type {string[]} */
193
- const bits = [f.shape === 'money' ? `an amount in ${f.unit !== undefined ? f.unit : f.currency}` : `a ${f.shape}`];
238
+ const bits = [f.shape === 'money' ? `an amount in ${moneyForms(f).join(' or ')}` : `a ${f.shape}`];
194
239
  if (f.shape === 'text') bits[0] = 'words';
195
240
  if (f.anchor !== undefined) bits.push(f.until !== undefined ? `the words right after "${f.anchor}" up to "${f.until}"` : `right after "${f.anchor}"`);
196
241
  if (f.rule) bits.push(`must be ${ruleWords(f)}`);
package/src/factwords.js CHANGED
@@ -7,6 +7,8 @@
7
7
  // page) is one space on both sides.
8
8
 
9
9
  import { labelOnList, normLabel, scanLabels } from './labelscan.js';
10
+ import { asList } from './facts.js';
11
+ import { dropSentence } from './pagedropwords.js';
10
12
 
11
13
  const WS = /[\s   ]+/g;
12
14
  const escapeRe = (/** @type {string} */ s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
@@ -46,19 +48,63 @@ export function countPhrase(source, words) {
46
48
  /** @param {string} shape @returns {'money'|'number'|'date'|'time'|'text'} the label list a fact's anchor is picked from */
47
49
  const listOf = (shape) => /** @type {any} */ (['money', 'number', 'date', 'time'].includes(shape) ? shape : 'text');
48
50
 
51
+
52
+ /**
53
+ * What the page-word map (src/pagemap.js) KEPT and DROPPED for one input: the sets the second proof reads. A look from the old search-and-read
54
+ * path has no `dropped` list, so it gives null and the label is proved against the code-made list as before.
55
+ * @param {{facts?: any[], dropped?: (import('./pagerules.js').Dropped & {source?: string})[]}|null|undefined} look @param {string} source
56
+ * @returns {{labels: Set<string>, units: Set<string>, currencies: Set<string>, words: Set<string>, dropped: Map<string, import('./pagerules.js').Dropped>}|null}
57
+ */
58
+ export function keptSetOf(look, source) {
59
+ if (!look || !Array.isArray(look.dropped)) return null;
60
+ const facts = (Array.isArray(look.facts) ? look.facts : []).filter((f) => f && f.source === source && Array.isArray(f.words));
61
+ const set = (/** @type {string[]} */ a) => new Set(a.map((w) => normLabel(String(w))));
62
+ /** @type {Map<string, import('./pagerules.js').Dropped>} */
63
+ const dropped = new Map();
64
+ for (const d of look.dropped) if (d.source === source && typeof d.word === 'string' && !dropped.has(normLabel(d.word))) dropped.set(normLabel(d.word), d);
65
+ return {
66
+ labels: set(facts.filter((f) => f.shape !== 'phrase').flatMap((f) => f.words)),
67
+ units: set(facts.flatMap((f) => f.unit ?? [])),
68
+ currencies: set(facts.flatMap((f) => f.currency ?? [])),
69
+ words: set(facts.flatMap((f) => [...f.words, ...(f.unit ?? []), ...(f.currency ?? [])])),
70
+ dropped,
71
+ };
72
+ }
73
+
49
74
  /**
50
75
  * The reds for ONE fact part against its frozen source text.
51
- * @param {{part: any, text: string, line: number|string, scan?: import('./labelscan.js').LabelScan}} o
76
+ * @param {{part: any, text: string, line: number|string, scan?: import('./labelscan.js').LabelScan, kept?: ReturnType<typeof keptSetOf>}} o
52
77
  * @returns {{code: string, detail: string}[]}
53
78
  */
54
- export function checkFactWords({ part, text, line, scan = scanLabels(text) }) {
79
+ export function checkFactWords({ part, text, line, scan = scanLabels(text), kept = null }) {
55
80
  /** @type {{code: string, detail: string}[]} */
56
81
  const reds = [];
57
82
  const src = part.source;
58
83
  const red = (/** @type {string} */ code, /** @type {string} */ msg) => reds.push({ code, detail: `line ${line}: ${msg} Nothing was signed.` });
59
84
  for (const f of Array.isArray(part.facts) ? part.facts : []) {
60
85
  const who = `fact "${f.name}"`;
61
- if (f.anchor !== undefined && f.shape !== 'phrase') {
86
+ // a word the page rules DROPPED (and no other fact kept) can never come back through the drafter (idea A core, spec 4.3)
87
+ const dropRed = (/** @type {string} */ what, /** @type {unknown} */ w) => {
88
+ const n = normLabel(String(w));
89
+ const d = kept?.dropped.get(n);
90
+ if (!kept || !d || kept.words.has(n)) return false;
91
+ red('rubric-fact-word-dropped', `${who}: the ${what} ${JSON.stringify(n)} cannot be used. ${dropSentence(d)}`);
92
+ return true;
93
+ };
94
+ if (f.anchor !== undefined && f.shape !== 'phrase' && kept) {
95
+ // a page input read by the page-word map: the label must be one the map KEPT (the code-made list cannot hold a marker after a number)
96
+ const label = normLabel(String(f.anchor));
97
+ if (dropRed('label', f.anchor)) { /* said above */ } else if (!kept.labels.has(label)) {
98
+ const some = [...kept.labels].slice(0, 12).map((l) => JSON.stringify(l)).join(', ');
99
+ red('rubric-fact-label-off-list', `${who}: the label ${JSON.stringify(label)} is not one of the labels the page words for "${src}" kept. A label is picked from those, never typed${some ? `: ${some}` : ' (there are none - leave the label out)'}.`);
100
+ } else if (countExact(text, label) < 1) {
101
+ red('rubric-fact-word-missing', `${who}: the label ${JSON.stringify(label)} is not in the input "${src}".`);
102
+ } else if (Number.isInteger(part.count?.min) && countExact(text, label) < part.count.min) {
103
+ const n = part.count.min;
104
+ const seen = countExact(text, label);
105
+ red('rubric-fact-label-rare', `${who}: the label ${JSON.stringify(label)} is in the input "${src}" only ${seen} time${seen === 1 ? '' : 's'}, but this line asks for ${n} results, so a label that sits in each result must be there at least ${n} times. Pick a label that is counted ${n} or more times.`);
106
+ }
107
+ } else if (f.anchor !== undefined && f.shape !== 'phrase') {
62
108
  const list = listOf(f.shape);
63
109
  const row = labelOnList(scan, list, f.anchor);
64
110
  if (!row) {
@@ -73,6 +119,7 @@ export function checkFactWords({ part, text, line, scan = scanLabels(text) }) {
73
119
  red('rubric-fact-label-rare', `${who}: the label ${JSON.stringify(row.label)} is in the input "${src}" only ${row.count} time${row.count === 1 ? '' : 's'}, but this line asks for ${n} results, so a label that sits in each result must be there at least ${n} times. Pick a label from the list that is counted ${n} or more times.`);
74
120
  }
75
121
  }
122
+ if (f.until !== undefined) dropRed('closing label', f.until);
76
123
  if (f.until !== undefined && countExact(text, f.until) < 1) red('rubric-fact-word-missing', `${who}: ${JSON.stringify(f.until)} is not in the input "${src}". Copy the closing words from the input, or leave them out.`);
77
124
  if (f.shape === 'phrase' && typeof f.phrase === 'string') {
78
125
  const seen = countPhrase(text, f.phrase);
@@ -84,7 +131,11 @@ export function checkFactWords({ part, text, line, scan = scanLabels(text) }) {
84
131
  red('rubric-fact-phrase-rare', `${who}: the words ${JSON.stringify(f.phrase)} are in the input "${src}" only ${seen} time${seen === 1 ? '' : 's'}, but this line asks for ${n} results - each result's own quote must say them. Pick words every result says, or drop this check.`);
85
132
  }
86
133
  }
87
- if (typeof f.unit === 'string' && countPhrase(text, f.unit) < 1) red('rubric-fact-word-missing', `${who}: the unit ${JSON.stringify(f.unit)} is not in the input "${src}". Copy the words the input really uses.`);
134
+ // a unit (one string, or every one of a list) must be found as whole words; a currency sign (each of a list) must be found as written
135
+ for (const u of asList(f.unit)) dropRed('unit', u);
136
+ for (const c of asList(f.currency)) dropRed('currency', c);
137
+ for (const u of asList(f.unit)) if (countPhrase(text, u) < 1) red('rubric-fact-word-missing', `${who}: the unit ${JSON.stringify(u)} is not in the input "${src}". Copy the words the input really uses.`);
138
+ for (const c of asList(f.currency)) if (countExact(text, c) < 1) red('rubric-fact-word-missing', `${who}: the currency ${JSON.stringify(c)} is not in the input "${src}". Copy the sign the input really uses.`);
88
139
  }
89
140
  return reds;
90
141
  }
@@ -93,9 +144,10 @@ export function checkFactWords({ part, text, line, scan = scanLabels(text) }) {
93
144
  * The reds for every fact part of a declaration. A fact part whose input text is not at hand is a red: a check whose words were never
94
145
  * proved against the source is never signed.
95
146
  * @param {any} declaration @param {Record<string, string>|null} texts label -> frozen text
147
+ * @param {{facts?: any[], dropped?: any[]}|null} [look] the page-word map's report: what it kept and dropped (null = no map, the code-made list is the proof)
96
148
  * @returns {{code: string, path: string, detail: string}[]}
97
149
  */
98
- export function checkDeclarationWords(declaration, texts) {
150
+ export function checkDeclarationWords(declaration, texts, look = null) {
99
151
  /** @type {{code: string, path: string, detail: string}[]} */
100
152
  const reds = [];
101
153
  if (!declaration || !Array.isArray(declaration.stages)) return reds;
@@ -114,7 +166,7 @@ export function checkDeclarationWords(declaration, texts) {
114
166
  return;
115
167
  }
116
168
  if (!scans.has(p.source)) scans.set(p.source, scanLabels(text));
117
- for (const r of checkFactWords({ part: p, text, line: st.params.step, scan: scans.get(p.source) })) reds.push({ code: r.code, path: at, detail: r.detail });
169
+ for (const r of checkFactWords({ part: p, text, line: st.params.step, scan: scans.get(p.source), kept: keptSetOf(look, p.source) })) reds.push({ code: r.code, path: at, detail: r.detail });
118
170
  });
119
171
  });
120
172
  return reds;
package/src/inputlook.js CHANGED
@@ -11,6 +11,7 @@
11
11
  import { scanLabels, labelOnList, renderLabelList, normLabel, LABEL_SHAPES } from './labelscan.js';
12
12
  import { FACT_SHAPES, FACT_OPS } from './facts.js';
13
13
  import { lineRules } from './rubricstep.js';
14
+ import { dropRows, dropLine } from './pagedropwords.js';
14
15
 
15
16
  /** the name of the answer tool - the one place the AI's report enters */
16
17
  export const LOOK_REPORT_TOOL = 'report_look';
@@ -149,13 +150,31 @@ export function lookPrompt({ jobLines, labels, lists }) {
149
150
  + `Report only through ${LOOK_REPORT_TOOL}.`;
150
151
  }
151
152
 
152
- /** @typedef {{line: number, name: string, shape: string, source: string, label?: string, labelId?: string, value?: string, phrase?: string, example: string, rule?: any, aiProposal?: boolean, forRule?: number}} LookFact */
153
+ /** @typedef {{line: number, name: string, shape: string, source: string, label?: string, labelId?: string, value?: string, phrase?: string, example: string, rule?: any, aiProposal?: boolean, forRule?: number, reading?: import('./pagerules.js').Reading,
154
+ * words?: string[], labels?: string[], unit?: string[], currency?: string[]}} LookFact `words` / `labels` / `unit` / `currency`: what the page-word map kept (idea A core) */
153
155
  /** @typedef {{line: number, rule?: number, words?: string, why: string}} LookNotFound */
154
- /** @typedef {{facts: LookFact[], prose: {line: number, text?: string}[], notFound: LookNotFound[]}} LookReport */
156
+ /** @typedef {{facts: LookFact[], prose: {line: number, text?: string}[], notFound: LookNotFound[], dropped?: import('./pagerules.js').Dropped[]}} LookReport */
155
157
 
156
158
  /** @param {unknown} v @returns {v is Record<string, any>} */
157
159
  const isRec = (v) => v !== null && typeof v === 'object' && !Array.isArray(v);
158
160
 
161
+ /**
162
+ * A fact the page-word map kept (src/pagerules.js `KeptFact`), as the look report carries it: a phrase fact holds its alternative
163
+ * phrases; any other holds its labels (the first is the primary), and a money fact its units and currencies.
164
+ * @param {import('./pagerules.js').KeptFact} k @param {string} source @returns {LookFact}
165
+ */
166
+ export function lookFactOf(k, source) {
167
+ /** @type {LookFact} */
168
+ const out = { line: k.line, name: k.name, shape: k.shape, source, example: '', words: [...k.words] };
169
+ if (k.shape === 'phrase') out.phrase = k.words[0];
170
+ else { out.labels = [...k.words]; if (k.words.length) out.label = k.words[0]; }
171
+ if (k.unit) out.unit = [...k.unit];
172
+ if (k.currency) out.currency = [...k.currency];
173
+ if (k.forRule !== undefined) out.forRule = k.forRule;
174
+ if (k.reading) out.reading = { ...k.reading };
175
+ return out;
176
+ }
177
+
159
178
  /**
160
179
  * Turn the AI's raw report into a VERIFIED one, by code, at $0. A fact is carried only when: its shape is a real shape, its source is
161
180
  * a real input, and its label is ON THE LIST for that input and shape (an id is resolved to its exact words). Anything else is moved
@@ -225,7 +244,17 @@ export function renderLookReport(report) {
225
244
  const add = (/** @type {number} */ n, /** @type {string} */ s) => byLine.set(n, [...(byLine.get(n) ?? []), s]);
226
245
  for (const f of report.facts) {
227
246
  const bits = [`${f.name} - ${f.shape}`, `from "${f.source}"`];
228
- if (f.phrase !== undefined) bits.push(`the quote must say ${JSON.stringify(f.phrase)}`);
247
+ if (Array.isArray(f.words)) {
248
+ // a fact the page-word map kept (idea A core): the words the page holds, each one proven by code; the first is the main one
249
+ const quote = (/** @type {string[]|undefined} */ a) => (a ?? []).map((w) => JSON.stringify(w)).join(' or ');
250
+ if (f.shape === 'phrase') bits.push(`the quote must say ${f.words.length > 1 ? 'ONE of these page words, copied exactly' : 'this page word'}: ${quote(f.words)}`);
251
+ else {
252
+ if (f.words.length) bits.push(`label ${quote(f.words)}${f.words.length > 1 ? ' (the first is the main one)' : ''}`);
253
+ else bits.push('no label (read by its unit or sign alone)');
254
+ if (f.unit?.length) bits.push(`unit ${quote(f.unit)}`);
255
+ if (f.currency?.length) bits.push(`currency ${quote(f.currency)}`);
256
+ }
257
+ } else if (f.phrase !== undefined) bits.push(`the quote must say ${JSON.stringify(f.phrase)}`);
229
258
  else if (f.label !== undefined) bits.push(`label ${JSON.stringify(f.label)} (id ${f.labelId})`);
230
259
  else bits.push('no label (the value as the quote writes it)');
231
260
  if (f.example) bits.push(`seen: ${JSON.stringify(f.example)}`);
@@ -234,6 +263,12 @@ export function renderLookReport(report) {
234
263
  if (f.forRule !== undefined) bits.push(`carries ~ rule ${f.forRule}`);
235
264
  add(f.line, `CAN CHECK: ${bits.join(', ')}`);
236
265
  }
266
+ // NO SILENT DROP: a word, fact or rule the page rules removed is said to the drafter too, so it is never written back as a check
267
+ for (const row of dropRows(report.dropped)) {
268
+ add(row.line, row.full
269
+ ? `NOT CHECKED${row.rule !== undefined ? ` (rule ${row.rule})` : ''}: "${row.fact}" - ${row.sentences.join(' ')}`
270
+ : `LEFT OUT of "${row.fact}" (the rest is still checked): ${row.sentences.join(' ')}`);
271
+ }
237
272
  for (const p of report.prose) add(p.line, 'WRITTEN TEXT (not looked up; keep its word/section counts)');
238
273
  for (const n of report.notFound) add(n.line, `NOT FOUND${n.rule ? ` (rule ${n.rule})` : ''}: ${n.words ? `${JSON.stringify(n.words)} - ` : ''}${n.why}`);
239
274
  const lines = [...byLine.keys()].sort((a, b) => a - b).map((n) => `line ${n}:\n${byLine.get(n).map((/** @type {string} */ s) => ` ${s}`).join('\n')}`);
package/src/job.js CHANGED
@@ -345,7 +345,7 @@ export function validateJob(input, { shellCapUsd = 2 } = {}) {
345
345
  red('invalid-value', 'model', 'non-empty string — the exact model id of a line ticked in Settings > Providers');
346
346
  }
347
347
  // THINKING (P8) — the job's "Thinking on" tick, OPTIONAL: absent means off (and an old spec keeps the hash it always had); present it is
348
- // signed like `model`. It covers DRAFTING and the rubric JUDGE only — the worker is never handed a thinking option. A boolean, nothing else.
348
+ // signed like `model`. It covers DRAFTING and the rubric JUDGE, and makes every WORKER call think (src/thinking.js `workerThinkingMode`; without it the worker's mode is decided from phase and attempt). A boolean, nothing else.
349
349
  if (spec.thinking !== undefined && typeof spec.thinking !== 'boolean') {
350
350
  red('invalid-value', 'thinking', 'true or false — the job\'s "Thinking on" tick (absent = off)');
351
351
  }
package/src/kinds.js CHANGED
@@ -304,6 +304,7 @@ export function mechanicalStages(stages) {
304
304
  * onJudgeCost?: (c: {stage: string|null, kind: string|null, path: string, attempt: number,
305
305
  * label: string, model: string, costUsd: number|null, unpricedRounds: number}) => void,
306
306
  * callBounds?: () => {timeoutMs?: number, deadlineMs?: number},
307
+ * onRubricEvidence?: (e: {step: number, evidence: any[], evidenceTotal?: number}) => void,
307
308
  * onStage?: (row: {stage: string|null, kind: string|null, verdict: string, durationMs: number}) => void}} Ctx
308
309
  * The arbiter's half of a stage run. Every field here is operator/runner
309
310
  * territory: a declaration parameterises kinds, never the contracts.
@@ -1593,6 +1594,10 @@ async function runRubricStep(stage, ctx) {
1593
1594
  }
1594
1595
  const graded = await gradeStep({ params: p, bytes: notRegular ? null : bytes, outputBytes: notRegular ? null : bytes, inputs, factQuotes, factMissing }, inputShas);
1595
1596
  if (graded.verdict === 'stopped') return stop(`the rubric stopped: ${graded.fault}`, STOP_FAULTS.CRASHED);
1597
+ // REPORT-ONLY: what each cited check read, handed to the runner's seam (the spine's `rubric-evidence`); nothing below reads it
1598
+ if (graded.evidence && typeof ctx.onRubricEvidence === 'function') {
1599
+ ctx.onRubricEvidence({ step, evidence: graded.evidence, ...(graded.evidenceTotal !== undefined ? { evidenceTotal: graded.evidenceTotal } : {}) });
1600
+ }
1596
1601
  const gap = new Gap(ctx.gapKeep, ctx.gapCap);
1597
1602
  if (notRegular) gap.push(`${stage.name}: line ${step}: ${stepFileRel(step)} is not a regular file`);
1598
1603
  for (const l of graded.gapLines) gap.push(`${stage.name}: ${l}`);
@@ -0,0 +1,64 @@
1
+ // IDEA A CORE (docs/product/ITEM33-BUILD.md "Addendum 2026-10-18 — Idea A core: build spec", sections 4.6 and 7): NO SILENT DROP. The ONE place
2
+ // that writes the sentence for a word, fact or rule that a page rule dropped. The plan card, the Job tab, the confirm card and the drafter's
3
+ // block all read these words; no other file writes a drop reason (a test greps for it). Plain words, written by CODE from the drop's reason
4
+ // code, never a raw code and never model-written. `<label>` is the page's own word.
5
+
6
+ /** @typedef {import('./pagerules.js').Dropped} Dropped */
7
+
8
+ /** reason code -> the plain sentence (`d.word` = the page's word, `d.fact` = the fact's name, `d.other` = what it was compared to) */
9
+ const WORDS = /** @type {Record<string, (d: Dropped) => string>} */ ({
10
+ 'not-on-page': (d) => `"${d.word}" is not written on the page.`,
11
+ 'no-value': (d) => `The page never shows a number right after or right before "${d.word}".`,
12
+ 'n2': (d) => `"${d.word}" is written more than once inside one line of the page, so one result cannot show it once.`,
13
+ 'r1': (d) => `"${d.word}" does not show the same values as "${d.other}", so it was not used as a second way to read it.`,
14
+ 'sign-twice': (d) => `"${d.word}" was listed twice, as a unit and as a currency; it is used once, as the currency.`,
15
+ 'r1l': (d) => d.word === d.other
16
+ ? `"${d.word}" was listed twice, as a unit and as a currency; it is used once, as the first way to write the price.`
17
+ : `"${d.word}" does not show the same amounts as "${d.other}", so it was not used as a second way to write the price.`,
18
+ 's1-name': () => 'One result is one record; ask for N results instead.',
19
+ 's1': (d) => `"${d.fact}" would read the same value as "${d.other}". The page gives no second label for it.`,
20
+ 'no-label': (d) => `No word on this page reads "${d.fact}" safely.`,
21
+ 'limit-currency': (d) => `Only the first 3 currencies are used; "${d.word}" was left out.`,
22
+ 'limit-unit': (d) => `Only the first 10 units are used; "${d.word}" was left out.`,
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.`,
25
+ 'person-left': () => 'You chose to leave it Not checked.',
26
+ 'bad-shape': (d) => `The AI named a kind of value for "${d.fact}" that a check cannot read.`,
27
+ });
28
+
29
+ /** @param {Dropped} d @returns {string} the plain sentence for one drop */
30
+ export function dropSentence(d) {
31
+ const f = WORDS[d.reason];
32
+ return f ? f(d) : `"${d.word ?? d.fact}" was left out.`;
33
+ }
34
+
35
+ /**
36
+ * One row per fact that lost something: the sentences (one per dropped word, then the fact's own), and whether the whole fact went.
37
+ * @param {Dropped[]|undefined|null} dropped
38
+ * @returns {{line: number, rule?: number, fact: string, full: boolean, sentences: string[]}[]}
39
+ */
40
+ export function dropRows(dropped) {
41
+ /** @type {Map<string, {line: number, rule?: number, fact: string, full: boolean, sentences: string[]}>} */
42
+ const rows = new Map();
43
+ for (const d of Array.isArray(dropped) ? dropped : []) {
44
+ const key = `${d.line}\u0000${d.rule ?? ''}\u0000${d.fact}`;
45
+ let row = rows.get(key);
46
+ if (!row) { row = { line: d.line, ...(d.rule === undefined ? {} : { rule: d.rule }), fact: d.fact, full: false, sentences: [] }; rows.set(key, row); }
47
+ if (d.word === undefined) row.full = true;
48
+ const sentence = dropSentence(d);
49
+ if (!row.sentences.includes(sentence)) row.sentences.push(sentence);
50
+ }
51
+ // a fact's own sentence ("No word ... reads it safely") reads last, after the words that were lost
52
+ return [...rows.values()].map((r) => ({ ...r, sentences: [...r.sentences.filter((s) => !/^No word on this page reads /.test(s)), ...r.sentences.filter((s) => /^No word on this page reads /.test(s))] }));
53
+ }
54
+
55
+ /**
56
+ * A row as one Not-checked line, in the person's own words: `line N: "<the ~ rule as written, or the fact's name>" — <sentences>`.
57
+ * @param {{line: number, rule?: number, fact: string, sentences: string[]}} row @param {any[]|null|undefined} jobLines @param {(l: any) => string[]} rulesOf
58
+ * @returns {string}
59
+ */
60
+ export function dropLine(row, jobLines, rulesOf) {
61
+ const jl = (jobLines ?? []).find((/** @type {any} */ l) => l && l.n === row.line);
62
+ const rule = row.rule === undefined || !jl ? undefined : rulesOf(jl)[row.rule - 1];
63
+ return `line ${row.line}: "${rule ?? row.fact}" — ${row.sentences.join(' ')}`;
64
+ }