bareloop 0.44.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 CHANGED
@@ -5,6 +5,24 @@ All notable changes to bareloop are documented here. Format:
5
5
  [SemVer](https://semver.org/spec/v2.0.0.html). Pre-1.0: **minor** = a ladder rung or
6
6
  feature lands, **patch** = docs, fixes, scaffolding.
7
7
 
8
+ ## [0.45.0] - 2026-10-22
9
+
10
+ ### Added
11
+
12
+ - **Phrase facts get the dry read.** A kept phrase fact (for example "destination city" = "Rome") is read against the page's result lines (the lines the job's kept price facts read), by the same checker the run uses. A phrase found only outside the results (such as a "Track prices from Madrid to Rome" switch) is dropped whole as `phrase-no-result` and shows under Not checked: `"Rome" is on the page but in none of its results, so it is not checked.` A job with no kept price fact has no known result lines, so its phrase facts are left as they were.
13
+
14
+ - **An S1 copy of a signed fact is a read note.** When the AI proposes a second fact that would read the same value as a fact that is still signed, the note now shows under "How the page was read" instead of "Not checked" (the guardrail is checked by the other fact). With the other fact not signed it stays Not checked. The signed fact names reach the split from the signed spec (older specs too) and from the look at the confirm turn.
15
+
16
+ - **A fit check before the look (F243, `src/fitcheck.js`).** For a rubric job in the panel, one AI call splits each job line into the short parts it asks for and quotes the saved page for each; code (not the AI) decides a part is found only when its quote is really on the page. If every part is found nothing changes; if some are not, the person gets one question ("I found X of Y of your asks on this page. Not found: ... Type go on to keep drafting, or anything else to stop...") before the look spends. "go on" continues; anything else stops with "Stopped before drafting: the page does not show everything the job asks for: ...". The answer changes nothing in the signed job. If the check cannot finish it fails open and drafting goes on. Its spend is booked as `fit-check` in the drafting part and its result is one `fit-check` row in the drafting log.
17
+ A bare number in an ask (a count like the 3 in "list 3 flights") is ignored: not counted, not asked about, kept in the drafting log row flagged `ignored: number`. The stop line reads "...the job asks for: wifi (line 1), meal (line 1). Change the job...".
18
+
19
+ ### Fixed
20
+
21
+ - **Code's notes about written forms it did not use no longer sit under Not checked.** A second label, unit or currency dropped from a fact that is still checked (listed twice, "does not show the same values", not on the page) now shows in its own fold, "How the page was read · N", right after Not checked and not counted in it; a whole fact left out stays under Not checked. Same wording per note, on the plan card, the Job tab, the Result tab and the CLI confirm menu (run mvij7kuk).
22
+
23
+ - **A currency word in the wrong box no longer kills the price check (F241).** Drafting now sorts the AI's currency forms by code: a sign ($, €, £) is the currency, any other form ("US dollars") is a unit, whichever box the AI put it in; `currency-not-symbol` is gone. Then a $0 dry read grades an honest claim on each result line with the run's own checker, and a price fact that reads no result line is left under Not checked (`reads-no-result`) instead of being signed to fail at the run.
24
+ - **A number the AI made up no longer passes as the person's own.** The test for "is this limit in the person's words" matched digits inside other words, so 5, 15 and 50 counted as said for "under $150 ... between 05:00 and 23:59". It now reads times, dates, amounts and bare numbers as whole items, and a limit counts as written only if it equals one of them (`boundInWords`, F242).
25
+
8
26
  ## [0.44.0] - 2026-10-20
9
27
 
10
28
  ### Added
@@ -4395,9 +4395,9 @@ Check what the person asked for, not what the worker chose to write. bareloop ne
4395
4395
  - **No record cutting (Part 1c).** A fact part carries no `record`, `tested` or `sample` (a `record` field is an unknown-field red). The worker's brief says "one quote per result, copied from that result's own text" (bareguard's quote cap of 4000 characters stays its own). Drafting does not grade: it only proves each fact CAN be checked (below).
4396
4396
  - **Repeat rule (Part 1c M1, M4).** On a fact line asking for N results (`count.min`), `checkFactWords` (`src/factwords.js`, the one owner) refuses at $0 an anchor label the label list counts fewer than N times (`rubric-fact-label-rare`) and a `phrase` fact whose words `countPhrase` finds in the input fewer than N times (`rubric-fact-phrase-rare`); count 1 or none keeps a once-only one legal. `RUBRIC_RULES` states both.
4397
4397
  - **The look, then the draft (Part 1c; the default look is now the page-word map, next bullet).** For a rubric job on files, `authorCloseForJob` runs the look (`lookFn`, default `runPageMap`; the older search-and-read `runInputLook` in `src/authorscout.js`, pure parts in `src/inputlook.js`, is unwired from the default path) on the frozen input text BEFORE the confirm turn and the draft, when a `provider` is present (`lookFn: null` = no look; a panel test that replaces `generate` gets none unless it passes `lookFn`). Read-only by construction: tools `search_input` / `read_input` over in-memory text, `LOOK_ROUNDS` 8, a reserved final round whose only tool is the strict answer form `report_look`, spend metered into the drafting book (`calls` absorbed like the repo scout's). Its report (facts with a label picked from the list, prose lines, not-found entries) is verified by code (`verifyLookReport`: a label off the list, an unknown shape/line/input, or a phrase not in the source is moved to not-found, named) and rendered into the drafter's prompt (`renderLookReport`); a failed or reportless look is an ABSENT survey and stops drafting at $0 (`scout-absent`, "the first look at your repo or input did not complete"). Every place, date or name in the job line is a fact too, bound to the label before it (a direction = phrase facts `Leaves <place>` / `arrives at <place>`); prose lines and their `~` limits are not looked up.
4398
- - **The page-word map (idea A core, `src/pagemap.js`, `src/pagerules.js`, `docs/product/ITEM33-BUILD.md` "Addendum 2026-10-18 — Idea A core").** The default `lookFn` is `runPageMap`: ONE AI call per page input (the job's drafter identity, thinking off, booked per call into `draft-spend.json`, cap checked before each call) maps the job to the page's OWN words as `{"facts":[{name, from, shape: place|money|time|phrase, words, currency?, unit?}]}`; it takes the old look's arguments and returns the same envelope. Plain code then keeps or drops each word (`applyPageRules(text, mapping, jobLines)` -> `{ facts, dropped }`, pure, ported from the frozen POC): found-on-page (count >= 1), N2 (a label written 2+ times inside one page line is unusable), R1 (a second label stays only if it reads the same values as the first), U1 (a label the page writes AFTER the number, "EUR 490 total", becomes a unit; a label with no number either side is dropped), R1L (a unit/currency form stays only if it reads the same numbers as the primary form; units first), S1 (a fact name that counts results is refused; two facts may not share shape AND label). The AI never grades; the answer is read strictly (no JSON repair): a malformed or truncated answer is retried ONCE, twice is the plain stop "The AI's answer about the page was not readable twice. Nothing was signed." (an ABSENT survey; a draft is not a run, so no Resume); one retry on a transport error (booked unpriced, so `spendComplete=false`) or a 429 (the vendor's stated wait). The scrubbed raw answer rides each call into `draft-log.jsonl` (`call.raw`), unsigned, so a run can be replayed at $0. A drafted fact may use only words the map KEPT: a signed anchor, closing label, unit or currency that was dropped is the red `rubric-fact-word-dropped` with the drop's reason, and an anchor must be a kept label (`rubric-fact-label-off-list`, now against the kept set).
4398
+ - **The page-word map (idea A core, `src/pagemap.js`, `src/pagerules.js`, `docs/product/ITEM33-BUILD.md` "Addendum 2026-10-18 — Idea A core").** The default `lookFn` is `runPageMap`: ONE AI call per page input (the job's drafter identity, thinking off, booked per call into `draft-spend.json`, cap checked before each call) maps the job to the page's OWN words as `{"facts":[{name, from, shape: place|money|time|phrase, words, currency?, unit?}]}`; it takes the old look's arguments and returns the same envelope. Plain code then keeps or drops each word (`applyPageRules(text, mapping, jobLines)` -> `{ facts, dropped }`, pure, ported from the frozen POC): found-on-page (count >= 1), N2 (a label written 2+ times inside one page line is unusable), R1 (a second label stays only if it reads the same values as the first), U1 (a label the page writes AFTER the number, "EUR 490 total", becomes a unit; a label with no number either side is dropped), R1L (a unit/currency form stays only if it reads the same numbers as the primary form; units first; before R1L the forms are sorted by code - a sign is a currency, any other form a unit, whichever box the AI used), S1 (a fact name that counts results is refused; two facts may not share shape AND label). After the rules, `dryReadMoney` (`src/pagedry.js`, called by `runPageMap`) grades an honest claim on every result line with the run's own checker; a money fact that reads none is left under Not checked (`reads-no-result`); then `dryReadPhrases` reads each kept phrase fact against those same result lines (the lines the job's kept money facts read) and drops a phrase found in none of them whole as `phrase-no-result` (Not checked: `"Rome" is on the page but in none of its results, so it is not checked.`); with no kept money fact the result lines are unknown and phrase facts pass untouched. The AI never grades; the answer is read strictly (no JSON repair): a malformed or truncated answer is retried ONCE, twice is the plain stop "The AI's answer about the page was not readable twice. Nothing was signed." (an ABSENT survey; a draft is not a run, so no Resume); one retry on a transport error (booked unpriced, so `spendComplete=false`) or a 429 (the vendor's stated wait). The scrubbed raw answer rides each call into `draft-log.jsonl` (`call.raw`), unsigned, so a run can be replayed at $0. A drafted fact may use only words the map KEPT: a signed anchor, closing label, unit or currency that was dropped is the red `rubric-fact-word-dropped` with the drop's reason, and an anchor must be a kept label (`rubric-fact-label-off-list`, now against the kept set).
4399
4399
  - **Currency and unit lists (idea A core, `src/facts.js`).** A money fact's `unit` and `currency` may each be one string (every spec signed before lists) or a non-empty list: at most 3 currencies (`$ € £` only; a code like `EUR` is a unit) and 10 units, none repeated, a unit at most 100 characters, enforced by our own plain reds before bareguard is asked. A string reads exactly as a one-element list and is never rewritten, so an old signed spec keeps its bytes and hash. The first listed form is the primary one in the plan row and the worker brief; others read "or". `checkFactWords` requires EVERY listed unit and sign to be on the page.
4400
- - **No silent drop (idea A core, `src/pagedropwords.js`, `withDropNotes` in `src/rubricstep.js`).** Every word, fact or `~` rule the page rules drop is shown as Not checked with a plain sentence from ONE table (no other file writes a drop reason). Before validation `withDropNotes` appends to the declaration: `closeDecl.refused` `{ line, rule, reason }` for a dropped fact whose `~` rule no part carries (never a line-level refusal; a rule the drafter already listed is not listed twice), and one line in `closeDecl.notes` for any other drop, including a word cut from a fact that is still checked. The confirm card gets the same lines (`plan.notChecked`) before drafting is paid for, and the drafter's look block says `NOT CHECKED (rule n)` / `LEFT OUT`. Nothing about this changes a signed spec's verdict rules; it is the Not checked list, in the person's words.
4400
+ - **No silent drop (idea A core, `src/pagedropwords.js`, `withDropNotes` in `src/rubricstep.js`).** Every word, fact or `~` rule the page rules drop is shown as Not checked with a plain sentence from ONE table (no other file writes a drop reason). Before validation `withDropNotes` appends to the declaration: `closeDecl.refused` `{ line, rule, reason }` for a dropped fact whose `~` rule no part carries (never a line-level refusal; a rule the drafter already listed is not listed twice), and one line in `closeDecl.notes` for any other drop, including a word cut from a fact that is still checked. Those word-level lines are not "Not checked": `splitReadNotes` / `isReadNote` (`src/pagedropwords.js`) sort a row whose every sentence only says which written form of a still-checked fact was not used (not on the page, listed twice, not the same values, list limits, and an `s1` copy of a fact that is still signed) into `readNotes`, shown in their own fold "How the page was read · N" right after Not checked (plan card, Job tab, Result tab `readNotes`, CLI confirm menu) and not counted in it; a whole fact left out (`no-label`, `reads-no-result`, `phrase-no-result`, an `s1` copy of an unsigned fact) stays Not checked. The signed fact names come from `signedFactNames(closeDecl)` (older specs too) or the look at the confirm turn (`plan.signedFacts`). The confirm card gets the same lines (`plan.notChecked`) before drafting is paid for, and the drafter's look block says `NOT CHECKED (rule n)` / `LEFT OUT`. Nothing about this changes a signed spec's verdict rules; it is the Not checked list, in the person's words.
4401
4401
  - **Labels are picked, never typed (`src/labelscan.js`).** `scanLabels(text)` lists, per shape (money, number, date, time, text), the words right before each value with counts (top 40 per shape; one generic scan, no per-site table; a page reader's `[ref=N]` tags are ignored). `labelOnList(scan, shape, idOrText)` is the one test (id or exact words, white-space blind). The look and the drafter pick labels only from the list shown in the prompt (`renderLabelList`). Fact-line reds are plain sentences; the confirm step and the drafter share one rule text (`RUBRIC_RULES`), and the drafter's per-shape field guide is generated from bareguard's `rubricVocabulary`.
4402
4402
  - **Found-once check (`src/factwords.js`, `checkDeclarationWords`, run after validation in `authorClose`).** Every anchor must be on the input's label list for its shape, and every `until`, phrase and unit must be in the source once (white-space blind: U+202F, U+00A0; phrases whole-word, case-blind). A miss is a red naming the word and the input: `rubric-fact-label-off-list`, `rubric-fact-word-missing`, `rubric-fact-unread` (an input that cannot be read is a red, never a skipped check). Nothing is graded; finding and grading are the run's and the close's job.
4403
4403
  - **No silently dropped rule (RF16).** When a close holds a fact part or any part with `rules`, every `~` rule of every job line must be carried out by a part (its number in the part's `rules`, 1 = the first `~` rule) or listed in `refused` as `{ line, rule, reason }`, or its whole line refused; a rule that is none is a $0 red (`rubric-rule-dropped`). `refused` entries may now carry `rule`. A number the person did not write (a bound not found in the line's own words) must be marked `aiProposal: true` (the card says "AI's proposal — check it"), and an AI-proposed time limit must be `between`, never one-sided (RF17).
@@ -4435,4 +4435,5 @@ For an integrating agent reading a run's record:
4435
4435
  - **`draft-log.jsonl` `human` rows** `{no, source: confirm | look | try-3, question, answer, line?, at}`; the answer is secret-masked before it is written and the row exists only when the question was answered.
4436
4436
  - **Money line** `moneyWithDraft(spendCore, draftUsd, budgetUsd, draftComplete, runUsd)`: `$run run + $draft drafting = $total of $cap`; without `runUsd` the sum is omitted. The panel's own spelling is `panelMoney2` (`src/panelmoney.js`, one Node owner; `src/panel/index.html` carries a pinned browser copy): 2 decimals from a cent up, 4 decimals for a real amount under a cent (`$0.0054`), `<$0.0001` below that, exact zero `$0.00`, null `unknown`; the CLI's `money()` keeps its own 4 decimals.
4437
4437
  - Not built from the signed spec (see the Part 5 commits): the early ask about a number the AI made (T1), because the data it needs does not exist at that point; T1 is replaced by Part 5b below. The "fetching the page…" state (Q2) was built after the run-start refetch moved to after the run is listed: the sign progress list shows the step (`settleFetchingStep`, `src/panel/authorroutes.js`) and a listed run with no log yet reads "fetching the page… the run has not started yet" (`startingStub`, `webFetchState`), both read from `source.json`'s `refrozenAt`.
4438
- - **The look reads, code asks (PRD v1.107 Part 5b; `src/pagemap.js`).** A map fact on a `time` or `money` shape may carry an optional `reading: {op, value | lo, hi}` (the signed-rule `op` set; a time bound is a string, a money bound a number); any other `reading` makes the whole map answer malformed (no repair). `askableFromLook(look, jobLines)` sorts every `~` rule: a kept page word carries it (no ask); a `reading` whose bound is NOT in the person's own words (`boundInWords`, the same test as `rubric-fact-unmarked`) asks `Your line N says "<rule>". I read it as: <fact> <ruleWords>. Agree?`; a rule no kept fact carries asks `... I can't check this on the page. Drop it?`. Readings come first, then can't-check asks. `agree` (or `yes`) writes the reading as a `~` rule in plain words, so the bound is the person's own and the fact signs plain; a typed answer is the `~` rule instead; `drop it` on a can't-check ask adds no rule and leaves it Not checked. `MAX_QUESTIONS` is 3: more asks than that stops the draft before the confirm turn (stop `look-asks-too-many`, no extra spend) with `tooManyAsksText`, a code-written list of every ask and its reading. Asks carry `ask: 'reading' | 'cant-check'` (and `reading` for the agree text) through `normalizeQuestions`.
4438
+ - **The look reads, code asks (PRD v1.107 Part 5b; `src/pagemap.js`).** A map fact on a `time` or `money` shape may carry an optional `reading: {op, value | lo, hi}` (the signed-rule `op` set; a time bound is a string, a money bound a number); any other `reading` makes the whole map answer malformed (no repair). `askableFromLook(look, jobLines)` sorts every `~` rule: a kept page word carries it (no ask); a `reading` whose bound is NOT in the person's own words (`boundInWords`, the same test as `rubric-fact-unmarked`; it reads the words by kind, F242: a number bound is said only as a bare number or an amount's number, a time bound only as a time token with the hour's leading zero ignored, a date bound only as a date token, never a piece of a token) asks `Your line N says "<rule>". I read it as: <fact> <ruleWords>. Agree?`; a rule no kept fact carries asks `... I can't check this on the page. Drop it?`. Readings come first, then can't-check asks. `agree` (or `yes`) writes the reading as a `~` rule in plain words, so the bound is the person's own and the fact signs plain; a typed answer is the `~` rule instead; `drop it` on a can't-check ask adds no rule and leaves it Not checked. `MAX_QUESTIONS` is 3: more asks than that stops the draft before the confirm turn (stop `look-asks-too-many`, no extra spend) with `tooManyAsksText`, a code-written list of every ask and its reading. Asks carry `ask: 'reading' | 'cant-check'` (and `reading` for the agree text) through `normalizeQuestions`.
4439
+ - **The fit check (F243, `src/fitcheck.js`; `docs/product/ITEM33-BUILD.md` "Addendum 2026-10-21 — fit check build spec").** Rubric jobs only, once, after the inputs are frozen and the content gate passes and BEFORE the look; it runs only when the caller passes the answer seam `fitAsk` to `authorCloseForJob` (the panel; the CLI door and any test without it never run it; `fitFn: null` = no check). `runFitCheck` makes ONE AI call (the drafter identity, the drafting thinking rule, label `fit-check`, cap checked before the call) that splits each job line (no `~` rules) into asks copied from that line and quotes the page for each; plain code (`decideFit`) decides: an ask is found only if `found` is true AND its quote is on an input (whitespace and case ignored), a found-with-off-page-quote is `madeUp` and counts as not found, a reply with an ask not copied from its line or a line with no ask is malformed (one retry). All found = `fits` (nothing shown); any not found = `ask`: one question through the panel `ask` seam (`I found X of Y of your asks on this page. Not found: <ask> (line N), .... Type go on to keep drafting, or anything else to stop and change the job or the page.`); `go on` continues to the look, anything else stops `fit-stopped` (plain words in `draftStopText`), no answer is `confirm-abandoned`. The answer is not a `~` rule and changes nothing in the job. Failure is OPEN: a cut, two unreadable replies, a failed call or a cap stop is `absent`, chat says "The fit check did not finish; drafting goes on." and drafting goes on. The result is one `fit-check` row in `draft-log.jsonl`.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bareloop",
3
- "version": "0.44.0",
3
+ "version": "0.45.0",
4
4
  "description": "Workflows that earn their own design, with receipts — agent-authored workflows for repeated, long, verifiable jobs that improve across runs under an un-gameable gate.",
5
5
  "type": "module",
6
6
  "main": "./src/index.js",
package/src/authorflow.js CHANGED
@@ -2444,7 +2444,7 @@ export async function runConfirmTurn({
2444
2444
  for (const row of dropRows(facts?.look?.dropped)) { const text = dropLine(row, jobLines, lineRules); if (!notChecked.includes(text)) notChecked.push(text); }
2445
2445
  const checkItems = normalizeChecks(r.plan.checks);
2446
2446
  r.plan = { ...r.plan, checks: checkItems.map((c) => c.text), checkItems };
2447
- const plan = { ...r.plan, protections, notChecked };
2447
+ const plan = { ...r.plan, protections, notChecked, signedFacts: (Array.isArray(facts?.look?.facts) ? facts.look.facts : []).map((/** @type {any} */ f) => String(f?.name)) };
2448
2448
  onPhase('confirm-done', { round, plan });
2449
2449
  const picked = await ask({ kind: 'menu', field: CONFIRM_MENU, plan });
2450
2450
  if (picked === null) return abandon(round);
package/src/authorjob.js CHANGED
@@ -116,6 +116,7 @@ import {
116
116
  runConfirmTurn, LANGUAGE_PICK_FIELD, normalizeQuestions, MAX_QUESTIONS,
117
117
  } from './authorflow.js';
118
118
  import { thinkingCallOptions, assertThinkingMode } from './thinking.js';
119
+ import { runFitCheck, fitQuestionText, notFoundList, isGoOn } from './fitcheck.js';
119
120
  import { pageContentChars } from './pagefetch.js';
120
121
  import { runAuthorScout, buildSeedListing, SCOUT_ATTEMPTS } from './authorscout.js';
121
122
  import { runPageMap, askableFromLook, tooManyAsksText } from './pagemap.js';
@@ -429,7 +430,8 @@ function composerRefusal(reds) {
429
430
  * rates?: {in: number, out: number}|null, draftThinking?: 'on'|'off',
430
431
  * onPhase?: (phase: string, data?: any) => void,
431
432
  * onCall?: (call: {label: string, costUsd: number|null, unpricedRounds: number, thinkingStatus?: string}) => void,
432
- * seedFn?: Function, scoutFn?: Function, listingFn?: Function, lookFn?: Function|null,
433
+ * seedFn?: Function, scoutFn?: Function, listingFn?: Function, lookFn?: Function|null, fitFn?: Function|null,
434
+ * fitAsk?: ((question: string) => Promise<string|null>)|null, onFit?: (fit: {state: string, asks: any[], notFound: any[]}) => void,
433
435
  * authorFn?: Function, authorOpts?: object, writeScope?: string[]|null,
434
436
  * signerFix?: Function|any, proposeFn?: Function, compileOpts?: object,
435
437
  * ask?: ((step: {kind: string, [k: string]: any}) => Promise<string|null>)|null,
@@ -473,6 +475,9 @@ export async function authorCloseForJob({
473
475
  // PART 1c / IDEA A CORE - the look at a rubric job's inputs: the default is the page-word map (src/pagemap.js `runPageMap`, one paid AI
474
476
  // call per page input, its words kept by code). `null` is an explicit "no look" (a test seam, like a caller-supplied `scout`).
475
477
  lookFn = runPageMap,
478
+ // THE FIT CHECK (F243): one paid call BEFORE the look, rubric jobs only. It runs only when the caller supplies the answer seam `fitAsk` (the panel);
479
+ // `fitFn` null is an explicit "no check" (a test seam). With no `fitAsk` (the CLI door) it never runs.
480
+ fitFn = runFitCheck, fitAsk = null, onFit = () => {},
476
481
  authorFn = authorClose, authorOpts = {},
477
482
  // SOFTGREEN modules 4+5. `signerFix` is the D5 seam: a function (or a value)
478
483
  // carrying the SIGNER's corrected artifacts back. Absent means the proposal is
@@ -613,6 +618,25 @@ export async function authorCloseForJob({
613
618
  // nothing to check a rule against. Plain words, before any model call.
614
619
  survey = { state: 'ABSENT', reason: 'your input holds nothing to read (it is empty, or only the site\'s own header and menus) - nothing was asked and nothing was spent', cause: 'call-failed', budgetStop: null, calls: [], raws: [], facts: null };
615
620
  } else if (frozen !== null && lookFn) {
621
+ if (fitAsk && fitFn) {
622
+ // F243: a page that does not show what the job asks for stops here, before the look spends. Failure is OPEN: an `absent` check
623
+ // (cut, unreadable twice, call failed, cap) asks nothing and drafting goes on; the answer is NOT a `~` rule and changes nothing in the job.
624
+ onPhase('fit-check', {});
625
+ const fitCallOptions = thinkingCallOptions(provider, draftThinking);
626
+ const fit = await fitFn({
627
+ jobLines, texts: frozen.texts, provider, ceilingUsd, onCall, ...(rates ? { rates } : {}),
628
+ ...(Object.keys(fitCallOptions).length ? { callOptions: fitCallOptions } : {}),
629
+ });
630
+ onPhase('fit-check-done', { state: fit.state });
631
+ onFit({ state: fit.state, asks: fit.asks, notFound: fit.notFound });
632
+ if (fit.state === 'ask') {
633
+ const answer = await fitAsk(fitQuestionText({ found: fit.asks.filter((/** @type {any} */ a) => !a.ignored && a.found).length, total: fit.asks.filter((/** @type {any} */ a) => !a.ignored).length, notFound: fit.notFound }));
634
+ if (answer === null) return { ...base, interview, verdictType: picked, seedRef: seed, reds: [], stop: 'confirm-abandoned', cost: null };
635
+ if (!isGoOn(answer)) {
636
+ return { ...base, interview, verdictType: picked, seedRef: seed, reds: [{ code: 'fit-stopped', path: 'jobLines', detail: notFoundList(fit.notFound) }], stop: 'fit-stopped', cost: null };
637
+ }
638
+ }
639
+ }
616
640
  onPhase('look', {});
617
641
  const lookCallOptions = thinkingCallOptions(provider, draftThinking);
618
642
  survey = await lookFn({
@@ -663,6 +663,11 @@ export function draftStopText({ stop, reds = null, refusal = null, spend = null,
663
663
  const detail = (Array.isArray(reds) ? reds : []).find((r) => r?.code === 'look-asks-too-many')?.detail;
664
664
  if (detail) return `${redactSecrets(String(detail))}\nSpent so far: ${spent ?? 'unknown'}.`;
665
665
  }
666
+ // F243: the fit check - the page does not show everything the job asks for; the not-found list (the code-made red detail) is the whole reason
667
+ if (stop === 'fit-stopped') {
668
+ const detail = (Array.isArray(reds) ? reds : []).find((r) => r?.code === 'fit-stopped')?.detail;
669
+ if (detail) return `Stopped before drafting: the page does not show everything the job asks for: ${redactSecrets(String(detail))}. Change the job or the page and draft again. Spent so far: ${spent ?? 'unknown'}.`;
670
+ }
666
671
  /** @type {Record<string, string>} */
667
672
  const heads = {
668
673
  'provider-red': 'The AI provider failed before a usable plan came back — a connection or service fault, not a problem with your job.',
package/src/authorrun.js CHANGED
@@ -128,6 +128,7 @@ import { resolveProvider, buildRunnerProviders, apiKeyProblem } from './provider
128
128
  import { commandFor } from './invoke.js';
129
129
  import { readSourceManifest, missingDependencies, nonRepoSourceMessage, isRubricFilesManifest } from './source.js';
130
130
  import { tallyCalls } from './text.js';
131
+ import { splitReadNotes } from './pagedropwords.js';
131
132
  import {
132
133
  declarationLines, rubricLines, calibrationLines, parseCeiling, ceilingLine, crashRecord, phaseLine,
133
134
  openQuestionLines, answeredQuestionLines, fellBackLines, PLAIN_PROPOSAL_STOPS, proposalStopText, signingStopText,
@@ -771,9 +772,14 @@ export async function main(argv, deps = {}) {
771
772
  for (const c of p.checks ?? []) out(` · ${c}`);
772
773
  out(' protections (always-on guards, never named in the goal):');
773
774
  for (const g of p.protections ?? []) out(` · ${g}`);
774
- if ((p.notChecked ?? []).length) {
775
+ const { notChecked: unchecked, readNotes } = splitReadNotes(p.notChecked, p.signedFacts);
776
+ if (unchecked.length) {
775
777
  out(' you asked for these, but nothing checks them:');
776
- for (const n of p.notChecked ?? []) out(` · ${n}`);
778
+ for (const n of unchecked) out(` · ${n}`);
779
+ }
780
+ if (readNotes.length) {
781
+ out(` how the page was read · ${readNotes.length}:`);
782
+ for (const n of readNotes) out(` · ${n}`);
777
783
  }
778
784
  if ((p.questions ?? []).length) {
779
785
  out(' open questions:');
@@ -0,0 +1,166 @@
1
+ // THE FIT CHECK (docs/product/ITEM33-BUILD.md "Addendum 2026-10-21 - fit check build spec", F243, fit POC v6). A rubric job, at drafting, once, after the
2
+ // inputs are frozen and before the look: ONE paid AI call splits each job line into the short parts it asks for (words copied from that line) and says,
3
+ // for each, whether the saved page shows it, with a quote copied from the page. The AI never decides: plain code checks every ask against its own
4
+ // line and every quote against the page, and a part not found becomes one question to the person (never a stop by itself, never a count). Failure is
5
+ // OPEN - a wrong stop is the costly mistake - so a call that does not finish is `absent` and drafting goes on. The call/book/retry pattern is
6
+ // src/pagemap.js's `runPageMap`.
7
+
8
+ import { createRequire } from 'node:module';
9
+ import { priceOf, scrubRaw, tallyCalls, capStop, callCasualty } from './text.js';
10
+ import { redactSecrets } from './validate.js';
11
+ import { isTransportFailure } from './transport.js';
12
+ import { isRateLimited, rateLimitWaitMs } from './ratelimit.js';
13
+ import { isOutputCutOff } from './authorflow.js';
14
+ import { AUTHOR_CALL_TIMEOUT_MS, AUTHOR_SCOUT_MAX_TOKENS, SURVEY_CAUSES } from './authorscout.js';
15
+
16
+ const require = createRequire(import.meta.url);
17
+ const { Loop } = require('bare-agent');
18
+
19
+ /** the label of the one paid call, as the drafting book and the progress line see it */
20
+ export const FIT_CALL_LABEL = 'fit-check';
21
+ /** what the chat says when the check did not finish (drafting goes on) */
22
+ export const FIT_ABSENT_NOTE = 'The fit check did not finish; drafting goes on.';
23
+ /** the answer that keeps drafting after the fit question (any case, a closing full stop allowed) */
24
+ export const FIT_GO_ON = 'go on';
25
+ /** @param {unknown} text @returns {boolean} */
26
+ export const isGoOn = (text) => norm(String(text ?? '').trim().replace(/\.$/, '')) === FIT_GO_ON;
27
+
28
+ export const FIT_SYSTEM = 'You check whether saved web pages show what a job asks for. You never invent words: every ask must be copied from its job line and every quote must be copied from the page, character for character.';
29
+
30
+ /** collapse whitespace, lower case: the one spelling every fit comparison uses @param {unknown} s @returns {string} */
31
+ export const norm = (s) => String(s).replace(/\s+/g, ' ').trim().toLowerCase();
32
+
33
+ /**
34
+ * What the AI is told: the job lines (the numbered text only, no `~` rules) and the WHOLE text of every input under its label. Adapted from the POC
35
+ * call (.claude/scratch/fit-poc6/run.mjs): the asks are copied from each job line instead of hand-written.
36
+ * @param {{jobLines: {n: number, text: string}[], texts: Record<string, string>}} o
37
+ * @returns {string}
38
+ */
39
+ export function fitPrompt({ jobLines, texts }) {
40
+ const job = jobLines.map((l) => `LINE ${l.n}: ${l.text}`).join('\n');
41
+ const pages = Object.entries(texts).map(([label, text]) => `THE PAGE (the input "${label}"):\n${text}`).join('\n\n');
42
+ return `THE JOB:\n${job}\n\n${pages}\n\n`
43
+ + 'For EACH job line, split it into the short parts it asks for (for example where it starts, where it ends, a price, a time), using ONLY words copied from that line. '
44
+ + 'Every line has at least one part. For EACH part, say whether the page shows it the way the job means it. A page that shows the opposite (for example a flight that arrives where the job says it leaves from, or a different city) does NOT show it. '
45
+ + 'If found, copy a short piece of page text that shows it, character for character. If not found, quote is null. '
46
+ + 'Reply with JSON only, no prose, no code fence: {"asks":[{"line":<line number>,"ask":"<the part, copied from that line>","found":true|false,"quote":"<page text or null>"}]}';
47
+ }
48
+
49
+ /** @typedef {{line: number, ask: string, found: boolean, quote: string|null, madeUp: boolean, ignored?: 'number'}} FitAsk */
50
+
51
+ /** an ask that is only a number (a count the job gives, not something the page shows): digits, optional spaces, commas, dots @param {string} ask @returns {boolean} */
52
+ const isNumberOnly = (ask) => /^\d[\d\s,.]*$/.test(norm(ask));
53
+
54
+ /**
55
+ * Read the AI's reply and decide, all in code. Malformed (no repair): not JSON, another form, a line that is not a job line, an ask not copied from its own
56
+ * line, found not a boolean, a quote neither text nor null, or a job line with no ask. An ask is found only if `found` is true AND its quote is on a page;
57
+ * `found` with a quote that is not on any page (or no quote) is made up and counts as not found.
58
+ * @param {unknown} text the reply
59
+ * @param {{n: number, text: string}[]} jobLines
60
+ * @param {Record<string, string>} texts the frozen input texts by label
61
+ * @returns {{status: 'malformed'}|{status: 'ok', state: 'fits'|'ask', asks: FitAsk[], notFound: {line: number, ask: string}[]}}
62
+ */
63
+ export function decideFit(text, jobLines, texts) {
64
+ /** @type {any} */
65
+ let j;
66
+ try { j = JSON.parse(String(text ?? '').trim()); } catch { return { status: 'malformed' }; }
67
+ if (j === null || typeof j !== 'object' || Array.isArray(j) || !Array.isArray(j.asks)) return { status: 'malformed' };
68
+ const lines = new Map(jobLines.map((l) => [l.n, norm(l.text)]));
69
+ const pages = Object.values(texts).map(norm);
70
+ /** @type {FitAsk[]} */
71
+ const asks = [];
72
+ for (const a of j.asks) {
73
+ if (a === null || typeof a !== 'object' || Array.isArray(a)) return { status: 'malformed' };
74
+ if (!Number.isInteger(a.line) || !lines.has(a.line)) return { status: 'malformed' };
75
+ if (typeof a.ask !== 'string' || norm(a.ask) === '' || !(/** @type {string} */ (lines.get(a.line))).includes(norm(a.ask))) return { status: 'malformed' };
76
+ if (typeof a.found !== 'boolean') return { status: 'malformed' };
77
+ const quote = a.quote ?? null;
78
+ if (quote !== null && typeof quote !== 'string') return { status: 'malformed' };
79
+ const q = quote === null ? '' : norm(quote);
80
+ const onPage = q !== '' && pages.some((p) => p.includes(q));
81
+ // a bare number is ignored: kept in the record (flagged), never counted, never asked about
82
+ asks.push({ line: a.line, ask: a.ask, found: a.found && onPage, quote, madeUp: a.found && !onPage, ...(isNumberOnly(a.ask) ? { ignored: /** @type {'number'} */ ('number') } : {}) });
83
+ }
84
+ if (!jobLines.every((l) => asks.some((a) => a.line === l.n))) return { status: 'malformed' };
85
+ const notFound = asks.filter((a) => !a.ignored && !a.found).map((a) => ({ line: a.line, ask: a.ask }));
86
+ return { status: 'ok', state: notFound.length === 0 ? 'fits' : 'ask', asks, notFound };
87
+ }
88
+
89
+ /** the not-found asks as the person reads them: `<ask> (line N), <ask> (line N)` @param {{line: number, ask: string}[]} notFound @returns {string} */
90
+ export const notFoundList = (notFound) => notFound.map((x) => `${x.ask} (line ${x.line})`).join(', ');
91
+
92
+ /**
93
+ * The one question, worded by code.
94
+ * @param {{found: number, total: number, notFound: {line: number, ask: string}[]}} o
95
+ * @returns {string}
96
+ */
97
+ export const fitQuestionText = ({ found, total, notFound }) => redactSecrets(
98
+ `I found ${found} of ${total} of your asks on this page. Not found: ${notFoundList(notFound)}. Type go on to keep drafting, or anything else to stop and change the job or the page.`,
99
+ );
100
+
101
+ /** the default loop seam - one spelling of how this repo drives a model */
102
+ const defaultLoop = (/** @type {any} */ { system, onLlmResult, provider, rates }) =>
103
+ new Loop({ provider, system, onLlmResult, ...(rates ? { rates } : {}) });
104
+
105
+ /**
106
+ * Run the fit check: one call (one retry on a malformed reply or a transport failure or a 429). Never throws on a call casualty; every failure is `absent`.
107
+ * @param {{jobLines: {n: number, text: string}[], texts: Record<string, string>, provider?: any, maxTokens?: number, ceilingUsd?: number|null,
108
+ * callTimeoutMs?: number, onCall?: (call: {label: string, costUsd: number|null, unpricedRounds: number, thinkingStatus?: string, raw?: string}) => void,
109
+ * createLoop?: Function, rates?: {in: number, out: number}|null, callOptions?: Record<string, any>, sleep?: (ms: number) => Promise<void>}} o
110
+ * @returns {Promise<{state: 'fits'|'ask'|'absent', asks: FitAsk[], notFound: {line: number, ask: string}[], reason: string|null, cause: string|null,
111
+ * calls: {label: string, costUsd: number|null, unpricedRounds: number}[], budgetStop: 'cap-halt'|'pricing-red'|null}>}
112
+ */
113
+ export async function runFitCheck({
114
+ jobLines, texts, provider = null, maxTokens = AUTHOR_SCOUT_MAX_TOKENS, ceilingUsd = null, callTimeoutMs = AUTHOR_CALL_TIMEOUT_MS,
115
+ onCall = () => {}, createLoop = defaultLoop, rates = null, callOptions = {}, sleep = (ms) => new Promise((r) => setTimeout(r, ms)),
116
+ }) {
117
+ const callTimeout = Number.isFinite(Number(callTimeoutMs)) && Number(callTimeoutMs) > 0
118
+ ? Math.min(Math.trunc(Number(callTimeoutMs)), AUTHOR_CALL_TIMEOUT_MS) : AUTHOR_CALL_TIMEOUT_MS;
119
+ const priced = rates ? { rates } : {};
120
+ /** @type {{label: string, costUsd: number|null, unpricedRounds: number}[]} */
121
+ const calls = [];
122
+ let attempts = 0;
123
+ const record = (/** @type {any} */ r) => {
124
+ const call = { label: FIT_CALL_LABEL, ...priceOf(r) };
125
+ calls.push(call);
126
+ const raw = scrubRaw({ label: FIT_CALL_LABEL, attempt: attempts, text: r?.text });
127
+ onCall({ ...call, raw: raw.text, ...(r?.thinkingStatus === undefined ? {} : { thinkingStatus: r.thinkingStatus }) });
128
+ };
129
+ const absent = (/** @type {string} */ reason, /** @type {string} */ cause, /** @type {'cap-halt'|'pricing-red'|null} */ budgetStop = null) => ({
130
+ state: /** @type {'absent'} */ ('absent'), asks: [], notFound: [], reason, cause, calls, budgetStop,
131
+ });
132
+ const content = fitPrompt({ jobLines, texts });
133
+ let malformed = 0;
134
+ let transportRetried = false;
135
+ let rateRetried = false;
136
+ for (;;) {
137
+ // the cap is checked BEFORE every call, never inside one
138
+ const halt = capStop({ ceilingUsd, ...tallyCalls(calls) });
139
+ if (halt) return absent(halt === 'cap-halt' ? `the fit check was not made: the $${ceilingUsd} ceiling was already spent` : 'the fit check was not made: spend could not be priced, so the ceiling could not be enforced', SURVEY_CAUSES.NOT_FUNDED, halt);
140
+ attempts += 1;
141
+ const loop = createLoop({ system: FIT_SYSTEM, onLlmResult: undefined, provider, ...priced });
142
+ /** @type {any} */
143
+ let r;
144
+ try {
145
+ r = await loop.run([{ role: 'user', content }], [], { cacheMessages: true, maxTokens, timeoutMs: callTimeout, ...callOptions });
146
+ } catch (e) {
147
+ if (isRateLimited(e) && !rateRetried) {
148
+ const wait = rateLimitWaitMs(e);
149
+ if (wait !== null) { rateRetried = true; await sleep(wait); continue; }
150
+ }
151
+ const casualty = callCasualty(e) ?? (isTransportFailure(e) ? String(/** @type {any} */ (e)?.message ?? e) : null);
152
+ if (casualty === null) throw e;
153
+ // the failed attempt may already be billed: booked UNPRICED, so the spend stays "not complete" (never a $0)
154
+ record({ error: casualty });
155
+ if (!transportRetried) { transportRetried = true; continue; }
156
+ return absent(`the fit check call failed: ${redactSecrets(casualty)}`, SURVEY_CAUSES.CALL_FAILED);
157
+ }
158
+ record(r);
159
+ if (isOutputCutOff(r)) return absent('the fit check answer was cut off', SURVEY_CAUSES.UNPARSEABLE);
160
+ if (r?.error) return absent(`the fit check call failed: ${redactSecrets(String(r.error))}`, SURVEY_CAUSES.CALL_FAILED);
161
+ const d = decideFit(r?.text, jobLines, texts);
162
+ if (d.status === 'ok') return { state: d.state, asks: d.asks, notFound: d.notFound, reason: null, cause: null, calls, budgetStop: null };
163
+ malformed += 1;
164
+ if (malformed >= 2) return absent('the fit check answer was not readable twice', SURVEY_CAUSES.UNPARSEABLE);
165
+ }
166
+ }
@@ -21,7 +21,8 @@ const WORDS = /** @type {Record<string, (d: Dropped) => string>} */ ({
21
21
  'limit-currency': (d) => `Only the first 3 currencies are used; "${d.word}" was left out.`,
22
22
  'limit-unit': (d) => `Only the first 10 units are used; "${d.word}" was left out.`,
23
23
  'unit-too-long': (d) => `"${d.word}" is longer than 100 characters, so it was not used.`,
24
- 'currency-not-symbol': (d) => `"${d.word}" is not a currency sign ($, € or £); a word the page writes after the amount is read as a unit.`,
24
+ 'reads-no-result': () => "the price rule could not read a price in the page's results, so it is not checked.",
25
+ 'phrase-no-result': (d) => `"${d.other}" is on the page but in none of its results, so it is not checked.`,
25
26
  'person-left': () => 'You chose to leave it Not checked.',
26
27
  'bad-shape': (d) => `The AI named a kind of value for "${d.fact}" that a check cannot read.`,
27
28
  });
@@ -62,3 +63,48 @@ export function dropLine(row, jobLines, rulesOf) {
62
63
  const rule = row.rule === undefined || !jl ? undefined : rulesOf(jl)[row.rule - 1];
63
64
  return `line ${row.line}: "${rule ?? row.fact}" — ${row.sentences.join(' ')}`;
64
65
  }
66
+
67
+ // HOW THE PAGE WAS READ (run mvij7kuk, flight-c1): a drop of ONE WORD whose fact is still checked is a NOTE about the form code did not use, not
68
+ // something left unchecked. The sentences for those reasons are WORD-level; every other sentence (a whole fact left out) keeps the line under
69
+ // Not checked. Classified from the line's own words, so it holds for a signed spec already on disk.
70
+ const WORD_REASONS = ['not-on-page', 'no-value', 'n2', 'r1', 'sign-twice', 'r1l', 'limit-currency', 'limit-unit', 'unit-too-long'];
71
+ /** @param {string} s */
72
+ const esc = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
73
+ const SENTENCE_RE = (() => {
74
+ const [W, O] = ['\u0001w\u0001', '\u0001o\u0001'];
75
+ /** @type {string[]} */
76
+ const alts = [];
77
+ for (const reason of WORD_REASONS) {
78
+ const probes = reason === 'r1l' ? [{ word: W, other: W }, { word: W, other: O }] : [{ word: W, other: O }];
79
+ for (const p of probes) alts.push(esc(WORDS[reason](/** @type {any} */ ({ ...p, reason, line: 0, fact: 'f' }))).split(esc(W)).join('[^"]+').split(esc(O)).join('[^"]+'));
80
+ }
81
+ return new RegExp(`^(?:(?:${alts.join('|')})(?: |$))+$`);
82
+ })();
83
+
84
+ /** the s1 sentence as a pattern whose one capture is the OTHER fact's name (the sentence is built from the one WORDS table) */
85
+ const S1_RE = (() => {
86
+ const [F, O] = ['\u0001f\u0001', '\u0001o\u0001'];
87
+ return new RegExp(esc(WORDS['s1'](/** @type {any} */ ({ fact: F, other: O, reason: 's1', line: 0 }))).split(esc(F)).join('[^"]+').split(esc(O)).join('([^"]+)'), 'g');
88
+ })();
89
+
90
+ /**
91
+ * An `s1` drop (a second fact that would read the same value as one already kept) is a NOTE when that OTHER fact is still signed: the guardrail
92
+ * is checked by it. With the other fact not signed, or no signed list given, the sentence stays Not checked. Every sentence of a row must qualify.
93
+ * @param {string} text one Not-checked line @param {string[]|null|undefined} [signed] the names of the facts the plan signs
94
+ * @returns {boolean} true when the line only says which written forms of a still-checked fact were not used
95
+ */
96
+ export function isReadNote(text, signed) {
97
+ const m = /^line \d+: "[^"]*" \u2014 (.+)$/s.exec(String(text));
98
+ if (m === null) return false;
99
+ const names = new Set(Array.isArray(signed) ? signed : []);
100
+ let found = 0;
101
+ const rest = m[1].replace(S1_RE, (all, other) => { if (!names.has(other)) return all; found += 1; return ''; }).replace(/\s+/g, ' ').trim();
102
+ return rest === '' ? found > 0 : SENTENCE_RE.test(rest);
103
+ }
104
+
105
+ /** @param {string[]|null|undefined} items @param {string[]|null|undefined} [signed] names of the signed facts (see isReadNote)
106
+ * @returns {{notChecked: string[], readNotes: string[]}} the lines split: what is left unchecked, and how the page was read */
107
+ export function splitReadNotes(items, signed) {
108
+ const all = Array.isArray(items) ? items : [];
109
+ return { notChecked: all.filter((t) => !isReadNote(t, signed)), readNotes: all.filter((t) => isReadNote(t, signed)) };
110
+ }
package/src/pagedry.js ADDED
@@ -0,0 +1,125 @@
1
+ // MONEY BUILD v2 (docs/product/ITEM33-BUILD.md "Addendum 2026-10-21 — money build spec v2", part 2): "CAN IT READ". After the page rules, a $0 dry
2
+ // read asks of each kept money fact whether the REAL run-time checker can read a price on the page's own result lines. For every result line it
3
+ // grades an HONEST claim (quote = that line, value = the line's amount) with `gradeStep` (src/rubricstep.js), the one reader the run itself
4
+ // uses - no copy of its logic here. A fact that reads no result line is dropped as `reads-no-result` and shows under Not checked.
5
+ // No limits, no counts, no pass bar: only "can it read". Async (gradeStep is), so it sits beside applyPageRules, not inside it: the caller is
6
+ // src/pagemap.js `runPageMap`. No I/O, no clock, no model.
7
+
8
+ import { createHash } from 'node:crypto';
9
+ import { gradeStep } from './rubricstep.js';
10
+ import { linesOf } from './pagerules.js';
11
+
12
+ /** @typedef {import('./pagerules.js').KeptFact} KeptFact @typedef {import('./pagerules.js').Dropped} Dropped */
13
+
14
+ /** a number as the page writes it, thousands commas allowed */
15
+ const NUM = '\\d[\\d,]*(?:\\.\\d+)?';
16
+ const AFTER_LABEL = new RegExp(`^\\s*(?:[$€£]\\s*)?(${NUM})`);
17
+ const BEFORE_UNIT = new RegExp(`(${NUM})\\s*$`);
18
+ /** the label every dry-read claim is made under (the checker only needs one name; the fact's own is not read) */
19
+ const CLAIM = 'price';
20
+ const SOURCE = 'page';
21
+
22
+ /**
23
+ * The result lines of a kept money fact: with a label, every page line where a kept label is followed by a number (one sign between allowed);
24
+ * with no label (U1 moved it to a unit, e.g. "€533 total"), every line where a number is followed by a kept unit.
25
+ * @param {string} text @param {KeptFact} k @returns {{quote: string, value: string}[]}
26
+ */
27
+ export function moneyResultLines(text, k) {
28
+ const raw = text.split('\n');
29
+ const squashed = linesOf(text);
30
+ /** @type {{quote: string, value: string}[]} */
31
+ const out = [];
32
+ squashed.forEach((ln, i) => {
33
+ /** @type {string|undefined} */
34
+ let amount;
35
+ if (k.words.length) {
36
+ for (const w of k.words) {
37
+ for (let at = ln.indexOf(w); at !== -1 && amount === undefined; at = ln.indexOf(w, at + w.length)) amount = AFTER_LABEL.exec(ln.slice(at + w.length))?.[1];
38
+ if (amount !== undefined) break;
39
+ }
40
+ } else {
41
+ for (const u of k.unit ?? []) {
42
+ for (let at = ln.indexOf(u); at !== -1 && amount === undefined; at = ln.indexOf(u, at + u.length)) amount = BEFORE_UNIT.exec(ln.slice(0, at))?.[1];
43
+ if (amount !== undefined) break;
44
+ }
45
+ }
46
+ if (amount !== undefined) out.push({ quote: raw[i].trim(), value: amount.replace(/,/g, '') });
47
+ });
48
+ return out;
49
+ }
50
+
51
+ /**
52
+ * The fact as a run would sign it, for the dry read: the primary label is the anchor (none when U1 moved every label), then the kept units and currencies.
53
+ * @param {KeptFact} k @returns {Record<string, any>}
54
+ */
55
+ const signedOf = (k) => ({ name: CLAIM, shape: 'money', ...(k.words.length ? { anchor: k.words[0] } : {}), ...(k.currency ? { currency: k.currency } : {}), ...(k.unit ? { unit: k.unit } : {}) });
56
+
57
+ /**
58
+ * Dry-read every kept money fact; drop (as `reads-no-result`) the ones whose forms read no result line. Other facts pass through untouched.
59
+ * @param {string} text the frozen page text @param {KeptFact[]} facts what applyPageRules kept
60
+ * @returns {Promise<{facts: KeptFact[], dropped: Dropped[], reads: Record<string, number>, results: Record<string, number>}>}
61
+ * `reads` / `results` = result lines the checker read / lines found, per fact name (for tests and reports; no verdict derives from the numbers)
62
+ */
63
+ export async function dryReadMoney(text, facts) {
64
+ const sha = createHash('sha256').update(text).digest('hex');
65
+ /** @type {KeptFact[]} */
66
+ const kept = [];
67
+ /** @type {Dropped[]} */
68
+ const dropped = [];
69
+ /** @type {Record<string, number>} */
70
+ const reads = {};
71
+ /** @type {Record<string, number>} */
72
+ const results = {};
73
+ for (const k of facts) {
74
+ if (k.shape !== 'money') { kept.push(k); continue; }
75
+ const fact = signedOf(k);
76
+ const lines = moneyResultLines(text, k);
77
+ let n = 0;
78
+ for (const r of lines) {
79
+ const params = { step: 1, parts: [{ text: 'dry read', class: 'fact', source: SOURCE, covers: [1], facts: [fact] }] };
80
+ const g = await gradeStep({ params, bytes: JSON.stringify({ results: [{ quote: r.quote, values: { [CLAIM]: r.value } }] }), outputBytes: null, inputs: { [SOURCE]: text } }, { [SOURCE]: sha });
81
+ if (g.verdict === 'green') n += 1;
82
+ }
83
+ reads[k.name] = n;
84
+ results[k.name] = lines.length;
85
+ if (n > 0) { kept.push(k); continue; }
86
+ dropped.push({ line: k.line, ...(k.forRule === undefined ? {} : { rule: k.forRule }), fact: k.name, reason: 'reads-no-result' });
87
+ }
88
+ return { facts: kept, dropped, reads, results };
89
+ }
90
+
91
+ /**
92
+ * The same "can it read" for PHRASE facts (hamr ruled 3a, run m10-walk-flights-3: "Rome" was on the page only in "Track prices from Madrid to
93
+ * Rome" switches, in none of the 18 result lines, so no result could ever say it). A kept phrase fact is read against the page's RESULT LINES =
94
+ * the lines the job's kept money facts read (`moneyResultLines`), each graded as an honest claim by the real `gradeStep` (quote = that line;
95
+ * a phrase has no value to write). A phrase that reads no result line is dropped whole as `phrase-no-result` and shows under Not checked.
96
+ * With no kept money fact the result lines are unknown, so phrase facts pass through untouched (nothing is guessed about what a result is).
97
+ * @param {string} text the frozen page text @param {KeptFact[]} facts what the money dry read kept
98
+ * @returns {Promise<{facts: KeptFact[], dropped: Dropped[], reads: Record<string, number>}>} `reads` = result lines that said the phrase, per fact name
99
+ */
100
+ export async function dryReadPhrases(text, facts) {
101
+ const sha = createHash('sha256').update(text).digest('hex');
102
+ /** @type {Map<string, true>} */
103
+ const quotes = new Map();
104
+ for (const k of facts) if (k.shape === 'money') for (const r of moneyResultLines(text, k)) quotes.set(r.quote, true);
105
+ /** @type {KeptFact[]} */
106
+ const kept = [];
107
+ /** @type {Dropped[]} */
108
+ const dropped = [];
109
+ /** @type {Record<string, number>} */
110
+ const reads = {};
111
+ for (const k of facts) {
112
+ if (k.shape !== 'phrase' || quotes.size === 0 || !k.words.length) { kept.push(k); continue; }
113
+ const fact = { name: k.name, shape: 'phrase', phrase: k.words[0] };
114
+ let n = 0;
115
+ for (const quote of quotes.keys()) {
116
+ const params = { step: 1, parts: [{ text: 'dry read', class: 'fact', source: SOURCE, covers: [1], facts: [fact] }] };
117
+ const g = await gradeStep({ params, bytes: JSON.stringify({ results: [{ quote, values: {} }] }), outputBytes: null, inputs: { [SOURCE]: text } }, { [SOURCE]: sha });
118
+ if (g.verdict === 'green') n += 1;
119
+ }
120
+ reads[k.name] = n;
121
+ if (n > 0) { kept.push(k); continue; }
122
+ dropped.push({ line: k.line, ...(k.forRule === undefined ? {} : { rule: k.forRule }), fact: k.name, other: k.words[0], reason: 'phrase-no-result' });
123
+ }
124
+ return { facts: kept, dropped, reads };
125
+ }
package/src/pagemap.js CHANGED
@@ -15,6 +15,7 @@ import { isOutputCutOff } from './authorflow.js';
15
15
  import { AUTHOR_CALL_TIMEOUT_MS, AUTHOR_SCOUT_MAX_TOKENS, SURVEY_CAUSES } from './authorscout.js';
16
16
  import { lineRules, boundInWords, wordsSaidOn } from './rubricstep.js';
17
17
  import { applyPageRules, validReading } from './pagerules.js';
18
+ import { dryReadMoney, dryReadPhrases } from './pagedry.js';
18
19
  import { ruleWords } from './facts.js';
19
20
  import { lookFactOf } from './inputlook.js';
20
21
 
@@ -164,8 +165,12 @@ export async function runPageMap({
164
165
  if (malformed >= 2) return absent(MAP_UNREADABLE, SURVEY_CAUSES.UNPARSEABLE);
165
166
  }
166
167
  const kept = applyPageRules(texts[label], mapping, jobLines);
167
- for (const f of kept.facts) facts.push(lookFactOf(f, label));
168
- for (const d of kept.dropped) dropped.push({ ...d, source: label });
168
+ // money build v2: "can it read" - a money fact whose forms read no result line of the page, by the run's own checker, is Not checked
169
+ const read = await dryReadMoney(texts[label], kept.facts);
170
+ // hamr 3a: a phrase fact is read against the same result lines (only when the job keeps a money fact)
171
+ const phr = await dryReadPhrases(texts[label], read.facts);
172
+ for (const f of phr.facts) facts.push(lookFactOf(f, label));
173
+ for (const d of [...kept.dropped, ...read.dropped, ...phr.dropped]) dropped.push({ ...d, source: label });
169
174
  maps.push({ source: label, facts: mapping.facts.length });
170
175
  looks.push(label);
171
176
  }
package/src/pagerules.js CHANGED
@@ -200,7 +200,9 @@ export function applyPageRules(text, mapping, jobLines) {
200
200
  /** @type {string[]} */ let uni = [];
201
201
  /** @type {string[]} */ let cur = [];
202
202
  if (f.shape === 'money') {
203
- // the forms: units first (the U1-moved words, then the AI's listed units), then currency signs
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
204
206
  const listedCurrencies = (Array.isArray(f.currency) ? f.currency : []).map(String);
205
207
  // a currency SIGN listed in BOTH unit and currency is said once: the unit copy goes (fwd F71 drafts)
206
208
  const listedUnits = (Array.isArray(f.unit) ? f.unit : []).map(String).filter((u) => {
@@ -208,12 +210,15 @@ export function applyPageRules(text, mapping, jobLines) {
208
210
  drop(f, u, 'sign-twice');
209
211
  return false;
210
212
  });
211
- const units = [...new Set([...moved, ...listedUnits])].filter((u) => {
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) => {
212
217
  if (countPhrase(text, u) >= 1) return true;
213
218
  drop(f, u, 'not-on-page');
214
219
  return false;
215
220
  });
216
- const curs = listedCurrencies.filter((c) => {
221
+ const curs = [...new Set(pool.filter((w) => CURRENCY_SYMBOLS.includes(w)))].filter((c) => {
217
222
  if (countExact(text, c) >= 1) return true;
218
223
  drop(f, c, 'not-on-page');
219
224
  return false;
@@ -230,12 +235,6 @@ export function applyPageRules(text, mapping, jobLines) {
230
235
  uni = keep.filter((x) => x.kind === 'unit').map((x) => x.w);
231
236
  cur = keep.filter((x) => x.kind === 'currency').map((x) => x.w);
232
237
  }
233
- // a currency that is a word ("US dollars") is read after the number, so it is a unit, never a currency sign
234
- cur = cur.filter((c) => {
235
- if (CURRENCY_SYMBOLS.includes(c)) return true;
236
- drop(f, c, 'currency-not-symbol');
237
- return false;
238
- });
239
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
240
239
  uni = uni.filter((u, i) => {
241
240
  if (u.length > MAX_UNIT_CHARS) { drop(f, u, 'unit-too-long'); return false; }
@@ -51,9 +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
56
  import { isDropIt, isAgree } from '../pagemap.js';
57
+ import { FIT_ABSENT_NOTE } from '../fitcheck.js';
57
58
  import { tallyCalls } from '../text.js';
58
59
  import { writeDraftSpend, appendDraftLog } from '../draftspend.js';
59
60
  import { PLAIN_PROPOSAL_STOPS, proposalStopText, signingStopText, draftStopText, redsRecord, declarationRecord, calibrationSummary, tryRefusedText } from '../authorreadout.js';
@@ -118,6 +119,9 @@ export const PHASE_STEP = Object.freeze({
118
119
  'scout-done': 'scout',
119
120
  look: 'look',
120
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',
121
125
  confirm: 'confirm',
122
126
  'confirm-turn-done': 'confirm',
123
127
  listing: 'listing',
@@ -166,7 +170,7 @@ export const PLAN_FOOTER = 'Sign & run to confirm this plan, or Revise to edit t
166
170
  * The confirm turn's plan as a chat message (P7): a STRUCTURED `{role:'bot', kind:'plan', plan}` the page draws with the
167
171
  * same renderer the Job tab uses — each numbered job line, its `~` rule, its drafted checks folded; the AI's own "not
168
172
  * checked"; the always-on guards. `questions` (what the plan still asks) ride inside the plan. One owner of the shape.
169
- * @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
170
174
  * @param {{n: number, text: string, rule: string}[]|null} jobLines
171
175
  * @returns {{role: 'bot', kind: 'plan', text: string, plan: ReturnType<typeof planFromConfirm> & {goal: string, questions: string[]}}}
172
176
  */
@@ -404,7 +408,7 @@ export function validateJobCard(card, opts = {}) {
404
408
  * @param {any} card the validated job card (see {@link validateJobCard})
405
409
  * @param {{env?: Record<string,string|undefined>, sessionsRoot?: string, home?: string, timeoutMs?: number,
406
410
  * scout?: any, generate?: Function, confirmGenerate?: Function, authorFn?: Function,
407
- * 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
408
412
  * TEST SEAMS ONLY — see the note just above where each is read, below. `reuse` (Reuse workflow) is NOT a seam: the
409
413
  * caller (`/api/author/start`) sets it only when the server's own same-job rule held.
410
414
  * @returns {any} the session object
@@ -560,7 +564,8 @@ export function createSession(card, deps = {}) {
560
564
  if (step.kind === 'language') say('bot', `${step.field?.prompt ?? 'Which language is this job about?'} (${(step.candidates ?? []).join(', ')})`);
561
565
  else if (step.kind === 'menu') {
562
566
  sayPlan(step.plan ?? {});
563
- } 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}`);
564
569
  else if (step.kind === 'goal') say('bot', 'Type the goal sentence yourself.');
565
570
  });
566
571
 
@@ -575,6 +580,19 @@ export function createSession(card, deps = {}) {
575
580
  };
576
581
 
577
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
+ };
578
596
  const onPhase = (name, data = {}) => {
579
597
  // COLLAPSED INTO THE PROGRESS INDICATOR ONLY (build item 3) — this used
580
598
  // to also `say('system', `… ${name}`)`, one jargon chat bubble per
@@ -1068,6 +1086,16 @@ export function createSession(card, deps = {}) {
1068
1086
  return { jobLines, answers, left };
1069
1087
  };
1070
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
+ }
1097
+ };
1098
+
1071
1099
  state.phase = 'drafting';
1072
1100
  // no early "drafting" line: the real author phase (PHASE_STEP) starts it, carrying draftDetail (see onPhase)
1073
1101
  // build item 4 — the DISPLAY id (card.model, e.g. "deepseek-flash"), not
@@ -1087,6 +1115,9 @@ export function createSession(card, deps = {}) {
1087
1115
  ...(deps.scout ? { scout: deps.scout } : {}),
1088
1116
  // PART 1c: a test that replaces the model boundary (`generate`) must not reach the real paid look; `deps.lookFn` brings its own
1089
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 } : {})),
1090
1121
  ...(authorFnOverride ? { authorFn: authorFnOverride } : {}),
1091
1122
  });
1092
1123
 
@@ -1135,7 +1166,7 @@ export function createSession(card, deps = {}) {
1135
1166
  const rec = reconcileRubricPlan({
1136
1167
  confirmed: authored.confirmed?.checkItems ?? [], notChecked: authored.confirmed?.notChecked ?? [], closeDecl: spec.closeDecl, jobLines,
1137
1168
  });
1138
- 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) };
1139
1170
  }
1140
1171
  await finish(spec, authored.seedRef);
1141
1172
  if (state.finalPlan && state.phase === 'prepared') {
@@ -1144,7 +1175,7 @@ export function createSession(card, deps = {}) {
1144
1175
  // dropped, so drafter prose can never sit above (and be read instead of) the code's sentences (run mv5e1ktb).
1145
1176
  state.messages = state.messages.filter((/** @type {any} */ m) => m.kind !== 'plan');
1146
1177
  state.messages.push({
1147
- ...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),
1148
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.',
1149
1180
  });
1150
1181
  }
@@ -3599,6 +3599,7 @@
3599
3599
  items.map(function(x){ return "<li>" + escapeXml(x) + "</li>"; }).join("") + "</ul></details>";
3600
3600
  };
3601
3601
  h += fold("Not checked (the AI's own reading)", plan.notChecked, "plan-notchecked");
3602
+ h += fold("How the page was read", plan.readNotes, "plan-readnotes");
3602
3603
  h += fold("Always checked", plan.alwaysOn, "plan-alwayson");
3603
3604
  return h + "</div>";
3604
3605
  }
@@ -3831,6 +3832,9 @@
3831
3832
  if((data.notChecked || []).length){
3832
3833
  h += '<div class="job-card"><h4>Not checked</h4>' + resultNotChecked("Not checked (the AI's own reading)", data.notChecked, "result-notchecked") + '</div>';
3833
3834
  }
3835
+ if((data.readNotes || []).length){
3836
+ h += '<div class="job-card"><h4>How the page was read</h4>' + resultNotChecked("How the page was read \u00b7 " + data.readNotes.length, data.readNotes, "result-readnotes") + '</div>';
3837
+ }
3834
3838
  if(detail.accept === true && !detail.live) h += resultDoorHtml(data.door);
3835
3839
  if((data.delivered || []).length){
3836
3840
  h += '<div class="job-card" data-testid="result-saved"><h4>Saved</h4>' + data.delivered.map(function(d){
@@ -11,11 +11,12 @@
11
11
  // `cls` is 'machine check' | 'judge' | null (null = not known yet: the confirm card's checks are the model's plan, the
12
12
  // stage kinds exist only after drafting). Pure; no file IO.
13
13
  import { KIND_CATALOGUE } from '../authoring.js';
14
- import { checksOfClose, proseNotChecked, lineRules, confirmedGoalOf } from '../rubricstep.js';
14
+ import { splitReadNotes } from '../pagedropwords.js';
15
+ import { checksOfClose, signedFactNames, proseNotChecked, lineRules, confirmedGoalOf } from '../rubricstep.js';
15
16
 
16
17
  /** @typedef {{name: string, cls: 'machine check'|'judge'|null}} PlanCheck */
17
18
  /** @typedef {{n: number, text: string, rule: string, rules: string[], checks: PlanCheck[], refused: string[]}} PlanLine */
18
- /** @typedef {{lines: PlanLine[], loose: PlanCheck[], notChecked: string[], alwaysOn: string[], older: boolean, goal?: string}} JobPlan */
19
+ /** @typedef {{lines: PlanLine[], loose: PlanCheck[], notChecked: string[], readNotes: string[], alwaysOn: string[], older: boolean, goal?: string}} JobPlan */
19
20
 
20
21
  /**
21
22
  * `machine check` for a kind a command decides, `judge` for one that needs judgement; `null` for a stage whose kind is
@@ -65,10 +66,12 @@ export function planFromSpec(spec) {
65
66
  .map((/** @type {any} */ s) => ({ name: s.name, cls: /** @type {'machine check'} */ ('machine check'), fromLine: null }));
66
67
  }
67
68
  // a prose step says what it does not check (PART 1b, RF5): the person judges the text at Accept
68
- const notes = [
69
+ const allNotes = [
69
70
  ...(Array.isArray(spec?.closeDecl?.notes) ? spec.closeDecl.notes.filter((/** @type {any} */ n) => typeof n === 'string' && n) : []),
70
71
  ...proseNotChecked(spec?.closeDecl),
71
72
  ];
73
+ // code's notes on which written forms of a still-checked fact were not used are "How the page was read", not Not checked
74
+ const { notChecked: notes, readNotes } = splitReadNotes(allNotes, signedFactNames(spec?.closeDecl));
72
75
  const jl = Array.isArray(spec?.jobLines) ? spec.jobLines.filter((/** @type {any} */ l) => l && Number.isInteger(l.n) && typeof l.text === 'string') : [];
73
76
  if (jl.length === 0) {
74
77
  return {
@@ -76,7 +79,7 @@ export function planFromSpec(spec) {
76
79
  n: 1, text: typeof spec?.goal === 'string' && spec.goal ? spec.goal : '(no goal recorded)', rule: '', rules: [],
77
80
  checks: stages.map((s) => ({ name: s.name, cls: s.cls })), refused: [],
78
81
  }],
79
- loose: [], notChecked: notes, alwaysOn: [], older: true,
82
+ loose: [], notChecked: notes, readNotes, alwaysOn: [], older: true,
80
83
  };
81
84
  }
82
85
  /** @type {PlanLine[]} */
@@ -102,14 +105,15 @@ export function planFromSpec(spec) {
102
105
  // the model's signed goal sentence rides along: the page draws it as the "AI's summary" row (an older job shows it as line 1 instead)
103
106
  // (a rubric job's signed goal is the whole worker brief: only the sentence the person confirmed is the summary)
104
107
  const summary = typeof spec?.goal === 'string' && spec.goal ? confirmedGoalOf(spec.goal) : null;
105
- return { lines, loose, notChecked: notes, alwaysOn, older: false, ...(summary ? { goal: summary } : {}) };
108
+ return { lines, loose, notChecked: notes, readNotes, alwaysOn, older: false, ...(summary ? { goal: summary } : {}) };
106
109
  }
107
110
 
108
111
  /**
109
112
  * The plan of the confirm turn, before anything is drafted: the person's own job lines, the model's checks under the
110
113
  * line each names, its own "not checked" words, and the real protections (code-owned) as "Always checked".
111
114
  * @param {{n: number, text: string, rule: string, rules?: string[]}[]|null|undefined} jobLines
112
- * @param {{checkItems?: {text: string, fromLine: number[]|null}[], checks?: string[], notChecked?: string[], protections?: string[]}} plan
115
+ * @param {{checkItems?: {text: string, fromLine: number[]|null}[], checks?: string[], notChecked?: string[], protections?: string[], signedFacts?: string[]}} plan
116
+ * `signedFacts` = the names of the facts the look signs (so an S1 copy of a signed fact reads as a note, not Not checked)
113
117
  * @returns {JobPlan}
114
118
  */
115
119
  export function planFromConfirm(jobLines, plan) {
@@ -126,7 +130,8 @@ export function planFromConfirm(jobLines, plan) {
126
130
  for (const line of served) line.checks.push({ name: c.text, cls: null });
127
131
  if (served.length === 0) loose.push({ name: c.text, cls: null });
128
132
  }
133
+ const { notChecked, readNotes } = splitReadNotes(plan?.notChecked, plan?.signedFacts);
129
134
  return {
130
- lines, loose, notChecked: [...(plan?.notChecked ?? [])], alwaysOn: [...(plan?.protections ?? [])], older: false,
135
+ lines, loose, notChecked, readNotes, alwaysOn: [...(plan?.protections ?? [])], older: false,
131
136
  };
132
137
  }
@@ -1445,6 +1445,7 @@ export function getRunResult(runid, /** @type {{ home?: string, sessionsRoot?: s
1445
1445
  pages,
1446
1446
  pageNote: pages.length ? RESULT_PAGE_MAY_CHANGE : null,
1447
1447
  notChecked: bottom,
1448
+ readNotes: plan.readNotes,
1448
1449
  // the door's words, from the one table (src/panel/status.js DOOR_WORDS); the page types none of them
1449
1450
  door: { head: DOOR_WORDS.waiting.head, ask: RESULT_DOOR_ASK },
1450
1451
  delivered: delivered.map((d, i) => ({
@@ -65,6 +65,10 @@ export const PROMPT_REGISTERS = Object.freeze([
65
65
  Object.freeze({ file: 'src/pagemap.js', name: 'MAP_SYSTEM' }),
66
66
  Object.freeze({ file: 'src/pagemap.js', name: 'mapPrompt() (inline template, not a const)' }),
67
67
 
68
+ // src/fitcheck.js - the fit check call (F243): the AI splits each job line into asks and quotes the page for each
69
+ Object.freeze({ file: 'src/fitcheck.js', name: 'FIT_SYSTEM' }),
70
+ Object.freeze({ file: 'src/fitcheck.js', name: 'fitPrompt() (inline template, not a const)' }),
71
+
68
72
  // src/readshim.js — the capped/deduplicating read shim's worker-facing strategy
69
73
  Object.freeze({ file: 'src/readshim.js', name: 'READ_SHIM_STRATEGY' }),
70
74
  Object.freeze({ file: 'src/readshim.js', name: 'READ_SHIM_DIFF_STRATEGY' }),
package/src/rubricstep.js CHANGED
@@ -613,18 +613,37 @@ export function lineRules(l) {
613
613
  return isRec(l) && typeof l.rule === 'string' && l.rule ? l.rule.split('; ').filter(Boolean) : [];
614
614
  }
615
615
 
616
- /** @param {unknown} b @returns {string[]} the spellings a rule bound can have in the person's own words */
617
- const boundSpellings = (b) => {
618
- const s = String(b);
619
- return [s, s.replace(/^0+(?=\d)/, ''), s.replace(/\.0+$/, '')];
616
+ /** @param {string} t @returns {string} a time token as compared: no spaces, lower case, no leading zero on the hour */
617
+ const timeKey = (t) => t.replace(/\s+/g, '').toLowerCase().replace(/^0(?=\d:)/, '');
618
+
619
+ /**
620
+ * The person's words read by KIND, so a digit inside a time, a date or an amount belongs to that token only (F242).
621
+ * @param {string} text @returns {{bare: Set<number>, amount: Set<number>, time: Set<string>, date: Set<string>}}
622
+ */
623
+ const tokensSaid = (text) => {
624
+ const out = { bare: new Set(), amount: new Set(), time: new Set(), date: new Set() };
625
+ const re = /(\d{1,2}:\d{2}(?:\s?[AaPp][Mm])?)|(\d{4}-\d{1,2}-\d{1,2}|\d{1,2}[/.]\d{1,2}[/.]\d{2,4})|([$\u20ac\u00a3]\s?\d[\d,]*(?:\.\d+)?|\d[\d,]*(?:\.\d+)?\s?(?:US\s)?(?:dollars?|euros?|pounds?|USD|EUR|GBP)\b)|(\d[\d,]*(?:\.\d+)?)/gi;
626
+ for (const m of text.matchAll(re)) {
627
+ if (m[1]) out.time.add(timeKey(m[1]));
628
+ else if (m[2]) out.date.add(m[2]);
629
+ else if (m[3]) out.amount.add(Number(m[3].match(/\d[\d,]*(?:\.\d+)?/)?.[0].replace(/,/g, '')));
630
+ else out.bare.add(Number(m[4].replace(/,/g, '')));
631
+ }
632
+ return out;
620
633
  };
621
634
 
622
635
  /**
623
636
  * Is this rule bound in the words the person wrote? The ONE test for "a number the AI made" (`rubric-fact-unmarked`, the L3 settle, and
624
- * the early ask of PRD v1.107 Part 5b all read it; never a second spelling).
637
+ * the early ask of PRD v1.107 Part 5b all read it; never a second spelling). By kind: a number is said only as a bare number or an
638
+ * amount's number; a time only as a time token (hour leading zero ignored); a date only as a date token. Never a piece of a token.
625
639
  * @param {unknown} b @param {string} said @returns {boolean}
626
640
  */
627
- export const boundInWords = (b, said) => boundSpellings(b).some((sp) => said.includes(sp));
641
+ export const boundInWords = (b, said) => {
642
+ const t = tokensSaid(said);
643
+ if (typeof b === 'number') return t.bare.has(b) || t.amount.has(b);
644
+ if (typeof b !== 'string') return false;
645
+ return /^\s*\d{1,2}:\d{2}/.test(b) ? t.time.has(timeKey(b)) : t.date.has(b.trim());
646
+ };
628
647
 
629
648
  /** @param {any} jl one job line @returns {string} the words the person wrote on it: the line and its `~` rules */
630
649
  export const wordsSaidOn = (jl) => `${jl.text ?? ''} ${lineRules(jl).join(' ')}`;
@@ -844,6 +863,17 @@ export function checksOfClose(closeDecl) {
844
863
  : []));
845
864
  }
846
865
 
866
+ /**
867
+ * The names of the facts a signed close checks (every fact part of every rubric-step stage). Pure.
868
+ * @param {any} closeDecl @returns {string[]}
869
+ */
870
+ export function signedFactNames(closeDecl) {
871
+ const stages = isRec(closeDecl) && Array.isArray(closeDecl.stages) ? closeDecl.stages : [];
872
+ return stages.flatMap((/** @type {any} */ st) => (isRec(st) && st.kind === 'rubric-step' && isRec(st.params) && Array.isArray(st.params.parts)
873
+ ? st.params.parts.filter((/** @type {any} */ p) => isRec(p) && p.class === 'fact' && Array.isArray(p.facts)).flatMap((/** @type {any} */ p) => p.facts.filter((/** @type {any} */ f) => isRec(f) && typeof f.name === 'string').map((/** @type {any} */ f) => f.name))
874
+ : []));
875
+ }
876
+
847
877
  /**
848
878
  * Reconcile the CONFIRMED plan with the close the composer actually wrote. The plan card after drafting shows exactly
849
879
  * the close's own checks (`checksOfClose`); every confirmed check that no part carries out (no part lists its number in
@@ -87,7 +87,8 @@ export function refuseLockedKind(kind: string): Refusal;
87
87
  * rates?: {in: number, out: number}|null, draftThinking?: 'on'|'off',
88
88
  * onPhase?: (phase: string, data?: any) => void,
89
89
  * onCall?: (call: {label: string, costUsd: number|null, unpricedRounds: number, thinkingStatus?: string}) => void,
90
- * seedFn?: Function, scoutFn?: Function, listingFn?: Function, lookFn?: Function|null,
90
+ * seedFn?: Function, scoutFn?: Function, listingFn?: Function, lookFn?: Function|null, fitFn?: Function|null,
91
+ * fitAsk?: ((question: string) => Promise<string|null>)|null, onFit?: (fit: {state: string, asks: any[], notFound: any[]}) => void,
91
92
  * authorFn?: Function, authorOpts?: object, writeScope?: string[]|null,
92
93
  * signerFix?: Function|any, proposeFn?: Function, compileOpts?: object,
93
94
  * ask?: ((step: {kind: string, [k: string]: any}) => Promise<string|null>)|null,
@@ -99,7 +100,7 @@ export function refuseLockedKind(kind: string): Refusal;
99
100
  * closeDecl: any, seedRef: string|null, authoring: any, judged: any, reds: Red[],
100
101
  * stop: string|null, cost: any, interview: any, confirmed: any}>}
101
102
  */
102
- export function authorCloseForJob({ answers, repoPath, lang, verdictType, questions, jobLines, inputs, generate, provider, seedRef, scout, listing, ceilingUsd, rates, draftThinking, writeScope, judgeModel, onPhase, onCall, seedFn, scoutFn, listingFn, lookFn, authorFn, authorOpts, signerFix, proposeFn, compileOpts, ask, confirmGenerate, isRepo, langResult, answerQuestions, }: {
103
+ export function authorCloseForJob({ answers, repoPath, lang, verdictType, questions, jobLines, inputs, generate, provider, seedRef, scout, listing, ceilingUsd, rates, draftThinking, writeScope, judgeModel, onPhase, onCall, seedFn, scoutFn, listingFn, lookFn, fitFn, fitAsk, onFit, authorFn, authorOpts, signerFix, proposeFn, compileOpts, ask, confirmGenerate, isRepo, langResult, answerQuestions, }: {
103
104
  answers: Record<string | number, any>;
104
105
  repoPath?: string | null;
105
106
  lang: string;
@@ -137,6 +138,13 @@ export function authorCloseForJob({ answers, repoPath, lang, verdictType, questi
137
138
  scoutFn?: Function;
138
139
  listingFn?: Function;
139
140
  lookFn?: Function | null;
141
+ fitFn?: Function | null;
142
+ fitAsk?: ((question: string) => Promise<string | null>) | null;
143
+ onFit?: (fit: {
144
+ state: string;
145
+ asks: any[];
146
+ notFound: any[];
147
+ }) => void;
140
148
  authorFn?: Function;
141
149
  authorOpts?: object;
142
150
  writeScope?: string[] | null;
@@ -0,0 +1,113 @@
1
+ /**
2
+ * What the AI is told: the job lines (the numbered text only, no `~` rules) and the WHOLE text of every input under its label. Adapted from the POC
3
+ * call (.claude/scratch/fit-poc6/run.mjs): the asks are copied from each job line instead of hand-written.
4
+ * @param {{jobLines: {n: number, text: string}[], texts: Record<string, string>}} o
5
+ * @returns {string}
6
+ */
7
+ export function fitPrompt({ jobLines, texts }: {
8
+ jobLines: {
9
+ n: number;
10
+ text: string;
11
+ }[];
12
+ texts: Record<string, string>;
13
+ }): string;
14
+ /**
15
+ * Read the AI's reply and decide, all in code. Malformed (no repair): not JSON, another form, a line that is not a job line, an ask not copied from its own
16
+ * line, found not a boolean, a quote neither text nor null, or a job line with no ask. An ask is found only if `found` is true AND its quote is on a page;
17
+ * `found` with a quote that is not on any page (or no quote) is made up and counts as not found.
18
+ * @param {unknown} text the reply
19
+ * @param {{n: number, text: string}[]} jobLines
20
+ * @param {Record<string, string>} texts the frozen input texts by label
21
+ * @returns {{status: 'malformed'}|{status: 'ok', state: 'fits'|'ask', asks: FitAsk[], notFound: {line: number, ask: string}[]}}
22
+ */
23
+ export function decideFit(text: unknown, jobLines: {
24
+ n: number;
25
+ text: string;
26
+ }[], texts: Record<string, string>): {
27
+ status: "malformed";
28
+ } | {
29
+ status: "ok";
30
+ state: "fits" | "ask";
31
+ asks: FitAsk[];
32
+ notFound: {
33
+ line: number;
34
+ ask: string;
35
+ }[];
36
+ };
37
+ /**
38
+ * Run the fit check: one call (one retry on a malformed reply or a transport failure or a 429). Never throws on a call casualty; every failure is `absent`.
39
+ * @param {{jobLines: {n: number, text: string}[], texts: Record<string, string>, provider?: any, maxTokens?: number, ceilingUsd?: number|null,
40
+ * callTimeoutMs?: number, onCall?: (call: {label: string, costUsd: number|null, unpricedRounds: number, thinkingStatus?: string, raw?: string}) => void,
41
+ * createLoop?: Function, rates?: {in: number, out: number}|null, callOptions?: Record<string, any>, sleep?: (ms: number) => Promise<void>}} o
42
+ * @returns {Promise<{state: 'fits'|'ask'|'absent', asks: FitAsk[], notFound: {line: number, ask: string}[], reason: string|null, cause: string|null,
43
+ * calls: {label: string, costUsd: number|null, unpricedRounds: number}[], budgetStop: 'cap-halt'|'pricing-red'|null}>}
44
+ */
45
+ export function runFitCheck({ jobLines, texts, provider, maxTokens, ceilingUsd, callTimeoutMs, onCall, createLoop, rates, callOptions, sleep, }: {
46
+ jobLines: {
47
+ n: number;
48
+ text: string;
49
+ }[];
50
+ texts: Record<string, string>;
51
+ provider?: any;
52
+ maxTokens?: number;
53
+ ceilingUsd?: number | null;
54
+ callTimeoutMs?: number;
55
+ onCall?: (call: {
56
+ label: string;
57
+ costUsd: number | null;
58
+ unpricedRounds: number;
59
+ thinkingStatus?: string;
60
+ raw?: string;
61
+ }) => void;
62
+ createLoop?: Function;
63
+ rates?: {
64
+ in: number;
65
+ out: number;
66
+ } | null;
67
+ callOptions?: Record<string, any>;
68
+ sleep?: (ms: number) => Promise<void>;
69
+ }): Promise<{
70
+ state: "fits" | "ask" | "absent";
71
+ asks: FitAsk[];
72
+ notFound: {
73
+ line: number;
74
+ ask: string;
75
+ }[];
76
+ reason: string | null;
77
+ cause: string | null;
78
+ calls: {
79
+ label: string;
80
+ costUsd: number | null;
81
+ unpricedRounds: number;
82
+ }[];
83
+ budgetStop: "cap-halt" | "pricing-red" | null;
84
+ }>;
85
+ /** the label of the one paid call, as the drafting book and the progress line see it */
86
+ export const FIT_CALL_LABEL: "fit-check";
87
+ /** what the chat says when the check did not finish (drafting goes on) */
88
+ export const FIT_ABSENT_NOTE: "The fit check did not finish; drafting goes on.";
89
+ /** the answer that keeps drafting after the fit question (any case, a closing full stop allowed) */
90
+ export const FIT_GO_ON: "go on";
91
+ export function isGoOn(text: unknown): boolean;
92
+ export const FIT_SYSTEM: "You check whether saved web pages show what a job asks for. You never invent words: every ask must be copied from its job line and every quote must be copied from the page, character for character.";
93
+ export function norm(s: unknown): string;
94
+ export function notFoundList(notFound: {
95
+ line: number;
96
+ ask: string;
97
+ }[]): string;
98
+ export function fitQuestionText({ found, total, notFound }: {
99
+ found: number;
100
+ total: number;
101
+ notFound: {
102
+ line: number;
103
+ ask: string;
104
+ }[];
105
+ }): string;
106
+ export type FitAsk = {
107
+ line: number;
108
+ ask: string;
109
+ found: boolean;
110
+ quote: string | null;
111
+ madeUp: boolean;
112
+ ignored?: "number";
113
+ };
@@ -23,4 +23,17 @@ export function dropLine(row: {
23
23
  fact: string;
24
24
  sentences: string[];
25
25
  }, jobLines: any[] | null | undefined, rulesOf: (l: any) => string[]): string;
26
+ /**
27
+ * An `s1` drop (a second fact that would read the same value as one already kept) is a NOTE when that OTHER fact is still signed: the guardrail
28
+ * is checked by it. With the other fact not signed, or no signed list given, the sentence stays Not checked. Every sentence of a row must qualify.
29
+ * @param {string} text one Not-checked line @param {string[]|null|undefined} [signed] the names of the facts the plan signs
30
+ * @returns {boolean} true when the line only says which written forms of a still-checked fact were not used
31
+ */
32
+ export function isReadNote(text: string, signed?: string[] | null | undefined): boolean;
33
+ /** @param {string[]|null|undefined} items @param {string[]|null|undefined} [signed] names of the signed facts (see isReadNote)
34
+ * @returns {{notChecked: string[], readNotes: string[]}} the lines split: what is left unchecked, and how the page was read */
35
+ export function splitReadNotes(items: string[] | null | undefined, signed?: string[] | null | undefined): {
36
+ notChecked: string[];
37
+ readNotes: string[];
38
+ };
26
39
  export type Dropped = import("./pagerules.js").Dropped;
@@ -0,0 +1,37 @@
1
+ /**
2
+ * The result lines of a kept money fact: with a label, every page line where a kept label is followed by a number (one sign between allowed);
3
+ * with no label (U1 moved it to a unit, e.g. "€533 total"), every line where a number is followed by a kept unit.
4
+ * @param {string} text @param {KeptFact} k @returns {{quote: string, value: string}[]}
5
+ */
6
+ export function moneyResultLines(text: string, k: KeptFact): {
7
+ quote: string;
8
+ value: string;
9
+ }[];
10
+ /**
11
+ * Dry-read every kept money fact; drop (as `reads-no-result`) the ones whose forms read no result line. Other facts pass through untouched.
12
+ * @param {string} text the frozen page text @param {KeptFact[]} facts what applyPageRules kept
13
+ * @returns {Promise<{facts: KeptFact[], dropped: Dropped[], reads: Record<string, number>, results: Record<string, number>}>}
14
+ * `reads` / `results` = result lines the checker read / lines found, per fact name (for tests and reports; no verdict derives from the numbers)
15
+ */
16
+ export function dryReadMoney(text: string, facts: KeptFact[]): Promise<{
17
+ facts: KeptFact[];
18
+ dropped: Dropped[];
19
+ reads: Record<string, number>;
20
+ results: Record<string, number>;
21
+ }>;
22
+ /**
23
+ * The same "can it read" for PHRASE facts (hamr ruled 3a, run m10-walk-flights-3: "Rome" was on the page only in "Track prices from Madrid to
24
+ * Rome" switches, in none of the 18 result lines, so no result could ever say it). A kept phrase fact is read against the page's RESULT LINES =
25
+ * the lines the job's kept money facts read (`moneyResultLines`), each graded as an honest claim by the real `gradeStep` (quote = that line;
26
+ * a phrase has no value to write). A phrase that reads no result line is dropped whole as `phrase-no-result` and shows under Not checked.
27
+ * With no kept money fact the result lines are unknown, so phrase facts pass through untouched (nothing is guessed about what a result is).
28
+ * @param {string} text the frozen page text @param {KeptFact[]} facts what the money dry read kept
29
+ * @returns {Promise<{facts: KeptFact[], dropped: Dropped[], reads: Record<string, number>}>} `reads` = result lines that said the phrase, per fact name
30
+ */
31
+ export function dryReadPhrases(text: string, facts: KeptFact[]): Promise<{
32
+ facts: KeptFact[];
33
+ dropped: Dropped[];
34
+ reads: Record<string, number>;
35
+ }>;
36
+ export type KeptFact = import("./pagerules.js").KeptFact;
37
+ export type Dropped = import("./pagerules.js").Dropped;
@@ -14,7 +14,7 @@ export function advanceSteps(steps: StepLine[], id: string, detail?: string, lab
14
14
  * The confirm turn's plan as a chat message (P7): a STRUCTURED `{role:'bot', kind:'plan', plan}` the page draws with the
15
15
  * same renderer the Job tab uses — each numbered job line, its `~` rule, its drafted checks folded; the AI's own "not
16
16
  * checked"; the always-on guards. `questions` (what the plan still asks) ride inside the plan. One owner of the shape.
17
- * @param {{goal?: string, checks?: string[], checkItems?: {text: string, fromLine: number[]|null}[], notChecked?: string[], questions?: string[], protections?: string[]}} p the confirm turn's plan
17
+ * @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
18
18
  * @param {{n: number, text: string, rule: string}[]|null} jobLines
19
19
  * @returns {{role: 'bot', kind: 'plan', text: string, plan: ReturnType<typeof planFromConfirm> & {goal: string, questions: string[]}}}
20
20
  */
@@ -28,6 +28,7 @@ export function planMessage(p: {
28
28
  notChecked?: string[];
29
29
  questions?: string[];
30
30
  protections?: string[];
31
+ signedFacts?: string[];
31
32
  }, jobLines: {
32
33
  n: number;
33
34
  text: string;
@@ -165,7 +166,7 @@ export function validateJobCard(card: any, opts?: {
165
166
  * @param {any} card the validated job card (see {@link validateJobCard})
166
167
  * @param {{env?: Record<string,string|undefined>, sessionsRoot?: string, home?: string, timeoutMs?: number,
167
168
  * scout?: any, generate?: Function, confirmGenerate?: Function, authorFn?: Function,
168
- * 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
169
+ * 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
169
170
  * TEST SEAMS ONLY — see the note just above where each is read, below. `reuse` (Reuse workflow) is NOT a seam: the
170
171
  * caller (`/api/author/start`) sets it only when the server's own same-job rule held.
171
172
  * @returns {any} the session object
@@ -182,6 +183,7 @@ export function createSession(card: any, deps?: {
182
183
  prepareSigningFn?: Function;
183
184
  npmCiFn?: Function;
184
185
  lookFn?: Function | null;
186
+ fitFn?: Function | null;
185
187
  fetchUrl?: (url: string) => Promise<{
186
188
  ok: true;
187
189
  text: string;
@@ -1,6 +1,6 @@
1
1
  /** @typedef {{name: string, cls: 'machine check'|'judge'|null}} PlanCheck */
2
2
  /** @typedef {{n: number, text: string, rule: string, rules: string[], checks: PlanCheck[], refused: string[]}} PlanLine */
3
- /** @typedef {{lines: PlanLine[], loose: PlanCheck[], notChecked: string[], alwaysOn: string[], older: boolean, goal?: string}} JobPlan */
3
+ /** @typedef {{lines: PlanLine[], loose: PlanCheck[], notChecked: string[], readNotes: string[], alwaysOn: string[], older: boolean, goal?: string}} JobPlan */
4
4
  /**
5
5
  * `machine check` for a kind a command decides, `judge` for one that needs judgement; `null` for a stage whose kind is
6
6
  * unknown (a hand-written command close has no kind: it is a command, so a machine check).
@@ -27,7 +27,8 @@ export function planFromSpec(spec: any): JobPlan;
27
27
  * The plan of the confirm turn, before anything is drafted: the person's own job lines, the model's checks under the
28
28
  * line each names, its own "not checked" words, and the real protections (code-owned) as "Always checked".
29
29
  * @param {{n: number, text: string, rule: string, rules?: string[]}[]|null|undefined} jobLines
30
- * @param {{checkItems?: {text: string, fromLine: number[]|null}[], checks?: string[], notChecked?: string[], protections?: string[]}} plan
30
+ * @param {{checkItems?: {text: string, fromLine: number[]|null}[], checks?: string[], notChecked?: string[], protections?: string[], signedFacts?: string[]}} plan
31
+ * `signedFacts` = the names of the facts the look signs (so an S1 copy of a signed fact reads as a note, not Not checked)
31
32
  * @returns {JobPlan}
32
33
  */
33
34
  export function planFromConfirm(jobLines: {
@@ -43,6 +44,7 @@ export function planFromConfirm(jobLines: {
43
44
  checks?: string[];
44
45
  notChecked?: string[];
45
46
  protections?: string[];
47
+ signedFacts?: string[];
46
48
  }): JobPlan;
47
49
  export type PlanCheck = {
48
50
  name: string;
@@ -60,6 +62,7 @@ export type JobPlan = {
60
62
  lines: PlanLine[];
61
63
  loose: PlanCheck[];
62
64
  notChecked: string[];
65
+ readNotes: string[];
63
66
  alwaysOn: string[];
64
67
  older: boolean;
65
68
  goal?: string;
@@ -330,6 +330,7 @@ export function getRunResult(runid: string, opts?: {
330
330
  pages: string[];
331
331
  pageNote: string | null;
332
332
  notChecked: string[];
333
+ readNotes: string[];
333
334
  door: {
334
335
  head: "waiting for you";
335
336
  ask: string;
@@ -256,6 +256,11 @@ export function checksOfClose(closeDecl: any): {
256
256
  text: string;
257
257
  covers: number[];
258
258
  }[];
259
+ /**
260
+ * The names of the facts a signed close checks (every fact part of every rubric-step stage). Pure.
261
+ * @param {any} closeDecl @returns {string[]}
262
+ */
263
+ export function signedFactNames(closeDecl: any): string[];
259
264
  /**
260
265
  * Reconcile the CONFIRMED plan with the close the composer actually wrote. The plan card after drafting shows exactly
261
266
  * the close's own checks (`checksOfClose`); every confirmed check that no part carries out (no part lists its number in