bareloop 0.41.0 → 0.42.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 (47) hide show
  1. package/CHANGELOG.md +63 -1
  2. package/README.md +13 -21
  3. package/bareloop.context.md +44 -4
  4. package/package.json +4 -3
  5. package/src/authorflow.js +337 -29
  6. package/src/authoring.js +92 -7
  7. package/src/authorjob.js +78 -16
  8. package/src/authorreadout.js +89 -5
  9. package/src/authorrun.js +14 -5
  10. package/src/authorscout.js +108 -0
  11. package/src/declaredclose.js +23 -12
  12. package/src/facts.js +317 -0
  13. package/src/factwords.js +129 -0
  14. package/src/inputlook.js +301 -0
  15. package/src/job.js +2 -1
  16. package/src/jobblock.js +7 -2
  17. package/src/kinds.js +84 -3
  18. package/src/labelscan.js +170 -0
  19. package/src/pagefetch.js +125 -0
  20. package/src/panel/authorroutes.js +15 -2
  21. package/src/panel/authorsession.js +99 -22
  22. package/src/panel/index.html +33 -7
  23. package/src/panel/jobplan.js +34 -11
  24. package/src/panel/server.js +19 -3
  25. package/src/planrun.js +3 -2
  26. package/src/rubricstep.js +804 -0
  27. package/src/source.js +166 -1
  28. package/src/u-readout.js +1 -1
  29. package/src/userrun.js +14 -2
  30. package/types/authorflow.d.ts +267 -13
  31. package/types/authoring.d.ts +29 -1
  32. package/types/authorjob.d.ts +7 -5
  33. package/types/authorreadout.d.ts +27 -2
  34. package/types/authorscout.d.ts +70 -0
  35. package/types/declaredclose.d.ts +4 -2
  36. package/types/facts.d.ts +71 -0
  37. package/types/factwords.d.ts +42 -0
  38. package/types/inputlook.d.ts +238 -0
  39. package/types/jobblock.d.ts +8 -1
  40. package/types/labelscan.d.ts +38 -0
  41. package/types/pagefetch.d.ts +71 -0
  42. package/types/panel/authorsession.d.ts +18 -2
  43. package/types/panel/jobplan.d.ts +12 -4
  44. package/types/panel/server.d.ts +4 -2
  45. package/types/rubricstep.d.ts +286 -0
  46. package/types/source.d.ts +94 -0
  47. package/types/userrun.d.ts +11 -0
@@ -0,0 +1,129 @@
1
+ // THE FOUND-ONCE CHECK (PART 1c, docs/product/ITEM33-BUILD.md "3. The look", PRD item 33 RF20): code proves, at $0, that every word a fact
2
+ // check depends on is really in the person's source - ONCE is enough - and that every label (anchor) is one PICKED from the list code
3
+ // made from that source (src/labelscan.js), never typed. A word that is not found is a red naming the word and the input. Nothing is
4
+ // graded here and no model is involved: the AI proposes the words, code proves they are there.
5
+ //
6
+ // Compared WHITE-SPACE BLIND: any run of white space (U+202F and U+00A0 included - Google writes U+202F before AM/PM, 259 times in one
7
+ // page) is one space on both sides.
8
+
9
+ import { labelOnList, normLabel, scanLabels } from './labelscan.js';
10
+
11
+ const WS = /[\s   ]+/g;
12
+ const escapeRe = (/** @type {string} */ s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
13
+
14
+ /** @param {string} t @returns {string} the source with every run of white space made one space */
15
+ export const squash = (t) => String(t ?? '').replace(WS, ' ');
16
+ /** @param {string} t @returns {string} lower case, markdown marks dropped, white space squashed - how a phrase or unit is compared */
17
+ const loose = (t) => squash(t).replace(/[*_`]/g, '').trim().toLowerCase();
18
+
19
+ /**
20
+ * How many times exact words occur in a source, white space blind. Zero for empty words.
21
+ * @param {string} source the raw source text @param {string} words
22
+ * @returns {number}
23
+ */
24
+ export function countExact(source, words) {
25
+ const w = squash(words).trim();
26
+ if (w === '') return 0;
27
+ const hay = squash(source);
28
+ let n = 0;
29
+ for (let i = hay.indexOf(w); i !== -1; i = hay.indexOf(w, i + w.length)) n += 1;
30
+ return n;
31
+ }
32
+
33
+ /**
34
+ * How many times a phrase or unit occurs as WHOLE words, case and spacing aside ("SFO" is not found inside "sfocus").
35
+ * @param {string} source @param {string} words @returns {number}
36
+ */
37
+ export function countPhrase(source, words) {
38
+ const w = loose(words);
39
+ if (w === '') return 0;
40
+ const re = new RegExp(`(^|[^\\p{L}\\p{N}])${escapeRe(w)}(?=$|[^\\p{L}\\p{N}])`, 'gu');
41
+ let n = 0;
42
+ for (const _m of loose(source).matchAll(re)) n += 1;
43
+ return n;
44
+ }
45
+
46
+ /** @param {string} shape @returns {'money'|'number'|'date'|'time'|'text'} the label list a fact's anchor is picked from */
47
+ const listOf = (shape) => /** @type {any} */ (['money', 'number', 'date', 'time'].includes(shape) ? shape : 'text');
48
+
49
+ /**
50
+ * 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
52
+ * @returns {{code: string, detail: string}[]}
53
+ */
54
+ export function checkFactWords({ part, text, line, scan = scanLabels(text) }) {
55
+ /** @type {{code: string, detail: string}[]} */
56
+ const reds = [];
57
+ const src = part.source;
58
+ const red = (/** @type {string} */ code, /** @type {string} */ msg) => reds.push({ code, detail: `line ${line}: ${msg} Nothing was signed.` });
59
+ for (const f of Array.isArray(part.facts) ? part.facts : []) {
60
+ const who = `fact "${f.name}"`;
61
+ if (f.anchor !== undefined && f.shape !== 'phrase') {
62
+ const list = listOf(f.shape);
63
+ const row = labelOnList(scan, list, f.anchor);
64
+ if (!row) {
65
+ const some = scan[list].slice(0, 12).map((r) => JSON.stringify(r.label)).join(', ');
66
+ red('rubric-fact-label-off-list', `${who}: the label ${JSON.stringify(normLabel(String(f.anchor)))} is not one of the labels the input "${src}" holds before a ${f.shape === 'text' ? 'text value' : f.shape}. A label is picked from that list, never typed${some ? `: ${some}` : ' (the list is empty - leave the label out)'}.`);
67
+ } else if (countExact(text, row.label) < 1) {
68
+ red('rubric-fact-word-missing', `${who}: the label ${JSON.stringify(row.label)} is not in the input "${src}".`);
69
+ } else if (Number.isInteger(part.count?.min) && row.count < part.count.min) {
70
+ // M1: a label that starts each result must repeat as often as results were asked for; one that sits once on the page
71
+ // (a sort tab, a switch) is in no result, so no one-result quote can hold it
72
+ const n = part.count.min;
73
+ 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
+ }
75
+ }
76
+ 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
+ if (f.shape === 'phrase' && typeof f.phrase === 'string') {
78
+ const seen = countPhrase(text, f.phrase);
79
+ if (seen < 1) red('rubric-fact-word-missing', `${who}: ${JSON.stringify(f.phrase)} is not in the input "${src}". Copy the words the input really uses, or remove the check.`);
80
+ else if (Number.isInteger(part.count?.min) && seen < part.count.min) {
81
+ // M4: the words each result's quote must say must repeat as often as results were asked for; page-header words (counted
82
+ // once or twice) are in no result, so no one-result quote can say them
83
+ const n = part.count.min;
84
+ 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
+ }
86
+ }
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.`);
88
+ }
89
+ return reds;
90
+ }
91
+
92
+ /**
93
+ * 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
+ * proved against the source is never signed.
95
+ * @param {any} declaration @param {Record<string, string>|null} texts label -> frozen text
96
+ * @returns {{code: string, path: string, detail: string}[]}
97
+ */
98
+ export function checkDeclarationWords(declaration, texts) {
99
+ /** @type {{code: string, path: string, detail: string}[]} */
100
+ const reds = [];
101
+ if (!declaration || !Array.isArray(declaration.stages)) return reds;
102
+ /** @type {Map<string, import('./labelscan.js').LabelScan>} */
103
+ const scans = new Map();
104
+ declaration.stages.forEach((/** @type {any} */ st, /** @type {number} */ i) => {
105
+ const parts = st?.kind === 'rubric-step' && Array.isArray(st.params?.parts) ? st.params.parts : [];
106
+ parts.forEach((/** @type {any} */ p, /** @type {number} */ k) => {
107
+ if (p?.class !== 'fact') return;
108
+ const at = `stages[${i}].params.parts[${k}]`;
109
+ const text = texts ? texts[p.source] : undefined;
110
+ /** @param {string} code @param {string} detail */
111
+ const red = (code, detail) => reds.push({ code, path: at, detail });
112
+ if (typeof text !== 'string') {
113
+ red('rubric-fact-unread', `line ${st.params.step}: the input "${p.source}" could not be read here to prove the words against it. Nothing was signed.`);
114
+ return;
115
+ }
116
+ 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 });
118
+ });
119
+ });
120
+ return reds;
121
+ }
122
+
123
+ /**
124
+ * The look's own proof hook: how often a phrase the look built is in an input.
125
+ * @param {Record<string, string>} texts @returns {(source: string, phrase: string) => number}
126
+ */
127
+ export function proverOf(texts) {
128
+ return (source, phrase) => (typeof texts[source] === 'string' ? countPhrase(texts[source], phrase) : 0);
129
+ }
@@ -0,0 +1,301 @@
1
+ // THE LOOK (PART 1c, docs/product/ITEM33-BUILD.md "3. The look", PRD item 33 RF19/RF20): before the drafter writes a single check, the AI
2
+ // LOOKS at the person's input - read only, bounded, with tools that can do nothing but search and read the input's own text - and
3
+ // reports, per job line, the facts it can check (name, shape, a label PICKED from the code-made list, one example value it saw) and
4
+ // what it could not find. This module holds the pure parts: the two read-only tools, the strict answer form, the prompt, and the
5
+ // code that turns the AI's report into a verified one. The paid loop around them is `runInputLook` in src/authorscout.js.
6
+ //
7
+ // LABELS ARE PICKED, NEVER TYPED: the AI names a label by its id on the list (src/labelscan.js) or by its exact words; a label that is
8
+ // not on the list is NOT carried into the report - it is moved to "could not check", named. Code never decides two words mean the
9
+ // same: the AI proposes, the list and the source prove.
10
+
11
+ import { scanLabels, labelOnList, renderLabelList, normLabel, LABEL_SHAPES } from './labelscan.js';
12
+ import { FACT_SHAPES, FACT_OPS } from './facts.js';
13
+ import { lineRules } from './rubricstep.js';
14
+
15
+ /** the name of the answer tool - the one place the AI's report enters */
16
+ export const LOOK_REPORT_TOOL = 'report_look';
17
+ /** how many hits one search returns at most, and how long a snippet or a read line is */
18
+ export const LOOK_MAX_HITS = 40;
19
+ const SNIPPET = 220;
20
+ const READ_LINE = 320;
21
+ const READ_LINES = 40;
22
+ const TAG = /\[[A-Za-z]+=[^\]\s]*\]/g;
23
+ const WS = /[\s   ]+/g;
24
+
25
+ /**
26
+ * The input as the AI sees it: lines with any run of white space (U+202F included) made one space and the page reader's bracketed tags
27
+ * (`[ref=549]`) removed - an id that is only the page reader's, never part of a label.
28
+ * @param {string} text @returns {string[]}
29
+ */
30
+ export function lookLines(text) {
31
+ return String(text ?? '').split('\n').map((l) => l.replace(TAG, ' ').replace(WS, ' ').trim());
32
+ }
33
+
34
+ /**
35
+ * The two read-only tools: search the input text, read a slice around a hit. They are closures over in-memory text and touch nothing
36
+ * else - read-only BY CONSTRUCTION, not by a fence. Several inputs: the `input` argument names which (its label).
37
+ * @param {Record<string, string>} texts label -> frozen text
38
+ * @returns {{name: string, description: string, parameters: any, execute: (a: any) => Promise<any>}[]}
39
+ */
40
+ export function lookReadTools(texts) {
41
+ const labels = Object.keys(texts);
42
+ /** @type {Record<string, string[]>} */
43
+ const lines = Object.fromEntries(labels.map((l) => [l, lookLines(texts[l])]));
44
+ const which = (/** @type {any} */ a) => (typeof a?.input === 'string' && lines[a.input] ? a.input : (labels.length === 1 ? labels[0] : null));
45
+ const inputProp = { type: 'string', description: `the input's label - one of: ${labels.join(', ')}` };
46
+ return [
47
+ {
48
+ name: 'search_input',
49
+ description: 'Case-insensitive search of one input for a word or phrase. Returns the line numbers and the start of each line that holds it. Search the person\'s own words AND other words that mean the same (nonstop / direct, San Francisco / SFO).',
50
+ parameters: { type: 'object', properties: { input: inputProp, text: { type: 'string', minLength: 1 }, max: { type: 'integer', minimum: 1, maximum: LOOK_MAX_HITS } }, required: ['text'] },
51
+ execute: async (a) => {
52
+ const name = which(a);
53
+ if (name === null) return { error: `name the input: ${labels.join(', ')}` };
54
+ const q = String(a?.text ?? '').toLowerCase().replace(WS, ' ').trim();
55
+ if (q === '') return { error: 'give the words to search for' };
56
+ const max = Math.min(Number.isInteger(a?.max) ? a.max : 20, LOOK_MAX_HITS);
57
+ /** @type {{line: number, text: string}[]} */
58
+ const hits = [];
59
+ let total = 0;
60
+ lines[name].forEach((l, i) => { if (l.toLowerCase().includes(q)) { total += 1; if (hits.length < max) hits.push({ line: i + 1, text: l.slice(0, SNIPPET) }); } });
61
+ return { input: name, total, hits };
62
+ },
63
+ },
64
+ {
65
+ name: 'read_input',
66
+ description: 'Read some lines of one input, starting at a line number (from a search).',
67
+ parameters: { type: 'object', properties: { input: inputProp, line: { type: 'integer', minimum: 1 }, count: { type: 'integer', minimum: 1, maximum: READ_LINES } }, required: ['line'] },
68
+ execute: async (a) => {
69
+ const name = which(a);
70
+ if (name === null) return { error: `name the input: ${labels.join(', ')}` };
71
+ const from = Math.max(1, Number.isInteger(a?.line) ? a.line : 1);
72
+ const n = Math.min(Number.isInteger(a?.count) ? a.count : 12, READ_LINES);
73
+ return { input: name, lines: lines[name].slice(from - 1, from - 1 + n).map((l, i) => ({ line: from + i, text: l.slice(0, READ_LINE) })) };
74
+ },
75
+ },
76
+ ];
77
+ }
78
+
79
+ /** the strict answer form (a JSON schema - never free prose with JSON inside) */
80
+ export function lookReportSchema() {
81
+ return {
82
+ type: 'object',
83
+ additionalProperties: false,
84
+ required: ['facts', 'prose', 'notFound'],
85
+ properties: {
86
+ facts: {
87
+ type: 'array',
88
+ items: {
89
+ type: 'object',
90
+ additionalProperties: false,
91
+ required: ['line', 'name', 'shape', 'source', 'example'],
92
+ properties: {
93
+ line: { type: 'integer', minimum: 1, description: 'the job line this fact belongs to' },
94
+ name: { type: 'string', minLength: 1, description: 'a short name, letters only' },
95
+ shape: { enum: [...FACT_SHAPES] },
96
+ source: { type: 'string', minLength: 1, description: 'the label of the input it is read from' },
97
+ label: { type: 'string', description: 'the words right before the value: the id (like t1) or the exact words of a label on the LIST for this input and shape - never a word you typed that is not on the list' },
98
+ value: { type: 'string', description: 'a phrase fact only: the fixed words, copied from the input exactly as it writes them (a place, a name, a word)' },
99
+ example: { type: 'string', description: 'ONE value you really saw in the input for this fact' },
100
+ rule: {
101
+ type: 'object',
102
+ additionalProperties: false,
103
+ required: ['op'],
104
+ properties: { op: { enum: [...FACT_OPS] }, value: {}, lo: {}, hi: {} },
105
+ description: 'only where the person asked for a limit (or you made one out of their words and set aiProposal)',
106
+ },
107
+ aiProposal: { type: 'boolean', description: 'true when YOU made this number out of the person\'s words (for example a night window for "not a red-eye")' },
108
+ forRule: { type: 'integer', minimum: 1, description: 'the number of the ~ rule under that line this fact carries out (1 = the first), when it carries one - a rule you turned into a number or range yourself ("not a red-eye") is carried by a fact with aiProposal, never reported as not found' },
109
+ },
110
+ },
111
+ },
112
+ prose: {
113
+ type: 'array',
114
+ description: 'job lines (and their ~ limits) that ask for written text - a summary, a report. They are not looked up.',
115
+ items: { type: 'object', additionalProperties: false, required: ['line'], properties: { line: { type: 'integer', minimum: 1 }, text: { type: 'string' } } },
116
+ },
117
+ notFound: {
118
+ type: 'array',
119
+ description: 'what you could not find in the input, and, when it is one ~ rule, which one',
120
+ items: {
121
+ type: 'object',
122
+ additionalProperties: false,
123
+ required: ['line', 'why'],
124
+ properties: { line: { type: 'integer', minimum: 1 }, rule: { type: 'integer', minimum: 1, description: 'the number of the ~ rule under that line (1 = the first), when the thing not found is one rule' }, words: { type: 'string', description: 'the person\'s own words that you could not find' }, why: { type: 'string', minLength: 1 } },
125
+ },
126
+ },
127
+ },
128
+ };
129
+ }
130
+
131
+ /**
132
+ * What the AI is told: the numbered job, the inputs and their label lists, and what to do.
133
+ * @param {{jobLines: {n: number, text: string, rule?: string, rules?: string[]}[], labels: string[], lists: Record<string, string>}} o
134
+ * @returns {string}
135
+ */
136
+ export function lookPrompt({ jobLines, labels, lists }) {
137
+ const job = jobLines.map((l) => `${l.n}. ${l.text}${(l.rules ?? (l.rule ? l.rule.split('; ') : [])).map((r, i) => `\n ~ ${i + 1}. ${r}`).join('')}`).join('\n');
138
+ return `The person's job (line numbers start at 1; "~" lines are the person's rules for the line above, numbered under it):\n${job}\n\n`
139
+ + `The inputs you can look at: ${labels.map((l) => JSON.stringify(l)).join(', ')}. You can only use search_input and read_input, then you MUST finish by calling ${LOOK_REPORT_TOOL} once.\n\n`
140
+ + `${labels.map((l) => lists[l]).join('\n\n')}\n\n`
141
+ + `Shapes a value can have: ${FACT_SHAPES.join(', ')} (phrase = fixed words the quote must contain; text = the words after a label).\n`
142
+ + 'For each job line, find the facts the person asked for and report ONLY what you can check in the input:\n'
143
+ + ' - Search the input with the person\'s words AND other words that mean the same, read the spots you find, and use what you know (for example what a red-eye is: propose a night time window and set aiProposal true).\n'
144
+ + ' - A ~ rule whose meaning you can turn into a number or a range from what you know (a red-eye is a night departure; a weekend; business hours) is NOT something to ask the person about and NOT "notFound": report it as a fact on the time, date or amount the input does hold, with a between (or other) rule, aiProposal true and forRule set to that rule\'s number. Use notFound with a rule number only for intent you cannot know (for example "one per week" when the input has no weekly grouping).\n'
145
+ + ' - Every place, date or name written in the job line itself is a fact too. Bind it to the label that sits right before it in the input - a direction like "from X to Y" is the label before the origin (for example Leaves) and the label before the destination (for example arrives at): report each as a phrase fact whose label is that label and whose value is the place EXACTLY as the input writes it.\n'
146
+ + ' - A label is PICKED from the LIST above (its id or its exact words) - never typed. A label that is not on the list is refused. Never use the person\'s own words as a label unless the list holds them, never a one-time id like [ref=...], and never one result\'s own value.\n'
147
+ + ' - Lines that ask for written prose (a summary, a short text) and the ~ limits on them (word count, paragraphs) are NOT looked up: put them in "prose" with their line number.\n'
148
+ + ' - Whatever you cannot find in the input goes to "notFound" with the person\'s own words; when it is one ~ rule, give that rule\'s number.\n'
149
+ + `Report only through ${LOOK_REPORT_TOOL}.`;
150
+ }
151
+
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, rule?: number, words?: string, why: string}} LookNotFound */
154
+ /** @typedef {{facts: LookFact[], prose: {line: number, text?: string}[], notFound: LookNotFound[]}} LookReport */
155
+
156
+ /** @param {unknown} v @returns {v is Record<string, any>} */
157
+ const isRec = (v) => v !== null && typeof v === 'object' && !Array.isArray(v);
158
+
159
+ /**
160
+ * 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
+ * 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
162
+ * to `notFound` with a plain reason naming the word - never dropped silently. `prove` is the "found in the source" test of the
163
+ * phrase a phrase fact builds (label + value), supplied by the caller.
164
+ * @param {unknown} raw
165
+ * @param {{scans: Record<string, import('./labelscan.js').LabelScan>, jobLines: {n: number}[], prove?: (source: string, phrase: string) => number}} o
166
+ * @returns {LookReport}
167
+ */
168
+ export function verifyLookReport(raw, { scans, jobLines, prove }) {
169
+ const rep = isRec(raw) ? raw : {};
170
+ const lineNos = new Set(jobLines.map((l) => l.n));
171
+ /** @type {LookFact[]} */
172
+ const facts = [];
173
+ /** @type {LookNotFound[]} */
174
+ const notFound = [];
175
+ const dropped = (/** @type {any} */ f, /** @type {string} */ why) => notFound.push({
176
+ line: Number.isInteger(f?.line) && lineNos.has(f.line) ? f.line : (jobLines[0]?.n ?? 1),
177
+ words: typeof f?.name === 'string' ? f.name : undefined,
178
+ why,
179
+ });
180
+ for (const f of Array.isArray(rep.facts) ? rep.facts : []) {
181
+ if (!isRec(f) || !Number.isInteger(f.line) || !lineNos.has(f.line)) { dropped(f, 'the look named a job line that does not exist'); continue; }
182
+ const name = typeof f.name === 'string' ? f.name.replace(/[^A-Za-z _-]/g, '').trim() : '';
183
+ if (name === '') { dropped(f, 'the look gave a fact no usable name'); continue; }
184
+ if (!FACT_SHAPES.includes(f.shape)) { dropped({ ...f, name }, `the look gave "${name}" a shape (${String(f.shape)}) that does not exist`); continue; }
185
+ const scan = typeof f.source === 'string' ? scans[f.source] : undefined;
186
+ if (!scan) { dropped({ ...f, name }, `the look read "${name}" from an input (${String(f.source)}) that is not one of yours`); continue; }
187
+ /** @type {LookFact} */
188
+ const out = { line: f.line, name, shape: f.shape, source: f.source, example: typeof f.example === 'string' ? f.example : '' };
189
+ if (typeof f.label === 'string' && f.label.trim() !== '') {
190
+ const row = labelOnList(scan, f.shape === 'phrase' ? 'text' : f.shape, f.label);
191
+ if (!row) { dropped({ ...f, name }, `the label "${normLabel(f.label)}" for "${name}" is not one of the labels found in "${f.source}", so it was not used`); continue; }
192
+ out.label = row.label;
193
+ out.labelId = row.id;
194
+ }
195
+ if (f.shape === 'phrase') {
196
+ const value = typeof f.value === 'string' ? normLabel(f.value) : '';
197
+ if (value === '') { dropped({ ...f, name }, `the look gave the phrase fact "${name}" no words`); continue; }
198
+ out.value = value;
199
+ out.phrase = out.label ? `${out.label} ${value}` : value;
200
+ if (prove && prove(f.source, out.phrase) < 1) { dropped({ ...f, name }, `"${out.phrase}" is not in the input "${f.source}"`); continue; }
201
+ }
202
+ if (isRec(f.rule)) out.rule = f.rule;
203
+ if (f.aiProposal === true) out.aiProposal = true;
204
+ if (Number.isInteger(f.forRule) && f.forRule >= 1) out.forRule = f.forRule;
205
+ facts.push(out);
206
+ }
207
+ for (const n of Array.isArray(rep.notFound) ? rep.notFound : []) {
208
+ if (!isRec(n) || !Number.isInteger(n.line) || !lineNos.has(n.line) || typeof n.why !== 'string') continue;
209
+ // a rule that a verified fact carries is checked, so it is not also "not found" (L3: a ~ rule is one or the other)
210
+ if (Number.isInteger(n.rule) && facts.some((x) => x.line === n.line && x.forRule === n.rule)) continue;
211
+ notFound.push({ line: n.line, ...(Number.isInteger(n.rule) ? { rule: n.rule } : {}), ...(typeof n.words === 'string' && n.words ? { words: n.words } : {}), why: n.why });
212
+ }
213
+ const prose = (Array.isArray(rep.prose) ? rep.prose : []).filter((p) => isRec(p) && Number.isInteger(p.line) && lineNos.has(p.line))
214
+ .map((p) => ({ line: p.line, ...(typeof p.text === 'string' ? { text: p.text.slice(0, 400) } : {}) }));
215
+ return { facts, prose, notFound };
216
+ }
217
+
218
+ /**
219
+ * The look's report as the DRAFTER reads it: one block, per job line, of what can be checked (with the exact words to copy) and what
220
+ * could not be found. Written by code from the verified report.
221
+ * @param {LookReport} report @returns {string}
222
+ */
223
+ export function renderLookReport(report) {
224
+ const byLine = new Map();
225
+ const add = (/** @type {number} */ n, /** @type {string} */ s) => byLine.set(n, [...(byLine.get(n) ?? []), s]);
226
+ for (const f of report.facts) {
227
+ const bits = [`${f.name} - ${f.shape}`, `from "${f.source}"`];
228
+ if (f.phrase !== undefined) bits.push(`the quote must say ${JSON.stringify(f.phrase)}`);
229
+ else if (f.label !== undefined) bits.push(`label ${JSON.stringify(f.label)} (id ${f.labelId})`);
230
+ else bits.push('no label (the value as the quote writes it)');
231
+ if (f.example) bits.push(`seen: ${JSON.stringify(f.example)}`);
232
+ if (f.rule) bits.push(`rule ${JSON.stringify(f.rule)}`);
233
+ if (f.aiProposal) bits.push("AI's proposal");
234
+ if (f.forRule !== undefined) bits.push(`carries ~ rule ${f.forRule}`);
235
+ add(f.line, `CAN CHECK: ${bits.join(', ')}`);
236
+ }
237
+ for (const p of report.prose) add(p.line, 'WRITTEN TEXT (not looked up; keep its word/section counts)');
238
+ 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
+ const lines = [...byLine.keys()].sort((a, b) => a - b).map((n) => `line ${n}:\n${byLine.get(n).map((/** @type {string} */ s) => ` ${s}`).join('\n')}`);
240
+ return `WHAT THE LOOK AT THE INPUTS FOUND - write the facts from this; copy the label and phrase words exactly\n\n${lines.join('\n') || '(the look reported nothing)'}`;
241
+ }
242
+
243
+ /**
244
+ * The label scans and the rendered lists for the inputs the look will read.
245
+ * @param {Record<string, string>} texts
246
+ * @returns {{scans: Record<string, import('./labelscan.js').LabelScan>, lists: Record<string, string>}}
247
+ */
248
+ export function labelListsOf(texts) {
249
+ /** @type {Record<string, import('./labelscan.js').LabelScan>} */
250
+ const scans = {};
251
+ /** @type {Record<string, string>} */
252
+ const lists = {};
253
+ for (const [label, text] of Object.entries(texts)) { scans[label] = scanLabels(text); lists[label] = renderLabelList(label, scans[label]); }
254
+ return { scans, lists };
255
+ }
256
+
257
+ export { LABEL_SHAPES };
258
+
259
+ /**
260
+ * L3: a reason the AI gave, cut only at a sentence end (never mid-sentence): whole sentences while they fit in `max` characters,
261
+ * and nothing at all if the first one alone does not fit. A trailing full stop is dropped (the caller closes the clause).
262
+ * @param {string} text @param {number} max @returns {string}
263
+ */
264
+ export function wholeSentences(text, max) {
265
+ const sentences = text.replace(/\s+/g, ' ').trim().split(/(?<=[.!?])\s+/).filter(Boolean);
266
+ let out = '';
267
+ for (const s of sentences) {
268
+ const next = out === '' ? s : `${out} ${s}`;
269
+ if (next.length > max) break;
270
+ out = next;
271
+ }
272
+ return out.replace(/[.!?]+$/, '');
273
+ }
274
+
275
+ /**
276
+ * I1 (hamr, 2026-10-13, "Ruled by hamr" 2): when the look shows the person's intent on one `~` rule was NOT captured - its words or
277
+ * meaning are not in the source - the person is asked about THAT rule at once, named by its line and rule. Only a look entry that names
278
+ * a rule number counts; anything else the look could not find is not asked piecemeal (it is Not checked, or the whole job is reviewed).
279
+ * The wording is the code's own, never the model's. The keep-or-drop and the cap are `normalizeQuestions` (src/authorflow.js).
280
+ * @param {LookNotFound[]} notFound @param {{n: number, text?: string, rule?: string, rules?: string[]}[]} jobLines
281
+ * @returns {{line: number, question: string}[]} every candidate question (the caller applies the machine's caps)
282
+ */
283
+ export function ruleQuestions(notFound, jobLines) {
284
+ /** @type {{line: number, question: string}[]} */
285
+ const out = [];
286
+ /** @type {Set<string>} */
287
+ const seen = new Set();
288
+ for (const n of Array.isArray(notFound) ? notFound : []) {
289
+ if (!Number.isInteger(n.rule)) continue;
290
+ const line = jobLines.find((l) => l.n === n.line);
291
+ const rule = line ? lineRules(line)[/** @type {number} */ (n.rule) - 1] : undefined;
292
+ if (!line || rule === undefined) continue;
293
+ const key = `${n.line}:${n.rule}`;
294
+ if (seen.has(key)) continue;
295
+ seen.add(key);
296
+ const words = n.words ? `${JSON.stringify(n.words)} (from your words)` : 'what this rule asks for';
297
+ const why = wholeSentences(String(n.why), 200);
298
+ out.push({ line: n.line, question: `Line ${n.line}, rule ${n.rule} - "${rule}": I could not find ${words} in your input${why ? ` (${why})` : ''}. What do you mean by it? Your answer is added as a new ~ rule on line ${n.line}.` });
299
+ }
300
+ return out;
301
+ }
package/src/job.js CHANGED
@@ -16,6 +16,7 @@
16
16
  import { createHash } from 'node:crypto';
17
17
  import { globToPrefix, scopeContained, isObj, isNonEmptyString, sweepSecretLiterals, hasNestedQuantifier, closeScriptCandidateToken } from './validate.js';
18
18
  import { validateCloseDecl, DECLARED_CLOSE_CLASSES } from './declaredclose.js';
19
+ import { inputLabelsOf } from './rubricstep.js';
19
20
  import { CLOSE_TIMEOUT_FLOOR_MS } from './closetimeout.js';
20
21
 
21
22
  // The menus below ARE the close-authoring hierarchy (PRD §7) and ship frozen:
@@ -795,7 +796,7 @@ function validatePlanShape(spec, red, reds) {
795
796
  // class the user picked (PRD v1.57 §2), so the gate cannot check D5 without
796
797
  // it. An unknown or locked class is refused there, on its own axis, beside
797
798
  // the counted `request-red` above.
798
- const cd = validateCloseDecl(spec.closeDecl, { at: 'closeDecl', deferListing: true, verdictType: spec.verdictType, writeScope: spec.writeScope, jobLines: Array.isArray(spec.jobLines) ? spec.jobLines : null });
799
+ const cd = validateCloseDecl(spec.closeDecl, { at: 'closeDecl', deferListing: true, verdictType: spec.verdictType, writeScope: spec.writeScope, jobLines: Array.isArray(spec.jobLines) ? spec.jobLines : null, inputLabels: inputLabelsOf(spec.inputs) });
799
800
  for (const r of cd.reds) reds.push(r);
800
801
  if (demanded !== undefined && !CLASS_BY_CLOSE.declared.includes(demanded)) {
801
802
  // the same laundering guard as the close-type hierarchy, one level up: a
package/src/jobblock.js CHANGED
@@ -113,16 +113,21 @@ export function renderInputs(inputs) {
113
113
  * The two cross-checks between a parsed job block and the Check type: a RUBRIC job needs at least one `PASS:` and one
114
114
  * `FAIL:` example (the calibration compile reads them); a DETERMINISTIC job has no judge, so an example line would be
115
115
  * silently dropped — a red instead.
116
+ * PART 1a (hamr's ruling P1=B, docs/product/ITEM33-BUILD.md "Addendum 2026-10-12"): the examples are required of a rubric
117
+ * job ONLY when it holds a judged part. A rubric job on the person's own local files holds none in this build (its
118
+ * parts are counted or checked against a quote), so the caller says `judged: false` and a block without examples fits.
119
+ * Examples written anyway are never a red there — they simply have no judge to read them yet.
116
120
  * @param {JobLine[]} lines
117
121
  * @param {boolean} rubric
122
+ * @param {{judged?: boolean}} [o] `judged` (default true): does this job hold a judged part, so that it needs examples?
118
123
  * @returns {string|null} the plain red, or null when the block fits the Check type
119
124
  */
120
- export function jobBlockFitsCheck(lines, rubric) {
125
+ export function jobBlockFitsCheck(lines, rubric, { judged = true } = {}) {
121
126
  const all = lines.flatMap((l) => l.examples.map((e) => ({ n: l.n, ...e })));
122
127
  if (!rubric) {
123
128
  return all.length ? `The job, line ${all[0].n}: PASS:/FAIL: examples are for a Rubric check — a Deterministic job has no judge to read them` : null;
124
129
  }
125
- if (!all.some((e) => e.verdict === 'PASS') || !all.some((e) => e.verdict === 'FAIL')) {
130
+ if (judged && (!all.some((e) => e.verdict === 'PASS') || !all.some((e) => e.verdict === 'FAIL'))) {
126
131
  return 'The job: a Rubric check needs at least one "~ PASS:" line and one "~ FAIL:" line under a job line';
127
132
  }
128
133
  return null;
package/src/kinds.js CHANGED
@@ -61,13 +61,14 @@
61
61
 
62
62
  import { spawn } from 'node:child_process';
63
63
  import { realpathSync } from 'node:fs';
64
- import { readFile, stat, mkdtemp, rm, symlink } from 'node:fs/promises';
64
+ import { readFile, stat, lstat, mkdtemp, rm, symlink } from 'node:fs/promises';
65
65
  import { tmpdir } from 'node:os';
66
- import { join, sep } from 'node:path';
66
+ import { join, sep, dirname } from 'node:path';
67
67
  import { globToPrefix, isNonEmptyString } from './validate.js';
68
68
  import { CLOSE_ENV_DENY } from './ralph.js';
69
69
  import { runLocate, decide, validateCard, LOCATE_AXES, LOCATE_LABEL } from './judged.js';
70
70
  import { rateSourceFields } from './text.js';
71
+ import { gradeStep, stepFileRel, STEP_MAX_BYTES, citedLabels, sha256Of } from './rubricstep.js';
71
72
 
72
73
  /** the close's two judgment exits — a stage's verdict, in the shape the
73
74
  * hand-written closes already spoke (exit code is truth) */
@@ -140,7 +141,7 @@ export const SIGNS = Object.freeze([1, -1]);
140
141
  * executor runs that the catalogue never names is a capability nothing can
141
142
  * reach. `judged-floor` joined at softgreen module 2 — the runner and the
142
143
  * catalogue unlock in ONE commit for exactly that reason. */
143
- export const LIVE_KINDS = Object.freeze(['command-exit', 'count-not-worse', 'pattern-absent-in-diff', 'files-changed', 'human-confirms', 'judged-floor']);
144
+ export const LIVE_KINDS = Object.freeze(['command-exit', 'count-not-worse', 'pattern-absent-in-diff', 'files-changed', 'human-confirms', 'judged-floor', 'rubric-step']);
144
145
  /**
145
146
  * The judged stage's ladder: ONE attempt plus ONE retry, and no more.
146
147
  *
@@ -1526,6 +1527,85 @@ async function runFilesChanged(stage, ctx) {
1526
1527
  });
1527
1528
  }
1528
1529
 
1530
+ /**
1531
+ * PART 1a — the `rubric-step` kind: grade ONE job line's step file (output/steps/N.json) with bareguard's rubric primitive.
1532
+ * It spawns nothing and buys nothing. The inputs a step cites are read from the frozen tree (`input/...`) and held to the
1533
+ * sha256 the SOURCE DOOR recorded in `<into>/source.json` (`inputs: [{label, rel, sha256}]`), so an input that changed after
1534
+ * the freeze is a stopped instrument, never a green. The rubric is built here, at every run, from the stage's own parameters
1535
+ * (the inputs belong to the run, not to the signed job). A step file that is missing, cut off, or not a JSON object is a RED
1536
+ * (the worker has not finished the line); an instrument that cannot read what it must is a STOP (97), never a red.
1537
+ * @param {any} stage @param {Ctx} ctx @returns {Promise<StageResult>}
1538
+ */
1539
+ async function runRubricStep(stage, ctx) {
1540
+ const p = stage.params;
1541
+ const step = p.step;
1542
+ const stop = (/** @type {string} */ why, /** @type {string} */ fault = STOP_FAULTS.FAILED) => stopped(stage, ctx, `INSTRUMENT: stage "${stage.name}": ${why}`, { step }, fault);
1543
+ // the source door's record of what was frozen: beside the tree, one level up (`<into>/source.json`, `<into>/tree`)
1544
+ /** @type {any} */
1545
+ let manifest;
1546
+ try { manifest = JSON.parse(await readFile(join(dirname(ctx.workdir), 'source.json'), 'utf8')); } catch (e) {
1547
+ return stop(`the source record (source.json beside the job's tree) could not be read: ${/** @type {Error} */ (e).message}`);
1548
+ }
1549
+ const recorded = Array.isArray(manifest?.inputs) ? manifest.inputs : [];
1550
+ /** @type {Record<string, string>} */
1551
+ const inputShas = {};
1552
+ /** @type {Record<string, string>} */
1553
+ const inputs = {};
1554
+ for (const label of citedLabels(p)) {
1555
+ const row = recorded.find((/** @type {any} */ r) => r && r.label === label);
1556
+ if (!row || typeof row.rel !== 'string' || typeof row.sha256 !== 'string') return stop(`the step cites "${label}", but the source record has no input with that label`);
1557
+ let buf;
1558
+ try { buf = await readFile(join(ctx.workdir, row.rel)); } catch (e) { return stop(`input "${label}" (${row.rel}) could not be read: ${/** @type {Error} */ (e).message}`); }
1559
+ if (sha256Of(buf) !== row.sha256) return stop(`input "${label}" (${row.rel}) no longer matches the bytes frozen at the source door`);
1560
+ inputShas[label] = row.sha256;
1561
+ inputs[label] = buf.toString('utf8');
1562
+ }
1563
+ // the step file: a regular file, read once, bounded
1564
+ const abs = join(ctx.workdir, stepFileRel(step));
1565
+ /** @type {string|null} */
1566
+ let bytes = null;
1567
+ let notRegular = false;
1568
+ try {
1569
+ const ls = await lstat(abs);
1570
+ if (!ls.isFile()) notRegular = true;
1571
+ else if (ls.size > STEP_MAX_BYTES) return stop(`${stepFileRel(step)} is over the ${STEP_MAX_BYTES} byte limit the rubric reads`);
1572
+ else bytes = (await readFile(abs)).toString('utf8');
1573
+ } catch (e) {
1574
+ if (/** @type {any} */ (e)?.code !== 'ENOENT') return stop(`${stepFileRel(step)} could not be read: ${/** @type {Error} */ (e).message}`);
1575
+ }
1576
+ // PART 1b: a prose step's numbers are tied to the quotes the earlier fact lines' step files hold (those stages ran, and were
1577
+ // green, before this one: the close is first-red-wins in stage order, which the validator pins). Unreadable = a stopped instrument.
1578
+ /** @type {Record<number, string[]>} */
1579
+ const factQuotes = {};
1580
+ /** @type {number[]} */
1581
+ const factMissing = [];
1582
+ const tie = Array.isArray(p.parts) ? p.parts.find((/** @type {any} */ x) => x && x.class === 'countable' && x.check === 'numbersFromFacts') : null;
1583
+ for (const n of tie ? tie.from : []) {
1584
+ try {
1585
+ const parsedFacts = JSON.parse(await readFile(join(ctx.workdir, stepFileRel(n)), 'utf8'));
1586
+ const results = Array.isArray(parsedFacts?.results) ? parsedFacts.results : [];
1587
+ factQuotes[n] = results.map((/** @type {any} */ r) => (r && typeof r.quote === 'string' ? r.quote : ''));
1588
+ } catch (e) {
1589
+ // M2: an earlier line's file that is not there (the seed read, before any worker wrote) is a normal red, never an instrument fault
1590
+ if (/** @type {any} */ (e)?.code === 'ENOENT') { factMissing.push(n); continue; }
1591
+ return stop(`the checked facts of line ${n} (${stepFileRel(n)}) could not be read for the number tie: ${/** @type {Error} */ (e).message}`);
1592
+ }
1593
+ }
1594
+ const graded = await gradeStep({ params: p, bytes: notRegular ? null : bytes, outputBytes: notRegular ? null : bytes, inputs, factQuotes, factMissing }, inputShas);
1595
+ if (graded.verdict === 'stopped') return stop(`the rubric stopped: ${graded.fault}`, STOP_FAULTS.CRASHED);
1596
+ const gap = new Gap(ctx.gapKeep, ctx.gapCap);
1597
+ if (notRegular) gap.push(`${stage.name}: line ${step}: ${stepFileRel(step)} is not a regular file`);
1598
+ for (const l of graded.gapLines) gap.push(`${stage.name}: ${l}`);
1599
+ const red = graded.verdict === 'red' || notRegular;
1600
+ return result(stage, {
1601
+ verdict: red ? 'red' : 'green',
1602
+ exitCode: red ? EXIT_RED : EXIT_GREEN,
1603
+ gapLines: gap.render(),
1604
+ // the trend number (`trendValueOf`, src/declaredclose.js, reads `detail.redCount` for a stage with no measurement)
1605
+ detail: { ...graded.detail, ...(notRegular ? { redCount: 1 } : {}) },
1606
+ });
1607
+ }
1608
+
1529
1609
  /**
1530
1610
  * THE SIGNER'S ANSWER, normalised at the seam that accepts it — the library half
1531
1611
  * of the three-button gate (2026-08-12 §1).
@@ -1954,6 +2034,7 @@ const RUNNERS = {
1954
2034
  'files-changed': runFilesChanged,
1955
2035
  'human-confirms': runHumanConfirms,
1956
2036
  'judged-floor': runJudgedFloor,
2037
+ 'rubric-step': runRubricStep,
1957
2038
  };
1958
2039
 
1959
2040
  /**