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,170 @@
1
+ // THE LABEL LIST (PART 1c, docs/product/ITEM33-BUILD.md "3. The look", "LABELS ARE PICKED, NEVER TYPED"): before the look, code scans a
2
+ // source at $0 and lists, per shape (money, number, date, time, text), the words that sit right before a value of that shape,
3
+ // with how often each occurs. The look and the drafter pick a label ONLY from this list (by id or exact text); a label that is not on
4
+ // it is a $0 red naming it. ONE generic scan for every page or file - there is no per-site table here and never will be.
5
+ //
6
+ // How a label is found (all of it plain string work, no model):
7
+ // - each line is cleaned (any run of white space, U+202F / U+00A0 included, becomes one space; bracketed tags like `[ref=549]` that a
8
+ // page reader adds are dropped);
9
+ // - values are found in a fixed order of shapes: date, time, money, number, and last `text` = any other token that holds a digit
10
+ // (a version like 0.41.0, an id). A value is only a place to look BEFORE: bareguard reads the real value at run time;
11
+ // - the words before a value are the text between the previous value on the line and this one, cut to its last one or two
12
+ // tokens (and the whole gap when it is short); a label holds no digit;
13
+ // - text facts also get the words before each REPEATED capitalised phrase (an airport, a city, a name): `Leaves` before one phrase,
14
+ // `arrives at` before another.
15
+ // A label is listed when it occurs at least MIN_COUNT times. Nothing here decides what a label MEANS.
16
+
17
+ /** the shapes a list is made for */
18
+ export const LABEL_SHAPES = Object.freeze(['money', 'number', 'date', 'time', 'text']);
19
+ /** a label must occur at least this often to be listed (1: a one-page document with one total still has its label; the list is cut to the highest counts) */
20
+ export const MIN_COUNT = 1;
21
+ /** how many labels one shape's list holds at most (the highest counts first) */
22
+ export const MAX_PER_SHAPE = 40;
23
+ /** the longest label, in characters */
24
+ const MAX_LABEL = 40;
25
+ /** the longest whole gap taken as a label when it is short */
26
+ const MAX_GAP_LABEL = 24;
27
+ /** a phrase must repeat this often to be a repeated phrase */
28
+ const MIN_PHRASE = 3;
29
+ /** how many repeated phrases are looked behind */
30
+ const MAX_PHRASES = 20;
31
+ /** a line is scanned up to this many characters, and for this many values (a minified page is one huge line; the bound keeps the scan linear) */
32
+ const MAX_LINE = 20000;
33
+ const MAX_VALUES_PER_LINE = 400;
34
+ /** only this much of a source is scanned (a source over it is scanned up to a line end) */
35
+ export const SCAN_MAX_CHARS = 2_000_000;
36
+
37
+ const WS = /[\s   ]+/g;
38
+ const TAG = /\[[A-Za-z]+=[^\]\s]*\]/g;
39
+ const MONTH = '(?:Jan(?:uary)?|Feb(?:ruary)?|Mar(?:ch)?|Apr(?:il)?|May|Jun(?:e)?|Jul(?:y)?|Aug(?:ust)?|Sep(?:t(?:ember)?)?|Oct(?:ober)?|Nov(?:ember)?|Dec(?:ember)?)';
40
+ /** value finders in priority order: an earlier shape owns a span a later one would also match */
41
+ const FINDERS = /** @type {[string, RegExp][]} */ ([
42
+ ['date', new RegExp(`\\b\\d{4}-\\d{2}-\\d{2}\\b|\\b\\d{1,2}/\\d{1,2}/\\d{2,4}\\b|\\b${MONTH}\\.? \\d{1,2}(?:, \\d{4})?\\b|\\b\\d{1,2} ${MONTH}\\.? \\d{4}\\b`, 'g')],
43
+ ['time', /\b\d{1,2}:\d{2}(?: ?[AaPp]\.?[Mm]\.?)?(?![\w:])/g],
44
+ ['money', /[$€£] ?\d[\d,]*(?:\.\d+)?|\b\d[\d,]*(?:\.\d+)? ?(?:US dollars?|dollars?|USD|euros?|EUR|pounds?|GBP)\b/g],
45
+ ['number', /(?<![\w.,])\d[\d,]*(?:\.\d+)?(?![\w]|\.\d)/g],
46
+ ['text', /[A-Za-z]*\d[\w.\-]*/g],
47
+ ]);
48
+ const PHRASE = /\b[A-Z][a-z]+(?: [A-Z][a-z]+)+\b/g;
49
+
50
+ /** @typedef {{id: string, label: string, count: number, example: string}} LabelEntry */
51
+ /** @typedef {Record<'money'|'number'|'date'|'time'|'text', LabelEntry[]>} LabelScan */
52
+
53
+ /** @param {string} t @returns {string} white space made one space, quote marks at the edges dropped, trimmed - how a label is compared */
54
+ export function normLabel(t) {
55
+ return String(t ?? '').replace(WS, ' ').trim().replace(/^["'“”]+|["'“”]+$/g, '').trim();
56
+ }
57
+
58
+ /** @param {string} gap @returns {string[]} the labels a gap of text before a value offers */
59
+ function candidates(gap) {
60
+ const g = normLabel(gap);
61
+ if (g === '') return [];
62
+ const tokens = g.split(' ');
63
+ /** @type {Set<string>} */
64
+ const out = new Set();
65
+ out.add(normLabel(tokens.at(-1) ?? ''));
66
+ if (tokens.length >= 2) out.add(normLabel(tokens.slice(-2).join(' ')));
67
+ if (g.length <= MAX_GAP_LABEL) out.add(g);
68
+ return [...out].filter((c) => c !== '' && c.length <= MAX_LABEL && !/\d/.test(c) && !/ref=/.test(c));
69
+ }
70
+
71
+ /**
72
+ * Scan one source text for the labels that sit before values, per shape.
73
+ * @param {string} text
74
+ * @returns {LabelScan}
75
+ */
76
+ export function scanLabels(text) {
77
+ let src = String(text ?? '');
78
+ if (src.length > SCAN_MAX_CHARS) { const nl = src.lastIndexOf('\n', SCAN_MAX_CHARS); src = src.slice(0, nl > 0 ? nl : SCAN_MAX_CHARS); }
79
+ /** @type {Record<string, Map<string, {count: number, example: string}>>} */
80
+ const seen = Object.fromEntries(LABEL_SHAPES.map((s) => [s, new Map()]));
81
+ const bump = (/** @type {string} */ shape, /** @type {string} */ label, /** @type {string} */ example) => {
82
+ const m = seen[shape];
83
+ const cur = m.get(label);
84
+ if (cur) cur.count += 1; else m.set(label, { count: 1, example });
85
+ };
86
+ /** @type {Map<string, number>} */
87
+ const phraseCount = new Map();
88
+ /** @type {string[]} */
89
+ const lines = [];
90
+ /** @type {{line: string, at: number, phrase: string}[]} */
91
+ const variants = [];
92
+ for (const raw of src.split('\n')) {
93
+ const line = raw.slice(0, MAX_LINE).replace(TAG, ' ').replace(WS, ' ').trim();
94
+ if (line === '') continue;
95
+ lines.push(line);
96
+ for (const m of line.matchAll(PHRASE)) {
97
+ // a capitalised run may start with a sentence-initial word ("Leaves San Francisco ..."): the run is also read without its first word
98
+ const words = m[0].split(' ');
99
+ let off = m.index ?? 0;
100
+ for (let j = 0; j <= 1 && words.length - j >= 2; j += 1) {
101
+ const phrase = words.slice(j).join(' ');
102
+ phraseCount.set(phrase, (phraseCount.get(phrase) ?? 0) + 1);
103
+ variants.push({ line, at: off, phrase });
104
+ off += words[j].length + 1;
105
+ }
106
+ }
107
+ }
108
+ for (const line of lines) {
109
+ /** @type {{shape: string, s: number, e: number, v: string}[]} */
110
+ const found = [];
111
+ for (const [shape, re] of FINDERS) {
112
+ for (const m of line.matchAll(re)) {
113
+ const s = m.index ?? 0;
114
+ const e = s + m[0].length;
115
+ if (found.length >= MAX_VALUES_PER_LINE) break;
116
+ if (found.some((f) => s < f.e && e > f.s)) continue;
117
+ found.push({ shape, s, e, v: m[0] });
118
+ }
119
+ }
120
+ found.sort((a, b) => a.s - b.s);
121
+ let prevEnd = 0;
122
+ for (const f of found) {
123
+ for (const c of candidates(line.slice(prevEnd, f.s))) bump(f.shape, c, f.v);
124
+ prevEnd = f.e;
125
+ }
126
+ }
127
+ // the words before each REPEATED capitalised phrase (a place, a name): text facts
128
+ const phrases = new Set([...phraseCount].filter(([, n]) => n >= MIN_PHRASE).sort((a, b) => b[1] - a[1]).slice(0, MAX_PHRASES).map(([p]) => p));
129
+ for (const v of variants) {
130
+ if (!phrases.has(v.phrase)) continue;
131
+ for (const c of candidates(v.line.slice(0, v.at))) bump('text', c, v.phrase);
132
+ }
133
+ /** @type {Record<string, string>} */
134
+ const prefix = { money: 'm', number: 'n', date: 'd', time: 't', text: 'x' };
135
+ return /** @type {LabelScan} */ (Object.fromEntries(LABEL_SHAPES.map((shape) => {
136
+ const rows = [...seen[shape]].filter(([, v]) => v.count >= MIN_COUNT)
137
+ .sort((a, b) => b[1].count - a[1].count || a[0].length - b[0].length || (a[0] < b[0] ? -1 : 1))
138
+ .slice(0, MAX_PER_SHAPE)
139
+ .map(([label, v], i) => ({ id: `${prefix[shape]}${i + 1}`, label, count: v.count, example: v.example }));
140
+ return [shape, rows];
141
+ })));
142
+ }
143
+
144
+ /**
145
+ * Is this label on the list for this shape? Compared the way a label is read: white space of any kind is one space, quote marks at the
146
+ * edges are dropped. `idOrText` may be a list id (`t1`) or the exact words.
147
+ * @param {LabelScan} scan @param {string} shape @param {unknown} idOrText
148
+ * @returns {LabelEntry|null}
149
+ */
150
+ export function labelOnList(scan, shape, idOrText) {
151
+ const rows = scan[/** @type {'money'} */ (shape)];
152
+ if (!rows || typeof idOrText !== 'string') return null;
153
+ const asId = rows.find((r) => r.id === idOrText.trim());
154
+ if (asId) return asId;
155
+ const want = normLabel(idOrText);
156
+ return rows.find((r) => r.label === want) ?? null;
157
+ }
158
+
159
+ /**
160
+ * The list as the look and the drafter are shown it, one block per shape that has any label.
161
+ * @param {string} inputLabel the input the list belongs to @param {LabelScan} scan
162
+ * @returns {string}
163
+ */
164
+ export function renderLabelList(inputLabel, scan) {
165
+ const blocks = LABEL_SHAPES.filter((s) => scan[/** @type {'money'} */ (s)].length > 0).map((s) => {
166
+ const rows = scan[/** @type {'money'} */ (s)].map((r) => ` ${r.id} ${JSON.stringify(r.label)} x${r.count} e.g. ${JSON.stringify(r.example)}`);
167
+ return `${s} (the words right before a ${s === 'text' ? 'repeated phrase or an id/version' : s}):\n${rows.join('\n')}`;
168
+ });
169
+ return `LABELS IN THE INPUT "${inputLabel}" - pick a label ONLY from this list (by its id or its exact words); a label that is not here is refused:\n\n${blocks.length ? blocks.join('\n\n') : '(no repeated label was found before a value)'}`;
170
+ }
@@ -0,0 +1,125 @@
1
+ // URL inputs (PART 1c, docs/product/ITEM33-BUILD.md "4. URL inputs" + live fix L1, PRD item 33 RF21/RF22): an Inputs line may be a web
2
+ // address the person pastes (a results page). bareloop fetches it with barebrowse, READ ONLY, headless and incognito, as published:
3
+ //
4
+ // page = await connect({ incognito: true, blockPrivateNetwork: true })
5
+ // await page.goto(url); await page.waitForNetworkIdle({ idle, timeout }); await page.snapshot({ mode: 'act' })
6
+ //
7
+ // L1: one-shot `browse()` snapshots at load, before the page's own scripts fill a results list (the same URL gave 77,951 / 8,801 / 719
8
+ // chars). So we wait for the network to go quiet (bounded) before the snapshot. Generic: no site selector. The snapshot mode is the one
9
+ // proven in step 0 ('read' and 'full' returned almost nothing from a results page). The text that comes back is the input. An empty
10
+ // fetch is a plain-words stop, never a silent empty input. Nothing here touches a model.
11
+
12
+ import { SECRET_PATTERNS } from './validate.js';
13
+
14
+ /** the session options (incognito: no auth ever enters; blockPrivateNetwork: SSRF guard); ONE frozen object so a test can pin them */
15
+ export const CONNECT_OPTIONS = Object.freeze({ incognito: true, blockPrivateNetwork: true });
16
+ /** the snapshot options step 0 proved */
17
+ export const SNAPSHOT_OPTIONS = Object.freeze({ mode: 'act' });
18
+ /** the page's network must be quiet this long (ms) before we read it: long enough to span the gap between a page's first load and the
19
+ * script requests that fill the results, short enough not to stretch a plain page */
20
+ export const NETWORK_IDLE_MS = 1500;
21
+ /** the most we wait for the network to go quiet (ms); a page that never settles (polling, streaming) is read as it stands after this */
22
+ export const NETWORK_IDLE_TIMEOUT_MS = 20_000;
23
+ /** the most we wait for the page to load at all (ms) */
24
+ export const GOTO_TIMEOUT_MS = 30_000;
25
+ /** a page that comes back with fewer characters than this (after trimming) is treated as "nothing came back" */
26
+ export const MIN_PAGE_CHARS = 200;
27
+ /** the page text left after the site's own header, menus and footer are taken out must hold at least this many characters, or the page is
28
+ * "only site chrome" (L2: a 719-character fetch of Google's header and sign-in passed the 200-character floor) */
29
+ export const MIN_CONTENT_CHARS = 100;
30
+ /** the page reader's landmark roles for a site's own chrome (the ARIA names of header, menu and footer); everything under one is not the page's content */
31
+ const CHROME_ROLES = new Set(['banner', 'navigation', 'contentinfo']);
32
+ /** our own deadline around one whole fetch (a hung browser leaves no trace; this is the only thing that fires on the absence of an answer) */
33
+ export const PAGE_FETCH_TIMEOUT_MS = 90_000;
34
+
35
+ /** @param {unknown} v @returns {boolean} an http(s) address (the one spelling of "this Inputs value is a web address") */
36
+ export function isWebUrl(v) {
37
+ return typeof v === 'string' && /^https?:\/\//i.test(v.trim());
38
+ }
39
+
40
+ /**
41
+ * How much of a fetched page is the page's own content: the snapshot's header lines (url, size note, hint) and every part of the tree
42
+ * under a banner / navigation / contentinfo landmark are taken out, the page reader's bracketed tags are removed, and what is left is
43
+ * counted. Generic - no site names, only the ARIA landmark roles. Text that is not a snapshot tree (a plain file) has no landmark, so it
44
+ * counts whole.
45
+ * @param {string} text @returns {number}
46
+ */
47
+ export function pageContentChars(text) {
48
+ const lines = String(text ?? '').split('\n');
49
+ let skipIndent = -1;
50
+ let n = 0;
51
+ let head = true;
52
+ for (const raw of lines) {
53
+ if (head && (/^url: /.test(raw) || /^[\d,]+ chars → /.test(raw) || /^hint: /.test(raw))) continue;
54
+ head = false;
55
+ const m = /^(\s*)- ([A-Za-z_]+)\b/.exec(raw);
56
+ const indent = m ? m[1].length : raw.length - raw.trimStart().length;
57
+ if (skipIndent >= 0) {
58
+ if (raw.trim() === '' || indent > skipIndent) continue;
59
+ skipIndent = -1;
60
+ }
61
+ 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
+ }
64
+ return n;
65
+ }
66
+
67
+ /**
68
+ * @typedef {{goto: (url: string, timeout?: number) => Promise<void>, waitForNetworkIdle: (o?: {idle?: number, timeout?: number}) => Promise<void>,
69
+ * snapshot: (o?: {mode?: string}) => Promise<string>, close: () => Promise<void>}} FetchPage the part of barebrowse's page handle the fetch uses
70
+ */
71
+
72
+ /**
73
+ * Fetch one page's text. `connect` is injected (a test never reaches the network); the default is barebrowse's own `connect`.
74
+ * @param {string} url
75
+ * @param {{connect?: (opts: Record<string, any>) => Promise<FetchPage>, timeoutMs?: number, idleMs?: number, idleTimeoutMs?: number, closeWaitMs?: number}} [o]
76
+ * @returns {Promise<{ok: true, text: string, url: string}|{ok: false, error: string}>} `error` is plain words, without a spend line
77
+ */
78
+ export async function fetchPageText(url, { connect, timeoutMs = PAGE_FETCH_TIMEOUT_MS, idleMs = NETWORK_IDLE_MS, idleTimeoutMs = NETWORK_IDLE_TIMEOUT_MS, closeWaitMs = 3000 } = {}) {
79
+ let u;
80
+ try { u = new URL(url.trim()); } catch { return { ok: false, error: `${url} is not a web address bareloop can read` }; }
81
+ if (u.protocol !== 'http:' && u.protocol !== 'https:') return { ok: false, error: `${url} is not an http or https address` };
82
+ if (u.username !== '' || u.password !== '') return { ok: false, error: 'a web address with a user name or password in it is refused - bareloop reads a page with no credentials at all' };
83
+ if (SECRET_PATTERNS.some((re) => re.test(u.href))) return { ok: false, error: 'the web address carries a known secret shape - secrets never enter a job' };
84
+ /** @type {(opts: Record<string, any>) => Promise<FetchPage>} */
85
+ let open;
86
+ if (connect) open = connect;
87
+ else {
88
+ try { open = /** @type {any} */ ((await import('barebrowse')).connect); } catch (e) { return { ok: false, error: `the page reader (barebrowse) could not be loaded: ${/** @type {Error} */ (e).message}` }; }
89
+ }
90
+ /** @type {NodeJS.Timeout|undefined} */
91
+ let timer;
92
+ /** @type {Promise<FetchPage>|undefined} */
93
+ let opening;
94
+ try {
95
+ opening = open({ ...CONNECT_OPTIONS });
96
+ const page = opening;
97
+ const text = await Promise.race([
98
+ (async () => {
99
+ const p = await page;
100
+ await p.goto(u.href, GOTO_TIMEOUT_MS);
101
+ // bounded: a page that never goes quiet (polling, streaming) is read as it stands - the wait is for a page that is still filling
102
+ try { await p.waitForNetworkIdle({ idle: idleMs, timeout: idleTimeoutMs }); } catch { /* read what is there */ }
103
+ return p.snapshot({ ...SNAPSHOT_OPTIONS });
104
+ })(),
105
+ new Promise((_, reject) => { timer = setTimeout(() => reject(new Error(`no answer inside ${Math.round(timeoutMs / 1000)} seconds`)), timeoutMs); }),
106
+ ]);
107
+ if (typeof text !== 'string') return { ok: false, error: `${u.href} did not come back as text` };
108
+ if (/\[BOT CHALLENGE DETECTED\]/.test(text)) return { ok: false, error: `${u.href} asked for a human check (a bot challenge), so the page could not be read` };
109
+ const size = text.trim().length;
110
+ if (size < MIN_PAGE_CHARS) return { ok: false, error: `${u.href} came back with ${size === 0 ? 'no text' : `only ${size} characters of text`} (less than ${MIN_PAGE_CHARS}) - the page did not load, or holds nothing to read` };
111
+ if (pageContentChars(text) < MIN_CONTENT_CHARS) return { ok: false, error: `${u.href} came back with only the site's own header and menus - the page did not load its content, so there is nothing to read` };
112
+ return { ok: true, text, url: u.href };
113
+ } catch (e) {
114
+ return { ok: false, error: `${u.href} could not be read: ${/** @type {Error} */ (e)?.message ?? String(e)}` };
115
+ } finally {
116
+ if (timer !== undefined) clearTimeout(timer);
117
+ // always close the browser, also when the deadline fired while it was still starting or loading
118
+ if (opening) {
119
+ /** @type {NodeJS.Timeout|undefined} */
120
+ let w;
121
+ await Promise.race([opening.then((p) => p.close()).catch(() => {}), new Promise((r) => { w = setTimeout(r, closeWaitMs); })]);
122
+ if (w !== undefined) clearTimeout(w);
123
+ }
124
+ }
125
+ }
@@ -22,9 +22,10 @@ import { spawn as realSpawn } from 'node:child_process';
22
22
  import { openSync } from 'node:fs';
23
23
  import { join } from 'node:path';
24
24
  import { readFileSync } from 'node:fs';
25
+ import { planMatchesClose } from '../rubricstep.js';
25
26
  import { createSession, validateJobCard, validateReuseCard, lockedFieldChanged, buildReuseSpec, cardFields, STEP_LABELS } from './authorsession.js';
26
27
  import { parseInputs } from '../jobblock.js';
27
- import { proveInputFiles } from '../source.js';
28
+ import { proveInputFiles, looksLikeRubricInput } from '../source.js';
28
29
  import { validateJob } from '../job.js';
29
30
  import { checkMonthlyRoom, monthlyRefusalText } from '../monthly.js';
30
31
  import { ConfigError } from '../config.js';
@@ -285,7 +286,10 @@ export function createAuthorRoutes(opts) {
285
286
  // A miss is a plain 400 naming the line, shown under the card exactly like every other refused start.
286
287
  const ip = parseInputs(card.inputs);
287
288
  if (!ip.ok) { send(400, { ok: false, error: ip.error }); return true; }
288
- proveInputFiles(ip.inputs).then((proof) => {
289
+ // Part 1a: a rubric job whose first input is a local FILE has no repo to prove inputs against; the session proves every
290
+ // line itself (`proveRubricInputs`), at $0, before any model call
291
+ const filesJob = reuse === null && card?.checkType === 'rubric' && looksLikeRubricInput(ip.inputs[0].value);
292
+ (filesJob ? Promise.resolve(/** @type {{ok: true}} */ ({ ok: true })) : proveInputFiles(ip.inputs)).then((proof) => {
289
293
  if (!proof.ok) { send(400, { ok: false, error: proof.error }); return; }
290
294
  if (hasLiveSession()) { send(409, { ok: false, error: 'an authoring session is already live — one at a time' }); return; }
291
295
  const session = createSession(cardFields(card), {
@@ -393,6 +397,15 @@ export function signRun(session, claimedHash, o) {
393
397
  if (e instanceof ConfigError) return { ok: false, error: `${e.message} — refusing rather than guess the monthly limit` };
394
398
  return { ok: false, error: 'could not read the signed spec to check the monthly limit — refusing to sign' };
395
399
  }
400
+ // PART 1a, the sign-vs-run invariant: the checks on the plan card the person saw are exactly the checks of the close about to be
401
+ // signed, per line. A mismatch is refused in plain words and nothing is spawned.
402
+ if (session.state.finalPlan) {
403
+ try {
404
+ const spec = JSON.parse(readFileSync(session.state.resolvedSpecPath, 'utf8'));
405
+ const m = planMatchesClose(session.state.finalPlan.checkItems, spec.closeDecl);
406
+ if (!m.ok) return { ok: false, error: m.error };
407
+ } catch { return { ok: false, error: 'could not read the signed spec to compare it with the plan you were shown — refusing to sign' }; }
408
+ }
396
409
  const logFile = join(session.state.outDir, 'run.log');
397
410
  // array argv, never a shell string — `--approve <hash>` is a literal
398
411
  // element, checkable byte-for-byte by a test stubbing `spawnFn`, and there
@@ -27,7 +27,7 @@ import {
27
27
  } from 'node:fs';
28
28
  import { join, resolve, dirname } from 'node:path';
29
29
  import {
30
- prepareSource, proveDestination, missingDependencies, looksLikeRepoSource, nearestGitAncestor, nonRepoSourceMessage,
30
+ prepareSource, proveDestination, proveRubricInputs, looksLikeRubricInput, missingDependencies, looksLikeRepoSource, nearestGitAncestor, nonRepoSourceMessage,
31
31
  } from '../source.js';
32
32
  import {
33
33
  worktreePath, uncommittedCount, shortHead, removeWorktree,
@@ -50,10 +50,12 @@ import {
50
50
  import { resolveJobJudge, defaultJudgeLoop } from '../judged.js';
51
51
  import { assertThinkingMode, thinkingPlanFor, thinkingRowText } from '../thinking.js';
52
52
  import { closeJudges } from '../kinds.js';
53
+ import { RUBRIC_LANG } from '../authoring.js';
54
+ import { rubricGoal, reconcileRubricPlan } from '../rubricstep.js';
53
55
  import { redactSecrets } from '../validate.js';
54
56
  import { tallyCalls } from '../text.js';
55
57
  import { writeDraftSpend, appendDraftLog } from '../draftspend.js';
56
- import { PLAIN_PROPOSAL_STOPS, proposalStopText, signingStopText, draftStopText, redsRecord, calibrationSummary } from '../authorreadout.js';
58
+ import { PLAIN_PROPOSAL_STOPS, proposalStopText, signingStopText, draftStopText, redsRecord, declarationRecord, calibrationSummary, tryRefusedText } from '../authorreadout.js';
57
59
  import { runNpmCi, NPM_CI_LOCKS } from '../npminstall.js';
58
60
  import {
59
61
  parseJobBlock, parseInputs, jobBlockFitsCheck, renderJobLines, renderExamples, signedJobLines,
@@ -75,6 +77,7 @@ export const STEP_LABELS = Object.freeze({
75
77
  reuse: 'reusing signed workflow',
76
78
  read: 'reading repo',
77
79
  scout: 'scouting repo',
80
+ look: 'looking at your input',
78
81
  listing: 'listing files',
79
82
  confirm: 'confirming plan',
80
83
  draft: 'drafting',
@@ -84,6 +87,16 @@ export const STEP_LABELS = Object.freeze({
84
87
  signed: 'signed hash',
85
88
  });
86
89
 
90
+ /**
91
+ * The same steps, worded for a job on the person's own local FILES (Part 1a): there is no worktree and no repo to read.
92
+ * Code-owned, one table beside STEP_LABELS; a step with no entry keeps its STEP_LABELS word.
93
+ * @type {Readonly<Record<string, string>>}
94
+ */
95
+ export const FILE_JOB_STEP_LABELS = Object.freeze({
96
+ copy: 'copying input files',
97
+ read: 'reading input files',
98
+ });
99
+
87
100
  /**
88
101
  * The library's own `onPhase(name, ...)` calls (`src/authorjob.js`/`src/authorflow.js`) -> the step each one advances.
89
102
  * A phase with no entry leaves the current step as it was.
@@ -101,6 +114,8 @@ export const PHASE_STEP = Object.freeze({
101
114
  seed: 'read',
102
115
  scout: 'scout',
103
116
  'scout-done': 'scout',
117
+ look: 'look',
118
+ 'look-done': 'look',
104
119
  confirm: 'confirm',
105
120
  'confirm-turn-done': 'confirm',
106
121
  listing: 'listing',
@@ -127,12 +142,13 @@ export function latestStep(steps, id) {
127
142
  * earlier line keeps its status and detail). Existing lines are never moved. Re-starting the step that is still the
128
143
  * LAST line just resumes that line.
129
144
  * @param {StepLine[]} steps @param {string} id @param {string} [detail] (absent = keep what the line has)
145
+ * @param {Readonly<Record<string, string>>} [labels] the label table (a file job passes its own)
130
146
  */
131
- export function advanceSteps(steps, id, detail) {
147
+ export function advanceSteps(steps, id, detail, labels = STEP_LABELS) {
132
148
  for (const x of steps) if (x.status === 'running' && x.id !== id) x.status = 'done';
133
149
  const last = steps.at(-1);
134
150
  if (last && last.id === id) { last.status = 'running'; if (detail !== undefined) last.detail = detail; return; }
135
- steps.push({ id, label: STEP_LABELS[id] ?? id, status: 'running', detail: detail ?? '' });
151
+ steps.push({ id, label: labels[id] ?? id, status: 'running', detail: detail ?? '' });
136
152
  }
137
153
 
138
154
  /** source-door refusals that come from CHECKING the copied source (the scan/freeze), not from reaching it */
@@ -327,10 +343,13 @@ export function validateJobCard(card, opts = {}) {
327
343
  if (card.thinking !== undefined && typeof card.thinking !== 'boolean') return { ok: false, error: 'Thinking on must be on or off' };
328
344
  const jb = parseJobBlock(card.jobText);
329
345
  if (!jb.ok) return { ok: false, error: jb.error };
330
- const misfit = jobBlockFitsCheck(jb.lines, checkType === 'rubric');
331
- if (misfit) return { ok: false, error: misfit };
332
346
  const ib = parseInputs(card.inputs);
333
347
  if (!ib.ok) return { ok: false, error: ib.error };
348
+ // P1=B: PASS/FAIL examples are required of a rubric job only when it holds a judged part. A rubric on the person's own
349
+ // local files (first input not a repo) holds none in this build; a rubric on a repo composes the judged floor.
350
+ const rubricFiles = checkType === 'rubric' && looksLikeRubricInput(ib.inputs[0].value);
351
+ const misfit = jobBlockFitsCheck(jb.lines, checkType === 'rubric', { judged: !rubricFiles });
352
+ if (misfit) return { ok: false, error: misfit };
334
353
  if (typeof destination !== 'string' || destination.trim() === '') return { ok: false, error: 'Destination is required' };
335
354
  if (typeof capUsd !== 'number' || !Number.isFinite(capUsd) || capUsd <= 0) {
336
355
  return { ok: false, error: '$ cap is required and must be a positive number' };
@@ -346,7 +365,7 @@ export function validateJobCard(card, opts = {}) {
346
365
  * @param {any} card the validated job card (see {@link validateJobCard})
347
366
  * @param {{env?: Record<string,string|undefined>, sessionsRoot?: string, home?: string, timeoutMs?: number,
348
367
  * scout?: any, generate?: Function, confirmGenerate?: Function, authorFn?: Function,
349
- * prepareSigningFn?: Function, npmCiFn?: Function, draftThinking?: "on"|"off", reuse?: {spec: any, workflowKey: string}}} [deps] `scout`..`prepareSigningFn` are
368
+ * 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
350
369
  * TEST SEAMS ONLY — see the note just above where each is read, below. `reuse` (Reuse workflow) is NOT a seam: the
351
370
  * caller (`/api/author/start`) sets it only when the server's own same-job rule held.
352
371
  * @returns {any} the session object
@@ -432,10 +451,13 @@ export function createSession(card, deps = {}) {
432
451
  });
433
452
  };
434
453
 
454
+ /** a rubric job on local files words its copy/read steps for files (set once the card is known) */
455
+ let fileJob = card.checkType === 'rubric' && looksLikeRubricInput(parseInputs(card.inputs).ok ? (/** @type {any} */ (parseInputs(card.inputs))).inputs[0].value : '');
435
456
  /** @param {string} id @param {string} [detail] (absent = keep what the step has) start (or restart) a step; whatever step was running is finished */
436
457
  const stepStart = (id, detail) => {
437
- advanceSteps(state.steps, id, detail);
438
- state.progressLabel = STEP_LABELS[id] ?? id;
458
+ const labels = fileJob ? { ...STEP_LABELS, ...FILE_JOB_STEP_LABELS } : STEP_LABELS;
459
+ advanceSteps(state.steps, id, detail, labels);
460
+ state.progressLabel = labels[id] ?? id;
439
461
  logSteps();
440
462
  };
441
463
  /** @param {string} id @param {string} detail set a step's latest line's detail without changing its status */
@@ -500,6 +522,7 @@ export function createSession(card, deps = {}) {
500
522
  return true;
501
523
  };
502
524
 
525
+ let lastTryRefused = false;
503
526
  const onPhase = (name, data = {}) => {
504
527
  // COLLAPSED INTO THE PROGRESS INDICATOR ONLY (build item 3) — this used
505
528
  // to also `say('system', `… ${name}`)`, one jargon chat bubble per
@@ -510,10 +533,24 @@ export function createSession(card, deps = {}) {
510
533
  // person needs to read; the step-by-step detail lives in one line.
511
534
  // ONE owner of the drafting line's detail (model + cap): the drafting line is started here, by the real author phase
512
535
  if (PHASE_STEP[name]) stepStart(PHASE_STEP[name], PHASE_STEP[name] === 'draft' ? `${card.model}, $${card.capUsd.toFixed(2)} cap` : undefined);
513
- // a drafting call after the first (`author-call` i >= 1, of up to `of + 1`) is a RETRY: the line says so in plain words, so a
514
- // long silence on call 2 of 3 does not read as a hang. The same one line, relabelled — never a second line.
536
+ // WHY a try was refused: the drafting log keeps it beside the call that made it (it used to say only "retry 3 of 3"), and the
537
+ // progress line shows it briefly, in code's words
538
+ if (name === 'author-try-failed') {
539
+ lastTryRefused = true;
540
+ const no = state.steps.findLastIndex((/** @type {any} */ x) => x.id === 'draft');
541
+ const reason = tryRefusedText(data);
542
+ appendDraftLog(outDir, {
543
+ kind: 'try-refused', no: no === -1 ? state.steps.length - 1 : no, call: String(data.call ?? ''), reason,
544
+ ...(Array.isArray(data.reds) && data.reds.length ? { reds: redsRecord(data.reds) } : {}), ...declarationRecord(data.declaration), at: new Date().toISOString(),
545
+ });
546
+ stepDetail('draft', `${data.call}: refused — ${reason.length > 160 ? `${reason.slice(0, 160)}…` : reason}`);
547
+ }
548
+ // a drafting call after the first (`author-call` i >= 1, of up to `of + 1`) is relabelled in plain words, so a long silence on call 2
549
+ // of 3 does not read as a hang. The loop drafts again after a try that PASSED too (the drafter sees its own seed-read), so
550
+ // "retry" is said only when the previous try was actually refused; otherwise it is "look". The same one line, never a second one.
515
551
  if (name === 'author-call' && Number.isInteger(data.i) && data.i >= 1 && Number.isInteger(data.of)) {
516
- const label = `${STEP_LABELS.draft} (retry ${data.i + 1} of ${data.of + 1})`;
552
+ const label = `${STEP_LABELS.draft} (${lastTryRefused ? 'retry' : 'look'} ${data.i + 1} of ${data.of + 1})`;
553
+ lastTryRefused = false;
517
554
  stepLabel('draft', label);
518
555
  state.progressLabel = label;
519
556
  }
@@ -644,6 +681,9 @@ export function createSession(card, deps = {}) {
644
681
  const verdictType = card.checkType === 'rubric' ? 'soft-green' : 'green';
645
682
  const into = join(outDir, 'source-seed');
646
683
  const isRepoLike = looksLikeRepoSource(SOURCE);
684
+ // PART 1a: a RUBRIC job whose first input is not a repo works on the person's own local FILES — every numbered input
685
+ // line is one file, frozen by `proveRubricInputs`; there is no repo, no language and no scout (docs/product/ITEM33-BUILD.md)
686
+ const RUBRIC_FILES = card.checkType === 'rubric' && !isRepoLike;
647
687
  // hamr's ruling B (2026-10-05): ONE rule for both a fresh card and a reuse card, at $0, before the copy and any model call
648
688
  const destProblem = repoDestinationProblem(card.destination, isRepoLike);
649
689
  if (destProblem !== null) { refuse(destProblem); return; }
@@ -667,10 +707,21 @@ export function createSession(card, deps = {}) {
667
707
  // fence read as a directory and refused as "not absolute" — hiding the real problem with the Source. A scope-shaped
668
708
  // (relative) Destination is therefore handed to the door only when the source is repo-like.
669
709
  const destIsDir = isAbsoluteDestination(card.destination);
670
- const prep = await prepareSource({
671
- source: SOURCE, into, ...(isRepoLike || destIsDir ? { destination: card.destination } : {}),
672
- ...(repoRoot === null ? {} : { worktree: worktreePath(repoRoot, id) }),
673
- });
710
+ if (RUBRIC_FILES && !destIsDir) {
711
+ refuse('Destination must be the full path of a folder, like /home/me/out — the finished files are delivered there. Nothing spent.');
712
+ return;
713
+ }
714
+ /** @type {any} */
715
+ let prep;
716
+ if (RUBRIC_FILES) {
717
+ const proved = await proveRubricInputs({ inputs: INPUTS, into, destination: card.destination, ...(deps.fetchUrl ? { fetchUrl: deps.fetchUrl } : {}) });
718
+ prep = proved.ok ? proved.prep : { stop: proved.error, code: proved.code };
719
+ } else {
720
+ prep = await prepareSource({
721
+ source: SOURCE, into, ...(isRepoLike || destIsDir ? { destination: card.destination } : {}),
722
+ ...(repoRoot === null ? {} : { worktree: worktreePath(repoRoot, id) }),
723
+ });
724
+ }
674
725
  if (repoRoot !== null && prep.stop === null && prep.manifest?.worktree) madeWorktree = { repo: repoRoot, dir: prep.manifest.worktree };
675
726
  if (abandoned) dropWorktree();
676
727
  if (prep.stop !== null) {
@@ -682,7 +733,7 @@ export function createSession(card, deps = {}) {
682
733
  stepStart('check');
683
734
 
684
735
  const IS_REPO_SOURCE = prep.manifest.kind === 'repo';
685
- let LANG = 'none-detected';
736
+ let LANG = RUBRIC_FILES ? RUBRIC_LANG : 'none-detected';
686
737
  /** @type {any} */
687
738
  let langResult = null;
688
739
  if (IS_REPO_SOURCE) {
@@ -741,7 +792,7 @@ export function createSession(card, deps = {}) {
741
792
  state.phase = 'drafting';
742
793
  }
743
794
  }
744
- if (!IS_REPO_SOURCE) {
795
+ if (!IS_REPO_SOURCE && !RUBRIC_FILES) {
745
796
  refuse(nonRepoSourceMessage(prep.manifest.kind));
746
797
  return;
747
798
  }
@@ -874,7 +925,9 @@ export function createSession(card, deps = {}) {
874
925
  return;
875
926
  }
876
927
 
877
- const writeScope = card.destination.split(/[,\n]/).map((s) => s.trim()).filter(Boolean);
928
+ // a rubric job on files writes only inside the hidden tree's own `output/`; its Destination is a drop-off folder
929
+ // (proven above, per run), never a fence
930
+ const writeScope = RUBRIC_FILES ? ['output/'] : card.destination.split(/[,\n]/).map((s) => s.trim()).filter(Boolean);
878
931
  /** @type {any} the confirm turn (below) fills `goal` in once accepted —
879
932
  * declared loosely rather than typed field-by-field, since this object's
880
933
  * own shape is a `job-v1` draft `validateJob` itself is the real
@@ -909,7 +962,8 @@ export function createSession(card, deps = {}) {
909
962
  /** @type {Record<string, string>} */
910
963
  const answers = { 1: renderJobLines(jobLines) };
911
964
  if (verdictType === 'soft-green') answers[SOFTGREEN_JUDGE_EXAMPLES_KEY] = renderExamples(jobLines);
912
- const required = requiredAnswersFor(verdictType);
965
+ // a rubric job on files holds no judged part in this build, so the PASS/FAIL examples are not required of it
966
+ const required = RUBRIC_FILES ? [1] : requiredAnswersFor(verdictType);
913
967
  for (const n of required) {
914
968
  if (!answers[n]) { refuse(`the ${verdictType} check type needs ${required.length} answers and one is missing (key ${n})`); return; }
915
969
  }
@@ -973,8 +1027,10 @@ export function createSession(card, deps = {}) {
973
1027
  rates: draftPrice?.rates ?? null,
974
1028
  draftThinking,
975
1029
  onPhase, onCall,
976
- ask, answerQuestions, confirmGenerate, isRepo: true, langResult,
1030
+ ask, answerQuestions, confirmGenerate, isRepo: !RUBRIC_FILES, langResult,
977
1031
  ...(deps.scout ? { scout: deps.scout } : {}),
1032
+ // PART 1c: a test that replaces the model boundary (`generate`) must not reach the real paid look; `deps.lookFn` brings its own
1033
+ ...(deps.lookFn !== undefined ? { lookFn: deps.lookFn } : (deps.generate ? { lookFn: null } : {})),
978
1034
  ...(authorFnOverride ? { authorFn: authorFnOverride } : {}),
979
1035
  });
980
1036
 
@@ -1008,13 +1064,34 @@ export function createSession(card, deps = {}) {
1008
1064
  : draftStopText({
1009
1065
  stop: String(authored.stop ?? 'authoring-failed'), reds: stopReds, refusal: authored.refusal,
1010
1066
  spend: { knownUsd: state.draftSpentUsd, spendComplete: state.draftSpendComplete },
1067
+ wholeJob: RUBRIC_FILES,
1011
1068
  }), authored.stop === 'confirm-abandoned' ? 'abandoned' : 'refused');
1012
1069
  return;
1013
1070
  }
1014
1071
 
1015
- if (authored.confirmed?.goal) draft.goal = redactSecrets(String(authored.confirmed.goal));
1072
+ if (RUBRIC_FILES) draft.goal = redactSecrets(rubricGoal({ jobLines, files: prep.manifest.inputs, confirmedGoal: authored.confirmed?.goal ?? null, closeDecl: authored.closeDecl }));
1073
+ else if (authored.confirmed?.goal) draft.goal = redactSecrets(String(authored.confirmed.goal));
1016
1074
  const spec = assembleSpec(draft, { ...authored, verdictType: /** @type {string} */ (authored.verdictType) });
1075
+ // PART 1a (run mv5bk400): the plan the person confirmed named three checks for line 1; the composer wrote ONE stage and put
1076
+ // "lists 3 releases" in its notes, so the card promised a check the close never ran. The card AFTER drafting is now built
1077
+ // from the close itself, and every confirmed check no part carries out is moved to Not checked, in code, before signing.
1078
+ if (RUBRIC_FILES) {
1079
+ const rec = reconcileRubricPlan({
1080
+ confirmed: authored.confirmed?.checkItems ?? [], notChecked: authored.confirmed?.notChecked ?? [], closeDecl: spec.closeDecl, jobLines,
1081
+ });
1082
+ state.finalPlan = { goal: String(draft.goal ?? ''), checkItems: rec.checkItems, notChecked: rec.notChecked, protections: authored.confirmed?.protections ?? [], moved: rec.moved };
1083
+ }
1017
1084
  await finish(spec, authored.seedRef);
1085
+ if (state.finalPlan && state.phase === 'prepared') {
1086
+ // The confirm turn's plan bubble (and a try-3 "answers are in" one) is the DRAFTER's wording of the checks, written before
1087
+ // any close existed. Once the close is signed-ready its card is the only plan the person sees: the earlier ones are
1088
+ // dropped, so drafter prose can never sit above (and be read instead of) the code's sentences (run mv5e1ktb).
1089
+ state.messages = state.messages.filter((/** @type {any} */ m) => m.kind !== 'plan');
1090
+ state.messages.push({
1091
+ ...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),
1092
+ 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.',
1093
+ });
1094
+ }
1018
1095
  }
1019
1096
 
1020
1097
  // fire-and-forget — the caller (src/panel/server.js) never awaits this; it