clearotron 0.2.2 → 0.2.4

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 (161) hide show
  1. package/.env.example +64 -37
  2. package/.nvmrc +1 -1
  3. package/CONTRIBUTING.md +8 -4
  4. package/INSTALL.md +36 -14
  5. package/README.md +18 -22
  6. package/THIRD-PARTY-NOTICES.md +1 -1
  7. package/bin/clearotron.mjs +22 -0
  8. package/bin/example.mjs +10 -5
  9. package/bin/onboard.mjs +370 -28
  10. package/bin/start.mjs +220 -12
  11. package/build-info.json +2 -2
  12. package/demo/README.md +1 -1
  13. package/demo/full-country-search/run/_driver/framework.json +1 -1
  14. package/demo/global-preliminary-search/run/_driver/framework.json +1 -1
  15. package/demo/knockout-search/run/_driver/framework.json +1 -1
  16. package/demo/multi-country-focus-search/run/_driver/framework.json +1 -1
  17. package/docs/E2E.md +5 -5
  18. package/docs/GLOSSARY.md +85 -0
  19. package/docs/PORTAL.md +2 -2
  20. package/docs/README.md +6 -4
  21. package/docs/SECURITY.md +17 -0
  22. package/docs/architecture/01-product-overview.md +21 -9
  23. package/docs/architecture/04-configuration-reference.md +2 -0
  24. package/docs/architecture/05-config-governance.md +19 -0
  25. package/docs/architecture/05-customer-profiles.md +2 -2
  26. package/docs/configuration.md +4 -3
  27. package/docs/decisions/0006-what-the-public-repository-carries.md +30 -5
  28. package/driver/CHANGELOG.md +90 -0
  29. package/driver/README.md +25 -6
  30. package/driver/common-law-receipts.mjs +1 -1
  31. package/driver/config-staleness.mjs +1 -1
  32. package/driver/connotation-search.mjs +1 -1
  33. package/driver/contract-audit.mjs +5 -1
  34. package/driver/contract-e3-baseline.json +11 -11
  35. package/driver/dev-portal.mjs +2 -2
  36. package/driver/doubt-selection.mjs +1 -1
  37. package/driver/drainer-identity.mjs +1 -1
  38. package/driver/driver.config.mjs +1 -1
  39. package/driver/effort-model.mjs +2 -2
  40. package/driver/engine/cli-version.mjs +103 -0
  41. package/driver/engine/probe.mjs +45 -5
  42. package/driver/enqueue-schema.mjs +8 -8
  43. package/driver/feedback-store.mjs +1 -1
  44. package/driver/findings-model.mjs +52 -2
  45. package/driver/flag-snapshot.mjs +20 -0
  46. package/driver/framework.mjs +1 -1
  47. package/driver/gateway.mjs +20 -3
  48. package/driver/outbox-backoff.mjs +1 -1
  49. package/driver/package.json +3 -3
  50. package/driver/party-facts.mjs +2 -2
  51. package/driver/phase0.mjs +1 -1
  52. package/driver/pipeline.mjs +211 -106
  53. package/driver/plain-register.mjs +134 -0
  54. package/driver/portal-access.mjs +1 -1
  55. package/driver/portal-config-view.mjs +70 -2
  56. package/driver/portal-families.mjs +1 -1
  57. package/driver/portal-local-auth.mjs +5 -1
  58. package/driver/portal-report.mjs +1 -1
  59. package/driver/portal-service.mjs +79 -9
  60. package/driver/portal-upstream.mjs +2 -2
  61. package/driver/predelivery-lint.mjs +151 -2
  62. package/driver/profile-page.html +10 -2
  63. package/driver/profile-service.mjs +4 -4
  64. package/driver/profiles/README.md +3 -3
  65. package/driver/profiles.mjs +144 -12
  66. package/driver/publish/index.mjs +2 -2
  67. package/driver/publish/knockout.mjs +7 -1
  68. package/driver/publish/render-knockout.mjs +453 -118
  69. package/driver/publish/render.mjs +111 -16
  70. package/driver/reference-strip-signatures.mjs +20 -0
  71. package/driver/search-policy.mjs +3 -3
  72. package/driver/skills/blind-frame/SKILL.md +1 -1
  73. package/driver/skills/knockout-assess/SKILL.md +84 -0
  74. package/driver/skills/matter-frame/SKILL.md +6 -6
  75. package/driver/skills/matter-frame/watchlist-reference.md +2 -2
  76. package/driver/skills/prelim-common-law/SKILL.md +1 -1
  77. package/driver/skills/prelim-search/SKILL.md +1 -1
  78. package/driver/skills/prelim-search/report-prose.md +82 -0
  79. package/driver/skills/prelim-search/risk-framework-demo.manifest.json +1 -1
  80. package/driver/skills/prelim-search/risk-framework-triage.manifest.json +1 -1
  81. package/driver/skills/prelim-search/risk-framework.manifest.json +1 -1
  82. package/driver/skills/prelim-search/synthesis-rules.md +1 -1
  83. package/driver/skills/prelim-variants/SKILL.md +3 -3
  84. package/driver/stage-context.mjs +13 -0
  85. package/driver/stages.mjs +54 -7
  86. package/driver/suite-census.json +220 -58
  87. package/driver/systemd/clearotron-worker.service +3 -3
  88. package/driver/tokens.mjs +1 -1
  89. package/driver/unit-inventory.mjs +214 -27
  90. package/driver/verify.mjs +1 -1
  91. package/driver/whatif-memo-run.mjs +26 -4
  92. package/driver/whatif-memo.mjs +19 -1
  93. package/examples/README.md +2 -2
  94. package/examples/grants.example.json +6 -7
  95. package/mcp-server/CHANGELOG.md +4 -0
  96. package/mcp-server/lib/scrub.mjs +1 -1
  97. package/mcp-server/lib/whatif.mjs +2 -2
  98. package/mcp-server/package.json +1 -1
  99. package/mcp-server/packs/ops/EXAMPLES.md +2 -2
  100. package/mcp-server/server.mjs +1 -1
  101. package/package.json +31 -19
  102. package/portal-ui/dist/assets/{index-Ct3iQtZ2.css → index-1ziUJX1E.css} +43 -2
  103. package/portal-ui/dist/assets/{index-BUGgiYtF.js → index-KFAHMgdT.js} +289 -73
  104. package/portal-ui/dist/index.html +2 -2
  105. package/portal-ui/package.json +1 -1
  106. package/providers/_shared/term-shape.mjs +1 -1
  107. package/providers/oauth-mcp-bridge/CHANGELOG.md +4 -0
  108. package/providers/oauth-mcp-bridge/package.json +1 -1
  109. package/providers/uspto-local/src/index-store.js +2 -2
  110. package/scripts/added-reference-check.mjs +45 -89
  111. package/scripts/citation-line-check.mjs +121 -2
  112. package/scripts/e2e.mjs +2 -2
  113. package/scripts/env-classify.mjs +67 -3
  114. package/scripts/generated-files-are-current.mjs +125 -0
  115. package/scripts/live-surface-check.mjs +38 -1
  116. package/scripts/mint-names-in-force.mjs +4 -2
  117. package/scripts/mint-public-residue.mjs +74 -0
  118. package/scripts/mint-reference-strip-backlog.mjs +12 -1
  119. package/scripts/mint-suite-census.mjs +5 -2
  120. package/scripts/nightly-notice.mjs +0 -2
  121. package/scripts/preinstall-node-check.mjs +24 -0
  122. package/scripts/render-check.mjs +13 -2
  123. package/scripts/report-frame-check.mjs +8 -1
  124. package/scripts/report-screenshot.mjs +6 -3
  125. package/scripts/revisit-render-check.mjs +6 -3
  126. package/scripts/score.mjs +1 -1
  127. package/scripts/strip-tracker-citations.mjs +38 -7
  128. package/scripts/test-full.mjs +226 -0
  129. package/scripts/test-run.mjs +16 -0
  130. package/shared/README.md +2 -1
  131. package/shared/anon-overlay.mjs +3 -3
  132. package/shared/browser-temp-root.mjs +142 -0
  133. package/shared/checkout-move.mjs +1 -1
  134. package/shared/client-door.mjs +4 -3
  135. package/shared/customer-store.mjs +1 -1
  136. package/shared/identifier-scan.mjs +1 -1
  137. package/shared/install-auth.mjs +35 -0
  138. package/shared/invocation.mjs +33 -1
  139. package/shared/listen.mjs +55 -3
  140. package/shared/names-in-force.mjs +6 -1
  141. package/shared/node-floor.mjs +97 -0
  142. package/shared/os-advice.mjs +91 -0
  143. package/shared/reap-on-exit.mjs +42 -0
  144. package/shared/reference-guard-classes.mjs +351 -0
  145. package/shared/staff-domain.mjs +154 -0
  146. package/shared/store-in-repo.mjs +23 -2
  147. package/shared/suite-census.mjs +31 -3
  148. package/shared/withheld-paths-access.mjs +36 -18
  149. package/skills/clearotron-ops/SKILL.md +1 -1
  150. package/driver/recipes/README.md +0 -49
  151. package/driver/skills/prelim-search/risk-framework-aurora.manifest.json +0 -19
  152. package/driver/skills/prelim-search/risk-framework-aurora.md +0 -77
  153. package/driver/skills/prelim-search/risk-framework-zephyr.manifest.json +0 -14
  154. package/driver/skills/prelim-search/risk-framework-zephyr.md +0 -33
  155. package/driver/skills/prelim-search/worked-examples-aurora.md +0 -10
  156. package/driver/skills/prelim-search/worked-examples-zephyr.md +0 -10
  157. package/scripts/ai-page-render-check.mjs +0 -532
  158. package/scripts/clearances-render-check.mjs +0 -480
  159. package/scripts/composer-render-check.mjs +0 -1027
  160. package/scripts/home-render-check.mjs +0 -526
  161. package/scripts/portal-lifecycle-check.mjs +0 -639
@@ -0,0 +1,125 @@
1
+ #!/usr/bin/env node
2
+ // SPDX-License-Identifier: AGPL-3.0-only
3
+ // Copyright 2026 Cordillera Sàrl. Additional terms under section 7 of the AGPL-3.0 apply — see ADDITIONAL-TERMS.md
4
+ //
5
+ // EVERY GENERATED FILE IN THIS TREE IS CURRENT, or this says which one is not.
6
+ //
7
+ // node scripts/generated-files-are-current.mjs
8
+ //
9
+ // ── WHY THIS EXISTS, AND WHY IT DISCOVERS ITS OWN POPULATION ─────────────────────────────────────
10
+ //
11
+ // Each `scripts/mint-*.mjs` writes a file that is committed and derived, and each takes `--check` to
12
+ // say whether the committed copy still matches the tree. Until now CI ran exactly one of them. The
13
+ // other three could go stale on `main` and nothing here would say so — the failure surfaced instead
14
+ // in a private control that refuses to start on a stale fixture, which is the most expensive place
15
+ // to learn it and the furthest from whoever caused it.
16
+ //
17
+ // So this enumerates `scripts/mint-*.mjs` rather than naming them. A list would have to be extended
18
+ // by whoever adds the fifth minter, and the whole reason the fourth went unchecked is that nobody
19
+ // extended anything. A discovered population is covered by construction.
20
+ //
21
+ // AN EMPTY POPULATION IS A FAULT, NOT A PASS. If the glob matches nothing, this file has been moved
22
+ // or the naming convention has changed, and reporting "all current" over zero checks is exactly the
23
+ // shape of green this guard exists to refuse.
24
+ import { readdirSync } from "node:fs";
25
+ import { spawnSync, execFileSync } from "node:child_process";
26
+ import { join, dirname } from "node:path";
27
+ import { fileURLToPath } from "node:url";
28
+
29
+ const HERE = dirname(fileURLToPath(import.meta.url));
30
+ const ROOT = join(HERE, "..");
31
+
32
+ /** Every minter in `scripts/`, by filename, ordered. */
33
+ export function minters(dir = HERE) {
34
+ return readdirSync(dir).filter((f) => f.startsWith("mint-") && f.endsWith(".mjs")).sort();
35
+ }
36
+
37
+ /**
38
+ * What the working tree looks like, so a minter that WROTE during `--check` can be caught writing.
39
+ *
40
+ * Returns null when the tree cannot be read — outside a checkout, say — and a null is carried as a
41
+ * stated limit rather than as a pass: the contract simply goes unchecked and the run says so.
42
+ */
43
+ export function treeState(root = ROOT) {
44
+ try {
45
+ return execFileSync("git", ["-C", root, "status", "--porcelain"], { encoding: "utf8", maxBuffer: 1 << 28 });
46
+ } catch { return null; }
47
+ }
48
+
49
+ /**
50
+ * Run every minter's `--check` and sort the answers.
51
+ *
52
+ * `dir` and `root` are parameters so the arms can drive all three outcomes over throwaway minters.
53
+ * The alternative — dirtying a real generated file and restoring it — is a shared-file mutation, and
54
+ * the test runner runs files in parallel, so it would be a race that reddens somebody else's arm.
55
+ */
56
+ export function checkAll({ dir = HERE, root = ROOT, log = console.log, readTree = () => treeState(root) } = {}) {
57
+ const found = minters(dir);
58
+ if (!found.length) return { found, stale: [], unreadable: [], wrote: [], empty: true };
59
+
60
+ const stale = [];
61
+ const unreadable = [];
62
+ const wrote = [];
63
+ for (const m of found) {
64
+ // ── `--check` IS A CONTRACT, AND NOTHING WAS VERIFYING IT ──────────────────────────────────────
65
+ //
66
+ // Every minter here is trusted to treat `--check` as "report, change nothing". A minter that
67
+ // ignores the flag and re-mints repairs the drift and then reports `current` — so the file is
68
+ // quietly fixed on whoever's machine ran it, the commit does not carry the repair, and this guard
69
+ // logs a clean line over a check that never happened. That is this file's own sentence one level
70
+ // in: a pass from a minter that did not look is not a pass either.
71
+ //
72
+ // The probe is the contract stated directly. `--check` must not write, so if the tree moved while
73
+ // one ran, it wrote. THE LIMIT, SAID RATHER THAN LEFT: this catches the harmful inert form, the
74
+ // one that silently repairs. A minter that ignores the flag and does nothing at all still reports
75
+ // `current`, and no probe from out here can tell that from a file that really is current.
76
+ const before = readTree();
77
+ const r = spawnSync(process.execPath, [join(dir, m), "--check"], { cwd: root, encoding: "utf8" });
78
+ const after = readTree();
79
+ const out = ((r.stdout || "") + (r.stderr || "")).trim();
80
+ if (before !== null && after !== null && before !== after) {
81
+ wrote.push({ m, out });
82
+ log(` WROTE ${m} (during --check)`);
83
+ continue;
84
+ }
85
+ // 0 is current, 1 is stale, anything else is a minter that could not look — reported separately,
86
+ // because "I could not read the tree" and "the file is out of date" need different things done.
87
+ if (r.status === 0) { log(` current ${m}`); continue; }
88
+ if (r.status === 1) { stale.push({ m, out }); log(` STALE ${m}`); continue; }
89
+ unreadable.push({ m, out, code: r.status });
90
+ log(` ? ${m} (exit ${r.status})`);
91
+ }
92
+ return { found, stale, unreadable, wrote, empty: false, contractChecked: readTree() !== null };
93
+ }
94
+
95
+ function main() {
96
+ const { found, stale, unreadable, wrote, empty, contractChecked } = checkAll();
97
+ if (empty) {
98
+ console.error("generated-files-are-current: no scripts/mint-*.mjs found. Either they moved or the "
99
+ + "naming changed — and a pass over nothing is not a pass.");
100
+ process.exit(2);
101
+ }
102
+ console.log(`\ngenerated-files-are-current: checked ${found.length} minter(s)`
103
+ + (contractChecked ? "" : "; the tree could not be read, so nothing verified that `--check` changed nothing"));
104
+
105
+ for (const { m, out } of [...wrote, ...stale, ...unreadable]) {
106
+ console.error(`\n──── ${m} ────\n${out}`);
107
+ }
108
+ if (wrote.length) {
109
+ console.error(`\n${wrote.length} minter(s) CHANGED THE TREE while running \`--check\`. \`--check\` `
110
+ + `reports and changes nothing; one that re-mints repairs the drift on whoever ran it, leaves the `
111
+ + `commit without the repair, and reports current over a check that did not happen. Fix the minter.`);
112
+ process.exit(2);
113
+ }
114
+ if (unreadable.length) {
115
+ console.error(`\n${unreadable.length} minter(s) could not look. That is not a pass; fix the minter first.`);
116
+ process.exit(2);
117
+ }
118
+ if (stale.length) {
119
+ console.error(`\n${stale.length} generated file(s) are out of date. Re-mint each one named above and `
120
+ + `commit the result — a derived file that drifts is a check reporting on a tree that no longer exists.`);
121
+ process.exit(1);
122
+ }
123
+ }
124
+
125
+ if (import.meta.url === `file://${process.argv[1]}`) main();
@@ -90,7 +90,7 @@ import { unitsActiveVerdict } from "../driver/unit-state-verdict.mjs";
90
90
  import { deploymentBox } from "../shared/deployment-box.mjs"; // — extracted; one allowlist, two readers
91
91
  import { unitFileDriftVerdict } from "../driver/unit-file-drift.mjs"; //
92
92
  import { placeholdersIn, resolveValues, renderUnit } from "../driver/systemd/render-units.mjs"; //
93
- import { CHECKED_UNITS, unitInventoryVerdict, serviceCommitVerdict, unitWorkingDirectory, unitClone } from "../driver/unit-inventory.mjs"; // · -bundle ·
93
+ import { CHECKED_UNITS, CHECKED_TIMERS, timerVerdict, unitInventoryVerdict, serviceCommitVerdict, unitWorkingDirectory, unitClone } from "../driver/unit-inventory.mjs"; // · -bundle ·
94
94
  import { entrypointOf } from "../driver/systemd/install-census.mjs"; // the ONE ExecStart parser — a unit says which module it runs
95
95
  import { treeOfRunning } from "../shared/checkout-move.mjs"; // …and the live argv says which tree that module came from
96
96
  import { findUnitFiles, unitFilePath } from "../driver/unit-files.mjs"; //
@@ -226,6 +226,35 @@ function userBusEnv() {
226
226
  // Returns {clones, probe}. `probe` is the honest answer to "could I look at all?" — {ok, why} — and it is
227
227
  // SEPARATE from the clones list on purpose. "enumerated, nothing to compare" and "could not enumerate"
228
228
  // were the same empty array before, and this suite exists because absences were read as successes.
229
+ /**
230
+ * Ask each declared TIMER about itself, as a timer.
231
+ *
232
+ * Deliberately not folded into the loop below. That loop asks a service for its WorkingDirectory and
233
+ * MainPID and attributes a running process to a checkout — none of which a `.timer` has, so putting
234
+ * timers through it would produce rows whose every attribution field is empty for a reason that is not
235
+ * a finding. Two questions, two calls, and each says what it could not answer.
236
+ */
237
+ function declaredTimers() {
238
+ if (!CHECKED_TIMERS.length) return { rows: [], probeFailed: null };
239
+ const env = userBusEnv();
240
+ const rows = [];
241
+ let probeFailed = null;
242
+ for (const unit of CHECKED_TIMERS) {
243
+ try {
244
+ const shown = execFileSync("systemctl", ["--user", "show", unit, "-p", "ActiveState", "-p", "LoadState"],
245
+ { encoding: "utf8", env, stdio: ["ignore", "pipe", "pipe"] });
246
+ const f = Object.fromEntries(shown.split("\n").filter(Boolean).map((l) => { const [k, ...v] = l.split("="); return [k, v.join("=")]; }));
247
+ // An EMPTY answer is the no-session-bus case, and it must not read as a stopped timer — that is
248
+ // the shape that turns "could not ask" into a finding about the box.
249
+ if (!f.ActiveState) { probeFailed = probeFailed ?? `systemctl --user answered nothing for ${unit}`; continue; }
250
+ rows.push({ unit, active: f.ActiveState || null, load: f.LoadState || null });
251
+ } catch (e) {
252
+ probeFailed = probeFailed ?? String(e?.message ?? e).slice(0, 120);
253
+ }
254
+ }
255
+ return { rows, probeFailed };
256
+ }
257
+
229
258
  function serviceClones() {
230
259
  // — the list is DECLARED, not written here. It used to be eight names inline, which put a unit
231
260
  // inside the drift guarantee or outside it by omission: `client-access` was live on production and in
@@ -889,6 +918,14 @@ else {
889
918
  const v = unitInventoryVerdict({ live: liveUnits, files: walk.files, collisions: walk.collisions,
890
919
  filesError: walk.error, box, probe });
891
920
  record("every live unit is declared", v.state, v.message);
921
+
922
+ // — AND WHETHER ANYTHING STILL STARTS THE TIMER-DRIVEN ONES. Reported separately from the line above
923
+ // because it answers a different question: that one says a declared unit exists and is not adrift,
924
+ // this one says its timer is still armed. A service whose timer was stopped satisfies the first and
925
+ // fails the second, and reads `inactive` for both — which is why one line could not carry both.
926
+ const t = declaredTimers();
927
+ const tv = timerVerdict(t.rows, { probeFailed: t.probeFailed });
928
+ record("every declared timer is still armed", tv.state, tv.message);
892
929
  }
893
930
 
894
931
  // ── — EVERY QUEUE THIS DEPLOYMENT WOULD DRAIN IS WATCHED BY SOMETHING ──────────────────────────
@@ -21,7 +21,7 @@
21
21
  // ── THE ORACLE THIS DERIVATION IS CHECKED AGAINST ────────────────────────────────────────────────
22
22
  //
23
23
  // A derivation that is only ever compared against itself proves nothing, which is the shape this repo
24
- // keeps paying for. So the arm in driver/test/retired-env-spellings.test.mjs holds this output to the
24
+ // keeps paying for. So scripts/generated-files-are-current.mjs, in CI's guards job, holds this output to the
25
25
  // THIRTEEN dead names Cart measured in the production install's own env file on 2026-09-05 — a
26
26
  // population this script never saw and cannot influence. All thirteen must resolve to a replacement.
27
27
  // If a future sweep breaks the suffix rule, that arm reds on real-world data rather than on a
@@ -82,7 +82,9 @@ function render(names) {
82
82
  //
83
83
  // node scripts/mint-names-in-force.mjs
84
84
  //
85
- // driver/test/retired-env-spellings.test.mjs regenerates this and refuses a stale copy.
85
+ // CI refuses a stale copy, from the guards job: scripts/generated-files-are-current.mjs runs every
86
+ // minter in scripts/ with --check. (This line used to name a test file that does not exist in this
87
+ // tree, so the only thing that ever caught this drifting was a private control refusing to start.)
86
88
 
87
89
  export const NAMES_IN_FORCE = Object.freeze([
88
90
  ${names.map((n) => ` "${n}",`).join("\n")}
@@ -0,0 +1,74 @@
1
+ #!/usr/bin/env node
2
+ // SPDX-License-Identifier: AGPL-3.0-only
3
+ // Copyright 2026 Cordillera Sàrl. Additional terms under section 7 of the AGPL-3.0 apply — see ADDITIONAL-TERMS.md
4
+ //
5
+ // RE-MINT THE PUBLIC RESIDUE BACKLOG — the per-file, per-class count that may only go down.
6
+ //
7
+ // node scripts/mint-public-residue.mjs # report the delta, change nothing
8
+ // node scripts/mint-public-residue.mjs --check # ... and exit 1 if the file is out of date
9
+ // node scripts/mint-public-residue.mjs --apply # write it
10
+ //
11
+ // THE ONLY REASON TO RUN `--apply` IS THAT THE NUMBER WENT DOWN. Nothing here refuses to write a
12
+ // higher one — a table that could not record growth would be unable to describe a tree somebody
13
+ // widened a class over — but the floor arm in driver/test/the-public-residue-is-a-floor.test.mjs
14
+ // refuses the growth itself, and it reads the committed file rather than this script's output. So an
15
+ // author who mints upward has recorded the regression rather than absorbed it, and the suite says so
16
+ // on the next run. The two halves are deliberately not the same program.
17
+ import { readFileSync, writeFileSync } from "node:fs";
18
+ import { join, dirname } from "node:path";
19
+ import { fileURLToPath } from "node:url";
20
+ import { CLASSES, censusOf, publishedOf } from "../shared/reference-guard-classes.mjs";
21
+ import { trackedFiles, skipReason } from "../shared/tracked-files.mjs";
22
+ import { isEntrypoint } from "../shared/is-entrypoint.mjs";
23
+
24
+ const ROOT = join(dirname(fileURLToPath(import.meta.url)), "..");
25
+ const FIXTURE = join(ROOT, "driver/test/fixtures/public-residue-backlog.json");
26
+ const GUARD = "public-residue-backlog";
27
+
28
+ /** The tree's current census, as the fixture records it. */
29
+ export function mint() {
30
+ const tracked = trackedFiles(GUARD, { root: ROOT });
31
+ if (tracked === null) return null;
32
+ // THE SAME POPULATION THE FLOOR READS, from the same helper. A mint over the index and a floor over
33
+ // HEAD would disagree under the overlay, and `--check` would report a difference that is only the
34
+ // two instruments asking different questions.
35
+ const p = publishedOf(tracked, ROOT);
36
+ if (p.error) { console.error(`mint-public-residue: ${p.error}`); process.exit(2); }
37
+ if (p.laid) console.log(`mint-public-residue: ${p.laid} tracked path(s) are not in HEAD — laid over this checkout, not published in it, and not counted`);
38
+ const c = censusOf(p.files, (f) => readFileSync(join(ROOT, f), "utf8"));
39
+ return { classes: CLASSES.map((x) => x.id), total: c.total, files: c.files };
40
+ }
41
+
42
+ /** Per-class totals, for a reader who wants to know WHICH number moved. */
43
+ export const byClass = (table) =>
44
+ CLASSES.map((c, i) => [c.id, Object.values(table.files).reduce((a, v) => a + v[i], 0)]);
45
+
46
+ function main() {
47
+ const apply = process.argv.includes("--apply");
48
+ const check = process.argv.includes("--check");
49
+
50
+ const now = mint();
51
+ // A COULD-NOT-LOOK EXITS 2, never 0. Outside a checkout there is no corpus, and a mint that wrote an
52
+ // empty table here would replace the whole backlog with nothing and call it a repair.
53
+ if (now === null) { console.error(`mint-public-residue: ${skipReason(GUARD)}`); process.exit(2); }
54
+
55
+ let was = null;
56
+ try { was = JSON.parse(readFileSync(FIXTURE, "utf8")); } catch { /* first mint */ }
57
+
58
+ console.log(`public residue: ${now.total} line(s) across ${Object.keys(now.files).length} file(s)`);
59
+ for (const [id, n] of byClass(now)) console.log(` ${String(n).padStart(5)} ${id}`);
60
+
61
+ const next = JSON.stringify(now, null, 2) + "\n";
62
+ const same = was && JSON.stringify(was, null, 2) + "\n" === next;
63
+ if (same) { console.log("the backlog is current"); return; }
64
+
65
+ if (was) {
66
+ const delta = now.total - was.total;
67
+ console.log(`\ntotal ${was.total} → ${now.total} (${delta >= 0 ? "+" : ""}${delta})`);
68
+ }
69
+ if (apply) { writeFileSync(FIXTURE, next); console.log("written"); return; }
70
+ console.log("\nre-run with --apply to write it");
71
+ if (check) process.exit(1);
72
+ }
73
+
74
+ if (isEntrypoint(import.meta.url)) main();
@@ -14,6 +14,7 @@
14
14
  // CI runs. Without it, the table is rewritten.
15
15
  import { readFileSync, writeFileSync } from "node:fs";
16
16
  import { execFileSync } from "node:child_process";
17
+ import { publishedOf } from "../shared/reference-guard-classes.mjs";
17
18
  import { join, dirname } from "node:path";
18
19
  import { fileURLToPath } from "node:url";
19
20
  import { SIGNATURES, censusOf } from "../driver/reference-strip-signatures.mjs";
@@ -21,8 +22,18 @@ import { SIGNATURES, censusOf } from "../driver/reference-strip-signatures.mjs";
21
22
  const ROOT = join(dirname(fileURLToPath(import.meta.url)), "..");
22
23
  const TABLE = join(ROOT, "driver/test/fixtures/reference-strip-backlog.json");
23
24
 
24
- const tracked = execFileSync("git", ["-C", ROOT, "ls-files"], { encoding: "utf8", maxBuffer: 1 << 28 })
25
+ // THE PUBLISHED POPULATION, NOT THE INDEX. `git ls-files` reads the index, and an overlay run stages
26
+ // the withheld corpus over a clone without committing it — so minting there wrote withheld hits into
27
+ // this public table with nothing reporting it. `publishedOf` filters the tracked list down to what
28
+ // HEAD carries; one helper, shared with the residue floor, because two spellings of this rule would
29
+ // disagree under the overlay and `--check` would report a difference that is only the two
30
+ // instruments asking different questions.
31
+ const all = execFileSync("git", ["-C", ROOT, "ls-files"], { encoding: "utf8", maxBuffer: 1 << 28 })
25
32
  .split("\n").filter(Boolean);
33
+ const p = publishedOf(all, ROOT);
34
+ if (p.error) { console.error(`mint-reference-strip-backlog: ${p.error}`); process.exit(2); }
35
+ if (p.laid) console.log(`mint-reference-strip-backlog: ${p.laid} tracked path(s) are not in HEAD — laid over this checkout, not published in it, and not counted`);
36
+ const tracked = p.files;
26
37
  const minted = censusOf(ROOT, tracked, (f) => readFileSync(join(ROOT, f), "utf8"));
27
38
 
28
39
  if (process.argv.includes("--check")) {
@@ -36,6 +36,9 @@ import { join, dirname } from "node:path";
36
36
  import { fileURLToPath } from "node:url";
37
37
  import { CENSUS_WORKSPACES, CENSUS_ROOT_SCRIPTS, countTestSites, collectionFromManifests, censusDisagreements,
38
38
  rootScriptDisagreements, censusBuckets } from "../shared/suite-census.mjs";
39
+ // THE MEASUREMENT SIDE of the root-script check: the census states the globs it expects and this
40
+ // supplies what the corpus actually resolves to, so the two can disagree.
41
+ import { providerTestFiles } from "./test-full.mjs";
39
42
  import { withheldEntryFor, announceWithheldMode } from "../shared/withheld-paths-access.mjs"; // — withheld is a stated absence, not a loss.: the record does not ship, and without it every absence is a loss
40
43
  import { isEntrypoint } from "../shared/is-entrypoint.mjs"; // — realpath both sides, or a symlinked invocation exits 0 silently
41
44
 
@@ -129,7 +132,7 @@ function main() {
129
132
  // — the same question of the ROOT scripts. Narrowing `test:providers`' glob silences a third of
130
133
  // that population at exit 0, and no per-workspace check can see it: `providers` is not a workspace.
131
134
  const disagreements = [...censusDisagreements(collectionFromManifests(readManifest)),
132
- ...rootScriptDisagreements(readManifest)];
135
+ ...rootScriptDisagreements(readManifest, undefined, () => providerTestFiles())];
133
136
  if (disagreements.length) {
134
137
  // — the message names BOTH declared sources now. It used to say "the census and `npm run
135
138
  // test:full` disagree" whatever had moved, so a root-script disagreement sent the reader to look at
@@ -150,7 +153,7 @@ function main() {
150
153
  try { prev = JSON.parse(readFileSync(CENSUS, "utf8")); } catch { /* first mint */ }
151
154
 
152
155
  const lost = [];
153
- // THE THIRD BUCKET, ruled by overwatch 2026-09-06.
156
+ // THE THIRD BUCKET, decided 2026-09-06.
154
157
  //
155
158
  // A reasoned skip is neither a pass nor a loss, and this census had no place to put one. Two guards
156
159
  // that are each right about their own failure had come to disagree: `a-bail-on-an-unmeetable-
@@ -151,8 +151,6 @@ export function noticeBody({ runUrl, sha, subject, failedJobs = [], lastGreenAt,
151
151
  + "passes — a run that never started cannot make the platform green."
152
152
  : "This thread is reused for every red night. Close it once the platform is green again — the next "
153
153
  + "red opens a new one.",
154
- "",
155
- "Agent: role-dev · bergface",
156
154
  ].join("\n");
157
155
  }
158
156
 
@@ -0,0 +1,24 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-only
2
+ // Copyright 2026 Cordillera Sàrl. Additional terms under section 7 of the AGPL-3.0 apply — see ADDITIONAL-TERMS.md
3
+ //
4
+ // The install refuses an unsupported Node BEFORE anything is written — tracker issue 364.
5
+ //
6
+ // Three declarations already stated the requirement and none of them bound anyone: `engines.node` is a
7
+ // WARNING unless the person installing has set `engine-strict`, `.nvmrc` is read by nvm and nothing
8
+ // else, and the install guide is prose. An outside report arrived as "some node issues, not as obvious
9
+ // as the README made it seem" — which is what a warning inside an install log looks like from outside.
10
+ //
11
+ // So this is a `preinstall` hook, node builtins only, because nothing is installed when it runs.
12
+ //
13
+ // IT NAMES THE LATER FAILURE, not just the version. The whole defect is that an unsupported Node
14
+ // installs cleanly and then fails somewhere else saying nothing about Node, so a message that only
15
+ // prints a semver range leaves the reader where they started.
16
+ import { declaredRange, floorOf, meetsFloor, nodeFloorRefusal } from "../shared/node-floor.mjs";
17
+
18
+ const range = declaredRange();
19
+ if (!meetsFloor(process.versions.node, floorOf(range))) {
20
+ process.stderr.write(`\n${nodeFloorRefusal({ current: process.versions.node, required: floorOf(range).join("."), range })}\n`);
21
+ process.stderr.write(" Nothing has been installed.\n\n nvm install 22 && nvm use 22\n\n");
22
+ process.stderr.write(" Without nvm, take the current 22.x from https://nodejs.org/.\n\n");
23
+ process.exit(1);
24
+ }
@@ -59,7 +59,8 @@
59
59
  // coupling ever bites, that is the moment to mount the real component instead of copying it.
60
60
 
61
61
  import { readFileSync, writeFileSync, mkdirSync, mkdtempSync, rmSync, existsSync, readdirSync } from "node:fs";
62
- import { reapOnExit } from "../shared/reap-on-exit.mjs"; // — a detached group dies with this script
62
+ import { reapOnExit, removeOnExit } from "../shared/reap-on-exit.mjs";
63
+ import { assertRootFits, browserEnv } from "../shared/browser-temp-root.mjs"; // — a detached group dies with this script
63
64
  import { execFileSync, spawn } from "node:child_process";
64
65
  import { createServer } from "node:http";
65
66
  import { basename, extname } from "node:path";
@@ -76,6 +77,16 @@ const has = (n) => process.argv.includes(`--${n}`);
76
77
 
77
78
  const ZOOMS = arg("zoom", "1.0,1.25").split(",").map((z) => z.trim()).filter(Boolean);
78
79
  const WORK = join(tmpdir(), `render-check-${process.pid}`);
80
+ // WORK is this run's browser temp root as well as its scratch: the browser is spawned with TMPDIR
81
+ // pointing here, so the process-singleton lock it creates lands inside and leaves with it. Asserted
82
+ // rather than assumed, because the budget is on the PATH and nothing about this one is fixed.
83
+ assertRootFits(WORK);
84
+ // The removal below runs on the paths somebody wrote a branch for. This one also covers the exits
85
+ // nobody writes a branch for, which is where the leaked locks came from.
86
+ const stopRootSweep = removeOnExit(WORK);
87
+ // ...unless the caller asked to keep it. Read here rather than at the cleanup below, because the
88
+ // sweep is registered at import time and `--keep` has to be honoured before any exit can happen.
89
+ if (has("keep")) stopRootSweep();
79
90
 
80
91
  /**
81
92
  * Replay the committed frozen run into a fresh pool and return its path.
@@ -412,7 +423,7 @@ async function measureAtZoom(zoom, deadlineMs = 90_000) {
412
423
  // — DETACHED so Chrome LEADS A PROCESS GROUP. Its renderer, GPU and zygote processes are
413
424
  // separate PIDs; without a group there is nothing to signal them with, and the teardown below could
414
425
  // only ever reach the parent.
415
- ], { stdio: ["ignore", "ignore", "ignore"], detached: true });
426
+ ], { stdio: ["ignore", "ignore", "ignore"], detached: true, env: browserEnv(WORK) });
416
427
  // — and the group dies with THIS script, on every exit it can observe.
417
428
  // The teardown below runs on the paths somebody wrote a branch for; a cancelled CI job (SIGTERM),
418
429
  // a Ctrl-C, or a throw elsewhere in this file are not among them — and that is where the measured
@@ -29,6 +29,8 @@ import { tmpdir } from "node:os";
29
29
  import { join, dirname } from "node:path";
30
30
  import { fileURLToPath } from "node:url";
31
31
  import { isEntrypoint } from "../shared/is-entrypoint.mjs"; // — one entry-point test, all spellings
32
+ import { removeOnExit } from "../shared/reap-on-exit.mjs";
33
+ import { assertRootFits, browserEnv } from "../shared/browser-temp-root.mjs";
32
34
 
33
35
  const ROOT = join(dirname(fileURLToPath(import.meta.url)), "..");
34
36
 
@@ -48,6 +50,11 @@ function main() {
48
50
  console.log(`shipped sandbox: ${tokens.join(" ")}`);
49
51
 
50
52
  const work = mkdtempSync(join(tmpdir(), "frame-check-"));
53
+ // The browser inherits TMPDIR from here, so its process-singleton lock lands under `work` and goes
54
+ // with it. `removeOnExit` covers the exits the `finally` below cannot see — a cancelled job, a
55
+ // Ctrl-C — which are the ones that left locks behind in the shared temp directory.
56
+ assertRootFits(work);
57
+ removeOnExit(work);
51
58
  try {
52
59
  // The page builds the frame with the SHIPPED string and reports what the browser kept.
53
60
  writeFileSync(join(work, "verify.html"), `<!doctype html><title>pending</title><body>
@@ -62,7 +69,7 @@ function main() {
62
69
  "--headless=new", "--disable-gpu", "--no-sandbox",
63
70
  `--user-data-dir=${join(work, "prof")}`,
64
71
  "--virtual-time-budget=8000", "--dump-dom", `file://${join(work, "verify.html")}`,
65
- ], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], timeout: 120000 });
72
+ ], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], timeout: 120000, env: browserEnv(work) });
66
73
 
67
74
  // ── IS THIS OUR PAGE, OR CHROME'S ERROR PAGE? (tracker issue 227) ───────────────────────────────
68
75
  //
@@ -20,10 +20,11 @@
20
20
  // "fix" either to match the other.
21
21
  import { spawn } from "node:child_process";
22
22
  import { assertPageLoaded, cjkCharsIn, cjkVerdict, fontsCovering } from "./headless-page.mjs"; // did chrome open the report, or its own error page?
23
- import { mkdtempSync, writeFileSync, existsSync } from "node:fs";
23
+ import { writeFileSync, existsSync } from "node:fs";
24
24
  import { tmpdir } from "node:os";
25
25
  import { join, dirname, resolve } from "node:path";
26
26
  import { fileURLToPath } from "node:url";
27
+ import { browserRun } from "../shared/browser-temp-root.mjs";
27
28
 
28
29
  const ROOT = join(dirname(fileURLToPath(import.meta.url)), "..");
29
30
  const OUT = resolve(process.argv[3] ?? join(ROOT, "docs", "assets", "example-report.png"));
@@ -43,12 +44,14 @@ if (!existsSync(page)) {
43
44
  process.exit(2);
44
45
  }
45
46
 
46
- const userDir = mkdtempSync(join(tmpdir(), "report-shot-"));
47
+ // The profile goes inside a run root whose TMPDIR the browser inherits, so the singleton
48
+ // lock it writes there leaves with the root instead of accumulating in the shared one.
49
+ const { profile: userDir, env: chromeEnv } = browserRun("report-shot-");
47
50
  const chrome = spawn("google-chrome", [
48
51
  "--headless=new", "--disable-gpu", "--no-sandbox", "--hide-scrollbars",
49
52
  `--user-data-dir=${userDir}`, `--window-size=${WIDTH},${HEIGHT}`,
50
53
  "--remote-debugging-port=0", `file://${page}`,
51
- ], { stdio: ["ignore", "pipe", "pipe"] });
54
+ ], { stdio: ["ignore", "pipe", "pipe"], env: chromeEnv });
52
55
 
53
56
  let stderr = "";
54
57
  const wsUrl = await new Promise((res, rej) => {
@@ -61,12 +61,13 @@
61
61
 
62
62
  import { navigateOrRefuse } from './headless-page.mjs' // Page.navigate returns an errorText, and nothing read it
63
63
  import { createServer } from 'node:http'
64
- import { readFileSync, existsSync, writeFileSync, mkdtempSync, rmSync } from 'node:fs'
64
+ import { readFileSync, existsSync, writeFileSync, rmSync } from 'node:fs'
65
65
  import { join, extname, dirname } from 'node:path'
66
66
  import { fileURLToPath } from 'node:url'
67
67
  import { spawn } from 'node:child_process'
68
68
  import { tmpdir } from 'node:os'
69
69
  import { reapOnExit } from '../shared/reap-on-exit.mjs' // — a detached group dies with this script
70
+ import { browserRun } from "../shared/browser-temp-root.mjs";
70
71
 
71
72
  const HERE = dirname(fileURLToPath(import.meta.url))
72
73
  const DIST = join(HERE, '..', 'portal-ui', 'dist')
@@ -174,11 +175,13 @@ const origin = `http://127.0.0.1:${server.address().port}`
174
175
 
175
176
  // ── chrome ──────────────────────────────────────────────────────────────────────────────────────────
176
177
 
177
- const profile = mkdtempSync(join(tmpdir(), 'revisit-check-'))
178
+ // The profile goes inside a run root whose TMPDIR the browser inherits, so the singleton
179
+ // lock it writes there leaves with the root instead of accumulating in the shared one.
180
+ const { profile, env: chromeEnv } = browserRun("revisit-check-")
178
181
  const chrome = spawn('google-chrome', [
179
182
  '--headless=new', '--disable-gpu', '--no-sandbox', '--remote-debugging-port=0',
180
183
  `--user-data-dir=${profile}`, '--window-size=1440,900', 'about:blank',
181
- ], { stdio: ['ignore', 'ignore', 'pipe'] })
184
+ ], { stdio: ['ignore', 'ignore', 'pipe'], env: chromeEnv })
182
185
  reapOnExit(chrome)
183
186
 
184
187
  const wsUrl = await new Promise((resolve, reject) => {
package/scripts/score.mjs CHANGED
@@ -583,7 +583,7 @@ function print(id, ref, run, s, delta, refPath) {
583
583
  // workspace archive instead of the pool — and the lane fact is visible on the `lane:` line anyway.
584
584
  console.log(row("withheld", "n/a", s.collapseReason));
585
585
  } else {
586
- // — THE SCOPE ON THE LINE, not in a footnote. `role-e2e` calls this "the bucket that changes
586
+ // — THE SCOPE ON THE LINE, not in a footnote. The testing doctrine calls this "the bucket that changes
587
587
  // what you fix", so a bare `0` beside that sentence reads as "no seam defect this round" — and on
588
588
  // the round that measured it, two live in-class rights the run retrieved and dropped were outside
589
589
  // the reference and could not have raised it. The footnote saying `withheld` is a reference-entry
@@ -58,6 +58,7 @@
58
58
  // wants a guard against reintroduction rather than a backlog. Different residue, different repair.
59
59
  import { readFileSync, writeFileSync } from "node:fs";
60
60
  import { execFileSync } from "node:child_process";
61
+ import { publishedOf } from "../shared/reference-guard-classes.mjs";
61
62
  import { join, dirname } from "node:path";
62
63
  import { fileURLToPath } from "node:url";
63
64
 
@@ -77,6 +78,15 @@ export const EXCLUDED = [
77
78
  "driver/test/a-bare-reference-added-in-a-diff-is-refused.test.mjs",
78
79
  "driver/test/prompt-payload-names-no-tracker-issue.test.mjs",
79
80
  "driver/test/euipo-environment-doctrine.test.mjs",
81
+ // THIS RULE'S OWN DEFINITION, and it caught itself. Both files below QUOTE the residue in order to
82
+ // define it — the specimens the sweep is tested against, and the worked examples in the header above.
83
+ // Sweeping them rewrites the corpus this instrument is checked by, and it would then pass on something
84
+ // it had stopped checking: the arms would assert that a stripped line equals a stripped line. The
85
+ // sibling module that counts the earlier strip's residue learned the same lesson and names its own
86
+ // three files for the same reason. Found by running the sweep after fixing its report, which is the
87
+ // only reason it surfaced: before that, these lines were counted and never listed.
88
+ "scripts/strip-tracker-citations.mjs",
89
+ "driver/test/the-citation-strip-removes-openers-and-nothing-else.test.mjs",
80
90
  // Pinned at a content hash; a prose repair is not worth spending a freeze on.
81
91
  "driver/publish/render.mjs",
82
92
  ];
@@ -86,28 +96,49 @@ export const isScannable = (f) =>
86
96
 
87
97
  /** Per-file classification. PURE, and `read` is injected so an arm can drive it over a synthetic tree. */
88
98
  export function surveyOf(files, read) {
89
- const stripped = {}, handoff = [];
99
+ const stripped = {}, handoff = [], unreadable = [];
90
100
  let strippedTotal = 0, remainingTotal = 0;
91
101
  for (const f of files.filter(isScannable)) {
92
102
  let text;
93
- try { text = read(f); } catch { continue; }
103
+ // AN UNREADABLE FILE IS A FINDING, NOT A QUIET SKIP. This used to `continue` into no counter and no
104
+ // error channel, so "I could not open this" and "there was nothing to do here" produced identical
105
+ // output — in the report whose whole job is to tell somebody a hundred-file rewrite is safe. The
106
+ // count is reported before anything else when it is non-zero.
107
+ try { text = read(f); } catch (e) { unreadable.push({ file: f, why: String(e?.message ?? e).slice(0, 120) }); continue; }
94
108
  let n = 0;
109
+ // CLASSIFY AFTER REPLACING, NOT INSTEAD OF IT. The replacement is not global, so a line carrying an
110
+ // opener AND a second citation further along was stripped once, counted as done, and never reached
111
+ // the hand-off list a person is told to read — its survivor was invisible in the one place it should
112
+ // have been named. Test the RESULT: a line can be both stripped and still owed to a reader.
95
113
  const out = text.split("\n").map((line, i) => {
96
- if (OPENER.test(line)) { n++; return line.replace(OPENER, "$1"); }
97
- if (ANY_CITATION.test(line)) handoff.push({ file: f, line: i + 1, text: line.trim() });
98
- return line;
114
+ const after = OPENER.test(line) ? (n++, line.replace(OPENER, "$1")) : line;
115
+ if (ANY_CITATION.test(after)) handoff.push({ file: f, line: i + 1, text: after.trim() });
116
+ return after;
99
117
  });
100
118
  if (n) { stripped[f] = n; strippedTotal += n; }
101
119
  remainingTotal += out.filter((l) => ANY_CITATION.test(l)).length;
102
120
  if (APPLY && n) writeFileSync(join(ROOT, f), out.join("\n"));
103
121
  }
104
- return { strippedTotal, remainingTotal, stripped, handoff };
122
+ return { strippedTotal, remainingTotal, stripped, handoff, unreadable };
105
123
  }
106
124
 
107
125
  if (import.meta.url === `file://${process.argv[1]}`) {
108
- const tracked = execFileSync("git", ["-C", ROOT, "ls-files"], { encoding: "utf8", maxBuffer: 1 << 28 })
126
+ // THE PUBLISHED POPULATION, NOT THE INDEX. The counts this prints are read as a statement about the
127
+ // public tree, and under an overlay `git ls-files` would have made them a statement about the
128
+ // withheld corpus instead. Same helper as the residue floor and the backlog minter.
129
+ const all = execFileSync("git", ["-C", ROOT, "ls-files"], { encoding: "utf8", maxBuffer: 1 << 28 })
109
130
  .split("\n").filter(Boolean);
131
+ const pub = publishedOf(all, ROOT);
132
+ if (pub.error) { console.error(`strip-tracker-citations: ${pub.error}`); process.exit(2); }
133
+ if (pub.laid) console.log(`${pub.laid} tracked path(s) are not in HEAD — laid over this checkout, not published in it, and not read`);
134
+ const tracked = pub.files;
110
135
  const s = surveyOf(tracked, (f) => readFileSync(join(ROOT, f), "utf8"));
136
+ // BEFORE ANYTHING ELSE, because every number under it is about the files that COULD be read.
137
+ if (s.unreadable.length) {
138
+ console.log(`${s.unreadable.length} file(s) COULD NOT BE READ — every count below excludes them:`);
139
+ for (const u of s.unreadable) console.log(` ${u.file} ${u.why}`);
140
+ console.log("");
141
+ }
111
142
  console.log(`${APPLY ? "stripped" : "would strip"} ${s.strippedTotal} opener(s) across ${Object.keys(s.stripped).length} file(s)`);
112
143
  console.log(`${s.handoff.length} citation(s) are NOT openers and need a reader — the hand-off list:`);
113
144
  for (const h of s.handoff) console.log(` ${h.file}:${h.line} ${h.text.slice(0, 110)}`);