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 +18 -0
- package/bareloop.context.md +4 -3
- package/package.json +1 -1
- package/src/authorflow.js +1 -1
- package/src/authorjob.js +25 -1
- package/src/authorreadout.js +5 -0
- package/src/authorrun.js +8 -2
- package/src/fitcheck.js +166 -0
- package/src/pagedropwords.js +47 -1
- package/src/pagedry.js +125 -0
- package/src/pagemap.js +7 -2
- package/src/pagerules.js +8 -9
- package/src/panel/authorsession.js +37 -6
- package/src/panel/index.html +4 -0
- package/src/panel/jobplan.js +12 -7
- package/src/panel/server.js +1 -0
- package/src/promptregisters.js +4 -0
- package/src/rubricstep.js +36 -6
- package/types/authorjob.d.ts +10 -2
- package/types/fitcheck.d.ts +113 -0
- package/types/pagedropwords.d.ts +13 -0
- package/types/pagedry.d.ts +37 -0
- package/types/panel/authorsession.d.ts +4 -2
- package/types/panel/jobplan.d.ts +5 -2
- package/types/panel/server.d.ts +1 -0
- package/types/rubricstep.d.ts +5 -0
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
|
package/bareloop.context.md
CHANGED
|
@@ -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
|
|
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.
|
|
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({
|
package/src/authorreadout.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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:');
|
package/src/fitcheck.js
ADDED
|
@@ -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
|
+
}
|
package/src/pagedropwords.js
CHANGED
|
@@ -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
|
-
'
|
|
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
|
-
|
|
168
|
-
|
|
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
|
-
|
|
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 =
|
|
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'
|
|
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
|
}
|
package/src/panel/index.html
CHANGED
|
@@ -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){
|
package/src/panel/jobplan.js
CHANGED
|
@@ -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 {
|
|
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
|
|
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
|
|
135
|
+
lines, loose, notChecked, readNotes, alwaysOn: [...(plan?.protections ?? [])], older: false,
|
|
131
136
|
};
|
|
132
137
|
}
|
package/src/panel/server.js
CHANGED
|
@@ -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) => ({
|
package/src/promptregisters.js
CHANGED
|
@@ -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 {
|
|
617
|
-
const
|
|
618
|
-
|
|
619
|
-
|
|
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) =>
|
|
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
|
package/types/authorjob.d.ts
CHANGED
|
@@ -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
|
+
};
|
package/types/pagedropwords.d.ts
CHANGED
|
@@ -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;
|
package/types/panel/jobplan.d.ts
CHANGED
|
@@ -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;
|
package/types/panel/server.d.ts
CHANGED
package/types/rubricstep.d.ts
CHANGED
|
@@ -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
|