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
@@ -49,8 +49,8 @@
49
49
  -->
50
50
  <link rel="preconnect" href="https://api.fontshare.com" crossorigin />
51
51
  <link href="https://api.fontshare.com/v2/css?f[]=satoshi@400,500,700,900&display=swap" rel="stylesheet" />
52
- <script type="module" crossorigin src="/portal/assets/index-BUGgiYtF.js"></script>
53
- <link rel="stylesheet" crossorigin href="/portal/assets/index-Ct3iQtZ2.css">
52
+ <script type="module" crossorigin src="/portal/assets/index-KFAHMgdT.js"></script>
53
+ <link rel="stylesheet" crossorigin href="/portal/assets/index-1ziUJX1E.css">
54
54
  </head>
55
55
  <body>
56
56
  <div id="root"></div>
@@ -2,7 +2,7 @@
2
2
  "name": "portal-ui",
3
3
  "private": true,
4
4
  "type": "module",
5
- "version": "0.2.2",
5
+ "version": "0.2.4",
6
6
  "license": "AGPL-3.0-only",
7
7
  "description": "The unified trademark portal UI. One address, one login: who you are decides what you see. Built as a static bundle, served by driver/portal-service.mjs — the browser never reaches profile-service or recipe-service.",
8
8
  "engines": {
@@ -115,7 +115,7 @@ const MARKUP_ARMS = [
115
115
  * long-form arm. A slogan mark has a legitimately long dominant element and cannot be restated any
116
116
  * shorter, so refusing a value for BEING LONG is right at the compiler and wrong at a corrective
117
117
  * stage gate — it hands back a reason with no remedy. An annotation always has a remedy: delete the
118
- * note, keep the term. — that is also why it may sit ABOVE a word-count floor: `ZEPHYR (root)`
118
+ * note, keep the term. — that is also why it may sit ABOVE a word-count floor: `COASTLINE (root)`
119
119
  * is an annotation by no measure a word count can see, exactly as the one-word `**BIOVELTRIN**`
120
120
  * was markup by no measure a word count could see.
121
121
  *
@@ -1,5 +1,9 @@
1
1
  # trademark-oauth-mcp-bridge
2
2
 
3
+ ## 0.2.4
4
+
5
+ ## 0.2.3
6
+
3
7
  ## 0.2.2
4
8
 
5
9
  ## 0.3.0-beta.0
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "trademark-oauth-mcp-bridge",
3
- "version": "0.2.2",
3
+ "version": "0.2.4",
4
4
  "license": "AGPL-3.0-only",
5
5
  "private": true,
6
6
  "description": "OAuth 2.1 MCP stdio bridge used by the engine's case-law gather stage (courtlistener / legaldatahunter).",
@@ -489,8 +489,8 @@ function predicateClause(predicate, term) {
489
489
  // predicate. Unanchored, because corporate names arrive in every spelling there is.
490
490
  //
491
491
  // NO FTS NARROWER HERE, deliberately, and it is the same trap as the suffix one. An FTS
492
- // token-prefix candidate set is NOT a superset of an unanchored contains: owner "AURORA INTERACTIVE"
493
- // searched for "URORA" satisfies the LIKE, but no token starts with URORA, so the narrower
492
+ // token-prefix candidate set is NOT a superset of an unanchored contains: owner "FOXGLADE INTERACTIVE"
493
+ // searched for "OXGLADE" satisfies the LIKE, but no token starts with URORA, so the narrower
494
494
  // would drop a true match and the verification could never put it back. A narrower may only
495
495
  // ever be a superset of its verifier. So this is an honest full scan, and how expensive that is
496
496
  // at register scale is UNMEASURED.
@@ -2,110 +2,59 @@
2
2
  // SPDX-License-Identifier: AGPL-3.0-only
3
3
  // Copyright 2026 Cordillera Sàrl. Additional terms under section 7 of the AGPL-3.0 apply — see ADDITIONAL-TERMS.md
4
4
  //
5
- // A BARE `#NNN` ADDED IN A DIFF IS REFUSED. What is already here is not this check's business.
5
+ // WHAT THIS CHANGE ADDS IS REFUSED. What is already here is not this check's business.
6
6
  //
7
7
  // node scripts/added-reference-check.mjs [--base <ref>]
8
8
  //
9
9
  // ── WHY THIS IS DIFF-SHAPED AND NOT A SWEEP ──────────────────────────────────────────────────────
10
10
  //
11
- // This tree carries 3,828 of these tokens across 542 files. They are opaque numbers into an archived
12
- // tracker no client data and no private name and they are the residual the export's ratchet
13
- // accepted, not a leak. A guard that refused them all would refuse every pull request from its first
14
- // day, and a guard everybody bypasses protects nothing. So this one asks a smaller question that has a
15
- // clean answer: did THIS change add another one.
11
+ // The classes below have a standing population in this tree: citations into a tracker nobody outside
12
+ // can open, account names, home directories, and the words this project uses for how it is built. A
13
+ // guard that refused them all would refuse every pull request from its first day, and a guard
14
+ // everybody bypasses protects nothing. So this one asks a smaller question that has a clean answer:
15
+ // did THIS change add another.
16
16
  //
17
- // The 3,828 are somebody's work, filed and costed. When they go, the allowlist below goes with them and
18
- // this check widens to the whole tree in the same pull request.
17
+ // The standing population is not therefore accepted. It is counted, per file and per class, in
18
+ // `driver/test/fixtures/public-residue-backlog.json`, and the floor beside it refuses any file that
19
+ // grows. Between the two, the number can only fall: this check stops the inflow and the floor stops
20
+ // the backsliding. Neither works alone — a diff guard with no floor watches the total drift upward one
21
+ // repaired-and-reintroduced line at a time, and a floor with no diff guard is a number that goes stale
22
+ // the first time somebody adds a file.
19
23
  //
20
24
  // ── WHAT IT READS, AND WHAT IT DELIBERATELY DOES NOT ─────────────────────────────────────────────
21
25
  //
22
26
  // COMMENTS AND PROSE ONLY. A hash followed by digits is not always a reference: a three-digit one is
23
27
  // also a CSS colour, and a composite key or a fixture string can hold anything. Reading only comment
24
- // markdown keeps the check away from every context where the token means something else, which is what
25
- // lets it refuse without a table of exceptions that would rot.
28
+ // and markdown text keeps the check away from every context where the token means something else,
29
+ // which is what lets it refuse without a table of exceptions that would rot.
26
30
  //
27
- // SO A TEST NAME IS OUT OF SCOPE, and that is a real hole rather than an oversight: 2,650 of the 3,828
28
- // are test names, which are string literals. Named here so the next reader does not have to rediscover
29
- // it — widening to string literals means deciding what to do about CSS and composite keys first, and
30
- // that decision belongs with the cleanup, not with this.
31
+ // SO A TEST NAME IS OUT OF SCOPE, and that is a real hole rather than an oversight: most of the
32
+ // standing citation population is test names, which are string literals. Named here so the next reader
33
+ // does not have to rediscover it — widening to string literals means deciding what to do about CSS and
34
+ // composite keys first, and that decision belongs with the cleanup, not with this.
31
35
  //
32
36
  // LINK TARGETS ARE EXEMPT. A markdown anchor and a URL fragment are addresses, not references.
33
37
  //
34
- // AND `tracker issue NNN` PASSES, because it carries no `#` at all. That is the form this project
35
- // writes, and the guard exists to make the wrong form loud rather than to ban the number.
36
- import { execFileSync } from "node:child_process";
37
-
38
- // THE TWELVE FILES RESTORED VERBATIM FROM THE FROZEN TIP AND KEPT. Thirteen were restored; the
39
- // duplicate-skip arms were dropped in the same branch, so twelve reach main. They came across
40
- // byte-exact, which is what made their arms trustworthy and also brought their references with them.
41
- // Exempted for that one merge and removed by the cleanup; see the note above.
42
- // RESTORED_VERBATIM IS GONE (tracker issue 188). It exempted twelve files by literal path so that a
43
- // byte-for-byte restoration could land without the guard refusing its own restored text. Their tokens
44
- // are now retired like everything else, so the list exempts nothing real — and a stale exemption list is
45
- // worse than none: it silently covers files nobody is checking any more, and it is invisible in a diff
46
- // that does not touch this file.
47
-
48
- const TOKEN = /#[0-9]{3,}/g;
49
-
50
- // A `#` COMMENT IS A COMMENT WHEREVER THE FILE FORMAT SAYS SO, not only in YAML (tracker issue 188).
51
- //
52
- // This read `#` as a comment for YAML alone, so the same sentence was refused in a .yml file and waved
53
- // through in .env.example, a systemd unit or a shell script. Those comments are exactly as publicly
54
- // visible, and `# REQUIRED — tracker issue 774 removed the code default` was sitting in .env.example on the public
55
- // tree while this guard reported the tree clean. Found while measuring the class for the retirement pass:
56
- // the guard's own rule flagged 359 tokens, and thousands more sat in files it had never classified.
57
- //
58
- // Extensionless is deliberate: a systemd unit or a dotfile often has no extension worth matching, so the
59
- // KNOWN `#`-comment names are listed and everything else keeps the source rule.
60
- const HASH_COMMENT = /(^|\/)(\.env[^/]*|[^/]*\.(ya?ml|sh|bash|service|timer|path|socket|conf|ini|toml|properties)|Dockerfile[^/]*|Makefile|\.gitignore|\.gitattributes)$/;
61
-
62
- /** Is this added line one the check reads at all? Comments in source, everything in markdown. */
63
- export const isProse = (path, line) => {
64
- if (/\.mde?$/.test(path) || path.endsWith(".md")) return true;
65
- const t = line.trim();
66
- if (HASH_COMMENT.test(path)) return t.startsWith("#");
67
- return t.startsWith("//") || t.startsWith("*") || t.startsWith("/*");
68
- };
69
-
70
- // ── A COLOUR IS NOT A CITATION, AND THE DIGITS ALONE CANNOT SAY WHICH ───────────────────────────
71
- //
72
- // `TOKEN` matches digits only, so a six-digit hex colour is read as its leading digits and refused as a
73
- // reference. An earlier sweep acted on exactly that reading and rewrote sixteen colour literals as issue
74
- // text, two of them live mermaid `classDef` directives — so the documentation's diagrams rendered
75
- // broken, and thirteen comments stated a value that was no longer there. Restoring them then hit this
76
- // guard, whose refusal told the author to write the very text that had caused it.
38
+ // `demo/**` AND `driver/skills/**` ARE NOT READ. The reasons are in shared/reference-guard-classes.mjs
39
+ // beside the rule itself, because that is where the next person changing it will be looking.
77
40
  //
78
- // TWO RULES, and between them they settle every case without a table of exceptions:
41
+ // ── WHERE THE TABLE LIVES ────────────────────────────────────────────────────────────────────────
79
42
  //
80
- // 1. A hex colour may contain a-f; an issue number is decimal. A token carrying a letter cannot be a
81
- // reference whatever its length, and no reading of the digits is needed to know it.
82
- // 2. For the all-digit case — three digits is both a short colour and a plausible issue number — the
83
- // SITE settles it: a value whose property is a colour is a colour. Same rule and the same property
84
- // list as the guard in `driver/test/prompt-payload-names-no-tracker-issue.test.mjs`, which already
85
- // plants both and requires them told apart.
86
- //
87
- // This does NOT widen to bare digits in prose. A reference in a comment is still refused, which is the
88
- // whole point of the check, and the arm asserts that direction too.
89
- const HEX_COLOUR = /#(?:[0-9a-fA-F]{8}|[0-9a-fA-F]{6}|[0-9a-fA-F]{3,4})\b/g;
90
- const COLOUR_PROPERTY = /(?:^|[;{\s(,])(?:color|background|background-color|border|border-color|fill|stroke|outline|box-shadow|text-shadow)\s*:\s*$/i;
91
-
92
- /** Strip hex colours, so what remains is only tokens that could be a reference. */
93
- export const withoutColourValues = (line) => String(line).replace(HEX_COLOUR, (m, offset, whole) => {
94
- if (/[a-fA-F]/.test(m.slice(1))) return ""; // letters ⇒ not a decimal number
95
- return COLOUR_PROPERTY.test(whole.slice(0, offset)) ? "" : m; // all digits ⇒ the property decides
96
- });
43
+ // shared/reference-guard-classes.mjs, with the census that reads the same table. Two definitions of
44
+ // "what a public tree must not acquire" is one definition and one imitation of it, and the imitation
45
+ // is whichever the reader did not run.
46
+ import { execFileSync } from "node:child_process";
47
+ import {
48
+ CLASSES, offendingClasses, isProse, isScannable,
49
+ withoutColourValues, withoutLinkTargets,
50
+ } from "../shared/reference-guard-classes.mjs";
97
51
 
98
- /** Strip the spans where a `#NNN` is an address rather than a reference. */
99
- export const withoutLinkTargets = (line) => line
100
- .replace(/\]\([^)]*\)/g, "]()") // markdown link targets, anchors included
101
- .replace(/https?:\/\/\S+/g, "") // bare URLs and their fragments
102
- .replace(/<[^>]*>/g, ""); // angle-bracket autolinks
52
+ // Re-exported because scripts/retire-bare-refs.mjs and the arms beside this file import them from
53
+ // here, and moving the table should not move every caller in the same commit.
54
+ export { CLASSES, offendingClasses, isProse, isScannable, withoutColourValues, withoutLinkTargets };
103
55
 
104
56
  /** Every offending token on one added line, or an empty array. */
105
- export const offendingTokens = (path, line) => {
106
- if (!isProse(path, line)) return [];
107
- return [...withoutColourValues(withoutLinkTargets(line)).matchAll(TOKEN)].map((m) => m[0]);
108
- };
57
+ export const offendingTokens = (path, line) => offendingClasses(path, line).map((c) => c.token);
109
58
 
110
59
  const baseArg = () => {
111
60
  const i = process.argv.indexOf("--base");
@@ -143,14 +92,21 @@ function main() {
143
92
  // difference between "looked and found nothing" and "had nothing to look at".
144
93
  const hits = [];
145
94
  for (const { path, line } of added) {
146
- for (const token of offendingTokens(path, line)) hits.push({ path, token, line: line.trim().slice(0, 100) });
95
+ for (const c of offendingClasses(path, line)) hits.push({ path, ...c, line: line.trim().slice(0, 100) });
147
96
  }
148
97
  console.log(`added-reference-check: read ${added.length} added line(s) against ${base}`);
149
98
  if (!hits.length) return;
150
- console.error(`\n${hits.length} bare reference(s) added in comments or prose:\n`);
151
- for (const h of hits) console.error(` ${h.path}: ${h.token}\n ${h.line}`);
152
- console.error("\nWrite `tracker issue NNN` instead. A bare `#NNN` linkifies into whatever repository "
153
- + "renders it, which is not the one the number belongs to, and it lives on in public history.");
99
+
100
+ // GROUPED BY CLASS, because the remedy is per class and a flat list makes the reader derive it eight
101
+ // times. Each heading is said once, then the lines it applies to.
102
+ console.error(`\n${hits.length} line(s) added that a public tree must not carry:\n`);
103
+ for (const { id, why } of CLASSES) {
104
+ const mine = hits.filter((h) => h.id === id);
105
+ if (!mine.length) continue;
106
+ console.error(` ${id} — ${why}`);
107
+ for (const h of mine) console.error(` ${h.path}: ${h.token}\n ${h.line}`);
108
+ console.error("");
109
+ }
154
110
  process.exit(1);
155
111
  }
156
112
 
@@ -31,12 +31,15 @@
31
31
  // That last paragraph prints on every run, including a clean one, because a guard whose "clean" is
32
32
  // near-meaningless and does not say so is worse than no guard: its presence reads as coverage.
33
33
  //
34
- // The remedy is the convention, not this script — CONTRIBUTING.md, "Cite the SYMBOL, not the line
35
- // number". A symbol survives every move; a number survives none. This guard cannot enforce that (the
34
+ // The remedy is the convention, not this script — ADR-0005, "Cite the symbol, not the line": a symbol
35
+ // survives every move and a number survives none, and where the target is not a named symbol, quote a few
36
+ // words of it. This used to name CONTRIBUTING.md, which contains no such section — that record says in as
37
+ // many words that it SUPERSEDES CONTRIBUTING's instruction, so the pointer was to the superseded half. This guard cannot enforce that (the
36
38
  // ruling explicitly declines a form guard without an allowlist for the ~800 existing citations), so it
37
39
  // holds the decidable perimeter and names the gap.
38
40
 
39
41
  import { readFileSync, writeFileSync, mkdirSync, existsSync } from "node:fs";
42
+ import { execFileSync } from "node:child_process"; // — the ratchet reads a range; injected in tests
40
43
  import { fileURLToPath } from "node:url";
41
44
  import { join, dirname, basename } from "node:path";
42
45
 
@@ -370,6 +373,41 @@ function main() {
370
373
  // — the no-symbol slice: a cited span that is entirely blank or brace-only.
371
374
  const { misses: blankTarget, unshipped: blankUnshipped } = structuralMisses(citations, readLines);
372
375
 
376
+ // ── `--ratchet`: refuse a NEW citation that carries a line number and no symbol ────────────────────
377
+ //
378
+ // Separate mode rather than folded into the default run, because it answers about a RANGE and the rest
379
+ // of this script answers about the tree. Mixing them would make one exit code stand for two questions.
380
+ if (process.argv.includes("--ratchet")) {
381
+ const i = process.argv.indexOf("--base");
382
+ const base = i >= 0 ? process.argv[i + 1] : "origin/main";
383
+ const { error, lines } = addedLinesSince(base);
384
+ if (error) {
385
+ // A RANGE THAT COULD NOT BE READ IS NOT A RANGE THAT ADDED NOTHING, and 2 keeps the two apart —
386
+ // 1 already means "found something" here.
387
+ console.error(`${GUARD} --ratchet: ${error}`);
388
+ console.error(" That is this check failing to look, not a clean range. Nothing was judged.");
389
+ process.exit(2);
390
+ }
391
+ const bare = newBareCitations(lines);
392
+ console.log(`${GUARD} --ratchet: ${lines.length} added line(s) against ${base}, ${bare.length} new bare citation(s)`);
393
+ for (const b of bare) console.log(` ${b.file}:${b.line} cites ${b.cited}:${b.start} with no symbol\n ${b.text.slice(0, 110)}`);
394
+ if (bare.length) {
395
+ console.error("\nA citation carrying a line number and no symbol is checked for EXISTENCE and nothing");
396
+ console.error("else, so it drifts onto a different real line and every test here still passes.");
397
+ console.error("");
398
+ console.error("The documented form (ADR-0005) carries no line number at all:");
399
+ console.error(" `toolGroupsForStage()` in `gather-config.mjs`");
400
+ console.error("Where the target is not a named symbol, quote a few words of it. A line number is also");
401
+ console.error("accepted when a bare identifier sits directly beside it — `pipeline.mjs:875 recordsFromSearch`");
402
+ console.error("— because that one this script can check. Backticks around the symbol are NOT stepped over,");
403
+ console.error("so a line number with a backticked symbol still reads as bare.");
404
+ console.error("");
405
+ console.error("The existing corpus is not your problem: this judges only what the range ADDS.");
406
+ process.exit(1);
407
+ }
408
+ process.exit(0);
409
+ }
410
+
373
411
  if (process.argv.includes("--json")) {
374
412
  console.log(JSON.stringify({
375
413
  files: corpus.length, citations: citations.length, resolved: resolved.length,
@@ -595,6 +633,87 @@ const CALLS = /^\s*\(/;
595
633
  const OWN_NUMBER = /^\s*(?:at\s+\d|:\d)/;
596
634
 
597
635
  /** The symbol-shaped tokens adjoining a citation, in BOTH grammars: `cite SYM` and `SYM (cite)`. PURE. */
636
+ // ── THE RATCHET: A NEWLY ADDED CITATION CARRIES A SYMBOL (tracker issue 125) ────────────────────────
637
+ //
638
+ // The blindness above is not closeable on the existing corpus. Of the line citations in this tree only a
639
+ // minority name a symbol beside the number, and the rest are checked for EXISTENCE and nothing more — a
640
+ // citation that drifted onto a different real line reads as correct to every test here. Repointing them by
641
+ // hand is not the repair: the two hand measurements recorded in BLINDNESS both found wrong lines
642
+ // introduced by exactly that kind of pass.
643
+ //
644
+ // So this closes the class GOING FORWARD, at the only moment the correct symbol is cheap to write — when
645
+ // the author still knows what they meant. Same shape as the catalogue and release-note ratchets: the
646
+ // existing population is left to convert by attrition, and the number that must not grow is the number of
647
+ // citations nothing can check.
648
+ //
649
+ // WHAT IS REFUSED IS NARROW, and the two questions the issue left open are answered here rather than
650
+ // assumed:
651
+ //
652
+ // · a line number WITH a symbol is fine. The symbol is what makes it checkable, and `symbolClaims`
653
+ // above already verifies it; forbidding the number as well would be stricter than the harm.
654
+ // · a citation with NO line number is fine, symbol or not. It cannot go stale from an edit above it,
655
+ // which is the whole failure. This is also the answer for a data file, a fixture or a generated
656
+ // table, where there is no symbol to name — cite the file, leave the line off.
657
+ //
658
+ // Refused, therefore, is exactly one shape: a NEW citation carrying a line number and no symbol.
659
+ //
660
+ // PURE, and the added lines are injected, so a test drives it without a git repository — a ratchet that
661
+ // can only run against the real tree cannot be shown to fail.
662
+ // THE RULE'S OWN DEFINITION IS EXEMPT, AND NAMED RATHER THAN PATTERN-MATCHED. Its specimens are bare
663
+ // citations on purpose — a test that cannot write the shape it refuses cannot exercise it — so scanning it
664
+ // puts the guard's own examples into the population it polices, and the ratchet refuses its own arrival.
665
+ // Caught by driving the check against a range containing it, not by review.
666
+ //
667
+ // The cost of naming a file is that a REAL bare citation added there also escapes. That is the same trade
668
+ // the reference-strip signatures make for the same reason, and it is preferable to a pattern, which would
669
+ // quietly widen to files nobody considered.
670
+ export const RATCHET_EXEMPT = ["driver/test/a-new-citation-carries-something-that-can-be-checked.test.mjs"];
671
+
672
+ export function newBareCitations(addedLines) {
673
+ const out = [];
674
+ for (const { file, line, text } of addedLines ?? []) {
675
+ if (RATCHET_EXEMPT.includes(file)) continue;
676
+ for (const m of String(text ?? "").matchAll(CITE_RE)) {
677
+ // The same discriminator the corpus scan uses: a second `:number` closing a paren is a V8 stack
678
+ // frame, not a citation. Reading them as citations would refuse a fixture that captured a trace.
679
+ if (/^:\d+\)/.test(String(text).slice(m.index + m[0].length))) continue;
680
+ if (symbolsBeside(String(text), m.index, m[0].length).length > 0) continue;
681
+ out.push({ file, line, cited: m[1], start: Number(m[2]), text: String(text).trim() });
682
+ }
683
+ }
684
+ return out;
685
+ }
686
+
687
+ /**
688
+ * The lines a range ADDS, as `{ file, line, text }`. `run` is injected for the same reason as above.
689
+ *
690
+ * `--unified=0` so context lines are not read as additions — with context, every citation near an edit
691
+ * would be reported as new and the ratchet would refuse changes that added nothing.
692
+ */
693
+ export function addedLinesSince(base = "origin/main", run = null) {
694
+ const exec = run ?? ((...a) => execFileSync("git", ["-C", ROOT, ...a], { encoding: "utf8", maxBuffer: 1e9 }));
695
+ let diff;
696
+ try { diff = exec("diff", "--unified=0", "--no-color", `${base}...HEAD`); }
697
+ catch (e) {
698
+ // A range that could not be read is not a range that added nothing, and the caller must not confuse them.
699
+ return { error: `could not diff ${base}...HEAD (${String(e?.message ?? e).split("\n")[0].slice(0, 90)})`, lines: [] };
700
+ }
701
+ const lines = [];
702
+ let file = null, next = 0;
703
+ for (const raw of diff.split("\n")) {
704
+ // The two file headers git writes, matched in full rather than by a "+++" prefix. An added line
705
+ // beginning with "++" arrives here as "+++...", so a prefix test drops it AND numbers every line
706
+ // after it in the hunk one low, because the line it skipped never advanced the counter. Both
707
+ // forms are exact: a deletion's header is the bare string, with no path and no timestamp.
708
+ if (raw.startsWith("+++ b/")) { file = raw.slice(6); continue; }
709
+ if (raw === "+++ /dev/null") { file = null; continue; }
710
+ const h = raw.match(/^@@ -\d+(?:,\d+)? \+(\d+)(?:,\d+)? @@/);
711
+ if (h) { next = Number(h[1]); continue; }
712
+ if (raw.startsWith("+")) { lines.push({ file, line: next++, text: raw.slice(1) }); }
713
+ }
714
+ return { error: null, lines };
715
+ }
716
+
598
717
  export function symbolsBeside(line, index, length) {
599
718
  const after = String(line).slice(index + length).replace(SEP_AFTER, "");
600
719
  const am = after.match(/^[A-Za-z_$][A-Za-z0-9_$]*/);
package/scripts/e2e.mjs CHANGED
@@ -630,7 +630,7 @@ export function bandForScenario(scenario, resolve = resolveForDoor) {
630
630
  /**
631
631
  * — WHICH ENGINE COMMIT THIS RUN LOADED, read from the run's own artifacts.
632
632
  *
633
- * `role-e2e`'s handover requires the commit on every run, and until this existed it was reconstructed
633
+ * the testing handover requires the commit on every run, and until this existed it was reconstructed
634
634
  * by joining a checkout's reflog against `startedAt`. That reconstruction expires with the reflog,
635
635
  * dies with a re-clone, and is simply wrong for any run made while the checkout sat detached — it
636
636
  * produced two near-miss wrong certifications in two days.
@@ -1354,7 +1354,7 @@ function evalAssertion(a, runDir) {
1354
1354
  // ── — THIS IS A DELIVERY CONTRACT, AND A RUN THAT DID NOT DELIVER NEVER ENTERED IT ────────
1355
1355
  //
1356
1356
  // Owner ruling, 2026-08-22, verbatim: "clean up the failed runs. they owe the client nothing."
1357
- // (Relayed by role-overwatch; recorded here because the rule this line encodes is a product
1357
+ // (Recorded here because the rule this line encodes is a product
1358
1358
  // decision, not a harness preference.)
1359
1359
  //
1360
1360
  // Measured before the ruling: `sendPending` is carried by 25 of 25 delivered runs and 0 of 29
@@ -321,7 +321,7 @@ export function defaultAtReadSite(name, root = ROOT) {
321
321
 
322
322
  /** The whole classification, pure over its inputs so a test drives it without a box. */
323
323
  export function classify({ catalogue, sources, setup = setupNames(), readSites = defaultAtReadSite,
324
- deploymentNames = DEPLOYMENT_NAMES } = {}) {
324
+ deploymentNames = DEPLOYMENT_NAMES, declared = new Map() } = {}) {
325
325
  // NAMED OVERRIDES, listed rather than folded into a pattern so that each can be argued with.
326
326
  //
327
327
  // `CLEAROTRON_AGENT_WHATSAPP` matches no deployment shape and is not a knob: production sets it to a
@@ -370,7 +370,11 @@ export function classify({ catalogue, sources, setup = setupNames(), readSites =
370
370
 
371
371
  const rows = catalogue.map((name) => {
372
372
  const setIn = ["prod", "test", "config", "ci", "e2e"].filter((k) => sources[k]?.has(name));
373
- return { name, class: cls(name), everSet: setIn, documented: Boolean(sources.docs?.has(name)) };
373
+ // `declared` is the catalogue's own `# effect:` for this name, carried on the row so the artifact
374
+ // says what the document claims beside what this script derived. The two are different questions and
375
+ // the row is where a reader compares them.
376
+ return { name, class: cls(name), everSet: setIn, declared: declared.get(name) ?? null,
377
+ documented: Boolean(sources.docs?.has(name)) };
374
378
  });
375
379
 
376
380
  // "Set nowhere" is not by itself a licence to delete, and this is where 62 of 84 left the list.
@@ -378,6 +382,57 @@ export function classify({ catalogue, sources, setup = setupNames(), readSites =
378
382
  const GATE_RE = /(===|!==|==|!=)\s*"[01]"|"[01]"\s*(===|!==|==|!=)|\|\|\s*"1"/;
379
383
  const sub = {};
380
384
  for (const n of tuning) {
385
+ // ── A DOCUMENT SAYING "THIS IS NOT A KNOB" KEEPS ITS NAME OFF THIS LIST ────────────────────────
386
+ //
387
+ // The classifier keys on the NAME and `tuning` is its residual — what a name falls to when no shape
388
+ // matches. So a name whose catalogue row declares any other effect reaches the deletion walk on a
389
+ // class its own documentation contradicts.
390
+ //
391
+ // THE TEST IS `!== "tuning"`, SO IT EXCLUDES ALL FIVE OTHER CLASSES. `tuning` is the only declared
392
+ // effect that AGREES with being a knob, so it is the only one that may reach the walk. Each of the
393
+ // five, in the catalogue's own words, and why deleting the name would cost something:
394
+ //
395
+ // silent-output-change "changes what a run produces, and nothing in the run's own artifacts says
396
+ // so" — the worst one to delete, because the loss is invisible in the output.
397
+ // disclosed-gate "changes what a run covers, AND the run discloses the gap" — deleting it
398
+ // silently restores coverage the operator chose to switch off, or removes
399
+ // their ability to switch it off at all.
400
+ // credential "absent, the run refuses at preflight by name" — deleting it turns a
401
+ // named refusal into an unexplained one.
402
+ // deployment "where input and output live; the conclusion a run reaches is unchanged"
403
+ // — unchanged CONCLUSION is not unchanged behaviour. This is the class the
404
+ // whole rule comes from: two notification addresses, legitimately unset on
405
+ // the deployment being read, were proposed for deletion on exactly this
406
+ // mismatch. Excluding it is the original finding, not an extension of it.
407
+ // harness "read only on a fixture, replay or self-test path; no production run
408
+ // reaches it" — the one that reads as safe to delete and is not. Deleting a
409
+ // harness name does not remove a knob nobody uses; it removes the only way
410
+ // a test can run. That argument is already made below for `instrument`, and
411
+ // it is the same argument. A declaration is a stronger version of it,
412
+ // because it does not depend on the spelling.
413
+ //
414
+ // The alternative — narrowing to the three classes this comment used to name — would put
415
+ // `deployment` back on the list and re-admit the defect the rule exists to stop.
416
+ //
417
+ // WHAT THIS TREE CAN SHOW YOU, and it is not the number to look for. Measured 2026-09-08: this tree
418
+ // carries 43 catalogued rows and 12 declarations, and NO name is in the contradicting position, so
419
+ // the rule changes nothing here and a check written against the live catalogue would pass while
420
+ // looking at nothing. That is why the checks plant a catalogue rather than reading this one. Over
421
+ // the full catalogue the same day the position holds seventeen names — ten declared
422
+ // `silent-output-change`, four `disclosed-gate`, two `harness`, one `credential` — and every one of
423
+ // them leaves the walk further down for an UNRELATED reason: the instrument regex matching their
424
+ // spelling, a non-numeric default, or no default found. The file already says what that is worth
425
+ // about a different name: exclusion "for an unrelated reason (no default found), which is luck, not
426
+ // a rule". Rename one of them to something without `DUMP` or `PROBE` in it and it joins the deletion
427
+ // population with a document beside it saying it changes what a run produces.
428
+ //
429
+ // Those counts are a dated reading and they move; two of the seventeen were added the same week.
430
+ // The RULE is what is being asserted here, not the population.
431
+ //
432
+ // So the declaration is read FIRST and it is the rule. Nothing is silently dropped: the names land
433
+ // in their own bucket, and the row carries the declaration that put them there.
434
+ const say = declared.get(n);
435
+ if (say && say !== "tuning") { sub[n] = "declared-not-a-knob"; continue; }
381
436
  const body = git("grep", "-n", "--", n, "--", "*.mjs", "*.js")
382
437
  .split("\n").filter((l) => l && !/(^|\/)test\//.test(l)).join("\n");
383
438
  const onlyTests = git("grep", "-l", "--", n, "--", "*.mjs", "*.js").split("\n").filter(Boolean)
@@ -405,6 +460,7 @@ export function classify({ catalogue, sources, setup = setupNames(), readSites =
405
460
  instrument: of("instrument"), "path-switch": of("path-switch"),
406
461
  "model-or-agent-selector": of("model-or-agent-selector"),
407
462
  "non-numeric-default": of("non-numeric-default"), "no-default-found": of("no-default-found"),
463
+ "declared-not-a-knob": of("declared-not-a-knob"),
408
464
  "deletable-number": of("deletable-number"),
409
465
  } };
410
466
  }
@@ -414,7 +470,11 @@ function build() {
414
470
  { encoding: "utf8", cwd: ROOT, maxBuffer: 1e8 }));
415
471
  const catalogue = audit.catalogue.rows.map((r) => r.name);
416
472
  const sources = gather();
417
- const { rows, buckets } = classify({ catalogue, sources });
473
+ // The catalogue's own declarations, handed in so the deletion walk can read them. Same rows the
474
+ // audit already produced — not a second parse of the same documents, which would be a second thing
475
+ // to keep in step.
476
+ const declared = new Map(audit.catalogue.rows.filter((r) => r.effect).map((r) => [r.name, r.effect]));
477
+ const { rows, buckets } = classify({ catalogue, sources, declared });
418
478
  const prodRead = sources.prodRead;
419
479
  const by = (k) => rows.filter((r) => r.class === k).length;
420
480
  return {
@@ -428,6 +488,10 @@ function build() {
428
488
  names: buckets["deletable-number"], count: buckets["deletable-number"].length,
429
489
  },
430
490
  _excludedFromDeletion: {
491
+ // FIRST, because it is the only one of these that is a RULE rather than an observation about a
492
+ // name's spelling or its default: the catalogue says this name changes what a run produces, so it
493
+ // is not a spare knob whatever its shape suggests.
494
+ "declared-not-a-knob": buckets["declared-not-a-knob"],
431
495
  instrument: buckets.instrument, "path-switch": buckets["path-switch"],
432
496
  "model-or-agent-selector": buckets["model-or-agent-selector"],
433
497
  "non-numeric-default": buckets["non-numeric-default"], "no-default-found": buckets["no-default-found"],