clearotron 0.3.2-beta.7 → 0.3.2-beta.9

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 (272) hide show
  1. package/.env.example +58 -26
  2. package/CONTRIBUTING.md +8 -8
  3. package/INSTALL.md +148 -81
  4. package/README.md +3 -3
  5. package/SECURITY.md +3 -3
  6. package/bin/brandowner.mjs +3 -3
  7. package/bin/framework-preflight.mjs +1 -1
  8. package/bin/onboard.mjs +637 -216
  9. package/bin/start.mjs +151 -27
  10. package/bin/update.mjs +58 -11
  11. package/build-info.json +2 -2
  12. package/docs/DELIVERY.md +2 -1
  13. package/docs/INTAKE.md +1 -1
  14. package/docs/ONBOARDING.md +1 -1
  15. package/docs/architecture/03-run-lifecycle.md +6 -6
  16. package/docs/architecture/04-configuration-reference.md +32 -14
  17. package/docs/architecture/05-config-governance.md +23 -8
  18. package/docs/architecture/05-customer-profiles.md +2 -2
  19. package/docs/architecture/06-operations-runbook.md +3 -3
  20. package/docs/architecture/08-development-guide.md +6 -6
  21. package/docs/configuration.md +5 -5
  22. package/docs/decisions/0003-credential-model.md +1 -1
  23. package/docs/writing-standard.md +4 -0
  24. package/driver/CHANGELOG.md +124 -0
  25. package/driver/README.md +3 -3
  26. package/driver/band-size.mjs +59 -0
  27. package/driver/binding-layers.mjs +1 -1
  28. package/driver/citation-census.json +3 -3
  29. package/driver/{prelim-variants-record.mjs → clearance-variants-record.mjs} +24 -24
  30. package/driver/common-law-receipts.mjs +2 -2
  31. package/driver/company-bundle.mjs +3 -3
  32. package/driver/compose-read.mjs +8 -14
  33. package/driver/config-inventory.mjs +112 -9
  34. package/driver/consumption-ledger.mjs +2 -2
  35. package/driver/contract-arm2-baseline.json +2 -5
  36. package/driver/contract-dictation-registry.mjs +19 -19
  37. package/driver/contract-e3-backlog.mjs +43 -43
  38. package/driver/contract-e3-baseline.json +14 -14
  39. package/driver/contract-vocabulary.mjs +68 -27
  40. package/driver/deliver-trigger.sh +16 -16
  41. package/driver/demo-container.mjs +3 -3
  42. package/driver/dev-portal.mjs +3 -3
  43. package/driver/disposition-call.mjs +1 -1
  44. package/driver/door-gates.mjs +41 -7
  45. package/driver/doubt-ledger.mjs +2 -2
  46. package/driver/drainer-identity.mjs +34 -8
  47. package/driver/driver.config.mjs +367 -104
  48. package/driver/engine/CONTRACT.md +10 -3
  49. package/driver/engine/README.md +2 -2
  50. package/driver/engine/anthropic-agent.mjs +77 -21
  51. package/driver/engine/auth.mjs +129 -10
  52. package/driver/engine/jx-turn.mjs +7 -6
  53. package/driver/engine/mcp/README.md +1 -1
  54. package/driver/engine/mcp/dispositions-server.mjs +3 -3
  55. package/driver/engine/mcp/gather-config.mjs +9 -9
  56. package/driver/engine/mcp/perplexity-server.mjs +2 -2
  57. package/driver/engine/mcp/recording-server.mjs +18 -5
  58. package/driver/engine/openai-agent.mjs +4 -2
  59. package/driver/engine/probe.mjs +110 -23
  60. package/driver/enqueue-schema.mjs +6 -2
  61. package/driver/findings-model.mjs +6 -3
  62. package/driver/flag-snapshot.mjs +34 -8
  63. package/driver/form-neighbourhood.mjs +54 -7
  64. package/driver/framework.mjs +4 -4
  65. package/driver/gateway.mjs +36 -24
  66. package/driver/jx-lanes.mjs +23 -4
  67. package/driver/jx-units.mjs +7 -4
  68. package/driver/jx.mjs +34 -4
  69. package/driver/knockout-review-record.mjs +56 -4
  70. package/driver/known-conflicts.mjs +1 -1
  71. package/driver/matter-frame-record.mjs +90 -1
  72. package/driver/named-band.mjs +1 -1
  73. package/driver/ordinary-words.mjs +51 -0
  74. package/driver/outbox-backoff.mjs +31 -16
  75. package/driver/package.json +1 -1
  76. package/driver/partial-payload-baseline.json +2 -2
  77. package/driver/phase0.mjs +3 -3
  78. package/driver/pipeline-knockout.mjs +5 -5
  79. package/driver/pipeline.mjs +396 -81
  80. package/driver/placement-form.mjs +77 -1
  81. package/driver/placement-model.mjs +1 -1
  82. package/driver/portal-config-view.mjs +30 -1
  83. package/driver/portal-report.mjs +107 -6
  84. package/driver/portal-service.mjs +80 -14
  85. package/driver/portal-upstream.mjs +1 -1
  86. package/driver/predelivery-lint.mjs +12 -2
  87. package/driver/preserve-merge.mjs +3 -3
  88. package/driver/product-rows.mjs +2 -2
  89. package/driver/products.mjs +1 -1
  90. package/driver/profiles/README.md +3 -3
  91. package/driver/profiles/demo-brand-owner.json +2 -2
  92. package/driver/profiles.mjs +55 -17
  93. package/driver/progress.mjs +18 -8
  94. package/driver/provider-usage.mjs +8 -8
  95. package/driver/publish/index.mjs +154 -8
  96. package/driver/publish/knockout.mjs +39 -5
  97. package/driver/publish/pool-admin.mjs +1 -1
  98. package/driver/publish/publish-inputs.mjs +18 -2
  99. package/driver/publish/render-knockout.mjs +184 -31
  100. package/driver/publish/render.mjs +323 -93
  101. package/driver/publish/report-data.mjs +4 -1
  102. package/driver/publish/report-topbar.mjs +58 -0
  103. package/driver/publish/search-depth.mjs +133 -4
  104. package/driver/publish/templates/report.css +78 -4
  105. package/driver/publish/xlsx.mjs +20 -1
  106. package/driver/queue-order.mjs +2 -2
  107. package/driver/recording-agreement.mjs +1 -1
  108. package/driver/reference-score.mjs +1 -1
  109. package/driver/register-availability.mjs +2 -2
  110. package/driver/register-count.mjs +50 -5
  111. package/driver/register-coverage.mjs +161 -1
  112. package/driver/register-digest-record.mjs +236 -11
  113. package/driver/register-grant-vocabulary.mjs +1 -1
  114. package/driver/register-plan.mjs +189 -2
  115. package/driver/registry-fidelity.mjs +3 -3
  116. package/driver/repair-composers.mjs +1 -1
  117. package/driver/repair-contract.mjs +1 -1
  118. package/driver/replay-archive.mjs +6 -6
  119. package/driver/report-overview-record.mjs +2 -2
  120. package/driver/result-noun-fields.mjs +2 -2
  121. package/driver/run-economics.mjs +41 -10
  122. package/driver/run-requirements.mjs +173 -9
  123. package/driver/runner.mjs +5 -5
  124. package/driver/scope-facts.mjs +20 -5
  125. package/driver/scope-ledger.mjs +5 -5
  126. package/driver/search-policy.mjs +22 -12
  127. package/driver/skills/README.md +15 -15
  128. package/driver/skills/blind-frame/SKILL.md +2 -2
  129. package/driver/skills/case-law-citation/SKILL.md +4 -4
  130. package/driver/skills/case-law-citation/sources/eurlex.md +1 -1
  131. package/driver/skills/{prelim-common-law → clearance-common-law}/SKILL.md +22 -22
  132. package/driver/skills/{prelim-common-law → clearance-common-law}/perplexity-prompts.md +1 -1
  133. package/driver/skills/{prelim-register → clearance-register}/SKILL.md +10 -10
  134. package/driver/skills/{prelim-register → clearance-register}/digest.md +2 -2
  135. package/driver/skills/{prelim-register → clearance-register}/providers/README.md +1 -1
  136. package/driver/skills/{prelim-register → clearance-register}/providers/clarivate.md +37 -35
  137. package/driver/skills/{prelim-register → clearance-register}/providers/corsearch.md +20 -11
  138. package/driver/skills/{prelim-register → clearance-register}/providers/signa.md +5 -5
  139. package/driver/skills/{prelim-register → clearance-register}/register-recipes.md +3 -3
  140. package/driver/skills/{prelim-register → clearance-register}/status-rules.md +2 -2
  141. package/driver/skills/{prelim-register → clearance-register}/stealth-filer-indicators.md +1 -1
  142. package/driver/skills/{prelim-register → clearance-register}/unit.md +2 -2
  143. package/driver/skills/{prelim-search → clearance-search}/SKILL.md +31 -31
  144. package/driver/skills/{prelim-search → clearance-search}/delivery-contract.md +1 -1
  145. package/driver/skills/{prelim-search → clearance-search}/phase2-execution.md +18 -18
  146. package/driver/skills/{prelim-search → clearance-search}/synthesis-rules.md +7 -7
  147. package/driver/skills/{prelim-variants → clearance-variants}/SKILL.md +18 -18
  148. package/driver/skills/{prelim-variants → clearance-variants}/transliteration-scripts.md +5 -5
  149. package/driver/skills/frame-diff/SKILL.md +1 -1
  150. package/driver/skills/knockout-assess/SKILL.md +10 -7
  151. package/driver/skills/matter-frame/SKILL.md +3 -3
  152. package/driver/skills/narrative-refutation/SKILL.md +9 -9
  153. package/driver/skills/placement-inquiry/SKILL.md +5 -5
  154. package/driver/stage-context.mjs +1 -1
  155. package/driver/stages-knockout.mjs +4 -4
  156. package/driver/stages.mjs +65 -61
  157. package/driver/status-snapshot.mjs +2 -2
  158. package/driver/suite-census.json +340 -136
  159. package/driver/surface-exit-verdict.mjs +58 -0
  160. package/driver/systemd/README.md +9 -6
  161. package/driver/systemd/clearotron-worker.service +1 -1
  162. package/driver/terminal-clamp.mjs +109 -1
  163. package/driver/tokens.mjs +169 -3
  164. package/driver/unit-environment.mjs +42 -15
  165. package/driver/unit-inventory.mjs +19 -2
  166. package/driver/usage-ledger.mjs +1 -1
  167. package/driver/variant-manifest-model.mjs +4 -4
  168. package/driver/verify-knockout.mjs +27 -0
  169. package/driver/verify.mjs +94 -6
  170. package/driver/whatif-queue.mjs +1 -1
  171. package/driver/wordlists/en.txt +63906 -0
  172. package/mcp-server/CHANGELOG.md +8 -0
  173. package/mcp-server/README.md +1 -1
  174. package/mcp-server/lib/README.md +1 -1
  175. package/mcp-server/lib/options.mjs +8 -7
  176. package/mcp-server/lib/plan.mjs +18 -2
  177. package/mcp-server/lib/runs.mjs +1 -1
  178. package/mcp-server/lib/usage.mjs +3 -3
  179. package/mcp-server/lib/whatif.mjs +1 -1
  180. package/mcp-server/package.json +1 -1
  181. package/mcp-server/server.mjs +18 -1
  182. package/package.json +12 -11
  183. package/portal-ui/dist/assets/{index-CVOIvdhc.css → index-CtvwLCti.css} +207 -3
  184. package/portal-ui/dist/assets/{index-5UyqAyNM.js → index-EVaSo5-g.js} +1580 -527
  185. package/portal-ui/dist/index.html +2 -2
  186. package/portal-ui/package.json +1 -1
  187. package/providers/README.md +1 -1
  188. package/providers/_shared/enumerate.mjs +6 -6
  189. package/providers/_shared/execute-plan.mjs +3 -3
  190. package/providers/_shared/ledger.mjs +119 -5
  191. package/providers/_shared/provider-text.mjs +2 -2
  192. package/providers/_shared/screen.mjs +2 -2
  193. package/providers/_shared/script-form.mjs +3 -3
  194. package/providers/_shared/territory-codes.mjs +23 -3
  195. package/providers/clarivate/README.md +1 -1
  196. package/providers/clarivate/src/capabilities.js +12 -12
  197. package/providers/clarivate/src/core.js +37 -43
  198. package/providers/corsearch/README.md +1 -1
  199. package/providers/corsearch/src/capabilities.js +5 -5
  200. package/providers/corsearch/src/core.js +3 -3
  201. package/providers/jx/README.md +2 -1
  202. package/providers/jx/src/turn-envelope.mjs +8 -3
  203. package/providers/oauth-mcp-bridge/CHANGELOG.md +8 -0
  204. package/providers/oauth-mcp-bridge/package.json +1 -1
  205. package/providers/perplexity/src/core.js +1 -1
  206. package/providers/signa/README.md +1 -1
  207. package/providers/signa/src/capabilities.js +42 -49
  208. package/providers/signa/src/core.js +106 -29
  209. package/providers/uspto-local/README.md +1 -1
  210. package/providers/uspto-local/src/sync.js +1 -1
  211. package/scripts/README.md +1 -0
  212. package/scripts/ask-ai-render-check.mjs +127 -1
  213. package/scripts/authority-boundary-probe.mjs +8 -6
  214. package/scripts/backfill-started-at.mjs +2 -2
  215. package/scripts/census-merge-driver.mjs +33 -2
  216. package/scripts/citation-anchor-report.mjs +181 -0
  217. package/scripts/dead-names.mjs +1 -1
  218. package/scripts/deprecate-below.mjs +66 -8
  219. package/scripts/drain-preflight.mjs +1 -1
  220. package/scripts/e2e.mjs +174 -0
  221. package/scripts/env-audit.mjs +39 -6
  222. package/scripts/env-classify.mjs +20 -2
  223. package/scripts/freeze-example-run.mjs +61 -18
  224. package/scripts/generated-files-are-current.mjs +69 -4
  225. package/scripts/live-surface-check.mjs +124 -41
  226. package/scripts/markdown-link-check.mjs +1 -1
  227. package/scripts/merge-shape-check.mjs +242 -0
  228. package/scripts/mint-names-in-force.mjs +5 -5
  229. package/scripts/mint-offered-territories.mjs +72 -0
  230. package/scripts/mint-public-residue.mjs +2 -2
  231. package/scripts/mint-reference-strip-backlog.mjs +2 -2
  232. package/scripts/mint-suite-census.mjs +75 -2
  233. package/scripts/mint-writing-standard-backlog.mjs +2 -2
  234. package/scripts/purge-runs.mjs +7 -7
  235. package/scripts/reconcile-runs.mjs +2 -2
  236. package/scripts/release-approve-parked.mjs +20 -2
  237. package/scripts/release-await-cut.mjs +120 -1
  238. package/scripts/release-note-required.mjs +76 -8
  239. package/scripts/report-header-render-check.mjs +164 -0
  240. package/scripts/settings-render-check.mjs +75 -2
  241. package/scripts/test-full.mjs +96 -3
  242. package/scripts/test-run.mjs +10 -0
  243. package/shared/brand.mjs +27 -0
  244. package/shared/connect-clients.mjs +39 -11
  245. package/shared/deployment-box.mjs +7 -2
  246. package/shared/driver-dir.mjs +1 -1
  247. package/shared/env-aliases.mjs +1 -1
  248. package/shared/identifier-scan.mjs +65 -9
  249. package/shared/identifier-sentinels.mjs +22 -0
  250. package/shared/names-in-force.mjs +4 -2
  251. package/shared/offered-territories.json +738 -0
  252. package/shared/pre-rename-spellings.mjs +53 -0
  253. package/shared/reference-guard-classes.mjs +40 -2
  254. package/shared/stdio-connect.mjs +39 -4
  255. package/shared/tree-commit.mjs +48 -0
  256. /package/driver/skills/{prelim-register → clearance-register}/providers/euipo.md +0 -0
  257. /package/driver/skills/{prelim-register → clearance-register}/providers/free-tier.md +0 -0
  258. /package/driver/skills/{prelim-register → clearance-register}/providers/uspto-local.md +0 -0
  259. /package/driver/skills/{prelim-search → clearance-search}/field-doctrine-pharma.md +0 -0
  260. /package/driver/skills/{prelim-search → clearance-search}/firm-wide-reasoning.md +0 -0
  261. /package/driver/skills/{prelim-search → clearance-search}/report-prose.md +0 -0
  262. /package/driver/skills/{prelim-search → clearance-search}/risk-framework-demo.manifest.json +0 -0
  263. /package/driver/skills/{prelim-search → clearance-search}/risk-framework-demo.md +0 -0
  264. /package/driver/skills/{prelim-search → clearance-search}/risk-framework-triage.manifest.json +0 -0
  265. /package/driver/skills/{prelim-search → clearance-search}/risk-framework-triage.md +0 -0
  266. /package/driver/skills/{prelim-search → clearance-search}/risk-framework.manifest.json +0 -0
  267. /package/driver/skills/{prelim-search → clearance-search}/risk-framework.md +0 -0
  268. /package/driver/skills/{prelim-search → clearance-search}/template-formatting.md +0 -0
  269. /package/driver/skills/{prelim-search → clearance-search}/templates/email/generic.md +0 -0
  270. /package/driver/skills/{prelim-search → clearance-search}/templates/search-request-form.html +0 -0
  271. /package/driver/skills/{prelim-search → clearance-search}/worked-examples-demo.md +0 -0
  272. /package/driver/skills/{prelim-search → clearance-search}/worked-examples.md +0 -0
@@ -77,6 +77,7 @@ if (shotsDir) mkdirSync(shotsDir, { recursive: true })
77
77
  // `productName` is resolved through the same expression portal-service.mjs evaluates, never typed: the
78
78
  // sibling instruments' rule, and it holds here for the same reason.
79
79
  const { reportIdentityFor } = await import('../driver/search-policy.mjs')
80
+ const { prepareReportForEmbed } = await import('../driver/portal-report.mjs')
80
81
 
81
82
  const KEY = 'acme'
82
83
  const NAME = 'Acme'
@@ -127,6 +128,19 @@ const DECKS = {
127
128
  stdio: { command: 'node server.mjs', note: 'from this install', verify: 'it prints ready' } },
128
129
  }
129
130
  let deck = 'connected'
131
+ // WHICH DOCUMENT THE FRAME HOLDS. The stub below for every state about the header; for a finding's own
132
+ // button, a document put through the same preparation the portal serves a report with, so the press
133
+ // travels the real injected script to the real listener. Two cards at fixed places: a numbered finding,
134
+ // and an "Also considered" card, which carries the button and no number.
135
+ let reportDoc = 'stub'
136
+ const FINDING_DOC = () => prepareReportForEmbed(`<!doctype html><html><head><meta charset="utf-8"><title>report</title></head>
137
+ <body style="margin:0;font-family:sans-serif;background:#fff;height:400px">
138
+ <div class="card" id="c3" style="position:absolute;top:40px;left:20px"><div class="cardhead"><span class="fnum">3</span>
139
+ <button class="ask-fi no-print" style="width:240px;height:32px">\u2726 Ask AI about this finding</button></div></div>
140
+ <div class="card ruled" style="position:absolute;top:140px;left:20px"><div class="cardhead">
141
+ <button class="ask-fi no-print" style="width:240px;height:32px">\u2726 Ask AI about this finding</button></div></div>
142
+ <script>parent.postMessage({ source: 'cordillera-report', type: 'controls', commands: ['exportPDF'] }, '*');</script>
143
+ </body></html>`, {}).html
130
144
 
131
145
  const MIME = { '.html': 'text/html', '.js': 'text/javascript', '.css': 'text/css', '.svg': 'image/svg+xml', '.json': 'application/json' }
132
146
  const json = (res, body) => { res.writeHead(200, { 'content-type': 'application/json' }); res.end(JSON.stringify(body)) }
@@ -143,6 +157,10 @@ const server = createServer((req, res) => {
143
157
  // A real document, because a 404 in the frame changes what Result draws around it. Its CONTENT is not
144
158
  // this file's subject — render-check.mjs owns the report's own layout. It announces the commands and
145
159
  // the height a published report's bridge announces, so the header draws the Export menu a reader meets.
160
+ if (p.startsWith('/portal/report/') && reportDoc === 'finding') {
161
+ res.writeHead(200, { 'content-type': 'text/html' })
162
+ return res.end(FINDING_DOC())
163
+ }
146
164
  if (p.startsWith('/portal/report/')) {
147
165
  res.writeHead(200, { 'content-type': 'text/html' })
148
166
  return res.end(`<!doctype html><meta charset="utf-8"><title>report</title>
@@ -409,6 +427,77 @@ await value('(() => { window.__pressContinue = true; return true })()')
409
427
  const back = await value(CONTINUE)
410
428
  const arrived = await value(PROBE)
411
429
 
430
+ // ── a finding's own button, pressed INSIDE the report ──
431
+ //
432
+ // A REAL CLICK AT THE BUTTON'S PLACE, not a message posted by this script: the document is sandboxed to a
433
+ // null origin and the point is the whole path — the injected script hears the press, posts it, and the
434
+ // report screen opens its control. The cards sit at fixed places in the document, so the frame's own
435
+ // position on the page is all that has to be measured.
436
+ const pressInFrame = async (top) => {
437
+ await value('(() => { window.scrollTo(0, 0); return true })()')
438
+ await new Promise((r) => setTimeout(r, 200))
439
+ const at = await value(`(() => { const f = document.querySelector('iframe'); if (!f) return null; const r = f.getBoundingClientRect(); return { x: r.left, y: r.top } })()`)
440
+ if (!at) return false
441
+ const x = Math.round(at.x + 140), y = Math.round(at.y + top + 16)
442
+ for (const type of ['mousePressed', 'mouseReleased']) await cmd('Input.dispatchMouseEvent', { type, x, y, button: 'left', clickCount: 1 })
443
+ await new Promise((r) => setTimeout(r, 400))
444
+ return true
445
+ }
446
+ const PANEL = `(() => {
447
+ const flat = (s) => (s || '').replace(/\\s+/g, ' ').trim();
448
+ const anchor = document.querySelector('[data-ask-ai]');
449
+ const float = anchor ? anchor.querySelector('.float') : null;
450
+ return { open: !!float, anchorState: anchor ? anchor.getAttribute('data-ask-ai') : null,
451
+ head: float && float.querySelector('.ask-ai-head') ? flat(float.querySelector('.ask-ai-head').textContent) : null,
452
+ text: float ? flat(float.innerText) : null,
453
+ checked: float ? [...float.querySelectorAll('[role="radio"]')].map(r => r.getAttribute('aria-checked')) : [] };
454
+ })()`
455
+ // THE BUTTON'S OWN OPEN STATE, AND THE CARET THAT TIES THE PANEL TO IT. Both are CSS, so both are read
456
+ // off the browser: a selector that never matched anything would still be in the stylesheet, and a
457
+ // source-text arm over it would pass on a panel a reader sees floating loose over the header.
458
+ const BUTTON = `(() => {
459
+ const b = document.querySelector('[data-ask-ai] > button');
460
+ if (!b) return null;
461
+ const s = getComputedStyle(b);
462
+ return { expanded: b.getAttribute('aria-expanded'), background: s.backgroundColor, border: s.borderTopColor };
463
+ })()`
464
+ const CARET = `(() => {
465
+ const f = document.querySelector('[data-ask-ai] .float');
466
+ if (!f) return null;
467
+ const edge = getComputedStyle(f, '::before'), fill = getComputedStyle(f, '::after'), panel = getComputedStyle(f);
468
+ return { edge: edge.borderBottomWidth, edgeColour: edge.borderBottomColor, panelBorder: panel.borderTopColor,
469
+ fill: fill.borderBottomWidth, fillColour: fill.borderBottomColor, panelFill: panel.backgroundColor, above: edge.top };
470
+ })()`
471
+ reportDoc = 'finding'
472
+ deck = 'connected'; await visit()
473
+ await value('(() => { window.__opened = []; window.open = (href) => { window.__opened.push(href); return null; }; return true })()')
474
+ const findingPressed = await pressInFrame(40)
475
+ const fromFinding = await value(PANEL)
476
+ await capture('ask-ai-from-finding')
477
+ const caret = await value(CARET)
478
+ const openButton = await value(BUTTON)
479
+ // PRESSED TWICE, THE SAME BUTTON SHUTS IT. A third press brings it back — which is also the state the
480
+ // questions below need, so this measurement leaves the deck exactly as it found it.
481
+ await pressInFrame(40)
482
+ const closedBySecondPress = await value(PANEL)
483
+ const shutButton = await value(BUTTON)
484
+ await pressInFrame(40)
485
+ const reopened = await value(PANEL)
486
+ const findingAsks = []
487
+ for (const i of QUESTIONS.keys()) {
488
+ if (i > 0) { await pressInFrame(40) }
489
+ findingAsks.push(await value(ASK(i)))
490
+ }
491
+ // The header's own button afterwards is about the whole report again.
492
+ const headerAfter = await value(`(async () => { document.querySelector('[data-ask-ai] > button').click(); await new Promise(r => setTimeout(r, 250)); return ${PANEL} })()`)
493
+ await value(`(async () => { document.querySelector('[data-ask-ai] > button').click(); await new Promise(r => setTimeout(r, 200)); return true })()`)
494
+ await pressInFrame(140)
495
+ const fromUnnumbered = await value(PANEL)
496
+ deck = 'neverBefore'; await visit()
497
+ await pressInFrame(40)
498
+ const findingNotConnected = await value(PANEL)
499
+ reportDoc = 'stub'
500
+
412
501
  deck = 'cannotTell'; await visit(); const unknown = await value(PROBE)
413
502
  deck = 'noConnector'; await visit(); const none = await value(PROBE)
414
503
  deck = 'localOnly'; await visit(); const local = await value(PROBE)
@@ -526,6 +615,43 @@ ok(none.drawn === false, `no connector: a button was drawn that can do nothing
526
615
  ok(local.drawn && local.opened, `local only: the case the old panel stranded is stranded again — ${show(local)}`)
527
616
  ok(local.anchorState === 'ask' && local.radios.length === 4, `local only: ${show(local.anchorState)} with ${local.radios.length} question(s)`)
528
617
 
618
+ // ── a finding's own button: the press reaches the control, and the finding is named ──
619
+ const FINDING_REPORT = `finding 3 of ${REPORT}`
620
+ ok(findingPressed, 'a finding: the report frame was not on the page to press inside')
621
+ ok(fromFinding && fromFinding.open && fromFinding.anchorState === 'ask',
622
+ `a finding: pressing "Ask AI about this finding" inside the report opened nothing — ${show(fromFinding)}`)
623
+ ok(fromFinding && fromFinding.head === `${MARK} · finding 3 · ${PRODUCT_NAME} · searched ${ISO_DATE}`,
624
+ `a finding: the panel's first line reads ${show(fromFinding?.head)}, not the finding named beside the mark`)
625
+ ok(show(fromFinding?.checked) === show(['true', 'false', 'false', 'false']), `a finding: the first question is not the one selected — ${show(fromFinding)}`)
626
+
627
+ // ── the panel reads as this button's, and one button opens and shuts it ──
628
+ ok(caret && caret.edge === '7px' && caret.fill === '7px',
629
+ `the panel draws no caret, so it reads as chrome floating over the header rather than as this button's — ${show(caret)}`)
630
+ ok(caret && caret.edgeColour === caret.panelBorder && caret.fillColour === caret.panelFill,
631
+ `the caret is not the panel's own edge continuing — ${show(caret)}`)
632
+ ok(caret && caret.above.startsWith('-'), `the caret sits at ${show(caret?.above)}, not above the panel pointing at the button`)
633
+ ok(openButton && openButton.expanded === 'true', `with the panel open the button says expanded=${show(openButton?.expanded)}`)
634
+ ok(shutButton && shutButton.expanded === 'false', `with the panel shut the button says expanded=${show(shutButton?.expanded)}`)
635
+ ok(openButton && shutButton && openButton.background !== shutButton.background && openButton.border !== shutButton.border,
636
+ `the button is drawn the same open as shut, so nothing on the page says which control the panel belongs to — ${show({ open: openButton, shut: shutButton })}`)
637
+ ok(closedBySecondPress && closedBySecondPress.open === false,
638
+ `a finding pressed twice: the second press reopened the panel instead of closing it — ${show(closedBySecondPress)}`)
639
+ ok(reopened && reopened.open && reopened.head === `${MARK} · finding 3 · ${PRODUCT_NAME} · searched ${ISO_DATE}`,
640
+ `a finding pressed a third time: the panel did not come back on that finding — ${show(reopened)}`)
641
+ const FINDING_TYPED = [`Brief me on ${FINDING_REPORT}.`, `Explain the main risks in ${FINDING_REPORT}.`,
642
+ `What needs further investigation in ${FINDING_REPORT}?`, `How would narrower goods change the assessment in ${FINDING_REPORT}?`]
643
+ for (const [i, press] of findingAsks.entries()) {
644
+ const href = press?.opened?.[press.opened.length - 1] ?? ''
645
+ const typed = (() => { try { return new URL(href).searchParams.get('q') } catch { return null } })()
646
+ ok(typed === FINDING_TYPED[i], `a finding, question ${i + 1}: the assistant opens with ${show(typed)}, not ${show(FINDING_TYPED[i])}`)
647
+ }
648
+ ok(headerAfter && headerAfter.open && headerAfter.head === `${MARK} · ${PRODUCT_NAME} · searched ${ISO_DATE}`,
649
+ `after a finding, the header's own Ask AI still names the finding rather than the report — ${show(headerAfter)}`)
650
+ ok(fromUnnumbered && fromUnnumbered.open && fromUnnumbered.head === `${MARK} · ${PRODUCT_NAME} · searched ${ISO_DATE}`,
651
+ `a card with no number: its button opened nothing, or named a finding it does not have — ${show(fromUnnumbered)}`)
652
+ ok(findingNotConnected && findingNotConnected.open && findingNotConnected.anchorState === 'connect' && /Connect your AI first/.test(findingNotConnected.text ?? ''),
653
+ `a finding, not connected: the press did not open the unchanged connect panel — ${show(findingNotConnected)}`)
654
+
529
655
  // ── NO ADDRESS AND NO COPY LINK, in any state that draws ──
530
656
  for (const [name, r] of [['connected', connected], ['never connected', never], ['could not tell', unknown], ['local only', local]]) {
531
657
  ok(!r.hasAddress, `${name}: a connector address is on the report again — ${show(r.text)}`)
@@ -537,5 +663,5 @@ if (fail.length) {
537
663
  console.error(`ask-ai-render-check: ${fail.length} thing(s) a reader would meet`)
538
664
  process.exit(1)
539
665
  }
540
- console.log(`ask-ai-render-check: the header, both panels and the unmeasured state render; all four questions reach the link typed in; Set it up leaves its note and Continue lands on the report with the panel open; no button where there is no connector`)
666
+ console.log(`ask-ai-render-check: the header, both panels and the unmeasured state render; the panel carries its caret and the button is drawn open, and a finding's own button opens and shuts it; all four questions reach the link typed in; Set it up leaves its note and Continue lands on the report with the panel open; no button where there is no connector`)
541
667
  if (shotsDir) console.log(`ask-ai-render-check: shots in ${shotsDir}`)
@@ -28,18 +28,18 @@ import { join } from "node:path";
28
28
  import { driverDir, ensureDriverDir } from "../shared/driver-dir.mjs"; // — one definition of where `_driver/` is
29
29
  import { tmpdir } from "node:os";
30
30
  import { buildClaudeArgs, spawnEnv } from "../driver/engine/anthropic-agent.mjs";
31
- import { envFrom } from "../shared/env-aliases.mjs"; // — resolves EITHER spelling; names the retired one because that is the live-writable half
31
+ import { resolveEngineProgram } from "../driver/driver.config.mjs"; // — the program a run would spawn, found the way a run finds it
32
32
 
33
33
  const arg = (f, d) => { const i = process.argv.indexOf(f); return i > 0 ? process.argv[i + 1] : d; };
34
34
  const model = arg("--model", "claude-sonnet-5");
35
35
 
36
36
  const root = mkdtempSync(join(tmpdir(), "authority-probe-"));
37
37
  const skills = join(root, "skills"), runDir = join(root, "run");
38
- mkdirSync(join(skills, "prelim-register"), { recursive: true });
38
+ mkdirSync(join(skills, "clearance-register"), { recursive: true });
39
39
  ensureDriverDir(runDir);
40
- writeFileSync(join(skills, "prelim-register", "SKILL.md"), "# doctrine\nThe method for this stage.\n");
41
- const doctrine = join(skills, "prelim-register", "NOTES.md"); // the incident's shape: a NEW file in the doctrine tree
42
- const existing = join(skills, "prelim-register", "SKILL.md"); // already on disk — the Edit target
40
+ writeFileSync(join(skills, "clearance-register", "SKILL.md"), "# doctrine\nThe method for this stage.\n");
41
+ const doctrine = join(skills, "clearance-register", "NOTES.md"); // the incident's shape: a NEW file in the doctrine tree
42
+ const existing = join(skills, "clearance-register", "SKILL.md"); // already on disk — the Edit target
43
43
  const record = driverDir(runDir, "probe-note.json"); // the forensic record a seat must never author
44
44
  const output = join(runDir, "findings.json"); // where this seat's own output belongs
45
45
 
@@ -73,7 +73,9 @@ console.log(`probe root: ${root}`);
73
73
  console.log(`--add-dir roots: ${args.filter((a, i) => args[i - 1] === "--add-dir").join(", ")}`);
74
74
  console.log(`--settings present: ${args.includes("--settings")}`);
75
75
 
76
- const child = spawn(envFrom(process.env, "CLEAROTRON_CLAUDE_PATH") || "claude", [...args, "--include-hook-events"],
76
+ // The program a run would spawn (driver.config.mjs resolveEngineProgram), so this probes the same copy.
77
+ const program = resolveEngineProgram("anthropic-agent");
78
+ const child = spawn(program.resolved ?? program.bin, [...args, "--include-hook-events"],
77
79
  { stdio: ["pipe", "pipe", "pipe"], cwd: runDir, env: spawnEnv() });
78
80
  let out = "", err = "";
79
81
  child.stdout.on("data", (d) => { out += d; });
@@ -28,7 +28,7 @@
28
28
  import "../shared/env-local.mjs"; // — FIRST: applies the CLEAROTRON_* translation before any module-top
29
29
  // capture evaluates. Reads no `.env` here — that load is gated on isCliEntry(argv[1]).
30
30
  import { readdirSync, readFileSync, writeFileSync, renameSync, existsSync } from "node:fs";
31
- import { join } from "node:path";
31
+ import { join } from "node:path"; import { studioDirFor } from "../shared/pre-rename-spellings.mjs";
32
32
  import { DRIVER_DIR, driverDir } from "../shared/driver-dir.mjs"; //
33
33
 
34
34
  // NAMED, never guessed. This used to fall back to a hardcoded production workspace path, so a run
@@ -115,7 +115,7 @@ function main() {
115
115
  const counts = {};
116
116
  const rows = [];
117
117
  for (const agent of AGENTS) {
118
- const studio = join(WORKSPACE_ROOT, `workspace-${agent}`, "studio", "prelim-search");
118
+ const studio = studioDirFor(join(WORKSPACE_ROOT, `workspace-${agent}`));
119
119
  if (!existsSync(studio)) continue;
120
120
  for (const runDir of findRunDirs(studio)) {
121
121
  const r = backfillRun(runDir, { apply: APPLY });
@@ -37,11 +37,24 @@
37
37
  // driver removes a conflict; it does not remove the check. That is the whole safety argument, and if
38
38
  // that arm is ever weakened this file becomes unsafe with it.
39
39
  //
40
- // ── WHAT IT STILL REFUSES ───────────────────────────────────────────────────────────────────────────
40
+ // ── WHAT IT STILL REFUSES, AND HOW IT REFUSES ───────────────────────────────────────────────────────
41
41
  //
42
42
  // Both sides changing the SAME file's counts to DIFFERENT values is a real disagreement — two branches
43
43
  // edited one test file — and it exits non-zero so git raises the conflict a human should see. A driver
44
44
  // that silently picked a side there would be the "matches neither tree" failure in a new costume.
45
+ //
46
+ // AND IT WRITES MARKERS, BECAUSE EXITING WITHOUT WRITING IS NOT A REFUSAL. Until 2026-09-18 the refusal
47
+ // path wrote nothing at all, which leaves `%A` holding the OURS side untouched: a file with no conflict
48
+ // markers that parses as valid JSON, beside git's own "CONFLICT (content)" line. Both cheap checks a
49
+ // person runs on a conflicted file — does it have markers, does it parse — passed, so it was staged
50
+ // blind, and every entry the union had taken from THEIRS went with it. Measured that day on a real
51
+ // merge: the result was byte-identical to ours, and one test file's entry was simply gone. A census that
52
+ // under-counts is a population floor reading low, which is the one failure the census exists to prevent.
53
+ //
54
+ // So a refusal now writes a file that CANNOT be staged blind: real markers at column 0, the named
55
+ // disagreements above them, the UNION on the ours side — so a reader who resolves by taking it keeps
56
+ // every entry this driver could merge — and theirs whole on the other. It parses as nothing, which is
57
+ // the honest direction for a file nobody has resolved yet.
45
58
 
46
59
  import { readFileSync, writeFileSync } from "node:fs";
47
60
 
@@ -139,9 +152,27 @@ const merged = {
139
152
  };
140
153
 
141
154
  if (conflicts.length) {
155
+ const named = conflicts.slice(0, 10).map((c) => ` ${c}`);
156
+ const more = conflicts.length > named.length ? [` …and ${conflicts.length - named.length} more`] : [];
142
157
  console.error(`census-merge-driver: ${conflicts.length} entr(y/ies) changed on BOTH sides — not ours to pick:`);
143
- for (const c of conflicts.slice(0, 10)) console.error(` ${c}`);
158
+ for (const line of [...named, ...more]) console.error(line);
144
159
  console.error(" Resolve by hand, or re-run `node scripts/mint-suite-census.mjs --apply` on the merged tree.");
160
+
161
+ // THE UNION RIDES THE OURS SIDE, and that is the point of writing at all. Everything this driver COULD
162
+ // merge is in it — including every entry that exists only on theirs — so resolving by taking this side
163
+ // loses nothing but the disagreements, which are named above it and are the only thing a human is
164
+ // being asked about.
165
+ writeFileSync(oursPath, [
166
+ `census-merge-driver: ${conflicts.length} entr(y/ies) changed on BOTH sides and were NOT merged.`,
167
+ ...named, ...more,
168
+ "Resolve, delete these lines and the markers, then run `node scripts/mint-suite-census.mjs --apply`.",
169
+ "<<<<<<< ours — the union of both sides, except the entries named above",
170
+ JSON.stringify(merged, null, 2),
171
+ "=======",
172
+ JSON.stringify(theirs, null, 2),
173
+ ">>>>>>> theirs",
174
+ "",
175
+ ].join("\n"));
145
176
  process.exit(1);
146
177
  }
147
178
 
@@ -0,0 +1,181 @@
1
+ #!/usr/bin/env node
2
+ // SPDX-License-Identifier: AGPL-3.0-only
3
+ // Copyright 2026 Cordillera Sarl. Additional terms under section 7 of the AGPL-3.0 apply — see ADDITIONAL-TERMS.md
4
+ // WHICH BARE CITATIONS COULD CARRY A SYMBOL, AND WHICH ONES DISAGREE WITH THEIR OWN PROSE.
5
+ //
6
+ // node scripts/citation-anchor-report.mjs the report
7
+ // node scripts/citation-anchor-report.mjs --list every row, not the first ten of each kind
8
+ //
9
+ // ── WHY THIS REPORTS AND DOES NOT REWRITE ────────────────────────────────────
10
+ //
11
+ // citation-line-check states its own blindness: it cannot see a citation pointing at the WRONG LIVE LINE
12
+ // while that line is real code, and it says the fix is to cite the symbol. The obvious next step is a
13
+ // codemod — the line is known and the file is known, so read the enclosing symbol out of the file and
14
+ // append it. Measured before building, that is the wrong move, and the measurement is worth keeping
15
+ // beside the thing it refutes.
16
+ //
17
+ // A codemod reads where the number CURRENTLY POINTS and writes that down. It cannot tell a citation that
18
+ // is right from one that has drifted, because both point somewhere, and appending the destination makes
19
+ // either self-consistent. Driven against the two citations a hand audit had already proved wrong:
20
+ //
21
+ // verify.mjs line 1539 would gain the name coverageEntryList — and the line is still
22
+ // `entries.push(e);` and the token it claims to site is minted three other places.
23
+ // stages.mjs line 844 would gain the name inputsForReference — and the line is still a comment,
24
+ // and that citation carries `[citation unverified]` today, which is a reader having
25
+ // doubted it. The rewrite strips the doubt and leaves a green tick.
26
+ //
27
+ // So the mechanism turns roughly a hundred unverifiable citations into verified-looking ones. This script
28
+ // produces the same information as a WORKLIST instead, and changes nothing.
29
+ //
30
+ // ── WHAT IT MEASURES, AND THE TWO RULES THAT ARE NOT OBVIOUS ────────────────
31
+ //
32
+ // THE ANCHOR IS THE TOP-LEVEL DECLARATION, NOT THE INNERMOST ONE. The innermost is the literal reading of
33
+ // "the symbol enclosing that line" and it is wrong here: measured over this tree it names a local or a
34
+ // one-to-three-letter binding 29 times — `e`, `hit`, `ob`, `one`, `out`, `known`, `unplaced` — against 2
35
+ // for the top-level rule, over the same 115 citations. An anchor nobody would have written by hand is not
36
+ // an anchor; it is the number wearing a name.
37
+ //
38
+ // A CITATION POINTING INSIDE A FUNCTION IT NAMES IS CORRECT, and the innermost rule reports it as a
39
+ // disagreement. Two of this report's first six "confirmed" rows were that bug rather than a bad citation:
40
+ // publish-inputs.mjs cites line 65 of mcp-server/lib/coverage.mjs and names `assertValidatorCoverage`, declared at
41
+ // 63, which is exactly right. The rule below is why they no longer appear.
42
+ import { readFileSync } from "node:fs";
43
+ import { join, dirname } from "node:path";
44
+ import { fileURLToPath } from "node:url";
45
+ import { corpusOf, citationsIn, indexByBasename } from "./citation-line-check.mjs";
46
+ import { isEntrypoint } from "../shared/is-entrypoint.mjs";
47
+
48
+ const ROOT = join(dirname(fileURLToPath(import.meta.url)), "..");
49
+
50
+ // The gate's own matchers, restated here rather than imported because they are not exported: keeping them
51
+ // identical is the property, so any drift between the two is a defect in this file and not in the gate.
52
+ const ANY_DECL = /^\s*(?:export\s+)?(?:default\s+)?(?:async\s+)?(?:function|const|let|var|class)\s+[A-Za-z_$]/;
53
+ const NAME_OF = /^\s*(?:export\s+)?(?:default\s+)?(?:async\s+)?(?:function|const|let|var|class)\s+([A-Za-z_$][\w$]*)/;
54
+ const INDENT_OF = (l) => (String(l).match(/^[ \t]*/) || [""])[0].length;
55
+
56
+ /**
57
+ * The top-level declaration whose span contains `line`, or null when the line is not in code at all.
58
+ * The span ends at the next top-level declaration, which is constructRange's sibling rule at indent 0.
59
+ * PURE.
60
+ */
61
+ export function topLevelAnchor(lines, line) {
62
+ let best = null;
63
+ for (let d = 0; d < lines.length; d++) {
64
+ if (!ANY_DECL.test(lines[d]) || INDENT_OF(lines[d]) !== 0) continue;
65
+ const name = (lines[d].match(NAME_OF) || [])[1];
66
+ if (!name) continue;
67
+ let end = d + 1;
68
+ while (end < lines.length && !(ANY_DECL.test(lines[end]) && INDENT_OF(lines[end]) === 0)) end++;
69
+ if (line >= d + 1 && line <= end && (!best || d + 1 > best.declaredAt)) best = { name, declaredAt: d + 1, end };
70
+ }
71
+ return best;
72
+ }
73
+
74
+ /**
75
+ * The identifiers the citing line CLAIMS, which is not every word in it. Only a backticked span or a name
76
+ * shaped like code counts — camelCase, SCREAMING_SNAKE, or a `$`/`_` in it.
77
+ *
78
+ * MEASURED, NOT ASSUMED: taking every word put "spec", "one", "model", "skills" and "config" forward as
79
+ * identifier claims, because each is also declared somewhere in some cited file, and the drifted count
80
+ * went from a handful to 120. A report whose worklist is mostly English words is a worklist nobody reads.
81
+ * Longest first, so a suffix never shadows its owner.
82
+ */
83
+ const namesIn = (sentence) => {
84
+ const s = String(sentence);
85
+ const ticked = [...s.matchAll(/`([A-Za-z_$][\w$]*)`/g)].map((m) => m[1]);
86
+ const shaped = [...s.matchAll(/[A-Za-z_$][\w$]{2,}/g)].map((m) => m[0])
87
+ .filter((n) => /[a-z][A-Z]/.test(n) || /^[A-Z0-9_]{3,}$/.test(n) || /[_$]/.test(n));
88
+ return [...new Set([...ticked, ...shaped])].sort((a, b) => b.length - a.length);
89
+ };
90
+
91
+ /**
92
+ * One row per bare citation whose target sits in code. PURE given its readers.
93
+ *
94
+ * `verdict` is one of:
95
+ * corroborated the citing prose names the anchor — safe to append, and the only class that is
96
+ * silent the prose names no identifier at all, so there is nothing to check the anchor against
97
+ * disagrees the prose names something else that is NOT declared in the cited file — weak evidence,
98
+ * because it is usually a caller or a type rather than a claim about this file
99
+ * drifted the prose names something DECLARED ELSEWHERE in the cited file, and the citation does
100
+ * not point inside it. That is the shape of a number that moved.
101
+ */
102
+ export function anchorRows(citations, linesOf) {
103
+ const rows = [];
104
+ for (const c of citations) {
105
+ if (c.symbols?.length) continue; // already checkable
106
+ const target = linesOf(c.path ?? c.cited);
107
+ if (!target) continue;
108
+ const anchor = topLevelAnchor(target, c.start);
109
+ if (!anchor) { rows.push({ ...c, verdict: "no-code", anchor: null }); continue; }
110
+ const prose = namesIn(linesOf(c.from)?.[c.atLine - 1] ?? "");
111
+ if (prose.includes(anchor.name)) { rows.push({ ...c, verdict: "corroborated", anchor }); continue; }
112
+ const others = prose.filter((n) => n !== anchor.name);
113
+ if (!others.length) { rows.push({ ...c, verdict: "silent", anchor }); continue; }
114
+ let elsewhere = null;
115
+ for (const n of others) {
116
+ const decl = new RegExp(`^\\s*(?:export\\s+)?(?:default\\s+)?(?:async\\s+)?(?:function|const|let|var|class)\\s+${n.replace(/\$/g, "\\$")}\\b`);
117
+ for (let i = 0; i < target.length; i++) {
118
+ if (!decl.test(target[i])) continue;
119
+ // THE CITATION POINTING INSIDE WHAT IT NAMES IS CORRECT, not drifted — see the header.
120
+ const span = topLevelAnchor(target, i + 1);
121
+ if (span && c.start >= span.declaredAt && c.start <= span.end) { elsewhere = null; i = target.length; n; break; }
122
+ elsewhere = { name: n, declaredAt: i + 1 };
123
+ break;
124
+ }
125
+ if (elsewhere) break;
126
+ }
127
+ rows.push({ ...c, verdict: elsewhere ? "drifted" : "disagrees", anchor, elsewhere });
128
+ }
129
+ return rows;
130
+ }
131
+
132
+ function main() {
133
+ const loaded = corpusOf(ROOT);
134
+ if (loaded == null) {
135
+ console.log("citation-anchor-report did not run — no tracked corpus. This is a SKIP, not a pass.");
136
+ process.exit(0);
137
+ }
138
+ const { files, corpus } = loaded;
139
+ const text = new Map(corpus.map((c) => [c.file, c.text.split("\n")]));
140
+ const linesOf = (p) => {
141
+ if (text.has(p)) return text.get(p);
142
+ try { const l = readFileSync(join(ROOT, p), "utf8").split("\n"); text.set(p, l); return l; } catch { return null; }
143
+ };
144
+ const citations = citationsIn(corpus, indexByBasename(files));
145
+ const bare = citations.filter((c) => !c.symbols?.length);
146
+ const rows = anchorRows(citations, linesOf);
147
+ const by = (v) => rows.filter((r) => r.verdict === v);
148
+ const all = process.argv.includes("--list");
149
+ const show = (v) => (all ? by(v) : by(v).slice(0, 10));
150
+
151
+ console.log(`citation-anchor-report — ${citations.length} citation(s), ${citations.length - bare.length} already name a symbol, ${bare.length} BARE`);
152
+ console.log(` ${by("no-code").length} of the bare point at prose, comments, skill documents or data — there is NO symbol to cite,`);
153
+ console.log(" and the gate's own advice cannot be followed on them. That is a guard-design question, not a chore.");
154
+ console.log(` ${rows.length - by("no-code").length} point at a line inside code:`);
155
+ console.log(` corroborated ${by("corroborated").length} the citing prose names the enclosing symbol — appending it is safe`);
156
+ console.log(` drifted ${by("drifted").length} the prose names something declared ELSEWHERE in the cited file`);
157
+ console.log(` disagrees ${by("disagrees").length} the prose names something not declared there — usually a caller, weak evidence`);
158
+ console.log(` silent ${by("silent").length} the prose names nothing to check the anchor against`);
159
+
160
+ if (by("drifted").length) {
161
+ console.log("\nDRIFTED — read these first. Each names a symbol this file declares somewhere the citation does not point:");
162
+ for (const r of show("drifted")) {
163
+ console.log(` ${r.from}:${r.atLine}`);
164
+ console.log(` cites ${r.cited}:${r.start}, which is inside ${r.anchor.name}`);
165
+ console.log(` names ${r.elsewhere.name}, declared at ${r.elsewhere.declaredAt}`);
166
+ }
167
+ }
168
+ if (by("corroborated").length) {
169
+ console.log("\nCORROBORATED — the anchor can be appended by hand with the prose as the check:");
170
+ for (const r of show("corroborated")) console.log(` ${r.from}:${r.atLine} ${r.cited}:${r.start} → ${r.anchor.name}`);
171
+ }
172
+ if (!all && (by("drifted").length > 10 || by("corroborated").length > 10)) console.log("\n (--list for every row)");
173
+
174
+ // A REPORT EXITS 0. Its `drifted` class is evidence for a person, not a decidable defect: the prose can
175
+ // name a symbol for a reason the citation is not about, and this file's own header records two rows it
176
+ // got wrong before the rule was fixed. A worklist that fails a build teaches people to silence it.
177
+ console.log("\nThis is a worklist, not a gate — it exits 0 on every finding. citation-line-check is the gate.");
178
+ process.exit(0);
179
+ }
180
+
181
+ if (isEntrypoint(import.meta.url)) main();
@@ -47,7 +47,7 @@ export const GUARD = "dead-names (#853)";
47
47
  // agent identities for individuals at this firm, shipped in an operator script's default roster, in a
48
48
  // systemd unit watching their queues, and in two test fixtures. That out-of-scope note ruled `alex`
49
49
  // and `sam` INVENTED, which is why those stay; these two were never covered by that ruling.
50
- export const DEAD_NAME_RE = /lorestar|lore_(pool|url|flags)|prelim_lore|lorectl|loreurl|lorecontrols|trademark-lore|\blore\b|\baughra|clawdi-(lisa|lidia)/i;
50
+ export const DEAD_NAME_RE = /lorestar|lore_(pool|url|flags)|clearance_lore|lorectl|loreurl|lorecontrols|trademark-lore|\blore\b|\baughra|clawdi-(lisa|lidia)/i;
51
51
 
52
52
  /**
53
53
  * Where a true match is somebody else's word rather than our dead name. NAMED, with the reason, and
@@ -78,6 +78,43 @@ function published() {
78
78
  return Array.isArray(versions) ? versions : [versions];
79
79
  }
80
80
 
81
+ /** How long to keep asking the registry to show a write it has already accepted. */
82
+ export const CONFIRM_BUDGET_MS = 90_000;
83
+ const CONFIRM_STEP_MS = 3_000;
84
+
85
+ /**
86
+ * Confirm writes the registry has ACCEPTED but may not be serving yet.
87
+ *
88
+ * WHY THIS IS A SECOND PASS AND NOT A READ AFTER EACH WRITE. `npm deprecate` returns when the registry
89
+ * has taken the write, not when every reader can see it. Reading back one line later asks a question
90
+ * the registry has not finished answering, and on 2026-09-17 that reported ELEVEN successful
91
+ * deprecations as failures — run 35219705516, where the messages were all in place minutes later and
92
+ * the job had already exited 1. The outside read was the truth and the job's red was not evidence.
93
+ *
94
+ * SO THE WRITES GO FIRST AND THE READING COMES AFTER, which also collapses a per-version read into one
95
+ * pass over what is left. Anything still unseen is asked for again until the budget runs out.
96
+ *
97
+ * A WRITE THAT WAS ACCEPTED AND IS NOT YET VISIBLE IS NOT A FAILED WRITE, and the caller is told so in
98
+ * those words rather than having the two folded together. `npm deprecate` exiting 0 is what says the
99
+ * write happened; this pass says whether the registry is serving it yet.
100
+ */
101
+ export async function confirmWrites({ versions, read, sleep, now = () => Date.now(),
102
+ budgetMs = CONFIRM_BUDGET_MS, stepMs = CONFIRM_STEP_MS }) {
103
+ const pending = new Set(versions);
104
+ const confirmed = [];
105
+ const started = now();
106
+ while (pending.size) {
107
+ for (const v of [...pending]) {
108
+ let msg = null;
109
+ try { msg = read(v); } catch { msg = null; } // a read that threw is asked again, not judged
110
+ if (msg) { confirmed.push(v); pending.delete(v); }
111
+ }
112
+ if (!pending.size || now() - started >= budgetMs) break;
113
+ await sleep(stepMs);
114
+ }
115
+ return { confirmed, unconfirmed: [...pending], waitedMs: now() - started };
116
+ }
117
+
81
118
  /** One version's deprecation message, read by its own exact spec. Null when it carries none. */
82
119
  function deprecationOf(version) {
83
120
  // `--json` on a field that is absent prints nothing at all, which JSON.parse refuses. An empty read is
@@ -89,7 +126,7 @@ function deprecationOf(version) {
89
126
  return typeof v === "string" && v.trim() ? v : null;
90
127
  }
91
128
 
92
- function main() {
129
+ async function main() {
93
130
  const argv = process.argv.slice(2);
94
131
  const at = (flag) => { const i = argv.indexOf(flag); return i >= 0 ? argv[i + 1] : null; };
95
132
  const DRY = argv.includes("--dry-run");
@@ -115,6 +152,7 @@ function main() {
115
152
  console.log(`deprecate-below: ${all.length} published version(s); ${candidates.length} below ${below}.`);
116
153
 
117
154
  let done = 0, skipped = 0, failed = 0;
155
+ const wrote = [];
118
156
  for (const v of candidates) {
119
157
  let existing;
120
158
  try { existing = deprecationOf(v); }
@@ -132,15 +170,35 @@ function main() {
132
170
  failed += 1;
133
171
  continue;
134
172
  }
135
- // READ BACK, PER VERSION, and report what the registry says rather than that the command exited 0.
136
- let now = null;
137
- try { now = deprecationOf(v); } catch { now = null; }
138
- if (now) { console.log(` ${v}: deprecated — registry reads back "${now.slice(0, 60)}"`); done += 1; }
139
- else { console.error(` ${v}: deprecate reported success and the registry reads back NOTHING.`); failed += 1; }
173
+ // ACCEPTED. Whether the registry is SERVING it yet is asked once, after every write, below.
174
+ console.log(` ${v}: deprecate accepted`);
175
+ wrote.push(v);
176
+ }
177
+
178
+ // ── NOW ASK WHETHER THE REGISTRY IS SERVING THEM ────────────────────────────────────────────────
179
+ let confirmed = [], unconfirmed = [], waitedMs = 0;
180
+ if (wrote.length && !DRY) {
181
+ ({ confirmed, unconfirmed, waitedMs } = await confirmWrites({
182
+ versions: wrote,
183
+ read: (v) => deprecationOf(v),
184
+ sleep: (ms) => new Promise((r) => setTimeout(r, ms)),
185
+ }));
186
+ done += confirmed.length;
187
+ for (const v of confirmed) console.log(` ${v}: serving the message`);
188
+ for (const v of unconfirmed) {
189
+ console.error(` ${v}: WRITE ACCEPTED, not yet served after ${Math.round(waitedMs / 1000)}s — `
190
+ + "the registry took it and is not showing it yet. Read the version from outside before treating "
191
+ + "this as undone; re-running is safe and will report it as already deprecated.");
192
+ }
193
+ } else if (DRY) {
194
+ done += wrote.length;
140
195
  }
141
196
 
142
- console.log(`deprecate-below: ${done} deprecated, ${skipped} already carried a message, ${failed} failed.`);
197
+ console.log(`deprecate-below: ${done} deprecated, ${skipped} already carried a message, `
198
+ + `${unconfirmed.length} accepted but not yet served, ${failed} failed.`);
199
+ // ONLY A FAILED WRITE IS A FAILURE. A write the registry accepted and has not caught up on is not a
200
+ // release gone wrong, and exiting 1 for it is what turned eleven successful deprecations into a red.
143
201
  return failed ? 1 : 0;
144
202
  }
145
203
 
146
- if (process.argv[1] && process.argv[1].endsWith("deprecate-below.mjs")) process.exit(main());
204
+ if (process.argv[1] && process.argv[1].endsWith("deprecate-below.mjs")) process.exit(await main());
@@ -108,7 +108,7 @@ export function backlogFinding(backlog, dir, stuckAfterSec = STUCK_AFTER_SEC) {
108
108
  * The queue directories a `.path` unit actually watches, with `%h` resolved.
109
109
  *
110
110
  * COMMENTED LINES ARE NOT WATCHES, and that matters here more than usual: the headless glob
111
- * (`%h/prelim-queue/*.json`) ships commented out, so a standalone deployment that never uncommented it
111
+ * (`%h/clearance-queue/*.json`) ships commented out, so a standalone deployment that never uncommented it
112
112
  * has event-driven pickup dead on the one queue it uses. Reading the file without honouring `#` would
113
113
  * report that box as watched.
114
114
  *