bareloop 0.44.0 → 0.46.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. package/CHANGELOG.md +40 -0
  2. package/bareloop.context.md +31 -5
  3. package/package.json +1 -1
  4. package/src/attemptdiff.js +116 -0
  5. package/src/authorflow.js +1 -1
  6. package/src/authorjob.js +25 -1
  7. package/src/authorreadout.js +13 -2
  8. package/src/authorrun.js +8 -2
  9. package/src/authorscout.js +1 -1
  10. package/src/bundle.js +90 -6
  11. package/src/close-integrity.js +39 -5
  12. package/src/config.js +5 -2
  13. package/src/fitcheck.js +166 -0
  14. package/src/inputlook.js +1 -47
  15. package/src/judged.js +3 -3
  16. package/src/labelscan.js +13 -4
  17. package/src/ladder.js +2 -2
  18. package/src/learn.js +943 -0
  19. package/src/learnstore.js +154 -0
  20. package/src/modellist.js +1 -1
  21. package/src/pagedropwords.js +47 -1
  22. package/src/pagedry.js +124 -0
  23. package/src/pagefetch.js +2 -1
  24. package/src/pagemap.js +7 -2
  25. package/src/pagerules.js +35 -13
  26. package/src/panel/authorroutes.js +1 -1
  27. package/src/panel/authorsession.js +37 -6
  28. package/src/panel/index.html +100 -0
  29. package/src/panel/jobplan.js +12 -7
  30. package/src/panel/server.js +8 -125
  31. package/src/planrun.js +40 -6
  32. package/src/promptregisters.js +4 -0
  33. package/src/providerrows.js +3 -3
  34. package/src/rubricstep.js +36 -6
  35. package/src/run.js +4 -2
  36. package/src/schedulewords.js +1 -1
  37. package/src/source.js +1 -1
  38. package/src/stagewords.js +144 -0
  39. package/src/text.js +4 -2
  40. package/src/userrun.js +28 -0
  41. package/src/validate.js +10 -5
  42. package/src/worktree.js +8 -2
  43. package/types/attemptdiff.d.ts +38 -0
  44. package/types/authorjob.d.ts +10 -2
  45. package/types/config.d.ts +1 -1
  46. package/types/fitcheck.d.ts +113 -0
  47. package/types/inputlook.d.ts +0 -23
  48. package/types/labelscan.d.ts +6 -0
  49. package/types/learn.d.ts +575 -0
  50. package/types/learnstore.d.ts +107 -0
  51. package/types/pagedropwords.d.ts +13 -0
  52. package/types/pagedry.d.ts +37 -0
  53. package/types/pagerules.d.ts +7 -1
  54. package/types/panel/authorsession.d.ts +4 -2
  55. package/types/panel/jobplan.d.ts +5 -2
  56. package/types/panel/server.d.ts +1 -0
  57. package/types/planrun.d.ts +13 -3
  58. package/types/reuse.d.ts +1 -1
  59. package/types/rubricstep.d.ts +5 -0
  60. package/types/run.d.ts +8 -2
  61. package/types/stagewords.d.ts +90 -0
package/CHANGELOG.md CHANGED
@@ -5,6 +5,46 @@ 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.46.0] - 2026-10-23
9
+
10
+ ### Added
11
+
12
+ - **Cross-run learnings, code only (`src/learn.js`, `src/learnstore.js`, `src/stagewords.js`; ships OFF).** At the end of every run ($0, no model call anywhere) the run's own spine and gate audit become cards: a check that went red and later passed in the same run (FIXED) or was still red at run end (NEVER FIXED), each field copied from the record (job, step, the check and its catalogue sentence, the arbiter's lines, what the passing try wrote and its `attempt-diff`). Two cards are one lesson when they share a job, a check, an error kind and 60% of their flagged-text words (code-less cards fold on the words alone when both sides have some; untested on real data). Lessons go into ONE ledger per user, `~/.config/bareloop/learn/ledger.json`, count starting at 1 (+1 per run per row, idempotent by run id), with per-model counts, the latest sample card, and climbing (a lesson in 3 jobs on one place, then 3 places, climbs; no check-wide set-aside, all-jobs hints are read last; the level is worked out when memory is read, from rows not older than 56 days only, so it can drop back, and the file stores none). The ledger keeps every row and deletes none; what the planner is handed is filtered at read time: a row last seen more than 56 days ago and a row of another signed job version are not shown (a new job version opens its own rows), and a lesson seen again after that counts +1 on its old row. No `expired` flag is stored. A rubric job's lessons are held until the human Accept at the review door (up to 60 days). With the new `learningHints` setting in `config.json` ON, the PLANNER (the draft and the first replan only, never a worker) is handed up to 2,000 characters: this job's lessons, then this place's, then all-jobs, highest count first; the exact text is recorded as a `learnings` spine record and shown read-only in the Audit tab. OFF still keeps the tally. A FIXED hint also shows the first 3 `+`/`-` lines of what the passing try changed (never on NEVER FIXED hints); "It said" shows the first real flagged line rather than a summary line, and a path already shown in the same hint is written by its basename. The check-catalogue sentences moved verbatim from `src/panel/server.js` to `src/stagewords.js`.
13
+
14
+ - **Each worker try records what it changed (`attempt-diff`).** After every step try and every close-fix try the spine gets one `attempt-diff` event: the unified diff (-U1) of each changed file inside the try's write fence, secrets masked, capped at 4 KB per file and 16 KB per event (cut at a line boundary, with the count of cut lines), binary files by path only. Taken with throwaway git trees, so the branch, HEAD, index and stash are untouched; a try that changed nothing records nothing; a git failure records a `gitError` field and never fails the run. Spine only: no prompt, panel or gate-audit change, no model call.
15
+
16
+ - **The Audit tab's "What the planner was told" is one fold box like the other Audit sections.** The header is a full-width toggle `[✓] learnings What the planner was told from earlier runs · 3 hints` (no arrow, open or closed; the count is the hints actually handed to the planner, and the `[✓] learnings` sign and word come from the same helper as the schedule and step headers). Opened, each hint is shown as rows in the Audit table look (the same label and value rows the schedule section uses): a bold title row (`1 - this job - seen 2 times - check - error code - fixed|never fixed`), then It said / Fix touched / What changed; no inner boxes, per-hint toggles or separate raw-text fold. A first-replan record shows under an "At the first replan" sub-heading in the same style. The fields come from the server (`parseHintBlock` in `src/learn.js`, the one reader of the block, tested against real `renderHints` output); the page never splits the prose. Read-only.
17
+
18
+ ### Fixed
19
+
20
+ - **A green worktree run no longer fails its final commit when the repo gitignores `.smoke`, `.litectx` or `gate-audit.jsonl`.** `git add` exits 1 when an exclude names a path that exists and is gitignored (pulselog's `.*` rule: run mvk2e4uy), so the work never landed on the branch and the folder was kept. The commit now excludes only the books git would otherwise stage (an ignored path is never staged anyway); a genuine git failure still throws. The `node_modules` glob exclude and the attempt-diff snapshot (`add -f`) were not affected.
21
+
22
+ - **A key right after a hyphen is now found and masked.** The secret-shape inventory (`SECRET_PATTERNS`) refused to match a key whose previous character was `-`, so `step-ghp_...` slipped past the scan and the redactor. The distinctive prefixes `ghp_`, `github_pat_`, `AKIA`, `AIza` and `xox[bap]-` now drop only the hyphen from their left bound (a word character or `_` in front still blocks the match, so `flask-sqlalchemy` and look-alike words stay clean). `sk-` keeps the full bound because `sk-` ends ordinary words (`task-`...); a key directly after a hyphen there (`step-sk-...`) is a documented known limit of the shape list. Only adds matches.
23
+
24
+ - **Quadratic and cubic regexes made linear (ReDoS audit; same matches, fuzz-proven).** A long run of one character in a pasted page, a model answer, a patient's source or a config value could make a backtracking regex rescan it from every start. Fixed in `labelscan`, `pagerules`/`pagedry` (the number before a label or unit is now a backward scan, one shared copy), `learn` (patient and worktree path prefixes, `PATH_RE`, the union-find in `climb()`), `text` (`extractArtifact` on an unclosed code fence), `ladder` (`normalizeGap`), `judged` (declaration and JSDoc finders), `authorreadout` (the miswrite sentence), `close-integrity` (the string-literal scan), the bundle import reader, and the trailing-slash, zero, dot and space trims in `config`, `providerrows`, `source`, `schedulewords`, `authorscout` and `modellist`. Each was checked against the old regex on 1M+ random and edge strings with 0 mismatches where a regex was replaced, and each has a regression test that fails on the old code. No change to any result, verdict or signed hash.
25
+
26
+ ### Changed
27
+
28
+ - **Dead exports removed.** `ruleQuestions` and `wholeSentences` (`src/inputlook.js`; no caller since the asks became code-forced from the page map) and `hintsMatchRecord` (`src/learn.js`; no caller) are deleted. None is reachable from the package root (`src/index.js`).
29
+
30
+ ## [0.45.0] - 2026-10-22
31
+
32
+ ### Added
33
+
34
+ - **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.
35
+
36
+ - **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.
37
+
38
+ - **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.
39
+ 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...".
40
+
41
+ ### Fixed
42
+
43
+ - **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).
44
+
45
+ - **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.
46
+ - **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).
47
+
8
48
  ## [0.44.0] - 2026-10-20
9
49
 
10
50
  ### Added
@@ -2723,6 +2723,20 @@ is not a spend record and no spend-slicing instrument needs to account for it.
2723
2723
  prints the same line when handed the run's spine file as its optional third positional.
2724
2724
 
2725
2725
 
2726
+ #### `attempt-diff` — what each worker try changed (`src/attemptdiff.js`, `src/planrun.js`)
2727
+
2728
+ One spine record per step try and per close-fix try that changed something inside the try's write fence:
2729
+ `{phase, step, iteration, files: [{path, diff, truncated, cutLines}], binary: [path], truncated, gitError?}`
2730
+ (`phase` is `step:<id>` or `fix`; `step` is `null` on a fix try). `diff` is one file's unified diff (`-U1`),
2731
+ cut at a line boundary at 4,096 bytes per file and 16,384 per event (`cutLines` = lines left out, `truncated`
2732
+ is the event-level flag); a binary file is listed by path only; every string passes the one secret inventory.
2733
+ It is read off two throwaway git trees (a temporary index at try start and at try end, then `git diff`
2734
+ between them), so the work branch, HEAD, the real index and the stash list are never touched; only
2735
+ unreferenced loose objects are left. Gitignored files inside the fence are seen (`add -f`, so a rubric job's
2736
+ `output/` shows); `.git`, `node_modules`, `.smoke`, `.litectx` and `gate-audit.jsonl` never are. A try that
2737
+ changed nothing in scope emits nothing, and a failed capture emits `gitError` with empty lists: a record,
2738
+ never a verdict, and it never throws. No prompt carries it; the learnings cards read it ("What changed").
2739
+
2726
2740
  ### `replayRun(spineEvents, auditEvents?, { runId? })` / `formatReplay(summary)` / `summarizeForAllLine(summary)` / `formatAllLines(entries)` — `src/replay.js`
2727
2741
 
2728
2742
  `summarizeForAllLine(summary)` reduces one `replayRun` summary to one `--all`/directory-
@@ -3866,6 +3880,17 @@ provider but `anthropic-api`, spine bundling for `formatReplay` on the minting r
3866
3880
  `runid` is noted), and tarball packing — a bundle is a plain directory; use `npm pack` on it
3867
3881
  yourself if you want a tarball.
3868
3882
 
3883
+ ### Cross-run learnings — a ledger of lessons, code only (`src/learn.js`, `src/learnstore.js`, `src/stagewords.js`; `docs/product/ITEM33-BUILD.md` "Addendum 2026-10-22 (rev 2)")
3884
+
3885
+ No model is called anywhere in learnings (a test walks the import closure). Pure rules in `learn.js`; file IO in `learnstore.js`.
3886
+
3887
+ - **Setting:** `learningHints` (`true`/`false`) in `config.json`, absent = OFF. OFF still writes the tally at run end; it only means the planner is handed nothing.
3888
+ - **Run end:** `execute` (`src/userrun.js`, shared by the panel, the CLI and the bundle runner) calls `learnAtRunEnd({ home, runid, events, auditFiles, spec, specHash, workdir, trust })` once, right after the claim settles, in its own try/catch: a failure prints one WARNING and never changes the verdict, exit code or spend. It writes nothing when the engine has no config home.
3889
+ - **Cards:** `buildCards({ runid, events, audit, workdir, spec, model, questions })` → `{ cards, suspect, thin, noise }`. FIXED = a check red then the same check passes later in the run; NEVER FIXED = still red at run end. Excluded and counted: noise runs (provider-red, close-crashed, worker-crash, no close spoke), CHECK-SUSPECT (a no-suppressions hit on a prose comment), TOO-THIN (no error kind and no fix files). Written files come from `attempt-diff` events and the gate audit's allow write/edit rows relative to the tree.
3890
+ - **Fold:** `cardsMatch` / `foldCards`: same job + check, a shared error kind, Jaccard >= 60% on flagged-text tokens (a fit on known cards, not a proof). Code-less cards (kind `other`) fold on the text rule alone, only when both token sets are non-empty (R1, 2026-10-23: UNTESTED on real data; the 60% was fitted on cards with codes). `kindAgrees` is the one kind test.
3891
+ - **Ledger** `<config home>/learn/ledger.json` (mode 600, atomic, secrets masked): rows `{ id, lesson, count, sample, models, workflow, place, level, trust, runIds, firstSeen, lastSeen, specHash }`, plus `pending` (held rubric cards), `suspect`, `health`. There is no check-wide set-aside (R2, 2026-10-23): a lesson seen in several jobs climbs by the climb rules and is read at its level; all-jobs hints come last. `countLedger` (+1 per run per row), `holdPending` / `releasePending` (rubric cards count only after the human Accept; `releaseRun` is called from the door's accept path), `climb` (the level of a lesson, derived at read time by `readableRows` from the rows not decayed at that moment: it can go back down when siblings age past 56 days; the file stores `workflow` only and ignores an older stored level). The ledger keeps EVERY row (nothing is deleted, no `expired` flag is stored; hamr 2026-10-23). Memory is the filter: `readableRows` / `renderHints` skip a row last seen more than 56 days ago (`isDecayed`) and a workflow row of another signed hash (`otherJobVersion`); a lesson seen again counts +1 on its old row. Old files with `status`/`expiredBy` still load; the fields are ignored and dropped at the next write.
3892
+ - **Reading:** `hintsForRun({ home, spec, specHash })` renders once at run start; `runJob(..., { learnHints: { text, ids } })` → `runPlan` hands it to the PLANNER at the draft and the first replan only, and emits a `learnings` spine record with the exact text. `renderHints(ledger, { workflow, place, specHash, now })` is the one render (this job, then this place, then all-jobs, highest count first, 2,000 characters, a hint whole or not at all; all-jobs hints last); a FIXED hint adds `What changed:` (first 3 `+`/`-` lines of the stored diff, 150 chars each, then `(+N more)`; never on NEVER FIXED) and a repeated path in a hint reads as its basename (R3, R5, 2026-10-23); the spine's recorded text is byte-equal to a fresh `renderHints(...).text` from the ledger as it stood. The Audit run detail carries `learnings` (read-only).
3893
+
3869
3894
  ### Settings — the keys file, `config.json`, the monthly $ limit, the Providers tab (PANEL-BUILD.md P4a, `src/keysfile.js`, `src/config.js`, `src/monthly.js`, `src/providerrows.js`, `src/panel/settingsroutes.js`)
3870
3895
 
3871
3896
  Two files in `~/.config/bareloop/`, outside any repo:
@@ -3879,7 +3904,7 @@ Two files in `~/.config/bareloop/`, outside any repo:
3879
3904
  real one). The CLI (`run-u`, `interview`, `author`) and the panel both use it.
3880
3905
  - **`config.json` — every setting, never a key value.** `readConfig({ home })` /
3881
3906
  `updateConfig(patch, { home })` (atomic tmp + rename, mode 600, unknown fields kept).
3882
- Fields: `monthlyLimitUsd` (number above 0; absent or `null` = no limit; any other value present
3907
+ Fields: `learningHints` (true/false; absent = off; see "Cross-run learnings"), `monthlyLimitUsd` (number above 0; absent or `null` = no limit; any other value present
3883
3908
  is a `ConfigError`, refused like an unreadable file), `keys.<ENV NAME>` = `{ shape, baseUrl, legacyMaxTokens }` (the Providers rows' settings: an API shape id, the typed Base URL,
3884
3909
  the "old max_tokens" tick), `models.<ENV NAME>` = a list of `{ model, use, inPerM?, outPerM? }` lines (replaced WHOLE on every save; the customer's own price below). Nothing is built
3885
3910
  in: a row with nothing saved is shape `openai-api`, no address, no lines. A secret-shaped
@@ -4395,9 +4420,9 @@ Check what the person asked for, not what the worker chose to write. bareloop ne
4395
4420
  - **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
4421
  - **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
4422
  - **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).
4423
+ - **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
4424
  - **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.
4425
+ - **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
4426
  - **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
4427
  - **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
4428
  - **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).
@@ -4405,7 +4430,7 @@ Check what the person asked for, not what the worker chose to write. bareloop ne
4405
4430
  - **The plan card.** One code-worded row per fact under its line, read by the one reader (`checksOfClose` / `factRows`) the confirm card, the Job tab (`planFromSpec`) and the sign-time `planMatchesClose` share, e.g. `fact · price — money in US dollars, at most 450 US dollars, after "Price", quoted from "Flights"`, then the count row. A `~` rule the drafter listed as not checked reads `line N: "<rule as written>" — <reason>`.
4406
4431
  - **The worker brief.** `rubricGoal({ ..., closeDecl })` adds a code-written brief per line (`lineBriefs`): a facts line gets its facts and its count; a text line that follows gets "every number must be copied exactly as written in a quote you wrote for job line N". `stepFileContract()` names both shapes.
4407
4432
  - **Web addresses as inputs (Part 1c, `src/pagefetch.js`, `src/source.js`).** An Inputs line may be `label: https://...` (also a rubric card's FIRST line; `looksLikeRubricInput`). `fetchPageText(url)` calls barebrowse `connect({ incognito: true, blockPrivateNetwork: true })` (`CONNECT_OPTIONS`) → `goto` → `waitForNetworkIdle({ idle: 1500, timeout: 20000 })` (bounded; a page that never settles is read as it stands) → `snapshot({ mode: 'act' })`, always closing the browser, with our own 90 s deadline (L1: a load-time `browse()` snapshot gave 77,951, 8,801 or 719 characters of the same results page; L2: a page whose text outside the banner / navigation / contentinfo landmarks is under `MIN_CONTENT_CHARS` = 100 (`pageContentChars`) is a stop too, "only the site's own header and menus", and an input with no content never reaches the look or an early ask: the survey is ABSENT, "holds nothing to read", nothing spent); an empty or under-200-character page (`MIN_PAGE_CHARS`), a bot challenge, an unreachable address, a credentialed or secret-shaped address, or a page carrying a secret shape is a plain-words $0 stop on its line. `proveRubricInputs({ ..., fetchUrl })` freezes the page text under `input/<label>.txt` and records `url` on the manifest input row. A FRESH `run-u --spec` start (not `--resume` / `--door` / `--decide`) calls `refreezeUrlInputs`: it fetches every recorded address again, writes that text into the prepared tree, commits it as the new seed and rewrites `source.json` (new `sha256` and `seed`, `draftSha256` keeps what the look saw) BEFORE the worker starts (inside `execute`, AFTER the run's row is listed with its pid and cap, so the Runs list shows it while the page loads; the Run tab reads "fetching the page…" until `source.json` carries `refrozenAt`), so quotes are checked against the run's copy and the worker reads it, never the live page. A failed or empty fetch stops the run (the row is settled at $0 as `failed` with the plain reason, `refused before start`, and the monthly claim is freed), "The run did not start. Nothing spent." A rubric job on a URL or file is recognised by the signed spec's verdict type on a Reuse and on a scheduled fire (`startSession`, `src/panel/authorroutes.js`), so line 1 is not proved as a repo path; a fresh job reads it from the card. Plan card (L3): `settlePersonsWords` (called in `authorClose` before validation) unmarks AI's proposal on a limit made of the person's own numbers, swaps in the one explicit positive time range the person wrote, and drops a `refused` entry for a `~` rule a part of its line carries; the look's fact may carry `forRule` (a rule it numbered from its own knowledge, marked AI's proposal), which removes that rule from the look's not-found list so it is not asked. Test seams: `fetchPageText(url, { connect })`, `deps.fetchUrl` (`run-u`), `deps.fetchUrl` / `deps.lookFn` (`createSession`).
4408
- - **Ask at once, only about intent (I1; the asks are now code-forced from the page map, see "The look reads, code asks" below).** A `~` rule the look cannot tie to a kept page word becomes a question worded by code (`askableFromLook`, `src/pagemap.js`: line and rule named; the older `ruleQuestions` in `src/inputlook.js` is no longer called by the draft path), asked before the confirm turn through the existing question gate (`answerQuestions`; `normalizeQuestions` caps it at `MAX_QUESTIONS`); the answer is a new `~` rule on that line. A rubric job on files never offers the piecemeal try-3 question; its failed-draft stop (`draftStopText({ wholeJob: true })`) says to review the whole job and submit it again.
4433
+ - **Ask at once, only about intent (I1; the asks are now code-forced from the page map, see "The look reads, code asks" below).** A `~` rule the look cannot tie to a kept page word becomes a question worded by code (`askableFromLook`, `src/pagemap.js`: line and rule named), asked before the confirm turn through the existing question gate (`answerQuestions`; `normalizeQuestions` caps it at `MAX_QUESTIONS`); the answer is a new `~` rule on that line. A rubric job on files never offers the piecemeal try-3 question; its failed-draft stop (`draftStopText({ wholeJob: true })`) says to review the whole job and submit it again.
4409
4434
  - **Known limits (documented, not built around).** Airport codes like SFO have no stable label on a flight page: direction is checkable only through the full airport names the page uses. An unanchored `text` fact cannot tell which field a value sits in. `am` / `pm` in lower case and `9:27AM` are malformed times; `v0.41.0` is one token. A fact step's quote proves the value is in a real record, not that it is the RIGHT record: "the cheapest" and "the last 3" are Not checked. Verified on real files only; the live proof (DeepSeek, both jobs through the panel) has not been run.
4410
4435
 
4411
4436
  ## Triggers — a job that starts by itself (panel-only; `src/trigger*.js`, `src/panel/schedules.js`, `schedulestart.js`, `scheduler.js`, `src/schedulewords.js`)
@@ -4435,4 +4460,5 @@ For an integrating agent reading a run's record:
4435
4460
  - **`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
4461
  - **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
4462
  - Not built from the signed spec (see the Part 5 commits): the early ask about a number the AI made (T1), because the data it needs does not exist at that point; T1 is replaced by Part 5b below. The "fetching the page…" state (Q2) was built after the run-start refetch moved to after the run is listed: the sign progress list shows the step (`settleFetchingStep`, `src/panel/authorroutes.js`) and a listed run with no log yet reads "fetching the page… the run has not started yet" (`startingStub`, `webFetchState`), both read from `source.json`'s `refrozenAt`.
4438
- - **The look reads, code asks (PRD v1.107 Part 5b; `src/pagemap.js`).** A map fact on a `time` or `money` shape may carry an optional `reading: {op, value | lo, hi}` (the signed-rule `op` set; a time bound is a string, a money bound a number); any other `reading` makes the whole map answer malformed (no repair). `askableFromLook(look, jobLines)` sorts every `~` rule: a kept page word carries it (no ask); a `reading` whose bound is NOT in the person's own words (`boundInWords`, the same test as `rubric-fact-unmarked`) asks `Your line N says "<rule>". I read it as: <fact> <ruleWords>. Agree?`; a rule no kept fact carries asks `... I can't check this on the page. Drop it?`. Readings come first, then can't-check asks. `agree` (or `yes`) writes the reading as a `~` rule in plain words, so the bound is the person's own and the fact signs plain; a typed answer is the `~` rule instead; `drop it` on a can't-check ask adds no rule and leaves it Not checked. `MAX_QUESTIONS` is 3: more asks than that stops the draft before the confirm turn (stop `look-asks-too-many`, no extra spend) with `tooManyAsksText`, a code-written list of every ask and its reading. Asks carry `ask: 'reading' | 'cant-check'` (and `reading` for the agree text) through `normalizeQuestions`.
4463
+ - **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`.
4464
+ - **The fit check (F243, `src/fitcheck.js`; `docs/product/ITEM33-BUILD.md` "Addendum 2026-10-21 — fit check build spec").** Rubric jobs only, once, after the inputs are frozen and the content gate passes and BEFORE the look; it runs only when the caller passes the answer seam `fitAsk` to `authorCloseForJob` (the panel; the CLI door and any test without it never run it; `fitFn: null` = no check). `runFitCheck` makes ONE AI call (the drafter identity, the drafting thinking rule, label `fit-check`, cap checked before the call) that splits each job line (no `~` rules) into asks copied from that line and quotes the page for each; plain code (`decideFit`) decides: an ask is found only if `found` is true AND its quote is on an input (whitespace and case ignored), a found-with-off-page-quote is `madeUp` and counts as not found, a reply with an ask not copied from its line or a line with no ask is malformed (one retry). All found = `fits` (nothing shown); any not found = `ask`: one question through the panel `ask` seam (`I found X of Y of your asks on this page. Not found: <ask> (line N), .... Type go on to keep drafting, or anything else to stop and change the job or the page.`); `go on` continues to the look, anything else stops `fit-stopped` (plain words in `draftStopText`), no answer is `confirm-abandoned`. The answer is not a `~` rule and changes nothing in the job. Failure is OPEN: a cut, two unreadable replies, a failed call or a cap stop is `absent`, chat says "The fit check did not finish; drafting goes on." and drafting goes on. The result is one `fit-check` row in `draft-log.jsonl`.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bareloop",
3
- "version": "0.44.0",
3
+ "version": "0.46.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",
@@ -0,0 +1,116 @@
1
+ // attempt-diff — what each worker TRY changed, as a spine record (ITEM33 addendum
2
+ // 2026-10-22). The spine and the gate audit keep only file names and byte sizes of
3
+ // edits; a learnings card could say WHICH files a passing try touched but never WHAT
4
+ // changed. This module is the ONE owner of that capture. It is a record, never a
5
+ // verdict: every failure is caught and returned as a field, nothing here throws.
6
+ //
7
+ // How: a git TREE snapshot at try start and at try end, each through a TEMPORARY
8
+ // index (GIT_INDEX_FILE + `git add -A` + `git write-tree`), then `git diff <t0> <t1>`.
9
+ // The work branch, HEAD, the real index and the stash list are never touched; the
10
+ // temp index is removed. Only unreferenced loose objects are left in the object store.
11
+
12
+ import { execFile } from 'node:child_process';
13
+ import { promisify } from 'node:util';
14
+ import { mkdtemp, rm } from 'node:fs/promises';
15
+ import { tmpdir } from 'node:os';
16
+ import { existsSync } from 'node:fs';
17
+ import { join, relative, resolve } from 'node:path';
18
+ import { redactSecrets } from './validate.js';
19
+
20
+ const exec = promisify(execFile);
21
+
22
+ /** per-file and per-event byte caps for the recorded diff text */
23
+ export const ATTEMPT_DIFF_FILE_MAX = 4096;
24
+ export const ATTEMPT_DIFF_EVENT_MAX = 16384;
25
+
26
+ /**
27
+ * Cut `text` to at most `max` bytes at a LINE boundary (never mid-line).
28
+ * @param {string} text @param {number} max
29
+ * @returns {{text: string, truncated: boolean, cutLines: number}}
30
+ */
31
+ export function cutAtLine(text, max) {
32
+ if (Buffer.byteLength(text) <= max) return { text, truncated: false, cutLines: 0 };
33
+ const lines = text.match(/[^\n]*\n|[^\n]+$/g) ?? [];
34
+ let used = 0;
35
+ let kept = 0;
36
+ for (const l of lines) {
37
+ const b = Buffer.byteLength(l);
38
+ if (used + b > max) break;
39
+ used += b;
40
+ kept++;
41
+ }
42
+ return { text: lines.slice(0, kept).join(''), truncated: true, cutLines: lines.length - kept };
43
+ }
44
+
45
+ /** @param {string} abs @param {string[]} prefixes */
46
+ const inFence = (abs, prefixes) => prefixes.some((p) => abs === p || abs.startsWith(p.endsWith('/') ? p : `${p}/`));
47
+
48
+ // the arbiter's books and installed packages never enter a snapshot, even inside a wide fence
49
+ const NOT_WORK = [':(exclude,glob)**/node_modules/**', ':(exclude,glob)**/.git/**', ':(exclude).smoke', ':(exclude).litectx', ':(exclude)gate-audit.jsonl'];
50
+
51
+ /**
52
+ * Snapshot the FENCE of the working tree to a git tree id via a throwaway index. Only the
53
+ * fence's existing prefixes are added, and with `-f`: a rubric job's `output/` must be seen
54
+ * even when the source folder's own .gitignore lists it (the hidden-git seed ignores that
55
+ * file's meaning; so does this).
56
+ * @param {string} workdir @param {string[]} fence @returns {Promise<string>}
57
+ */
58
+ async function snapshotTree(workdir, fence) {
59
+ const tmp = await mkdtemp(join(tmpdir(), 'bareloop-adiff-'));
60
+ try {
61
+ const env = { ...process.env, GIT_INDEX_FILE: join(tmp, 'index') };
62
+ const specs = fence.filter((p) => existsSync(p)).map((p) => `:(literal)${relative(workdir, p) || '.'}`);
63
+ if (specs.length > 0) await exec('git', ['add', '-A', '-f', '--', ...specs, ...NOT_WORK], { cwd: workdir, env, maxBuffer: 1 << 26 });
64
+ const { stdout } = await exec('git', ['write-tree'], { cwd: workdir, env });
65
+ return stdout.trim();
66
+ } finally {
67
+ await rm(tmp, { recursive: true, force: true });
68
+ }
69
+ }
70
+
71
+ /**
72
+ * Begin recording one worker try. Call BEFORE the worker's turn; call `finish()` after
73
+ * (in a `finally`). Never throws.
74
+ * @param {{workdir: string, fence: string[], redact?: (s: string) => string}} o
75
+ * `fence` = the ABSOLUTE write-scope prefixes the gate enforces for this try.
76
+ * @returns {Promise<{finish: () => Promise<null | {files: {path: string, diff: string, truncated: boolean, cutLines: number}[], binary: string[], truncated: boolean, gitError?: string}>}>}
77
+ * `finish()` resolves null when the try changed nothing in scope.
78
+ */
79
+ export async function startAttemptDiff({ workdir, fence, redact = redactSecrets }) {
80
+ /** @type {string|null} */
81
+ let t0 = null;
82
+ /** @type {string|null} */
83
+ let startError = null;
84
+ try { t0 = await snapshotTree(workdir, fence); } catch (e) { startError = String(/** @type {any} */ (e)?.message ?? e); }
85
+ return {
86
+ async finish() {
87
+ try {
88
+ if (t0 === null) throw new Error(`start snapshot failed: ${startError}`);
89
+ const t1 = await snapshotTree(workdir, fence);
90
+ if (t1 === t0) return null;
91
+ const g = (/** @type {string[]} */ a) => exec('git', ['-c', 'core.quotepath=false', ...a], { cwd: workdir, maxBuffer: 1 << 26 });
92
+ const { stdout: names } = await g(['diff', '--name-only', '-z', '--relative', /** @type {string} */ (t0), t1]);
93
+ const paths = names.split('\0').filter(Boolean).filter((p) => inFence(resolve(workdir, p), fence));
94
+ /** @type {{path: string, diff: string, truncated: boolean, cutLines: number}[]} */
95
+ const files = [];
96
+ /** @type {string[]} */
97
+ const binary = [];
98
+ let left = ATTEMPT_DIFF_EVENT_MAX;
99
+ let anyCut = false;
100
+ for (const p of paths) {
101
+ const { stdout } = await g(['diff', '-U1', '--no-color', '--no-ext-diff', '--relative', /** @type {string} */ (t0), t1, '--', `:(literal)${p}`]);
102
+ const path = redact(p);
103
+ if (/^Binary files .* differ$/m.test(stdout) || /^GIT binary patch$/m.test(stdout)) { binary.push(path); continue; }
104
+ const cut = cutAtLine(redact(stdout), Math.min(ATTEMPT_DIFF_FILE_MAX, left));
105
+ left -= Buffer.byteLength(cut.text);
106
+ if (cut.truncated) anyCut = true;
107
+ files.push({ path, diff: cut.text, truncated: cut.truncated, cutLines: cut.cutLines });
108
+ }
109
+ if (files.length === 0 && binary.length === 0) return null;
110
+ return { files, binary, truncated: anyCut };
111
+ } catch (e) {
112
+ return { files: [], binary: [], truncated: false, gitError: redact(String(/** @type {any} */ (e)?.message ?? e)).slice(0, 500) };
113
+ }
114
+ },
115
+ };
116
+ }
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({
@@ -409,6 +409,12 @@ const PLAIN_MAX_BULLETS = 8;
409
409
  const RECORD_MAX_REDS = 40;
410
410
  const RECORD_STR_MAX = 600;
411
411
 
412
+ // Each of these two finds its text on the FIRST line that has it, anchored to a line start: a lazy (.*?) that
413
+ // fails runs to the end of its line, and the same pattern retried at every later start on that line fails the
414
+ // same way (the later text is a part of the earlier one's), so retrying them only made the read quadratic.
415
+ const MISSING_REDS_RE = /(?<=^|[\n\r\u2028\u2029])(?:(?!expected red\(s\) the pipe did not raise: )[^\n\r\u2028\u2029])*expected red\(s\) the pipe did not raise: (.*?)(?: — |\. The floor)/;
416
+ const EXTRA_REDS_RE = /(?<=^|[\n\r\u2028\u2029])(?:(?!red\(s\) the pipe raised that nobody signed: )[^\n\r\u2028\u2029])*red\(s\) the pipe raised that nobody signed: (.*?)(?: — |\. The floor)/;
417
+
412
418
  /** a code-written sentence that carries the person's own words (a rubric-step sorting red): scrubbed and one line, bounded wide enough to stay whole
413
419
  * @param {unknown} s @returns {string} */
414
420
  function fullText(s) {
@@ -663,6 +669,11 @@ export function draftStopText({ stop, reds = null, refusal = null, spend = null,
663
669
  const detail = (Array.isArray(reds) ? reds : []).find((r) => r?.code === 'look-asks-too-many')?.detail;
664
670
  if (detail) return `${redactSecrets(String(detail))}\nSpent so far: ${spent ?? 'unknown'}.`;
665
671
  }
672
+ // 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
673
+ if (stop === 'fit-stopped') {
674
+ const detail = (Array.isArray(reds) ? reds : []).find((r) => r?.code === 'fit-stopped')?.detail;
675
+ 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'}.`;
676
+ }
666
677
  /** @type {Record<string, string>} */
667
678
  const heads = {
668
679
  'provider-red': 'The AI provider failed before a usable plan came back — a connection or service fault, not a problem with your job.',
@@ -754,8 +765,8 @@ const ruleFns = (list) => list.split(';').map((x) => x.trim()).filter(Boolean).m
754
765
  */
755
766
  function miswriteSentences(r) {
756
767
  const who = `Case "${safeText(r.caseId ?? /^case "(.*?)" was graded/.exec(r.detail)?.[1] ?? '')}"`;
757
- const missing = /expected red\(s\) the pipe did not raise: (.*?)(?: — |\. The floor)/.exec(r.detail)?.[1];
758
- const extra = /red\(s\) the pipe raised that nobody signed: (.*?)(?: — |\. The floor)/.exec(r.detail)?.[1];
768
+ const missing = MISSING_REDS_RE.exec(r.detail)?.[1];
769
+ const extra = EXTRA_REDS_RE.exec(r.detail)?.[1];
759
770
  /** @type {string[]} */
760
771
  const out = [];
761
772
  for (const { rule, fn } of missing ? ruleFns(missing) : []) {
package/src/authorrun.js CHANGED
@@ -128,6 +128,7 @@ import { resolveProvider, buildRunnerProviders, apiKeyProblem } from './provider
128
128
  import { commandFor } from './invoke.js';
129
129
  import { readSourceManifest, missingDependencies, nonRepoSourceMessage, isRubricFilesManifest } from './source.js';
130
130
  import { tallyCalls } from './text.js';
131
+ import { splitReadNotes } from './pagedropwords.js';
131
132
  import {
132
133
  declarationLines, rubricLines, calibrationLines, parseCeiling, ceilingLine, crashRecord, phaseLine,
133
134
  openQuestionLines, answeredQuestionLines, fellBackLines, PLAIN_PROPOSAL_STOPS, proposalStopText, signingStopText,
@@ -771,9 +772,14 @@ export async function main(argv, deps = {}) {
771
772
  for (const c of p.checks ?? []) out(` · ${c}`);
772
773
  out(' protections (always-on guards, never named in the goal):');
773
774
  for (const g of p.protections ?? []) out(` · ${g}`);
774
- if ((p.notChecked ?? []).length) {
775
+ const { notChecked: unchecked, readNotes } = splitReadNotes(p.notChecked, p.signedFacts);
776
+ if (unchecked.length) {
775
777
  out(' you asked for these, but nothing checks them:');
776
- for (const n of p.notChecked ?? []) out(` · ${n}`);
778
+ for (const n of unchecked) out(` · ${n}`);
779
+ }
780
+ if (readNotes.length) {
781
+ out(` how the page was read · ${readNotes.length}:`);
782
+ for (const n of readNotes) out(` · ${n}`);
777
783
  }
778
784
  if ((p.questions ?? []).length) {
779
785
  out(' open questions:');
@@ -778,7 +778,7 @@ export const COUNTS_ONLY_NAMES = 12;
778
778
  * `"src (also …)"`). Strip the annotation, keep the path.
779
779
  * @param {any} e
780
780
  */
781
- export const cleanEntry = (e) => String(e ?? '').split(' (')[0].trim().replace(/\/+$/, '');
781
+ export const cleanEntry = (e) => String(e ?? '').split(' (')[0].trim().replace(/(?<!\/)\/+$/, '');
782
782
 
783
783
  /**
784
784
  * `git ls-tree -r --name-only <seed>` — the files as of the SEED COMMIT.
package/src/bundle.js CHANGED
@@ -115,10 +115,95 @@ function isBareloopSrcImport(spec) {
115
115
  return spec === 'bareloop' || /^(\.\.?\/)+src\//.test(spec);
116
116
  }
117
117
 
118
- // Every STATIC import statement, clause captured whole (undefined when there
119
- // is no `from` — a bare side-effect `import 'x'`). Deliberately permissive:
120
- // the shape check below is what decides legality, not this regex.
121
- const STATIC_IMPORT_RE = /import\s+(?:([^'";]*?)\s+from\s+)?(['"])([^'"]+)\2/g;
118
+ /**
119
+ * Every STATIC import statement, clause captured whole (undefined when there
120
+ * is no `from` — a bare side-effect `import 'x'`). Deliberately permissive:
121
+ * the shape check below is what decides legality, not this reader.
122
+ *
123
+ * It reads exactly what `/import\s+(?:([^'";]*?)\s+from\s+)?(['"])([^'"]+)\2/g`
124
+ * read (that regex was cubic on many `import` words or a long run of spaces,
125
+ * each start rescanning the text after it). After `import` and its whitespace
126
+ * the statement's specifier quote can only be the FIRST quote or semicolon
127
+ * that follows (the clause may hold neither), so the `from` before it, the
128
+ * clause before that and the closing quote after it are each found by one
129
+ * look at that spot, and the spot is remembered for the next `import` word.
130
+ * The one odd shape the regex also accepted is kept: `import from 'x'` (two
131
+ * or more whitespace characters, `from` straight after them) reads with an
132
+ * empty clause, the regex having given one whitespace character back to it.
133
+ * @param {string} source
134
+ * @returns {{ raw: string, clause: string|undefined, spec: string }[]}
135
+ */
136
+ function staticImportsOf(source) {
137
+ /** @type {{ raw: string, clause: string|undefined, spec: string }[]} */
138
+ const out = [];
139
+ const n = source.length;
140
+ const isSpace = (/** @type {number} */ i) => /\s/.test(source[i]);
141
+ const stops = /['";]/g;
142
+ const quotes = /['"]/g;
143
+ // first quote-or-semicolon at or after `stopFrom` (n when none), reused while the next word still lies before it
144
+ let stopFrom = 0;
145
+ let stopAt = -1;
146
+ const firstStop = (/** @type {number} */ i) => {
147
+ if (stopAt >= 0 && i >= stopFrom && i <= stopAt) return stopAt;
148
+ stops.lastIndex = i;
149
+ stopFrom = i;
150
+ stopAt = stops.exec(source)?.index ?? n;
151
+ return stopAt;
152
+ };
153
+ // what follows the specifier quote at `q`, read once per quote: the end of the statement, or -1
154
+ let tailQuote = -1;
155
+ let tailEnd = -1;
156
+ let closerFrom = 0;
157
+ let closerAt = -1;
158
+ const tailOf = (/** @type {number} */ q) => {
159
+ if (q === tailQuote) return tailEnd;
160
+ tailQuote = q;
161
+ if (!(closerAt >= 0 && q + 1 >= closerFrom && q + 1 <= closerAt)) {
162
+ quotes.lastIndex = q + 1;
163
+ closerFrom = q + 1;
164
+ closerAt = quotes.exec(source)?.index ?? n;
165
+ }
166
+ tailEnd = closerAt > q + 1 && closerAt < n && source[closerAt] === source[q] ? closerAt + 1 : -1;
167
+ return tailEnd;
168
+ };
169
+ // the `from` standing just before the whitespace before quote `q`: its start, or -1; read once per quote
170
+ let fromQuote = -1;
171
+ let fromStart = -1;
172
+ const fromBefore = (/** @type {number} */ q) => {
173
+ if (q === fromQuote) return fromStart;
174
+ fromQuote = q;
175
+ let k = q;
176
+ while (k > 0 && isSpace(k - 1)) k -= 1;
177
+ fromStart = k < q && k >= 4 && source.startsWith('from', k - 4) ? k - 4 : -1;
178
+ return fromStart;
179
+ };
180
+ let from = 0;
181
+ for (;;) {
182
+ const at = source.indexOf('import', from);
183
+ if (at === -1) return out;
184
+ from = at + 1;
185
+ let b = at + 6;
186
+ while (b < n && isSpace(b)) b += 1;
187
+ const blanks = b - (at + 6);
188
+ if (blanks === 0 || b >= n) continue;
189
+ const q = firstStop(b);
190
+ if (q >= n || (source[q] !== '"' && source[q] !== "'")) continue;
191
+ const end = tailOf(q);
192
+ if (end === -1) continue;
193
+ const f = fromBefore(q);
194
+ /** @type {string|undefined} */
195
+ let clause;
196
+ if (q === b) clause = undefined; // `import 'x'`, no clause
197
+ else if (f === b && blanks >= 2) clause = ''; // `import from 'x'`
198
+ else if (f > b && isSpace(f - 1)) {
199
+ let j = f - 1;
200
+ while (isSpace(j - 1)) j -= 1;
201
+ clause = source.slice(b, j);
202
+ } else continue;
203
+ out.push({ raw: source.slice(at, end), clause, spec: source.slice(q + 1, end - 1) });
204
+ from = end;
205
+ }
206
+ }
122
207
  // Dynamic `import('x')` (and `await import('x')`, `import ('x')`, …) — no
123
208
  // clause exists for this form at all, so it is ALWAYS unparsed when it
124
209
  // targets something this bundle would otherwise have to check.
@@ -146,8 +231,7 @@ function isRelative(spec) {
146
231
  function relevantImportsOf(source) {
147
232
  /** @type {{ raw: string, spec: string, clause: string|null, dynamic: boolean }[]} */
148
233
  const out = [];
149
- for (const m of source.matchAll(STATIC_IMPORT_RE)) {
150
- const [raw, clause, , spec] = m;
234
+ for (const { raw, clause, spec } of staticImportsOf(source)) {
151
235
  if (!isRelative(spec) && spec !== 'bareloop') continue;
152
236
  out.push({ raw: raw.trim(), spec, clause: clause === undefined ? null : clause.trim(), dynamic: false });
153
237
  }
@@ -72,7 +72,44 @@ export const SYSTEM_PATH_PREFIXES = ['/usr/', '/bin/', '/sbin/', '/lib/', '/lib6
72
72
  // Every quoted string literal (single/double/backtick). Backtick literals
73
73
  // carrying `${…}` interpolation are filtered out below — they are not a
74
74
  // fixed literal, so they cannot be "a path baked into the script" at all.
75
- const STRING_LITERAL_RE = /'([^'\\]*(?:\\.[^'\\]*)*)'|"([^"\\]*(?:\\.[^"\\]*)*)"|`([^`\\]*(?:\\.[^`\\]*)*)`/g;
75
+ const STRING_BODY = {
76
+ "'": /[^'\\]*(?:\\.[^'\\]*)*/y,
77
+ '"': /[^"\\]*(?:\\.[^"\\]*)*/y,
78
+ '`': /[^`\\]*(?:\\.[^`\\]*)*/y,
79
+ };
80
+
81
+ /**
82
+ * The quoted string literals of `source`, in order: each opening quote reads its body (backslash pairs taken
83
+ * whole, so an escaped quote does not close it) and is a literal only when its own quote closes it. An opening
84
+ * quote that never finds its closer would be retried by every later quote of the same kind inside what it
85
+ * read (each of them is an escaped one and would read the same body to the same dead end), so the end of the
86
+ * failed read is remembered and those retries are skipped, which keeps a long unterminated string linear.
87
+ * @param {string} source
88
+ * @returns {{ value: string, backtick: boolean }[]}
89
+ */
90
+ function stringLiteralsOf(source) {
91
+ /** @type {{ value: string, backtick: boolean }[]} */
92
+ const out = [];
93
+ const deadUntil = { "'": 0, '"': 0, '`': 0 };
94
+ const opener = /['"`]/g;
95
+ let from = 0;
96
+ for (;;) {
97
+ opener.lastIndex = from;
98
+ const hit = opener.exec(source);
99
+ if (hit === null) return out;
100
+ const at = hit.index;
101
+ const quote = /** @type {"'"|'"'|'`'} */ (source[at]);
102
+ from = at + 1;
103
+ if (at < deadUntil[quote]) continue;
104
+ const body = STRING_BODY[quote];
105
+ body.lastIndex = at + 1;
106
+ body.exec(source);
107
+ const end = body.lastIndex;
108
+ if (source[end] !== quote) { deadUntil[quote] = end; continue; }
109
+ out.push({ value: source.slice(at + 1, end), backtick: quote === '`' });
110
+ from = end + 1;
111
+ }
112
+ }
76
113
 
77
114
  /**
78
115
  * F8/F129 — a close judges the cwd the runner gives it, never a path baked
@@ -93,10 +130,7 @@ const STRING_LITERAL_RE = /'([^'\\]*(?:\\.[^'\\]*)*)'|"([^"\\]*(?:\\.[^"\\]*)*)"
93
130
  export function absolutePathLiteralsOf(source) {
94
131
  /** @type {string[]} */
95
132
  const hits = [];
96
- for (const m of source.matchAll(STRING_LITERAL_RE)) {
97
- const isBacktick = m[3] !== undefined;
98
- const value = m[1] ?? m[2] ?? m[3];
99
- if (value === undefined) continue;
133
+ for (const { value, backtick: isBacktick } of stringLiteralsOf(source)) {
100
134
  if (isBacktick && value.includes('${')) continue; // interpolated, not a fixed literal
101
135
  if (!value.startsWith('/')) continue;
102
136
  if (SYSTEM_PATH_PREFIXES.some((p) => value.startsWith(p))) continue;