bareloop 0.45.0 → 0.46.1

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 (48) hide show
  1. package/CHANGELOG.md +48 -0
  2. package/bareloop.context.md +34 -7
  3. package/package.json +1 -1
  4. package/src/attemptdiff.js +116 -0
  5. package/src/authorreadout.js +55 -14
  6. package/src/authorscout.js +1 -1
  7. package/src/bundle.js +90 -6
  8. package/src/close-integrity.js +39 -5
  9. package/src/config.js +5 -2
  10. package/src/inputlook.js +1 -47
  11. package/src/judged.js +3 -3
  12. package/src/labelscan.js +13 -4
  13. package/src/ladder.js +2 -2
  14. package/src/learn.js +943 -0
  15. package/src/learnstore.js +154 -0
  16. package/src/modellist.js +1 -1
  17. package/src/pagedry.js +2 -3
  18. package/src/pagefetch.js +2 -1
  19. package/src/pagerules.js +27 -4
  20. package/src/panel/authorroutes.js +4 -3
  21. package/src/panel/authorsession.js +5 -7
  22. package/src/panel/index.html +108 -0
  23. package/src/panel/schedulestart.js +5 -0
  24. package/src/panel/server.js +16 -134
  25. package/src/panel/status.js +11 -0
  26. package/src/planrun.js +40 -6
  27. package/src/providerrows.js +3 -3
  28. package/src/run.js +4 -2
  29. package/src/schedulewords.js +1 -1
  30. package/src/source.js +1 -1
  31. package/src/stagewords.js +145 -0
  32. package/src/text.js +4 -2
  33. package/src/userrun.js +28 -0
  34. package/src/validate.js +10 -5
  35. package/src/worktree.js +8 -2
  36. package/types/attemptdiff.d.ts +38 -0
  37. package/types/authorreadout.d.ts +7 -0
  38. package/types/config.d.ts +1 -1
  39. package/types/inputlook.d.ts +0 -23
  40. package/types/labelscan.d.ts +6 -0
  41. package/types/learn.d.ts +575 -0
  42. package/types/learnstore.d.ts +107 -0
  43. package/types/pagerules.d.ts +7 -1
  44. package/types/panel/status.d.ts +8 -0
  45. package/types/planrun.d.ts +13 -3
  46. package/types/reuse.d.ts +1 -1
  47. package/types/run.d.ts +8 -2
  48. package/types/stagewords.d.ts +91 -0
package/CHANGELOG.md CHANGED
@@ -5,6 +5,54 @@ 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.1] - 2026-10-24
9
+
10
+ ### Fixed
11
+
12
+ - **A blank Base URL in the New job readiness line reads "Type a Base URL."** It showed `[✗] no-base-url — may be flaky, not blocking`; it now shows the server's own sentence (owned by `src/modellist.js`).
13
+
14
+ - **The Audit learnings header counts distinct hints across the draft and the first replan.** It counted only the draft block (3 at draft, 5 at replan read "3 hints"). A hint handed at both draft and replan counts once (identity = ledger row id).
15
+
16
+ - **A scheduled fire refused at the run-start fetch shows its status word on the schedule card.** The card read `last 10:30 · run-4` with no word because the run list's events were not passed to `summarizeRow`; the refused row now carries its `failed` status.
17
+
18
+ - **Every drafting stop reads in plain words from one table.** `confirm-abandoned` and `confirm-restart` printed `Stopped: <code>`, and a code outside an 8-entry table fell back to `Drafting stopped (<code>).`. `STOP_HEADS` (with `STOP_TODO` where "draft again" is wrong) in `src/authorreadout.js` now covers about 20 stop codes and `authorsession` routes every stop through `draftStopText`; an unknown code gets a generic sentence, never the raw code. The confirm-abandoned stop now ends "nothing more was spent" (it promised a figure that reads "unknown" when spend is incomplete). Wording signed by hamr 2026-10-23.
19
+
20
+ - **One `isReusableGreen` decides every Reuse-eligibility check.** The picker, `getStartFrom`, the start gate and prefill flag in `authorroutes`, and the Ended block each spelled it their own way, and the Ended block still treated an already-green as not reusable. An already-green now ends "Nothing to do." like a green, and a scheduled already-green run is held for Accept like a green. Scheduling itself still needs a real green (`greenFor` in `src/schedules.js` is unchanged).
21
+
22
+ - **A watched-folder file whose name holds a line break or ends in whitespace is refused.** A newline in the name became extra Inputs lines in the fire's card text, and a trailing space trimmed to a path that does not exist. `perFireSpec` now refuses it through the ordinary refused-fire path ("Rename the file. Nothing started."; one refused row, no session) and never rewrites the name. A leading space is harmless and not refused.
23
+
24
+ - **`scripts/hermetic.mjs` refuses a test path that does not exist.** A mistyped path was skipped and the run exited 0, so a green count could miss a file. A path-looking argument that does not exist now exits 1 with a plain message and runs nothing; `npm test` with no arguments, flags and globs are untouched. The value of a value-taking flag (`--test-name-pattern "a/b"`, `--test-reporter`, `--import`, `-r`, ...) is never checked as a path; a boolean flag followed by a mistyped path is still refused.
25
+
26
+ ### Changed
27
+
28
+ - **Linear-time trailing trims in the reuse-preprobe `normPath` (`scripts/`, not shipped).** The `[.,;:)\]}'"`]+$` and `\/+$` trims were the quadratic class (about 1.2 s CPU on 30000 trailing dots); same results on 1.5M random strings, 0 mismatches.
29
+
30
+ - **`stageQuestionText`'s JSDoc now sits on the function, not on `GENRE_WHAT`.** Comments and declaration order only.
31
+
32
+ - **Tests strengthened (test-only, no source change).** A seeded differential test pins the linear `stringLiteralsOf` and `staticImportsOf` scanners against the old regexes; `buildCards`' card-level secret mask, the judged-strike trend number (read from the real `runDeclaredStages`), the Revise-at-menu spend figure (waits for the unwind, runs a second draft), and money-as-read in symbol-first and far-apart layouts each have a test that fails on a mutant.
33
+
34
+ ## [0.46.0] - 2026-10-23
35
+
36
+ ### Added
37
+
38
+ - **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`.
39
+
40
+ - **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.
41
+
42
+ - **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.
43
+
44
+ ### Fixed
45
+
46
+ - **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.
47
+
48
+ - **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.
49
+
50
+ - **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.
51
+
52
+ ### Changed
53
+
54
+ - **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`).
55
+
8
56
  ## [0.45.0] - 2026-10-22
9
57
 
10
58
  ### Added
@@ -627,7 +627,7 @@ a step, or test it without a provider.
627
627
  `error: 'truncated:max_tokens'`, neutral `stopReason: 'max_tokens'`) is a FAILED TRY on `askStructured`'s retry ladder with the
628
628
  reason "the AI's answer was cut off at the output limit" (`axis: 'output-truncated'`) — NOT `provider-red`; all attempts stay
629
629
  booked, `AUTHOR_MAX_TOKENS` is never raised, and only a real transport failure stays `provider-red`. `draftStopText`
630
- (`src/authorreadout.js`) words any drafting stop that has no wording of its own; the panel puts it on the failed step. (2)
630
+ (`src/authorreadout.js`) words EVERY drafting stop from one table (`STOP_HEADS` for the sentence, `STOP_TODO` where "draft again" is the wrong advice; a code the table lacks reads one generic sentence, never the raw code); the panel puts it on the failed step. (2)
631
631
  `authorClose({ offerQuestions })` (default `false`; the CLI never sets it) offers the optional `questions` property on the LAST
632
632
  try of the full ladder only, and only when the two tries before it both failed — validation OR the seed read (hamr ruled 2026-10-10; this DIFFERS
633
633
  from fwdloop's validation-only rule): a seed-read failure is a work stage that instrument-stopped or a close with no work stage
@@ -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-
@@ -3060,7 +3074,9 @@ Enforcement is **local only** — wired into `npm test` (see `package.json`'s `t
3060
3074
  script), never into `.github/workflows/*` (CI already runs `npm test`, so the rule is
3061
3075
  enforced there without an ask-first CI edit). `npm test` itself now runs `node --test`
3062
3076
  through `scripts/hermetic.mjs`, which strips this machine's `HOME`/git identity so a
3063
- test that leans on either fails locally instead of only on CI (F136). The check itself lives outside the
3077
+ test that leans on either fails locally instead of only on CI (F136). It also refuses a
3078
+ test path that does not exist (`node --test` skips one silently, so a mistyped file could hide
3079
+ behind a green count); the separate value of a value-taking flag is never read as a path. The check itself lives outside the
3064
3080
  published package, at `scripts/prompt-commit-check.mjs` (pure decision logic in
3065
3081
  `scripts/promptcommitlib.mjs`, `scripts/` is not in `package.json`'s `files`, so none of
3066
3082
  this ships):
@@ -3866,6 +3882,17 @@ provider but `anthropic-api`, spine bundling for `formatReplay` on the minting r
3866
3882
  `runid` is noted), and tarball packing — a bundle is a plain directory; use `npm pack` on it
3867
3883
  yourself if you want a tarball.
3868
3884
 
3885
+ ### 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)")
3886
+
3887
+ No model is called anywhere in learnings (a test walks the import closure). Pure rules in `learn.js`; file IO in `learnstore.js`.
3888
+
3889
+ - **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.
3890
+ - **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.
3891
+ - **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.
3892
+ - **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.
3893
+ - **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.
3894
+ - **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); its header counts DISTINCT hints (the same ledger row handed at the draft and the first replan counts once).
3895
+
3869
3896
  ### 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
3897
 
3871
3898
  Two files in `~/.config/bareloop/`, outside any repo:
@@ -3879,7 +3906,7 @@ Two files in `~/.config/bareloop/`, outside any repo:
3879
3906
  real one). The CLI (`run-u`, `interview`, `author`) and the panel both use it.
3880
3907
  - **`config.json` — every setting, never a key value.** `readConfig({ home })` /
3881
3908
  `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
3909
+ Fields: `learningHints` (true/false; absent = off; see "Cross-run learnings"), `monthlyLimitUsd` (number above 0; absent or `null` = no limit; any other value present
3883
3910
  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
3911
  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
3912
  in: a row with nothing saved is shape `openai-api`, no address, no lines. A secret-shaped
@@ -3951,7 +3978,7 @@ above 0, or `null`/blank to clear) — an empty or key-less body is a `400`, nev
3951
3978
  only the shell holds when config already names it (`keys.<ENV>` or `models.<ENV>`; its status reads `found in shell`) — nothing is hardcoded, there is no add-row and no remove-row control. A missing `.env`
3952
3979
  is created (mode 600, dir 700) on the first Providers read with two comment lines and no key. A value of `null` counts as SET and means "no real key" (the provider is handed the placeholder
3953
3980
  `no-key-needed`, never the word `null`; the row shows `set in file`). A key whose value has a space or a line break shows `The key has a space or a line break in it. Fix it in your keys file, then
3954
- reload keys.` Each row: the env NAME + status, an **API shape** select (Anthropic / OpenAI-compatible / Gemini), the typed **Base URL** (empty = no run, no list, `type the Base URL`), the **Old max_tokens**
3981
+ reload keys.` Each row: the env NAME + status, an **API shape** select (Anthropic / OpenAI-compatible / Gemini), the typed **Base URL** (empty = no run, no list, `type the Base URL`; the Test line reads the server's own sentence `Type a Base URL.`), the **Old max_tokens**
3955
3982
  tick (applies to `openai-api` only), **Tokens** (the key's spend), `[ Test ] [ ⟳ Refresh ]`, and its model lines: a **Use** tick, the model name, **In / Out $/1M** boxes, tokens (`—` when no spend row names the model).
3956
3983
  A listed id from Refresh is shown unticked and is not saved until touched; `+ type a model name` adds a line unticked. Saves are on change; success reads `saved — applies from the next model call`;
3957
3984
  every refusal is a plain sentence in `say` (`Fill both prices to tick this model.`, `Fill both price boxes or clear both.`, `A price must be a number, 0 or more.`, …). **Open keys folder** (in the KEYS strip,
@@ -4405,7 +4432,7 @@ Check what the person asked for, not what the worker chose to write. bareloop ne
4405
4432
  - **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
4433
  - **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
4434
  - **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.
4435
+ - **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
4436
  - **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
4437
 
4411
4438
  ## Triggers — a job that starts by itself (panel-only; `src/trigger*.js`, `src/panel/schedules.js`, `schedulestart.js`, `scheduler.js`, `src/schedulewords.js`)
@@ -4414,12 +4441,12 @@ Check what the person asked for, not what the worker chose to write. bareloop ne
4414
4441
  - **Only the panel fires.** `createPanelServer` runs one 500 ms unref'd scheduler (poll only, never `fs.watch`); the CLI never fires. Panel off = nothing fires; slots missed collapse to ONE catch-up (marked when more than 2 minutes late). `createPanelServer` test seams: `scheduleNow`, `schedulerIntervalMs` (0 = no timer), `sessionDeps`, `acceptWaitMs`.
4415
4442
  - **Files live under `<config home>/triggers/<job>/`**: `schedule.json` (the current signed schedule) and ONE append-only `ledger.jsonl` (every row carries `sig`, the spec hash it was written under). A run that a schedule started carries `trigger.json` beside its `resolved-spec.json`.
4416
4443
  - **Setting one is two clicks, both $0** (`POST /api/author/schedule-prepare`, then `/api/author/schedule` with the hash). It needs a real green run of the job's CURRENT workflow (a rubric job also an honoured Accept). A file trigger is for plain-files jobs only. Cancel is never refused. A reuse never clears a schedule: Reuse workflow (the one door, v1.101) keeps the origin's name while no job box is changed and switches the Job name to the next free `<base>-N` once one is; a changed job is a new workflow that needs its own green before it can be scheduled.
4417
- - **A fire goes busy, then monthly limit, then `wouldAccept`, then the one starter** (the same session start and `signRun` as a Reuse the person clicked, with `--review-door`). Busy, monthly and refused fires consume nothing and are told once per `key|outcome|why`, so they retry. A run is busy while its pid is alive AND its process started no later than 5 s after its run row was written (a recycled pid is not that run). A run waiting at its review door is not busy.
4444
+ - **A fire goes busy, then monthly limit, then `wouldAccept`, then the one starter** (the same session start and `signRun` as a Reuse the person clicked, with `--review-door`). Busy, monthly and refused fires consume nothing and are told once per `key|outcome|why`, so they retry. A file fire whose name holds a line break or ends in whitespace is refused at `perFireSpec` (the card text is `label: value` lines, so it would read back as another Inputs line); the person is told to rename the file. A run is busy while its pid is alive AND its process started no later than 5 s after its run row was written (a recycled pid is not that run). A run waiting at its review door is not busy.
4418
4445
  - **An install-needed refusal backs off, then gives up.** When a fire's copy needs packages, that job's next attempt waits 1, 2, 3, 5, 8, 13, then 21 minutes (in memory: a restart begins at the first wait); a wait that would pass 60 minutes in total is not taken: the schedule writes ONE `gave-up` row and is paused (nothing fires; files still queue as `arrived`), and the job shows in Needs you with the pill `Check` (`listGaveUp`). Signing the schedule again writes `resumed` and clears it. Constants: `INSTALL_RETRY_STEPS_MS`, `INSTALL_GIVE_UP_MS` (`src/panel/schedules.js`).
4419
4446
  - **A scheduled green is held until accepted**: `getStartFrom` reads its outcome as `green-held` and the Reuse list skips it, until the spine holds an honoured accept.
4420
4447
  - **Accept** (`POST /api/runs/<id>/accept`) runs the engine's own door path (`run-u --spec <signed spec> --door <id> --decide accept --approve <hash>`): the mechanical stages re-run, a red is `door-accept-red`, the run's verdict is untouched. It shows only while `doorWaiting(events)` (`src/reviewdoor.js`) is true; the same answer fills the runs list's Needs-you `Accept`.
4421
4448
  - **Every word the page shows** comes from `src/schedulewords.js` (`SCHEDULE_WORDS` is the one button table the server sends).
4422
- - **The schedule is shown on three tabs** (v1.102): the Run tab's one summary block (ENDED dated, NEXT, SCHEDULE with `last`/`next`; row and frame red when the last fire started nothing), the Job tab's Started and Schedule rows (`getRunJob`), and the Audit's own folded `schedule` section on every run of a scheduled job (v1.104: `detail.scheduleSection` `{title, sign, word, head, rows}`, drawn `[sign] word schedule · head` with the sign and word both from the status table, v1.106 Part 4; a run a cancelled schedule started keeps `detail.startedAudit`). `/api/runs` `scheduled[]` and `gaveUp[]` carry `runNo` so the small lists read `job · run-N`. `trigger.json` carries `triggerKind`, `sessionId`, `originRunid`, `sig`, `lateMs`; each signing appends a `signed` ledger row naming its origin run.
4449
+ - **The schedule is shown on three tabs** (v1.102): the Run tab's one summary block (ENDED dated, NEXT, SCHEDULE with `last`/`next`; row and frame red when the last fire started nothing; `last` wears the run's status word even for a fire refused at the run-start fetch, which has no spine, only a settled event), the Job tab's Started and Schedule rows (`getRunJob`), and the Audit's own folded `schedule` section on every run of a scheduled job (v1.104: `detail.scheduleSection` `{title, sign, word, head, rows}`, drawn `[sign] word schedule · head` with the sign and word both from the status table, v1.106 Part 4; a run a cancelled schedule started keeps `detail.startedAudit`). `/api/runs` `scheduled[]` and `gaveUp[]` carry `runNo` so the small lists read `job · run-N`. `trigger.json` carries `triggerKind`, `sessionId`, `originRunid`, `sig`, `lateMs`; each signing appends a `signed` ledger row naming its origin run.
4423
4450
 
4424
4451
 
4425
4452
  ## The Audit's tries, checks and answered questions (PRD v1.106 Part 5)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bareloop",
3
- "version": "0.45.0",
3
+ "version": "0.46.1",
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
+ }
@@ -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) {
@@ -650,6 +656,50 @@ export function proposalStopText({ stop, reds, cases = null, spend = null, sourc
650
656
  /** the reason inside a `scout-absent` red's detail: `the survey is ABSENT (<reason>) — an absent facts object ...` (src/authorflow.js) */
651
657
  const SCOUT_REASON_RE = /^the survey is [^(]*\((.*)\) — an absent facts/s;
652
658
 
659
+ /**
660
+ * ONE owner of the plain sentence for every drafting stop code (hamr 2026-10-09: "a user should get a reason they can
661
+ * understand and do something about"). What happened; `STOP_TODO` below overrides the default "draft again" where that is wrong.
662
+ * Used by `draftStopText` for every stop the panel and the CLI show; a code not listed reads `STOP_UNKNOWN`, never the raw code.
663
+ * @type {Record<string, string>}
664
+ */
665
+ export const STOP_HEADS = {
666
+ 'provider-red': 'The AI provider failed before a usable plan came back — a connection or service fault, not a problem with your job.',
667
+ 'artifact-red': 'The AI answered, but its answer could not be used as a plan.',
668
+ 'cap-halt': 'Drafting stopped on your cost cap.',
669
+ 'pricing-red': 'Drafting stopped: the cost of a call could not be priced, so the cap cannot be enforced.',
670
+ 'max-revisions': 'The AI used all its tries without a plan that passed the checks.',
671
+ precheck: 'Drafting stopped before any call: something it needs was missing.',
672
+ unchanged: 'Drafting stopped: the AI sent the same plan twice.',
673
+ 'scout-absent': 'Drafting stopped: the first look at your repo or input did not complete.',
674
+ refused: 'Drafting stopped: something in the job or its inputs is not allowed.',
675
+ 'interview-incomplete': 'Drafting stopped: the job form was not complete, so there was nothing to draft from.',
676
+ 'calibration-missing': 'Drafting stopped: the practice cases that prove the rubric were missing, so the check could not be trusted.',
677
+ 'invalid-set': 'Drafting stopped: the rubric and its practice cases do not fit together, so they could not be graded.',
678
+ 'broken-close': 'Drafting stopped: the final check the model wrote could not run.',
679
+ 'listing-absent': 'Drafting stopped: the list of your files was not available, so the checks could not name any.',
680
+ 'listing-conflict': 'Drafting stopped: the list of your files was given twice and the two did not agree.',
681
+ 'listing-unreadable': 'Drafting stopped: your repo\'s file list could not be read.',
682
+ 'non-code-source': 'Drafting stopped: this source is not a code repo, and this kind of job only works on code.',
683
+ 'authoring-failed': 'Drafting stopped for a reason that was not recorded.',
684
+ 'confirm-abandoned': 'Drafting stopped: the confirm step ended before you answered, so nothing was signed.',
685
+ 'confirm-restart': 'Drafting stopped: you chose to start over, so nothing was signed.',
686
+ };
687
+
688
+ /** the "What to do" for the stops where "draft again" is not the right advice */
689
+ const STOP_TODO = /** @type {Record<string, string>} */ ({
690
+ refused: 'change what the message above names, then draft again.',
691
+ 'interview-incomplete': 'fill in every line of the job form, then draft again.',
692
+ 'calibration-missing': 'draft again; if it stops the same way twice, make your PASS/FAIL examples more concrete.',
693
+ 'invalid-set': 'draft again; if it stops the same way twice, make your PASS/FAIL examples more concrete.',
694
+ 'non-code-source': 'point the job at a code repo, or use a folder or file job instead.',
695
+ 'listing-unreadable': 'check the repo is a readable git repo with at least one commit, then draft again.',
696
+ 'confirm-abandoned': 'start a new draft when you are ready; nothing more was spent.',
697
+ 'confirm-restart': 'change the answers you want and start the draft again.',
698
+ });
699
+
700
+ /** a stop code this table does not know: still plain words, never the code */
701
+ const STOP_UNKNOWN = 'Drafting stopped for a reason this screen has no wording for.';
702
+
653
703
  /**
654
704
  * @param {{stop: string, reds?: PlainRed[]|null, refusal?: {detail?: string}|null,
655
705
  * spend?: {knownUsd?: number|null, spendComplete?: boolean|null}|null, wholeJob?: boolean}} o `wholeJob` - a rubric job on the person's
@@ -668,23 +718,13 @@ export function draftStopText({ stop, reds = null, refusal = null, spend = null,
668
718
  const detail = (Array.isArray(reds) ? reds : []).find((r) => r?.code === 'fit-stopped')?.detail;
669
719
  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
720
  }
671
- /** @type {Record<string, string>} */
672
- const heads = {
673
- 'provider-red': 'The AI provider failed before a usable plan came back — a connection or service fault, not a problem with your job.',
674
- 'artifact-red': 'The AI answered, but its answer could not be used as a plan.',
675
- 'cap-halt': 'Drafting stopped on your cost cap.',
676
- 'pricing-red': 'Drafting stopped: the cost of a call could not be priced, so the cap cannot be enforced.',
677
- 'max-revisions': 'The AI used all its tries without a plan that passed the checks.',
678
- precheck: 'Drafting stopped before any call: something it needs was missing.',
679
- unchanged: 'Drafting stopped: the AI sent the same plan twice.',
680
- 'scout-absent': 'Drafting stopped: the first look at your repo or input did not complete.',
681
- };
721
+ const heads = STOP_HEADS;
682
722
  const redList = Array.isArray(reds) ? reds : [];
683
723
  // F217: the first look at the repo failing (a dropped connection, a timeout) arrives as a `precheck` stop with a
684
724
  // `scout-absent` red. That is NOT "something it needs was missing", and its reason sits past the 60-char quote bound
685
725
  // behind a fixed lead-in, so the person saw neither. Word it as the scout's stop and quote the reason in full (scrubbed).
686
726
  const scoutRed = stop === 'precheck' ? redList.find((r) => r?.code === 'scout-absent') : undefined;
687
- const head = scoutRed ? heads['scout-absent'] : (heads[stop] ?? `Drafting stopped (${safeText(stop)}).`);
727
+ const head = scoutRed ? heads['scout-absent'] : (Object.hasOwn(heads, stop) ? heads[stop] : STOP_UNKNOWN);
688
728
  /** @type {string[]} */
689
729
  const said = scoutRed
690
730
  ? [SCOUT_REASON_RE.exec(String(scoutRed.detail ?? ''))?.[1]
@@ -699,6 +739,7 @@ export function draftStopText({ stop, reds = null, refusal = null, spend = null,
699
739
  }
700
740
  lines.push(stop === 'cap-halt' || stop === 'pricing-red'
701
741
  ? 'What to do: raise the cap or fix the price in Settings, then draft again.'
742
+ : Object.hasOwn(STOP_TODO, stop) ? `What to do: ${STOP_TODO[stop]}`
702
743
  : (wholeJob ? 'What to do: review the whole job - every line and its ~ rules, and the inputs - and submit it again.' : 'What to do: draft again.'));
703
744
  return lines.join('\n');
704
745
  }
@@ -759,8 +800,8 @@ const ruleFns = (list) => list.split(';').map((x) => x.trim()).filter(Boolean).m
759
800
  */
760
801
  function miswriteSentences(r) {
761
802
  const who = `Case "${safeText(r.caseId ?? /^case "(.*?)" was graded/.exec(r.detail)?.[1] ?? '')}"`;
762
- const missing = /expected red\(s\) the pipe did not raise: (.*?)(?: — |\. The floor)/.exec(r.detail)?.[1];
763
- const extra = /red\(s\) the pipe raised that nobody signed: (.*?)(?: — |\. The floor)/.exec(r.detail)?.[1];
803
+ const missing = MISSING_REDS_RE.exec(r.detail)?.[1];
804
+ const extra = EXTRA_REDS_RE.exec(r.detail)?.[1];
764
805
  /** @type {string[]} */
765
806
  const out = [];
766
807
  for (const { rule, fn } of missing ? ruleFns(missing) : []) {
@@ -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;