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.
- package/CHANGELOG.md +63 -1
- package/README.md +13 -21
- package/bareloop.context.md +44 -4
- package/package.json +4 -3
- package/src/authorflow.js +337 -29
- package/src/authoring.js +92 -7
- package/src/authorjob.js +78 -16
- package/src/authorreadout.js +89 -5
- package/src/authorrun.js +14 -5
- package/src/authorscout.js +108 -0
- package/src/declaredclose.js +23 -12
- package/src/facts.js +317 -0
- package/src/factwords.js +129 -0
- package/src/inputlook.js +301 -0
- package/src/job.js +2 -1
- package/src/jobblock.js +7 -2
- package/src/kinds.js +84 -3
- package/src/labelscan.js +170 -0
- package/src/pagefetch.js +125 -0
- package/src/panel/authorroutes.js +15 -2
- package/src/panel/authorsession.js +99 -22
- package/src/panel/index.html +33 -7
- package/src/panel/jobplan.js +34 -11
- package/src/panel/server.js +19 -3
- package/src/planrun.js +3 -2
- package/src/rubricstep.js +804 -0
- package/src/source.js +166 -1
- package/src/u-readout.js +1 -1
- package/src/userrun.js +14 -2
- package/types/authorflow.d.ts +267 -13
- package/types/authoring.d.ts +29 -1
- package/types/authorjob.d.ts +7 -5
- package/types/authorreadout.d.ts +27 -2
- package/types/authorscout.d.ts +70 -0
- package/types/declaredclose.d.ts +4 -2
- package/types/facts.d.ts +71 -0
- package/types/factwords.d.ts +42 -0
- package/types/inputlook.d.ts +238 -0
- package/types/jobblock.d.ts +8 -1
- package/types/labelscan.d.ts +38 -0
- package/types/pagefetch.d.ts +71 -0
- package/types/panel/authorsession.d.ts +18 -2
- package/types/panel/jobplan.d.ts +12 -4
- package/types/panel/server.d.ts +4 -2
- package/types/rubricstep.d.ts +286 -0
- package/types/source.d.ts +94 -0
- package/types/userrun.d.ts +11 -0
package/src/labelscan.js
ADDED
|
@@ -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
|
+
}
|
package/src/pagefetch.js
ADDED
|
@@ -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
|
-
|
|
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:
|
|
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
|
-
|
|
438
|
-
state.
|
|
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
|
|
514
|
-
//
|
|
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
|
-
|
|
671
|
-
|
|
672
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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 (
|
|
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
|