bareloop 0.43.0 → 0.45.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 (64) hide show
  1. package/CHANGELOG.md +60 -0
  2. package/bareloop.context.md +53 -13
  3. package/package.json +2 -2
  4. package/src/authorflow.js +27 -12
  5. package/src/authoring.js +1 -1
  6. package/src/authorjob.js +52 -16
  7. package/src/authorreadout.js +12 -6
  8. package/src/authorrun.js +8 -2
  9. package/src/declaredclose.js +4 -1
  10. package/src/draftspend.js +20 -2
  11. package/src/facts.js +54 -9
  12. package/src/factwords.js +58 -6
  13. package/src/fitcheck.js +166 -0
  14. package/src/inputlook.js +38 -3
  15. package/src/job.js +1 -1
  16. package/src/kinds.js +5 -0
  17. package/src/pagedropwords.js +110 -0
  18. package/src/pagedry.js +125 -0
  19. package/src/pagemap.js +269 -0
  20. package/src/pagerules.js +318 -0
  21. package/src/panel/authorroutes.js +34 -30
  22. package/src/panel/authorsession.js +66 -11
  23. package/src/panel/index.html +469 -65
  24. package/src/panel/jobplan.js +14 -7
  25. package/src/panel/runroutes.js +46 -22
  26. package/src/panel/server.js +792 -28
  27. package/src/panel/status.js +33 -0
  28. package/src/panelmoney.js +29 -0
  29. package/src/planrun.js +53 -4
  30. package/src/promptregisters.js +8 -0
  31. package/src/replay.js +118 -7
  32. package/src/rubricstep.js +157 -15
  33. package/src/runlist.js +2 -1
  34. package/src/schedulewords.js +6 -3
  35. package/src/source.js +18 -0
  36. package/src/thinking.js +63 -2
  37. package/src/userrun.js +28 -14
  38. package/types/authorflow.d.ts +42 -6
  39. package/types/authorjob.d.ts +20 -4
  40. package/types/declaredclose.d.ts +2 -0
  41. package/types/draftspend.d.ts +27 -1
  42. package/types/facts.d.ts +15 -0
  43. package/types/factwords.d.ts +26 -3
  44. package/types/fitcheck.d.ts +113 -0
  45. package/types/inputlook.d.ts +15 -0
  46. package/types/kinds.d.ts +5 -0
  47. package/types/pagedropwords.d.ts +39 -0
  48. package/types/pagedry.d.ts +37 -0
  49. package/types/pagemap.d.ts +154 -0
  50. package/types/pagerules.d.ts +86 -0
  51. package/types/panel/authorroutes.d.ts +17 -14
  52. package/types/panel/authorsession.d.ts +4 -2
  53. package/types/panel/jobplan.d.ts +5 -2
  54. package/types/panel/runroutes.d.ts +2 -2
  55. package/types/panel/server.d.ts +156 -2
  56. package/types/panel/status.d.ts +51 -0
  57. package/types/panelmoney.d.ts +5 -0
  58. package/types/replay.d.ts +21 -1
  59. package/types/rubricstep.d.ts +62 -1
  60. package/types/runlist.d.ts +4 -1
  61. package/types/schedulewords.d.ts +4 -2
  62. package/types/source.d.ts +11 -0
  63. package/types/thinking.d.ts +39 -0
  64. package/types/userrun.d.ts +4 -0
@@ -0,0 +1,318 @@
1
+ // IDEA A CORE (docs/product/ITEM33-BUILD.md "Addendum 2026-10-18 — Idea A core: build spec", section 4.2): the plain code rules that
2
+ // keep or drop each page word the AI's map named. The AI never grades: it names words, code keeps only what the saved page proves.
3
+ // Pure - no I/O, no clock, no model. The rule bodies are PORTED from the frozen POC (.claude/scratch/idea-a-v2/rules.mjs and
4
+ // rules-v3-candidate.mjs; sha256 recorded in commit 0bcc5fe), with the OUT branches (R2, R2a, R2b, R2c, R3, R3U, V4, pageClaims) deleted.
5
+
6
+ import { lineRules } from './rubricstep.js';
7
+ import { MAX_CURRENCIES, MAX_UNITS, MAX_UNIT_CHARS, FACT_OPS, boundOk } from './facts.js';
8
+ import { countExact, countPhrase, squash } from './factwords.js';
9
+ import { normLabel } from './labelscan.js';
10
+
11
+ /**
12
+ * @typedef {{op: string, value?: string|number, lo?: string|number, hi?: string|number}} Reading what a `~` rule means as a limit (PRD v1.107 Part 5b): the same shape as a signed fact rule
13
+ * @typedef {{name: string, from?: string, shape: string, words?: string[], currency?: string[], unit?: string[], reading?: Reading}} MapFact the AI's map, one fact
14
+ * @typedef {{facts: MapFact[]}} PageMap
15
+ * @typedef {{line: number, name: string, shape: string, from: string, words: string[], unit?: string[], currency?: string[], forRule?: number, reading?: Reading}} KeptFact
16
+ * `words` = what the page proves: the labels of a money/number/time fact, or the alternative phrases of a phrase fact (a place is a phrase)
17
+ * @typedef {{line: number, rule?: number, fact: string, word?: string, reason: string, other?: string}} Dropped `other` = what the drop is compared to: the first label (R1), the primary form (R1L), the fact it would have repeated (S1)
18
+ */
19
+
20
+ /** @param {unknown} v @returns {v is Record<string, any>} */
21
+ const isRec = (v) => v !== null && typeof v === 'object' && !Array.isArray(v);
22
+
23
+ /**
24
+ * Which job line, and which `~` rule under it, a map fact carries out. The map writes `from` as "line N", "line N rule M", "rule M" or
25
+ * "job line"; anything else is the first job line.
26
+ * @param {unknown} from @param {{n: number, text?: string, rule?: string, rules?: string[]}[]} jobLines
27
+ * @returns {{line: number, rule?: number}}
28
+ */
29
+ export function tieOf(from, jobLines) {
30
+ const s = String(from ?? '');
31
+ const lineM = /line\s*(\d+)/i.exec(s);
32
+ const ruleM = /rule\s*(\d+)/i.exec(s);
33
+ const first = jobLines[0]?.n ?? 1;
34
+ let line = lineM && jobLines.some((l) => l.n === Number(lineM[1])) ? Number(lineM[1]) : null;
35
+ const rule = ruleM ? Number(ruleM[1]) : undefined;
36
+ if (line === null && rule !== undefined) line = jobLines.find((l) => lineRules(l).length >= rule)?.n ?? null;
37
+ const out = { line: line ?? first };
38
+ if (rule !== undefined && lineRules(jobLines.find((l) => l.n === out.line)).length >= rule) return { ...out, rule };
39
+ return out;
40
+ }
41
+
42
+ /**
43
+ * Is this a legal `reading` for a fact of this shape? Only a time or money fact carries one, with the op set and the bound types of a signed
44
+ * fact rule (src/facts.js): no other key, no missing bound. A malformed one makes the whole map answer malformed (no repair).
45
+ * @param {unknown} shape @param {unknown} r @returns {boolean}
46
+ */
47
+ export function validReading(shape, r) {
48
+ if (shape !== 'time' && shape !== 'money') return false;
49
+ if (!isRec(r) || !FACT_OPS.includes(r.op)) return false;
50
+ const keys = Object.keys(r).sort().join();
51
+ return r.op === 'between' ? keys === 'hi,lo,op' && boundOk(shape, r.lo) && boundOk(shape, r.hi) : keys === 'op,value' && boundOk(shape, r.value);
52
+ }
53
+
54
+ /** @param {string} text @returns {string[]} the page's lines, white space squashed (the unit every per-line rule counts in) */
55
+ export const linesOf = (text) => text.split('\n').map(squash);
56
+
57
+ /**
58
+ * How many times a label occurs inside ONE page line, at most (plain indexOf on squashed lines, non-overlapping): the N2 measure.
59
+ * @param {string[]} lines @param {string} phrase @returns {number}
60
+ */
61
+ function maxPerLine(lines, phrase) {
62
+ const p = squash(phrase).trim();
63
+ if (!p) return 0;
64
+ let max = 0;
65
+ for (const ln of lines) {
66
+ let n = 0;
67
+ for (let i = ln.indexOf(p); i !== -1; i = ln.indexOf(p, i + p.length)) n += 1;
68
+ if (n > max) max = n;
69
+ }
70
+ return max;
71
+ }
72
+
73
+ /** the value each shape reads right after a label: a money or number is digits (a sign may come first), a time is h:mm and AM/PM */
74
+ const READ = {
75
+ money: /^\s*[$€£]?\s*(\d[\d,]*(?:\.\d+)?)/,
76
+ time: /^\s*(\d{1,2}:\d{2}[\s\u202f\u00a0]?[AaPp][Mm])/,
77
+ number: /^\s*(\d[\d,]*(?:\.\d+)?)/,
78
+ };
79
+
80
+ /**
81
+ * R1 reader: the set of values of a fact's shape written right after each occurrence of a label on the page.
82
+ * @param {string[]} lines @param {string} label @param {'money'|'time'|'number'} shape @returns {Set<string>}
83
+ */
84
+ function valueSet(lines, label, shape) {
85
+ /** @type {Set<string>} */
86
+ const s = new Set();
87
+ const p = squash(label).trim();
88
+ if (!p) return s;
89
+ for (const ln of lines) {
90
+ for (let i = ln.indexOf(p); i !== -1; i = ln.indexOf(p, i + p.length)) {
91
+ const m = READ[shape].exec(ln.slice(i + p.length));
92
+ if (m) s.add(m[1]);
93
+ }
94
+ }
95
+ return s;
96
+ }
97
+ /** @param {Set<string>} a @param {Set<string>} b @returns {boolean} */
98
+ const sameSet = (a, b) => a.size === b.size && [...a].every((x) => b.has(x));
99
+
100
+ /** a number as the page writes it: digits, thousands commas, optional decimals (a currency sign next to it is not part of it) */
101
+ const NUM = '\\d[\\d,]*(?:\\.\\d+)?';
102
+ const BEFORE = new RegExp(`(${NUM})\\s*$`);
103
+ const AFTER = new RegExp(`^\\s*(?:[$€£]\\s*)?(${NUM})`);
104
+ const AFTER_CUR = new RegExp(`^\\s*(${NUM})`);
105
+ const stripCommas = (/** @type {string} */ s) => s.replace(/,/g, '');
106
+
107
+ /** @param {string[]} lines @param {string} w @returns {[string, number][]} every occurrence of w as [its line, where it starts] */
108
+ function occs(lines, w) {
109
+ /** @type {[string, number][]} */
110
+ const out = [];
111
+ for (const l of lines) for (let i = l.indexOf(w); i !== -1; i = l.indexOf(w, i + w.length)) out.push([l, i]);
112
+ return out;
113
+ }
114
+
115
+ /**
116
+ * U1: how many times a label has a number right BEFORE it (whitespace only between) and right AFTER it (whitespace and one currency sign
117
+ * between). A marker the page writes after the number ("490 total") has B > 0 and A = 0.
118
+ * @param {string[]} lines @param {string} label @returns {{B: number, A: number}}
119
+ */
120
+ function beforeAfter(lines, label) {
121
+ let B = 0;
122
+ let A = 0;
123
+ for (const [l, i] of occs(lines, label)) {
124
+ if (BEFORE.test(l.slice(0, i))) B += 1;
125
+ if (AFTER.test(l.slice(i + label.length))) A += 1;
126
+ }
127
+ return { B, A };
128
+ }
129
+ /** R1L: the numbers a UNIT form reads - the one right before each occurrence @param {string[]} lines @param {string} u @returns {Set<string>} */
130
+ const unitSet = (lines, u) => new Set(occs(lines, u).map(([l, i]) => BEFORE.exec(l.slice(0, i))?.[1]).filter((v) => typeof v === 'string').map((v) => stripCommas(/** @type {string} */ (v))));
131
+ /** R1L: the numbers a CURRENCY form reads - the one right after each occurrence @param {string[]} lines @param {string} c @returns {Set<string>} */
132
+ const curSet = (lines, c) => new Set(occs(lines, c).map(([l, i]) => AFTER_CUR.exec(l.slice(i + c.length))?.[1]).filter((v) => typeof v === 'string').map((v) => stripCommas(/** @type {string} */ (v))));
133
+
134
+ /** S1: a fact name that counts results ("the second flight") cannot be checked per result - one result is one record, ask for N results instead */
135
+ const ORDINALS = ['first', 'second', 'third', 'fourth', 'fifth', 'sixth', 'seventh', 'eighth', 'ninth', 'tenth', 'latest', 'earliest', 'newest', 'oldest', 'last', 'next', 'previous', 'final', 'nth'];
136
+
137
+ /** the symbols a currency may be (src/facts.js); a code like "EUR" is a word read after the number, so it is a unit */
138
+ const CURRENCY_SYMBOLS = ['$', '€', '£'];
139
+ /** the shapes that read a value after a label */
140
+ const VALUE_SHAPES = ['money', 'time', 'number'];
141
+
142
+ /**
143
+ * Apply the rules to the AI's map over one saved page. Returns the kept facts and, for everything a rule removed, one `dropped` entry.
144
+ * The order of the rules is the POC's (apply-v3.mjs): found-on-page, then the rules that read the page. A place is read as a phrase.
145
+ * @param {string} text the frozen page text @param {PageMap} mapping @param {{n: number, text?: string, rule?: string, rules?: string[]}[]} jobLines
146
+ * @returns {{facts: KeptFact[], dropped: Dropped[]}}
147
+ */
148
+ export function applyPageRules(text, mapping, jobLines) {
149
+ /** @type {Dropped[]} */
150
+ const dropped = [];
151
+ const all = Array.isArray(mapping?.facts) ? mapping.facts.filter(isRec) : [];
152
+ /** @type {Map<MapFact, {line: number, rule?: number}>} */
153
+ const ties = new Map(all.map((f) => [f, tieOf(f.from, jobLines)]));
154
+ const drop = (/** @type {MapFact} */ f, /** @type {string|undefined} */ word, /** @type {string} */ reason, /** @type {string|undefined} */ other = undefined) => {
155
+ const t = /** @type {{line: number, rule?: number}} */ (ties.get(f));
156
+ dropped.push({ line: t.line, ...(t.rule === undefined ? {} : { rule: t.rule }), fact: String(f.name ?? ''), ...(word === undefined ? {} : { word }), reason, ...(other === undefined ? {} : { other }) });
157
+ };
158
+
159
+ // ---- S1, name part: a fact whose name counts results is refused before anything else reads it ----
160
+ const list = all.filter((f) => {
161
+ const hit = String(f.name ?? '').toLowerCase().split(/[^a-z]+/).find((w) => ORDINALS.includes(w));
162
+ if (hit === undefined) return true;
163
+ drop(f, undefined, 's1-name');
164
+ return false;
165
+ });
166
+
167
+ // ---- found-on-page, then U1 and R1L for the facts that read an amount ----
168
+ const lines = linesOf(text);
169
+ /** @type {{f: MapFact, kept: string[], cur: string[], uni: string[], first?: string|null, anchorless?: boolean}[]} */
170
+ const pending = [];
171
+ for (const f of list) {
172
+ const words = (Array.isArray(f.words) ? f.words : []).map((w) => String(w)).filter((w) => w.trim() !== '');
173
+ if (f.shape === 'place' || f.shape === 'phrase') {
174
+ const kept = [];
175
+ for (const w of words) {
176
+ const n = f.shape === 'phrase' ? countPhrase(text, w) : countExact(text, w);
177
+ if (n >= 1) kept.push(w); else drop(f, w, 'not-on-page');
178
+ }
179
+ pending.push({ f, kept, cur: [], uni: [] });
180
+ } else if (VALUE_SHAPES.includes(String(f.shape))) {
181
+ const amount = f.shape === 'money' || f.shape === 'number';
182
+ /** labels the page holds (and, for an amount, that have a number right after them) */
183
+ const labels = [];
184
+ /** the AI's labels as U1 leaves them, a word the page does not hold included: the first of these is R1's reference */
185
+ const asListed = [];
186
+ /** U1: labels the page writes after the number */
187
+ const moved = [];
188
+ for (const w0 of words) {
189
+ const w = normLabel(w0);
190
+ if (!w) continue;
191
+ if (countExact(text, w) < 1) { asListed.push(w0); drop(f, w0, 'not-on-page'); continue; }
192
+ if (amount) {
193
+ const { B, A } = beforeAfter(lines, w);
194
+ if (A === 0 && B > 0) { moved.push(w); continue; }
195
+ if (A === 0 && B === 0) { drop(f, w, 'no-value'); continue; }
196
+ }
197
+ asListed.push(w0);
198
+ labels.push(w);
199
+ }
200
+ /** @type {string[]} */ let uni = [];
201
+ /** @type {string[]} */ let cur = [];
202
+ if (f.shape === 'money') {
203
+ // the forms: units first (the U1-moved words, then the AI's listed units), then currency signs. SORT, never drop for the box (money
204
+ // build v2): the box is position only - a sign (before the number) is a currency, any other form (a word after it) is a unit - so every
205
+ // form the AI listed in either box is pooled and moved to the box its own shape belongs to
206
+ const listedCurrencies = (Array.isArray(f.currency) ? f.currency : []).map(String);
207
+ // a currency SIGN listed in BOTH unit and currency is said once: the unit copy goes (fwd F71 drafts)
208
+ const listedUnits = (Array.isArray(f.unit) ? f.unit : []).map(String).filter((u) => {
209
+ if (!(CURRENCY_SYMBOLS.includes(u) && listedCurrencies.includes(u))) return true;
210
+ drop(f, u, 'sign-twice');
211
+ return false;
212
+ });
213
+ // a WORD listed in both boxes is one form, said once (a record of the double listing is kept, as R1L always said it)
214
+ for (const w of new Set(listedCurrencies.filter((c) => !CURRENCY_SYMBOLS.includes(c) && listedUnits.includes(c)))) drop(f, w, 'r1l', w);
215
+ const pool = [...moved, ...listedUnits, ...listedCurrencies];
216
+ const units = [...new Set(pool.filter((w) => !CURRENCY_SYMBOLS.includes(w)))].filter((u) => {
217
+ if (countPhrase(text, u) >= 1) return true;
218
+ drop(f, u, 'not-on-page');
219
+ return false;
220
+ });
221
+ const curs = [...new Set(pool.filter((w) => CURRENCY_SYMBOLS.includes(w)))].filter((c) => {
222
+ if (countExact(text, c) >= 1) return true;
223
+ drop(f, c, 'not-on-page');
224
+ return false;
225
+ });
226
+ /** @type {{kind: 'unit'|'currency', w: string}[]} */
227
+ const forms = [...units.map((u) => /** @type {const} */ ({ kind: 'unit', w: u })), ...curs.map((c) => /** @type {const} */ ({ kind: 'currency', w: c }))];
228
+ if (forms.length) {
229
+ // R1L: every form reads the same set of numbers as the primary (the first form); one that reads more or fewer is dropped
230
+ const read = (/** @type {{kind: string, w: string}} */ x) => (x.kind === 'unit' ? unitSet(lines, x.w) : curSet(lines, x.w));
231
+ const ps = read(forms[0]);
232
+ const keep = [forms[0]];
233
+ // the same word listed as both a unit and a currency is ONE form: it is never compared with itself, only said (the unit, listed first, stays)
234
+ for (const x of forms.slice(1)) { if (x.w !== forms[0].w && sameSet(read(x), ps)) keep.push(x); else drop(f, x.w, 'r1l', forms[0].w); }
235
+ uni = keep.filter((x) => x.kind === 'unit').map((x) => x.w);
236
+ cur = keep.filter((x) => x.kind === 'currency').map((x) => x.w);
237
+ }
238
+ // the guard's list limits (src/facts.js), applied here so a longer list is cut by code and SAID, never refused later: the first forms stay
239
+ uni = uni.filter((u, i) => {
240
+ if (u.length > MAX_UNIT_CHARS) { drop(f, u, 'unit-too-long'); return false; }
241
+ if (i >= MAX_UNITS) { drop(f, u, 'limit-unit'); return false; }
242
+ return true;
243
+ });
244
+ cur = cur.filter((c, i) => {
245
+ if (i < MAX_CURRENCIES) return true;
246
+ drop(f, c, 'limit-currency');
247
+ return false;
248
+ });
249
+ }
250
+ pending.push({ f, kept: labels, cur, uni, first: asListed.length ? normLabel(asListed[0]) : null, anchorless: amount && asListed.length === 0 });
251
+ } else drop(f, undefined, 'bad-shape');
252
+ }
253
+
254
+ // ---- R1, same-value: the labels after the AI's FIRST listed one are kept only if they read the same set of values as it does ----
255
+ for (const p of pending) {
256
+ if (!VALUE_SHAPES.includes(String(p.f.shape))) continue;
257
+ const shape = /** @type {'money'|'time'|'number'} */ (p.f.shape);
258
+ const ref = p.first ? valueSet(lines, p.first, shape) : new Set();
259
+ p.kept = p.kept.filter((w) => {
260
+ if (w === p.first) return true;
261
+ if (sameSet(valueSet(lines, w, shape), ref)) return true;
262
+ drop(p.f, w, 'r1', p.first ?? undefined);
263
+ return false;
264
+ });
265
+ }
266
+
267
+ // ---- N2, per line: a label written 2+ times inside one page line cannot say which value is meant, so it is unusable. (R2 grow-left is OUT.) ----
268
+ for (const p of pending) {
269
+ if (!VALUE_SHAPES.includes(String(p.f.shape))) continue;
270
+ p.kept = p.kept.filter((w) => {
271
+ if (maxPerLine(lines, w) <= 1) return true;
272
+ drop(p.f, w, 'n2');
273
+ return false;
274
+ });
275
+ }
276
+
277
+ // ---- the kept facts, in the POC's order: places, phrases, then values ----
278
+ /** @type {KeptFact[]} */
279
+ const signed = [];
280
+ const make = (/** @type {{f: MapFact, kept: string[], cur: string[], uni: string[]}} */ p) => {
281
+ const t = /** @type {{line: number, rule?: number}} */ (ties.get(p.f));
282
+ /** @type {KeptFact} */
283
+ const out = { line: t.line, name: String(p.f.name), shape: p.f.shape === 'place' ? 'phrase' : String(p.f.shape), from: String(p.f.from ?? ''), words: [...p.kept] };
284
+ if (p.f.shape === 'money') {
285
+ if (p.uni.length) out.unit = [...p.uni];
286
+ if (p.cur.length) out.currency = [...p.cur];
287
+ }
288
+ if (t.rule !== undefined) out.forRule = t.rule;
289
+ if (validReading(out.shape, p.f.reading)) out.reading = /** @type {Reading} */ ({ ...p.f.reading });
290
+ return out;
291
+ };
292
+ for (const kind of ['place', 'phrase', 'value']) {
293
+ for (const p of pending) {
294
+ const isValue = VALUE_SHAPES.includes(String(p.f.shape));
295
+ if (kind === 'value' ? !isValue : p.f.shape !== kind) continue;
296
+ // a fact left with no label is dropped, unless U1 moved every label to a unit/currency form of a money fact (it then reads that form
297
+ // alone); a money fact also needs a currency or a unit the page holds
298
+ const forms = p.cur.length + p.uni.length;
299
+ const ok = p.f.shape === 'money' ? forms > 0 && (p.kept.length > 0 || p.anchorless === true) : p.kept.length > 0;
300
+ if (!ok) { drop(p.f, undefined, 'no-label'); continue; }
301
+ signed.push(make(p));
302
+ }
303
+ }
304
+ // ---- S1, shape and label part: two facts of one result may not share a shape AND a label (they would read the same value and can never differ).
305
+ // The first one stays; a fact with no label is keyed by its first unit or currency, so two different amounts are never confused. ----
306
+ /** @type {Map<string, string>} */
307
+ const seen = new Map();
308
+ const kept = signed.filter((k) => {
309
+ const key = `${k.shape}\u0000${k.words[0] ?? k.unit?.[0] ?? k.currency?.[0] ?? ''}`;
310
+ const first = seen.get(key);
311
+ if (first === undefined) { seen.set(key, k.name); return true; }
312
+ const f = /** @type {MapFact} */ (all.find((x) => String(x.name) === k.name));
313
+ drop(f, undefined, 's1', first);
314
+ return false;
315
+ });
316
+ const order = list.map((f) => String(f.name));
317
+ return { facts: kept.sort((a, b) => order.findIndex((n) => a.name.startsWith(n)) - order.findIndex((n) => b.name.startsWith(n))), dropped };
318
+ }
@@ -19,24 +19,27 @@
19
19
 
20
20
  import { randomBytes } from 'node:crypto';
21
21
  import { spawn as realSpawn } from 'node:child_process';
22
- import { openSync } from 'node:fs';
22
+ import { openSync, existsSync } from 'node:fs';
23
23
  import { join } from 'node:path';
24
24
  import { readFileSync } from 'node:fs';
25
25
  import { planMatchesClose } from '../rubricstep.js';
26
26
  import { createSession, validateJobCard, validateReuseCard, jobBoxesChanged, buildReuseSpec, cardFields, nextFreeJobName, startDestinationProblem, STEP_LABELS } from './authorsession.js';
27
27
  import { parseInputs } from '../jobblock.js';
28
- import { proveInputFiles, looksLikeRubricInput } from '../source.js';
28
+ import { proveInputFiles, looksLikeRubricInput, webFetchState } from '../source.js';
29
29
  import { validateJob } from '../job.js';
30
30
 
31
31
  /** plain words for a run whose saved job no longer passes today's `validateJob` (RULED 2026-10-15): never the raw red codes */
32
32
  export const OLD_JOB_REUSE_WORDS = "This run was made by an older bareloop. Its saved job doesn't pass today's checks, so it can't be reused. Start a new job instead.";
33
33
  export const OLD_JOB_RESUME_WORDS = "This run was made by an older bareloop. Its saved job doesn't pass today's checks, so it can't be resumed. Use Reuse workflow on a newer run, or start a new job.";
34
34
  export const OLD_JOB_ACCEPT_WORDS = "This run was made by an older bareloop. Its saved job doesn't pass today's checks, so it can't be accepted. Use Reuse workflow on a newer run, or start a new job.";
35
+ export const OLD_JOB_RERUN_WORDS = "This run was made by an older bareloop. Its saved job doesn't pass today's checks, so it can't be rerun. Use Reuse workflow on a newer run, or start a new job.";
36
+ export const OLD_JOB_PAUSE_WORDS = "This run was made by an older bareloop. Its saved job doesn't pass today's checks, so it can't be paused from here. Use Reuse workflow on a newer run, or start a new job.";
35
37
  import { checkMonthlyRoom, monthlyRefusalText } from '../monthly.js';
36
38
  import { ConfigError } from '../config.js';
37
39
  import { keysForDoor, keysHome } from '../keysfile.js';
38
40
  import { endpointOf, judgePick, keyValueOf, rowsForHome, tickedLines } from '../providerrows.js';
39
41
  import { thinkingForLine } from '../thinking.js';
42
+ import { panelMoney2 } from '../panelmoney.js';
40
43
  import { apiKeyProblem, checkProviderReachable } from '../providers.js';
41
44
 
42
45
  /** @returns {string} a fresh per-process token — never persisted, never logged */
@@ -90,6 +93,18 @@ export function checkHumanGuard(req, { token, port }) {
90
93
  * one; a terminal session is kept (so its chat/hash stay readable) but no
91
94
  * longer counts against the one-at-a-time rule.
92
95
  */
96
+ /**
97
+ * PRD v1.106 Part 5 Q2: the "fetching the page..." step is done once the run's own refetch has written `refrozenAt` (source.json, written last).
98
+ * Settled when the state is read, so nothing polls and the step never stays running after the fetch.
99
+ * @param {any} state
100
+ */
101
+ export function settleFetchingStep(state) {
102
+ const st = Array.isArray(state?.steps) ? state.steps.find((/** @type {any} */ x) => x.id === 'fetching') : null;
103
+ if (!st || st.status !== 'running') return;
104
+ const file = join(String(state.outDir), 'source-seed', 'source.json');
105
+ if (existsSync(file) && !webFetchState(file, st.since).fetching) st.status = 'done';
106
+ }
107
+
93
108
  const TERMINAL_PHASES = new Set(['refused', 'abandoned', 'error', 'signed', 'signing-failed']);
94
109
 
95
110
  /**
@@ -131,11 +146,17 @@ export function createAuthorRoutes(opts) {
131
146
  * @param {any} card the form (fields only)
132
147
  * @param {{spec: any, workflowKey: string}|null} reuse
133
148
  * @param {import('../jobblock.js').JobInput[]} inputs the parsed Inputs
134
- * @param {{headless?: boolean, filesJob?: boolean}} [o]
149
+ * @param {{headless?: boolean}} [o]
135
150
  * @returns {Promise<{ok: true, session: ReturnType<typeof createSession>}|{ok: false, status: number, error: string}>}
136
151
  */
137
152
  function startSession(card, reuse, inputs, o = {}) {
138
- return (o.filesJob ? Promise.resolve(/** @type {{ok: true}} */ ({ ok: true })) : proveInputFiles(inputs)).then((proof) => {
153
+ // ONE owner of "is this a rubric job on files": a reuse (and so a scheduled fire) reads it from the signed spec's verdict type,
154
+ // never from the card; a fresh job reads the card. A rubric job whose line 1 is a file or URL has no repo to prove inputs against;
155
+ // the session proves every line itself (`proveRubricInputs`), at $0, before any model call.
156
+ const rubric = reuse ? reuse.spec?.verdictType === 'soft-green' : card?.checkType === 'rubric';
157
+ if (reuse) card = { ...card, checkType: rubric ? 'rubric' : 'deterministic' };
158
+ const filesJob = rubric && inputs.length > 0 && looksLikeRubricInput(inputs[0].value);
159
+ return (filesJob ? Promise.resolve(/** @type {{ok: true}} */ ({ ok: true })) : proveInputFiles(inputs)).then((proof) => {
139
160
  if (!proof.ok) return { ok: /** @type {false} */ (false), status: 400, error: proof.error };
140
161
  if (!o.headless && hasLiveSession()) return { ok: /** @type {false} */ (false), status: 409, error: 'an authoring session is already live — one at a time' };
141
162
  // the SAME Destination check the session runs, at $0, BEFORE the session (and its folder) exists
@@ -299,6 +320,7 @@ export function createAuthorRoutes(opts) {
299
320
  if (pathname === '/api/author/live') {
300
321
  if (req.method !== 'GET') { send(405, { ok: false, error: 'GET only' }); return true; }
301
322
  const live = [...sessions.values()].find((s) => !TERMINAL_PHASES.has(s.state.phase));
323
+ if (live) settleFetchingStep(live.state);
302
324
  send(200, { ok: true, sessionId: live ? live.state.id : null, state: live ? live.state : null });
303
325
  return true;
304
326
  }
@@ -357,10 +379,7 @@ export function createAuthorRoutes(opts) {
357
379
  // A miss is a plain 400 naming the line, shown under the card exactly like every other refused start.
358
380
  const ip = parseInputs(card.inputs);
359
381
  if (!ip.ok) { send(400, { ok: false, error: ip.error }); return true; }
360
- // Part 1a: a rubric job whose first input is a local FILE has no repo to prove inputs against; the session proves every
361
- // line itself (`proveRubricInputs`), at $0, before any model call
362
- const filesJob = reuse === null && card?.checkType === 'rubric' && looksLikeRubricInput(ip.inputs[0].value);
363
- startSession(card, reuse, ip.inputs, { filesJob }).then((r) => {
382
+ startSession(card, reuse, ip.inputs).then((r) => {
364
383
  if (!r.ok) { send(r.status, { ok: false, error: r.error }); return; }
365
384
  send(200, { ok: true, sessionId: r.session.id, reuse: reuse !== null, state: r.session.state });
366
385
  });
@@ -375,6 +394,7 @@ export function createAuthorRoutes(opts) {
375
394
 
376
395
  if (sub === null) {
377
396
  if (req.method !== 'GET') { send(405, { ok: false, error: 'GET only' }); return true; }
397
+ settleFetchingStep(session.state);
378
398
  send(200, { ok: true, state: session.state });
379
399
  return true;
380
400
  }
@@ -417,28 +437,7 @@ export function createAuthorRoutes(opts) {
417
437
  };
418
438
  }
419
439
 
420
- /**
421
- * The panel's OWN 2-decimal money render for a chat line — hamr's ruling
422
- * 2026-09-28 (2nd addendum, panel money 2-decimals): a positive amount below
423
- * a cent reads `<$0.01`, never `$0.00` (a real cost must never read as
424
- * free). This is the server-side (Node, chat-text) sibling of
425
- * `src/panel/index.html`'s own inline `panelMoney` — the two can't share
426
- * code (one runs in the browser as inlined script, the other in Node), the
427
- * same duplication `src/replay.js`'s `money()`/`index.html`'s `money()`
428
- * already carry, so both apply the identical rule rather than drift.
429
- * @param {number} n a non-negative dollar amount
430
- * @returns {string}
431
- */
432
- export function panelMoney2(n) {
433
- if (n > 0 && n < 0.01) return '<$0.01';
434
- // two-step rounding (matching index.html's own panelMoney): clean to
435
- // 6-decimal precision first (the same precision src/text.js's tallyCalls
436
- // sums to) before rounding to cents, so a summed float landing just under
437
- // an exact cent boundary is never mis-rounded.
438
- const clean = Math.round(n * 1e6) / 1e6;
439
- const cents = Math.round(clean * 100 + 1e-6);
440
- return `$${(cents / 100).toFixed(2)}`;
441
- }
440
+ export { panelMoney2 };
442
441
 
443
442
  /**
444
443
  * THE ONLY function in this file (or anywhere in the panel) that spawns a
@@ -520,5 +519,10 @@ export function signRun(session, claimedHash, o) {
520
519
  ? `drafting spent ${draftIncomplete ? 'at least ' : ''}${panelMoney2(draftSpentUsd)} (folds out of the run's own cap)`
521
520
  : '';
522
521
  if (Array.isArray(session.state.steps)) session.state.steps.push({ id: 'signed', label: STEP_LABELS.signed, status: 'done', detail: draftLine });
522
+ // PRD v1.106 Part 5 Q2: a run with a web input fetches the page again before its log exists (3-5 s of silence): say so, next to the sign step
523
+ const sinceIso = new Date().toISOString();
524
+ if (Array.isArray(session.state.steps) && webFetchState(join(session.state.outDir, 'source-seed', 'source.json'), sinceIso).hasWeb) {
525
+ session.state.steps.push({ id: 'fetching', label: STEP_LABELS.fetching, status: 'running', since: sinceIso });
526
+ }
523
527
  return { ok: true, job: session.state.resolvedSpecPath };
524
528
  }
@@ -51,8 +51,10 @@ import { resolveJobJudge, defaultJudgeLoop } from '../judged.js';
51
51
  import { assertThinkingMode, thinkingPlanFor, thinkingRowText } from '../thinking.js';
52
52
  import { closeJudges } from '../kinds.js';
53
53
  import { RUBRIC_LANG } from '../authoring.js';
54
- import { rubricGoal, reconcileRubricPlan } from '../rubricstep.js';
54
+ import { rubricGoal, reconcileRubricPlan, signedFactNames } from '../rubricstep.js';
55
55
  import { redactSecrets } from '../validate.js';
56
+ import { isDropIt, isAgree } from '../pagemap.js';
57
+ import { FIT_ABSENT_NOTE } from '../fitcheck.js';
56
58
  import { tallyCalls } from '../text.js';
57
59
  import { writeDraftSpend, appendDraftLog } from '../draftspend.js';
58
60
  import { PLAIN_PROPOSAL_STOPS, proposalStopText, signingStopText, draftStopText, redsRecord, declarationRecord, calibrationSummary, tryRefusedText } from '../authorreadout.js';
@@ -85,6 +87,7 @@ export const STEP_LABELS = Object.freeze({
85
87
  gates: 'checking signing gates',
86
88
  hash: 'generating hash',
87
89
  signed: 'signed hash',
90
+ fetching: 'fetching the page…',
88
91
  });
89
92
 
90
93
  /**
@@ -116,6 +119,9 @@ export const PHASE_STEP = Object.freeze({
116
119
  'scout-done': 'scout',
117
120
  look: 'look',
118
121
  'look-done': 'look',
122
+ // F243: the fit check's one call is counted in the look's line, like the look
123
+ 'fit-check': 'look',
124
+ 'fit-check-done': 'look',
119
125
  confirm: 'confirm',
120
126
  'confirm-turn-done': 'confirm',
121
127
  listing: 'listing',
@@ -164,7 +170,7 @@ export const PLAN_FOOTER = 'Sign & run to confirm this plan, or Revise to edit t
164
170
  * The confirm turn's plan as a chat message (P7): a STRUCTURED `{role:'bot', kind:'plan', plan}` the page draws with the
165
171
  * same renderer the Job tab uses — each numbered job line, its `~` rule, its drafted checks folded; the AI's own "not
166
172
  * checked"; the always-on guards. `questions` (what the plan still asks) ride inside the plan. One owner of the shape.
167
- * @param {{goal?: string, checks?: string[], checkItems?: {text: string, fromLine: number[]|null}[], notChecked?: string[], questions?: string[], protections?: string[]}} p the confirm turn's plan
173
+ * @param {{goal?: string, checks?: string[], checkItems?: {text: string, fromLine: number[]|null}[], notChecked?: string[], questions?: string[], protections?: string[], signedFacts?: string[]}} p the confirm turn's plan
168
174
  * @param {{n: number, text: string, rule: string}[]|null} jobLines
169
175
  * @returns {{role: 'bot', kind: 'plan', text: string, plan: ReturnType<typeof planFromConfirm> & {goal: string, questions: string[]}}}
170
176
  */
@@ -402,7 +408,7 @@ export function validateJobCard(card, opts = {}) {
402
408
  * @param {any} card the validated job card (see {@link validateJobCard})
403
409
  * @param {{env?: Record<string,string|undefined>, sessionsRoot?: string, home?: string, timeoutMs?: number,
404
410
  * scout?: any, generate?: Function, confirmGenerate?: Function, authorFn?: Function,
405
- * prepareSigningFn?: Function, npmCiFn?: Function, lookFn?: Function|null, fetchUrl?: (url: string) => Promise<{ok: true, text: string, url: string}|{ok: false, error: string}>, draftThinking?: "on"|"off", reuse?: {spec: any, workflowKey: string}}} [deps] `scout`..`prepareSigningFn` are
411
+ * prepareSigningFn?: Function, npmCiFn?: Function, lookFn?: Function|null, fitFn?: Function|null, fetchUrl?: (url: string) => Promise<{ok: true, text: string, url: string}|{ok: false, error: string}>, draftThinking?: "on"|"off", reuse?: {spec: any, workflowKey: string}}} [deps] `scout`..`prepareSigningFn` are
406
412
  * TEST SEAMS ONLY — see the note just above where each is read, below. `reuse` (Reuse workflow) is NOT a seam: the
407
413
  * caller (`/api/author/start`) sets it only when the server's own same-job rule held.
408
414
  * @returns {any} the session object
@@ -539,13 +545,27 @@ export function createSession(card, deps = {}) {
539
545
  * @param {{kind: string, [k: string]: any}} step
540
546
  * @returns {Promise<string|null>}
541
547
  */
542
- const ask = (step) => new Promise((resolveFn) => {
548
+ const ask = (step) => new Promise((resolveFn0) => {
549
+ // PRD v1.106 Part 5 Q1: every question the person answers (the confirm card's, the early look ask, the last-try ask) is one `human` row in the
550
+ // drafting log, with the answer secret-masked BEFORE it is written. Written only when the answer exists (a blank one re-asks and leaves nothing).
551
+ const resolveFn = step.kind !== 'answer' ? resolveFn0 : (/** @type {string|null} */ value) => {
552
+ const said = value === null ? '' : redactSecrets(String(value).trim());
553
+ if (said !== '' && typeof step.question === 'string') {
554
+ const running = state.steps.findLastIndex((/** @type {any} */ x) => x.status === 'running');
555
+ appendDraftLog(outDir, {
556
+ kind: 'human', no: running === -1 ? Math.max(0, state.steps.length - 1) : running, source: typeof step.source === 'string' ? step.source : 'confirm',
557
+ question: redactSecrets(step.question), answer: said, ...(Number.isInteger(step.line) ? { line: step.line } : {}), at: new Date().toISOString(),
558
+ });
559
+ }
560
+ resolveFn0(value);
561
+ };
543
562
  state.pendingAsk = step;
544
563
  resolvePending = resolveFn;
545
564
  if (step.kind === 'language') say('bot', `${step.field?.prompt ?? 'Which language is this job about?'} (${(step.candidates ?? []).join(', ')})`);
546
565
  else if (step.kind === 'menu') {
547
566
  sayPlan(step.plan ?? {});
548
- } else if (step.kind === 'answer') say('bot', `A question the plan raised (${step.index} of ${step.total})${Number.isInteger(step.line) ? `, about job line ${step.line}` : ''}: ${step.question}`);
567
+ } else if (step.kind === 'answer' && step.source === 'fit-check') say('bot', step.question);
568
+ else if (step.kind === 'answer') say('bot', `A question the plan raised (${step.index} of ${step.total})${Number.isInteger(step.line) ? `, about job line ${step.line}` : ''}: ${step.question}`);
549
569
  else if (step.kind === 'goal') say('bot', 'Type the goal sentence yourself.');
550
570
  });
551
571
 
@@ -560,6 +580,19 @@ export function createSession(card, deps = {}) {
560
580
  };
561
581
 
562
582
  let lastTryRefused = false;
583
+ // F243: the fit check's result is one drafting-log row (the asks, what was found, what the quote proved); an `absent` check says so in chat
584
+ // (a seam of its own - onPhase never posts a chat message)
585
+ const onFit = (/** @type {any} */ data) => {
586
+ const running = state.steps.findLastIndex((/** @type {any} */ x) => x.status === 'running');
587
+ appendDraftLog(outDir, {
588
+ kind: 'fit-check', no: running === -1 ? Math.max(0, state.steps.length - 1) : running, state: String(data.state ?? 'absent'),
589
+ asks: (Array.isArray(data.asks) ? data.asks : []).map((/** @type {any} */ a) => ({
590
+ line: a.line, ask: redactSecrets(String(a.ask)), found: a.found === true, quote: typeof a.quote === 'string' ? redactSecrets(a.quote) : null, madeUp: a.madeUp === true, ...(a.ignored === 'number' ? { ignored: 'number' } : {}),
591
+ })),
592
+ notFound: (Array.isArray(data.notFound) ? data.notFound : []).map((/** @type {any} */ x) => ({ line: x.line, ask: redactSecrets(String(x.ask)) })), at: new Date().toISOString(),
593
+ });
594
+ if (data.state === 'absent') say('bot', FIT_ABSENT_NOTE);
595
+ };
563
596
  const onPhase = (name, data = {}) => {
564
597
  // COLLAPSED INTO THE PROGRESS INDICATOR ONLY (build item 3) — this used
565
598
  // to also `say('system', `… ${name}`)`, one jargon chat bubble per
@@ -639,6 +672,8 @@ export function createSession(card, deps = {}) {
639
672
  kind: 'call', no: callNo, step: state.steps[callNo]?.id ?? null, label: call.label, model: draftIdentity?.model ?? null,
640
673
  thinking: draftThinkingPlan === null ? null : draftThinkingPlan.mode, thinkingSupport: draftThinkingPlan === null ? null : draftThinkingPlan.support,
641
674
  ...(call.thinkingStatus === undefined ? {} : { thinkingStatus: call.thinkingStatus }),
675
+ // idea A core: the page-word map's scrubbed answer (src/pagemap.js) - unsigned, kept so a run can be replayed at $0
676
+ ...(typeof call.raw === 'string' ? { raw: call.raw } : {}),
642
677
  costUsd: call.costUsd ?? null, unpricedRounds: call.unpricedRounds ?? 0, at: nowIso,
643
678
  });
644
679
  try {
@@ -1021,27 +1056,44 @@ export function createSession(card, deps = {}) {
1021
1056
  // ask path as every other (the chat box + Send); the answer is REQUIRED (a blank one asks again, there is no skip) and is
1022
1057
  // added WORD FOR WORD as a `~` rule on the job line it names — never rewritten. The lines are mutated in place, so the
1023
1058
  // signed `jobLines`, the answers text and the plan card all read the same words.
1024
- const answerQuestions = async (/** @type {{questions: {line: number, question: string}[], dropped: {reason: string, raw: any}[], confirmed: any}} */ o) => {
1059
+ const answerQuestions = async (/** @type {{questions: {line: number, question: string, rule?: number, source?: string, ask?: string, reading?: string}[], dropped: {reason: string, raw: any}[], confirmed: any}} */ o) => {
1025
1060
  if (o.dropped.length) appendDraftLog(outDir, { kind: 'questions-dropped', no: Math.max(0, state.steps.length - 1), dropped: o.dropped, at: new Date().toISOString() });
1026
1061
  const total = o.questions.length;
1062
+ /** @type {{line: number, rule: number}[]} */
1063
+ const left = [];
1027
1064
  for (let k = 0; k < total; k += 1) {
1028
1065
  const q = o.questions[k];
1029
1066
  let text = '';
1030
1067
  for (;;) {
1031
- const a = await ask({ kind: 'answer', index: k + 1, total, question: q.question, line: q.line });
1068
+ const a = await ask({ kind: 'answer', source: typeof q.source === 'string' ? q.source : 'try-3', index: k + 1, total, question: q.question, line: q.line });
1032
1069
  if (a === null) return null;
1033
1070
  // one `~` line is one line: the person's own words, newlines folded to spaces, nothing else touched
1034
1071
  text = String(a).replace(/\s*\n\s*/g, ' ').trim();
1035
1072
  if (text) break;
1036
1073
  say('bot', 'An answer is needed here — there is no skip. Type it in the box below and press Send.');
1037
1074
  }
1075
+ // PRD v1.107 Part 5b: a code-forced look question about a `~` rule. A can't-check ask answered "drop it" adds NO rule; the caller puts that
1076
+ // rule on the look's Not checked list. A reading ask answered "agree" adds the code's reading, in plain words, as the `~` rule (so the
1077
+ // bound is now the person's own words); anything else typed is the person's own rule, word for word.
1078
+ if (q.source === 'look' && q.ask === 'reading' && typeof q.reading === 'string' && isAgree(text)) text = q.reading;
1079
+ if (q.source === 'look' && q.ask === 'cant-check' && Number.isInteger(q.rule) && isDropIt(text)) { left.push({ line: q.line, rule: /** @type {number} */ (q.rule) }); continue; }
1038
1080
  const line = jobLines.find((l) => l.n === q.line);
1039
1081
  if (line) { line.rule = line.rule ? `${line.rule}; ${text}` : text; line.rules.push(text); }
1040
1082
  }
1041
1083
  draft.jobLines = signedJobLines(jobLines);
1042
1084
  answers[1] = renderJobLines(jobLines);
1043
- state.messages.push({ ...planMessage(o.confirmed ?? {}, jobLines), text: 'Your answers are in as ~ rules on those job lines. Drafting again.' });
1044
- return { jobLines, answers };
1085
+ state.messages.push({ ...planMessage(o.confirmed ?? {}, jobLines), text: left.length === total ? 'Noted: those stay Not checked. Drafting.' : 'Your answers are in as ~ rules on those job lines. Drafting again.' });
1086
+ return { jobLines, answers, left };
1087
+ };
1088
+
1089
+ // F243: the fit check's one question, through the same `ask` (the chat box + Send; a blank answer asks again, there is no skip)
1090
+ const fitAsk = async (/** @type {string} */ question) => {
1091
+ for (;;) {
1092
+ const a = await ask({ kind: 'answer', source: 'fit-check', index: 1, total: 1, question });
1093
+ if (a === null) return null;
1094
+ if (String(a).trim()) return String(a);
1095
+ say('bot', 'An answer is needed here — there is no skip. Type it in the box below and press Send.');
1096
+ }
1045
1097
  };
1046
1098
 
1047
1099
  state.phase = 'drafting';
@@ -1063,6 +1115,9 @@ export function createSession(card, deps = {}) {
1063
1115
  ...(deps.scout ? { scout: deps.scout } : {}),
1064
1116
  // PART 1c: a test that replaces the model boundary (`generate`) must not reach the real paid look; `deps.lookFn` brings its own
1065
1117
  ...(deps.lookFn !== undefined ? { lookFn: deps.lookFn } : (deps.generate ? { lookFn: null } : {})),
1118
+ // F243: the fit check asks through the ordinary ask path; like the look, a test that replaces the model boundary never reaches the real paid call
1119
+ fitAsk, onFit,
1120
+ ...(deps.fitFn !== undefined ? { fitFn: deps.fitFn } : (deps.generate ? { fitFn: null } : {})),
1066
1121
  ...(authorFnOverride ? { authorFn: authorFnOverride } : {}),
1067
1122
  });
1068
1123
 
@@ -1111,7 +1166,7 @@ export function createSession(card, deps = {}) {
1111
1166
  const rec = reconcileRubricPlan({
1112
1167
  confirmed: authored.confirmed?.checkItems ?? [], notChecked: authored.confirmed?.notChecked ?? [], closeDecl: spec.closeDecl, jobLines,
1113
1168
  });
1114
- state.finalPlan = { goal: String(draft.goal ?? ''), checkItems: rec.checkItems, notChecked: rec.notChecked, protections: authored.confirmed?.protections ?? [], moved: rec.moved };
1169
+ state.finalPlan = { goal: String(draft.goal ?? ''), checkItems: rec.checkItems, notChecked: rec.notChecked, protections: authored.confirmed?.protections ?? [], moved: rec.moved, signedFacts: signedFactNames(spec.closeDecl) };
1115
1170
  }
1116
1171
  await finish(spec, authored.seedRef);
1117
1172
  if (state.finalPlan && state.phase === 'prepared') {
@@ -1120,7 +1175,7 @@ export function createSession(card, deps = {}) {
1120
1175
  // dropped, so drafter prose can never sit above (and be read instead of) the code's sentences (run mv5e1ktb).
1121
1176
  state.messages = state.messages.filter((/** @type {any} */ m) => m.kind !== 'plan');
1122
1177
  state.messages.push({
1123
- ...planMessage({ checkItems: state.finalPlan.checkItems, checks: state.finalPlan.checkItems.map((/** @type {any} */ c) => c.text), notChecked: state.finalPlan.notChecked, protections: state.finalPlan.protections, questions: [] }, jobLines),
1178
+ ...planMessage({ checkItems: state.finalPlan.checkItems, checks: state.finalPlan.checkItems.map((/** @type {any} */ c) => c.text), notChecked: state.finalPlan.notChecked, protections: state.finalPlan.protections, signedFacts: state.finalPlan.signedFacts, questions: [] }, jobLines),
1124
1179
  text: 'These are the checks the signed close will run, and what it will not check. Sign & run to accept them, or Revise to edit the boxes and draft again.',
1125
1180
  });
1126
1181
  }