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.
- package/CHANGELOG.md +60 -0
- package/bareloop.context.md +53 -13
- package/package.json +2 -2
- package/src/authorflow.js +27 -12
- package/src/authoring.js +1 -1
- package/src/authorjob.js +52 -16
- package/src/authorreadout.js +12 -6
- package/src/authorrun.js +8 -2
- package/src/declaredclose.js +4 -1
- package/src/draftspend.js +20 -2
- package/src/facts.js +54 -9
- package/src/factwords.js +58 -6
- package/src/fitcheck.js +166 -0
- package/src/inputlook.js +38 -3
- package/src/job.js +1 -1
- package/src/kinds.js +5 -0
- package/src/pagedropwords.js +110 -0
- package/src/pagedry.js +125 -0
- package/src/pagemap.js +269 -0
- package/src/pagerules.js +318 -0
- package/src/panel/authorroutes.js +34 -30
- package/src/panel/authorsession.js +66 -11
- package/src/panel/index.html +469 -65
- package/src/panel/jobplan.js +14 -7
- package/src/panel/runroutes.js +46 -22
- package/src/panel/server.js +792 -28
- package/src/panel/status.js +33 -0
- package/src/panelmoney.js +29 -0
- package/src/planrun.js +53 -4
- package/src/promptregisters.js +8 -0
- package/src/replay.js +118 -7
- package/src/rubricstep.js +157 -15
- package/src/runlist.js +2 -1
- package/src/schedulewords.js +6 -3
- package/src/source.js +18 -0
- package/src/thinking.js +63 -2
- package/src/userrun.js +28 -14
- package/types/authorflow.d.ts +42 -6
- package/types/authorjob.d.ts +20 -4
- package/types/declaredclose.d.ts +2 -0
- package/types/draftspend.d.ts +27 -1
- package/types/facts.d.ts +15 -0
- package/types/factwords.d.ts +26 -3
- package/types/fitcheck.d.ts +113 -0
- package/types/inputlook.d.ts +15 -0
- package/types/kinds.d.ts +5 -0
- package/types/pagedropwords.d.ts +39 -0
- package/types/pagedry.d.ts +37 -0
- package/types/pagemap.d.ts +154 -0
- package/types/pagerules.d.ts +86 -0
- package/types/panel/authorroutes.d.ts +17 -14
- package/types/panel/authorsession.d.ts +4 -2
- package/types/panel/jobplan.d.ts +5 -2
- package/types/panel/runroutes.d.ts +2 -2
- package/types/panel/server.d.ts +156 -2
- package/types/panel/status.d.ts +51 -0
- package/types/panelmoney.d.ts +5 -0
- package/types/replay.d.ts +21 -1
- package/types/rubricstep.d.ts +62 -1
- package/types/runlist.d.ts +4 -1
- package/types/schedulewords.d.ts +4 -2
- package/types/source.d.ts +11 -0
- package/types/thinking.d.ts +39 -0
- package/types/userrun.d.ts +4 -0
package/src/pagerules.js
ADDED
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
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((
|
|
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'
|
|
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
|
}
|