clearotron 0.4.0-beta.1 → 0.4.0-beta.2

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 (250) hide show
  1. package/INSTALL.md +4 -31
  2. package/README.md +5 -5
  3. package/bin/brandowner.mjs +1 -1
  4. package/bin/onboard.mjs +18 -74
  5. package/bin/start.mjs +58 -14
  6. package/bin/update.mjs +4 -1
  7. package/build-info.json +2 -2
  8. package/demo/full-country-search/run/_driver/search-policy.json +1 -1
  9. package/demo/full-country-search/run/status.json +1 -1
  10. package/demo/global-preliminary-search/run/_driver/search-policy.json +1 -1
  11. package/demo/global-preliminary-search/run/status.json +1 -1
  12. package/demo/knockout-search/run/_driver/search-policy.json +1 -1
  13. package/demo/knockout-search/run/status.json +1 -1
  14. package/demo/multi-country-focus-search/run/_driver/search-policy.json +1 -1
  15. package/demo/multi-country-focus-search/run/status.json +1 -1
  16. package/docs/README.md +1 -0
  17. package/docs/SECURITY-OWASP.md +298 -0
  18. package/docs/SECURITY.md +18 -13
  19. package/docs/architecture/02-architecture.md +5 -5
  20. package/docs/architecture/03-run-lifecycle.md +28 -29
  21. package/docs/architecture/04-configuration-reference.md +3 -4
  22. package/docs/architecture/05-config-governance.md +5 -7
  23. package/docs/architecture/06-operations-runbook.md +0 -12
  24. package/docs/architecture/07-quality-and-audit.md +12 -13
  25. package/docs/architecture/09-security-and-data.md +9 -4
  26. package/driver/CHANGELOG.md +60 -0
  27. package/driver/ask-ledger.mjs +2 -0
  28. package/driver/authority-trees.mjs +90 -8
  29. package/driver/band-shape.mjs +5 -5
  30. package/driver/case-law-ledger.mjs +19 -3
  31. package/driver/claim-liveness.mjs +30 -1
  32. package/driver/clearance-variants-record.mjs +10 -3
  33. package/driver/common-law-receipts.mjs +62 -12
  34. package/driver/commonlaw-carry.mjs +1 -1
  35. package/driver/compare.mjs +2 -2
  36. package/driver/connotation-search.mjs +30 -101
  37. package/driver/contract-arm2-baseline.json +6 -5
  38. package/driver/contract-e3-backlog.mjs +60 -60
  39. package/driver/contract-vocabulary.mjs +38 -31
  40. package/driver/coverage-form-io.mjs +13 -1
  41. package/driver/coverage-form.mjs +42 -4
  42. package/driver/coverage-ledger.mjs +3 -5
  43. package/driver/cross-check-wait.mjs +43 -0
  44. package/driver/declination-call.mjs +33 -9
  45. package/driver/declination-tool.mjs +27 -15
  46. package/driver/deferral-row.mjs +139 -0
  47. package/driver/degraded-parts.mjs +377 -0
  48. package/driver/deliver-trigger.sh +1 -1
  49. package/driver/demo-container.mjs +4 -1
  50. package/driver/dev-portal.mjs +4 -4
  51. package/driver/dispatch-record.mjs +2 -2
  52. package/driver/driver.config.mjs +177 -87
  53. package/driver/effective-scope.mjs +58 -2
  54. package/driver/engine/anthropic-agent.mjs +103 -27
  55. package/driver/engine/auth.mjs +2 -2
  56. package/driver/engine/child-record.mjs +11 -0
  57. package/driver/engine/cli-version.mjs +4 -3
  58. package/driver/engine/common.mjs +17 -2
  59. package/driver/engine/deny-authority-write.mjs +3 -1
  60. package/driver/engine/engine-env.mjs +174 -0
  61. package/driver/engine/engine-spawn.mjs +179 -0
  62. package/driver/engine/mcp/band-server.mjs +1 -1
  63. package/driver/engine/mcp/codex-config.mjs +112 -3
  64. package/driver/engine/mcp/declination-server.mjs +9 -8
  65. package/driver/engine/mcp/dispositions-server.mjs +2 -1
  66. package/driver/engine/mcp/fetch-server.mjs +14 -11
  67. package/driver/engine/mcp/gather-config.mjs +22 -10
  68. package/driver/engine/mcp/perplexity-server.mjs +96 -16
  69. package/driver/engine/mcp/probe-server.mjs +43 -21
  70. package/driver/engine/mcp/public-fetch.mjs +195 -0
  71. package/driver/engine/mcp/recording-server.mjs +78 -13
  72. package/driver/engine/mcp/supplemental.mjs +13 -13
  73. package/driver/engine/mcp/unit-note-server.mjs +50 -1
  74. package/driver/engine/openai-agent.mjs +109 -28
  75. package/driver/engine/probe.mjs +152 -19
  76. package/driver/enqueue-schema.mjs +3 -3
  77. package/driver/findings-model.mjs +64 -13
  78. package/driver/floor-duty.mjs +21 -5
  79. package/driver/form-neighbourhood.mjs +5 -2
  80. package/driver/framework-method.mjs +304 -0
  81. package/driver/gateway.mjs +44 -16
  82. package/driver/hand-off-exits.mjs +129 -0
  83. package/driver/jx.mjs +8 -5
  84. package/driver/knockout-assess-record.mjs +7 -1
  85. package/driver/knockout-frame-record.mjs +96 -34
  86. package/driver/knockout-review-record.mjs +7 -2
  87. package/driver/matter-frame-record.mjs +31 -2
  88. package/driver/methodology-witness.mjs +6 -3
  89. package/driver/named-band.mjs +1 -1
  90. package/driver/owner-use-check.mjs +45 -10
  91. package/driver/package.json +1 -1
  92. package/driver/partial-payload-baseline.json +4 -0
  93. package/driver/phase0.mjs +2 -2
  94. package/driver/pipeline-knockout.mjs +128 -35
  95. package/driver/pipeline.mjs +373 -598
  96. package/driver/placement-carry.mjs +20 -1
  97. package/driver/placement-form-io.mjs +18 -4
  98. package/driver/placement-form.mjs +52 -3
  99. package/driver/placement-union.mjs +32 -5
  100. package/driver/portal-report.mjs +3 -3
  101. package/driver/portal-service.mjs +4 -3
  102. package/driver/portal-static.mjs +11 -18
  103. package/driver/profile-page.html +5 -5
  104. package/driver/profiles.mjs +5 -5
  105. package/driver/progress.mjs +5 -3
  106. package/driver/provider-usage.mjs +1 -1
  107. package/driver/publish/index.mjs +94 -22
  108. package/driver/publish/knockout.mjs +133 -12
  109. package/driver/publish/profiles-page.mjs +2 -0
  110. package/driver/publish/publish-inputs.mjs +10 -4
  111. package/driver/publish/render-knockout.mjs +47 -20
  112. package/driver/publish/render.mjs +12 -7
  113. package/driver/publish/report-data.mjs +2 -0
  114. package/driver/publish/search-depth.mjs +77 -4
  115. package/driver/publish/templates/report.css +1 -1
  116. package/driver/publish/xlsx.mjs +34 -15
  117. package/driver/reasoning-tripwires.mjs +2 -160
  118. package/driver/recall-receipt.mjs +27 -0
  119. package/driver/record-carry.mjs +8 -8
  120. package/driver/reference-score.mjs +142 -20
  121. package/driver/register-count.mjs +5 -3
  122. package/driver/register-digest-record.mjs +13 -0
  123. package/driver/register-plan.mjs +156 -75
  124. package/driver/register-records.mjs +27 -3
  125. package/driver/registration-scripts.mjs +22 -0
  126. package/driver/repair-composers.mjs +19 -3
  127. package/driver/repair-contract.mjs +1 -1
  128. package/driver/repairs.mjs +5 -2
  129. package/driver/reviewer-open-points.mjs +33 -0
  130. package/driver/rule-shape.mjs +8 -8
  131. package/driver/run-economics.mjs +2 -2
  132. package/driver/run-integrity.mjs +2 -2
  133. package/driver/runner.mjs +17 -5
  134. package/driver/scope-facts.mjs +89 -12
  135. package/driver/score-redaction.mjs +247 -0
  136. package/driver/search-policy.mjs +17 -0
  137. package/driver/skills/README.md +2 -2
  138. package/driver/skills/clearance-common-law/SKILL.md +29 -36
  139. package/driver/skills/clearance-common-law/perplexity-prompts.md +5 -27
  140. package/driver/skills/clearance-register/SKILL.md +15 -16
  141. package/driver/skills/clearance-register/digest.md +14 -30
  142. package/driver/skills/clearance-register/register-recipes.md +14 -56
  143. package/driver/skills/clearance-register/unit.md +36 -47
  144. package/driver/skills/clearance-search/SKILL.md +6 -8
  145. package/driver/skills/clearance-search/delivery-contract.md +0 -7
  146. package/driver/skills/clearance-search/firm-wide-reasoning.md +5 -4
  147. package/driver/skills/clearance-search/phase2-execution.md +5 -6
  148. package/driver/skills/clearance-search/risk-framework-triage.md +2 -2
  149. package/driver/skills/clearance-search/synthesis-rules.md +5 -5
  150. package/driver/skills/clearance-search/template-formatting.md +2 -2
  151. package/driver/skills/clearance-variants/SKILL.md +6 -6
  152. package/driver/skills/clearance-variants/transliteration-scripts.md +1 -1
  153. package/driver/skills/knockout-assess/SKILL.md +31 -1
  154. package/driver/skills/knockout-frame/SKILL.md +42 -10
  155. package/driver/skills/matter-frame/SKILL.md +12 -5
  156. package/driver/skills/narrative-refutation/SKILL.md +3 -1
  157. package/driver/skills/placement-inquiry/SKILL.md +4 -0
  158. package/driver/stage-context.mjs +23 -2
  159. package/driver/stages-knockout.mjs +92 -79
  160. package/driver/stages.mjs +88 -53
  161. package/driver/stray-artifacts.mjs +9 -5
  162. package/driver/suite-census.json +620 -140
  163. package/driver/synthesis-record.mjs +26 -4
  164. package/driver/systemd/README.md +5 -6
  165. package/driver/systemd/clearotron-worker.service +6 -1
  166. package/driver/tokens.mjs +2 -2
  167. package/driver/unit-inventory.mjs +21 -40
  168. package/driver/variant-manifest-model.mjs +21 -2
  169. package/driver/verify-knockout.mjs +163 -4
  170. package/driver/verify.mjs +40 -14
  171. package/driver/web-grid.mjs +150 -0
  172. package/driver/withheld-families.mjs +113 -4
  173. package/driver/worker-heartbeat.mjs +13 -2
  174. package/mcp-server/CHANGELOG.md +4 -0
  175. package/mcp-server/README.md +1 -1
  176. package/mcp-server/lib/knockout.mjs +1 -1
  177. package/mcp-server/lib/ops.mjs +10 -10
  178. package/mcp-server/lib/scrub.mjs +1 -1
  179. package/mcp-server/lib/trace.mjs +3 -2
  180. package/mcp-server/package.json +1 -1
  181. package/mcp-server/server.mjs +2 -2
  182. package/package.json +1 -1
  183. package/portal-ui/dist/assets/{index-D_O_55vK.js → index-BPAUjcI0.js} +1 -1
  184. package/portal-ui/dist/assets/{index-5CCwiJG7.css → index-D2wrw9cH.css} +18 -2
  185. package/portal-ui/dist/assets/plus-jakarta-sans-BUCHxqJ-.woff2 +0 -0
  186. package/portal-ui/dist/index.html +2 -15
  187. package/portal-ui/package.json +1 -1
  188. package/providers/_shared/enumerate.mjs +155 -50
  189. package/providers/_shared/execute-plan.mjs +62 -16
  190. package/providers/_shared/ledger.mjs +1 -1
  191. package/providers/_shared/plan-guards.mjs +7 -0
  192. package/providers/clarivate/src/core.js +54 -6
  193. package/providers/corsearch/README.md +1 -2
  194. package/providers/corsearch/src/core.js +2 -2
  195. package/providers/oauth-mcp-bridge/CHANGELOG.md +4 -0
  196. package/providers/oauth-mcp-bridge/README.md +8 -7
  197. package/providers/oauth-mcp-bridge/bridge.mjs +21 -5
  198. package/providers/oauth-mcp-bridge/package.json +1 -1
  199. package/providers/oauth-mcp-bridge/systemd/courtlistener-mcp.service +1 -1
  200. package/providers/oauth-mcp-bridge/warm-server.mjs +16 -5
  201. package/providers/perplexity/src/core.js +246 -21
  202. package/providers/signa/src/core.js +27 -25
  203. package/scripts/README.md +2 -1
  204. package/scripts/added-reference-check.mjs +2 -1
  205. package/scripts/backup-recall-stores.mjs +5 -5
  206. package/scripts/demo-evidence.mjs +2 -1
  207. package/scripts/drive-env-check.mjs +2 -1
  208. package/scripts/e2e-first-time.mjs +469 -0
  209. package/scripts/e2e-scenario-ops.mjs +441 -0
  210. package/scripts/e2e.mjs +104 -16
  211. package/scripts/env-audit.mjs +8 -0
  212. package/scripts/freeze-example-run.mjs +7 -7
  213. package/scripts/generated-files-are-current.mjs +2 -1
  214. package/scripts/hand-off-exits-probe.mjs +75 -0
  215. package/scripts/import-cycle-check.mjs +2 -1
  216. package/scripts/merge-shape-check.mjs +2 -1
  217. package/scripts/package-size-budget.mjs +2 -1
  218. package/scripts/record-carry-probe.mjs +1 -1
  219. package/scripts/release-install-check.mjs +4 -1
  220. package/scripts/release-note-required.mjs +44 -10
  221. package/scripts/render-check.mjs +5 -2
  222. package/scripts/report-offline-render-check.mjs +169 -0
  223. package/scripts/score.mjs +76 -5
  224. package/scripts/strip-titles-and-attributions.mjs +2 -1
  225. package/scripts/strip-tracker-citations.mjs +2 -1
  226. package/scripts/test-full.mjs +2 -1
  227. package/scripts/test-run.mjs +10 -3
  228. package/scripts/third-party-notices.mjs +3 -1
  229. package/scripts/travelling-predicates.mjs +1 -1
  230. package/scripts/writing-standard-check.mjs +2 -1
  231. package/shared/brand-fonts.mjs +59 -0
  232. package/shared/brand.mjs +5 -5
  233. package/shared/browser-temp-root.mjs +3 -2
  234. package/shared/doctrine-overlay.mjs +1 -1
  235. package/shared/driver-dir.mjs +27 -2
  236. package/shared/fonts/OFL-fira-code.txt +93 -0
  237. package/shared/fonts/OFL-plus-jakarta-sans.txt +93 -0
  238. package/shared/fonts/README.md +37 -0
  239. package/shared/fonts/fira-code.woff2 +0 -0
  240. package/shared/fonts/plus-jakarta-sans.woff2 +0 -0
  241. package/shared/npm-cli.mjs +23 -0
  242. package/shared/os-advice.mjs +14 -2
  243. package/shared/path-seps.mjs +42 -0
  244. package/shared/process-table.mjs +71 -3
  245. package/shared/reference-guard-classes.mjs +30 -6
  246. package/shared/root-doc-commands.mjs +6 -2
  247. package/shared/running-start.mjs +24 -7
  248. package/shared/scope.mjs +2 -2
  249. package/shared/wsl.mjs +1 -1
  250. package/driver/known-conflicts.mjs +0 -345
@@ -110,18 +110,6 @@ and the environment file holding the secrets.
110
110
  - **A scheduled gateway restart** (chat-channel session and plugin-loader hygiene) should *defer*
111
111
  off live work rather than skip: wait while a run is live, up to a bound, then restart anyway.
112
112
  `XDG_RUNTIME_DIR` must be set for `systemctl --user` to work from cron.
113
- - **Pin the agent id before upgrading an install made before 0.2.2.** The default agent id changed
114
- from `clawdi` to `localagent`, and that id is a path segment: runs live under
115
- `<workspaceRoot>/workspace-<agent>/studio/clearance-search/`. An install that never set one starts
116
- reading an empty workspace, and empty reads as "no runs" rather than as an error. Set **both**
117
- variables in the environment file — the gather servers read their own:
118
-
119
- ```
120
- CLEAROTRON_DEFAULT_AGENT=clawdi
121
- CLEAROTRON_GATHER_AGENT=clawdi
122
- ```
123
-
124
- An install that already sets them is unaffected, and a fresh install needs neither.
125
113
  - **Integrator-platform upgrades** go through that platform's own guarded upgrade path — dry-run,
126
114
  exact-version confirm, stop → update → start. Never `npm i -g` / `pnpm add -g` directly.
127
115
 
@@ -92,9 +92,15 @@ under-grading ships a wrong CLEAR. The reviewer's context includes code-computed
92
92
  and a mandatory plan-execution check. CONDITIONAL/BLOCKING triggers corrective re-synthesis, a
93
93
  freshness gate proving the named corrections actually reached `findings.json`, and a warm verdict
94
94
  re-check that can only fall back to the *entry* verdict. A terminal BLOCKING first gets the
95
- degenerate-artifact refusal (a "BLOCKING" with zero cited defects is re-asked fresh, once), then
96
- fails the run — a report the reviewer won't stand behind never ships looking finished
97
- ("delivered with open questions" is retired). The reviewer's verdict is treated as documented-noisy;
95
+ degenerate-artifact refusal (a "BLOCKING" with zero cited defects is re-asked fresh, once), and
96
+ then the run **delivers**. Ruling 2026-08-26, verbatim: "Deliver always, with open points printed.
97
+ The refusal on a blocking review goes." The corrective ladder is still the fix arm and still runs
98
+ first; what changed is only what happens once it is exhausted. "Never ships" and "never ships
99
+ *looking finished*" were treated as one requirement and are two — only the second was ever
100
+ load-bearing, and it is met by the open-points section being code-built from the review. Ruling
101
+ 2026-09-24 settled where that section goes: reviewer notes never reach the client page, and a
102
+ report the reviewer still refuses ships with its rating and nothing added, the points recorded
103
+ beside the review for the reviewing lawyer. The reviewer's verdict is treated as documented-noisy;
98
104
  that is precisely why every *honesty* signal below derives from ledgers and receipts, not review
99
105
  prose.
100
106
 
@@ -138,16 +144,10 @@ What makes the coverage statement trustworthy:
138
144
  - **The clamps** (`applyCoverageFloor`) only ever raise CLEAR → CONDITIONAL, never lower, never
139
145
  manufacture BLOCKING: live condition actions, the lawyer's own `coverage_judgment.sufficient:
140
146
  false` (sufficiency is judgment's call — code only carries it), frame residuals, register gaps
141
- (deferred rows ∪ taint axes ∪ material recall regressions — independent of the model's
142
- self-report), and in-window deadlines delivered without their dates. The verdict sidecar
147
+ (deferred rows ∪ taint axes — independent of the model's self-report). The verdict sidecar
143
148
  (`_driver/verdict.json`) is then the single label authority for every surface; one shared
144
149
  predicate keeps the validator and the auto-correct judging "unconditional proceed" with the same
145
150
  eyes.
146
- - **Recall memory**: confirmed conflicts persist per mark in a workspace store
147
- (`_known-conflicts/`, rows are only added — never deleted or rewritten, save the machine-provenance
148
- `terminal` a delivered run may upgrade — human edits always win, non-Latin marks handled); the next run
149
- folds them in as deterministic plan probes, and a prior-confirmed conflict that neither resurfaces
150
- nor gets a recorded justification is a *material regression* that clamps.
151
151
 
152
152
  Register budgets deserve one honest sentence: the per-worker call budgets in the skill prose
153
153
  (enumerates, phoneme ≤ 5, image ≤ 10) are **observed, prompt-level budgets, not code-enforced
@@ -194,9 +194,8 @@ pass over inherited artifacts is visible as such.
194
194
  `reasoning-tripwires.mjs` is the mechanical net under principles held holistically in the skills:
195
195
  recall floor (a live identical registration dropped in the noise rows but not carried), seed
196
196
  neutrality (upstream artifacts must state facts, never grades), probative grading, matrix ceilings
197
- (legacy scale only), status honesty (clean headline over material gaps), deadline urgency and
198
- deadline carry, unresolved disagreements, orphan register findings, uncross-checked demotions,
199
- recall regression. All of it lands in `_driver/reasoning-integrity.json` and surfaces on the audit
197
+ (legacy scale only), status honesty (clean headline over material gaps), deadline urgency,
198
+ unresolved disagreements, orphan register findings, uncross-checked demotions. All of it lands in `_driver/reasoning-integrity.json` and surfaces on the audit
200
199
  workbook and quality pages — never as a banner on the rendered report.
201
200
  Two of these pure functions *also* feed the verdict clamps (register gap, deadline gap) — but via
202
201
  the clamp path on ledger facts, never as a tripwire "failure". `rule-shape.mjs` (rating decided by
@@ -3,10 +3,15 @@
3
3
  > Part of the architecture pack (`docs/architecture/`). The driver's module tree and the headless
4
4
  > integrator contract are in [`driver/README.md`](../../driver/README.md).
5
5
 
6
- The security posture in one sentence: **nothing listens on the network except loopback services
7
- behind an authenticated tunnel; agents that touch the outside world cannot execute anything; the
8
- process that executes everything is not an agent; and secrets reach exactly the processes that need
9
- them, by inheritance, never by file-copy into configs.**
6
+ The security posture in brief: **nothing listens on the network except loopback services behind an
7
+ authenticated tunnel. A stage on Claude is offered no tool that runs a command. A stage on Codex runs its
8
+ commands inside Codex's own sandbox, or with the permissions of the account Clearotron runs as where a
9
+ machine sets the bypass. The process that runs everything else is ordinary code, not an agent. Each
10
+ stage's AI program starts with a named list of settings, and the key that signs access keys is handed
11
+ only to the portal and the two doors. On Claude, a stage's file tools can read nothing outside its run folder, its
12
+ instruction folders and any folder the machine's own Claude settings add. On Codex with its sandbox on,
13
+ a stage's commands can read nothing outside its run folder, its instruction folders, the temporary
14
+ folders, and the system and program files a command needs to run.**
10
15
 
11
16
  ## Trust boundaries
12
17
 
@@ -1,5 +1,65 @@
1
1
  # clearotron-driver
2
2
 
3
+ ## 0.4.0-beta.2
4
+
5
+ ### Minor Changes
6
+
7
+ - New: A knockout search now also asks whether each name is already in use in the client's field, such as a game character or a drink.
8
+ - New: Searching by judgment.
9
+
10
+ This release changes how a clearance decides what to search. Until now the engine followed fixed rules: a set number of spellings, a fixed list of stores, a stop after the first identical mark. Fixed rules are fast and cheap, but they miss things. On a recent matter, a lawyer's review found marks the engine had counted but never read, and others it had raised that did not matter.
11
+
12
+ So the approach changes. Wherever the engine holds a pile of results, it now looks at what is there and decides what a lawyer would raise. It carries that forward and writes down what it set aside, and why. Nothing is dropped silently. Every wider search, every spelling set aside and every store left out appears in the audit workbook with its reason.
13
+
14
+ What you will notice:
15
+ - Searches widen where it could change the advice, and narrow where it cannot.
16
+ - Near spellings no buyer would confuse with the mark are set aside, with the reason recorded.
17
+ - Searches in other scripts run only in markets that file marks in that script.
18
+ - A large company's register is read for the marks that share the searched name. The rest is counted, not fetched.
19
+ - Marketplace searches cover the stores that sell the client's goods, and say which stores were left out and why.
20
+ - The report no longer opens with the internal reviewer's notes. They reach the reviewing lawyer separately.
21
+ - A large marketplace search no longer fails because its results were too big to return in one piece.
22
+ - Where a company's framework rates through named inputs, the report shows them beside each band.
23
+
24
+ A word on regressions. While testing this line we found two. A crowded search failed before delivering its report, and a knockout's ratings moved one step away from the lawyer's. Both were caught by running the same matters against a lawyer's answers, and both are fixed here. Every build is now tested that way before it ships. We are tuning for three things at once: a report that is right the first time, delivered fast, at a sensible cost. They pull against each other, and each release is our best current balance.
25
+
26
+ ### Patch Changes
27
+
28
+ - For operators: on the Anthropic engine, a search can no longer read the install's settings or other private files.
29
+ - For operators: stages on the Anthropic engine can no longer run commands on the server; every tool that runs a command is removed from them.
30
+ - Fixed: In a crowded field, the search now reads the spellings a lawyer would raise, rather than the ones that fit under a record limit.
31
+ - Fixed: When a knockout's search for filings fails, the report and the audit workbook no longer show the raw error.
32
+ - Fixed: On Windows, a failed finding card no longer shows as covered in the audit workbook.
33
+ - Fixed: When a search could not be made, the report says so instead of giving a wrong reason.
34
+ - Fixed: A register search limited to the client's goods now searches every other office when one office cannot filter by goods.
35
+ - Fixed: a right the report keeps is no longer dropped because other registrations of the same right were ruled out.
36
+ - Fixed: A knockout search no longer drops a store listing or fan wiki page its web search found; the rating now reads every result.
37
+ - Fixed: A knockout's audit workbook also lists a part that failed as a whole, such as its filings listing or plain-language review. A filing shown without its link now says why.
38
+ - Fixed: on a Mac, `clearotron start` no longer offers to run in the background, which only works on Linux.
39
+ - Fixed: on a Mac, a search step could write into Clearotron's own protected folders by naming them in a different letter case. Those writes are now refused.
40
+ - Fixed: A register search that times out across many countries is asked again in smaller parts; one that still times out is reported as incomplete.
41
+ - Fixed: on the OpenAI engine, Codex's own sandbox no longer refuses the register searches a clearance runs.
42
+ - For operators: on the OpenAI engine with Codex's sandbox on, the commands a search runs can no longer read the install's settings or other private files.
43
+ - Fixed: the audit workbook's What was searched tab no longer shows a web or marketplace search that found similar listings as clean.
44
+ - Fixed: Worldwide searches are no longer narrowed to an account's default territories, and results too crowded to read in full are no longer reported clean.
45
+ - Fixed: A report no longer offers a native-language investigation to a client who ordered one, and says when that investigation was not completed.
46
+ - Fixed: When a knockout's lookup of a filing owner fails, its read now says the lookup did not answer, instead of saying it found nothing.
47
+ - New: Clearotron runs natively on Windows, from PowerShell: no WSL2, no Git and no administrator rights.
48
+ - Fixed: a full country search's report no longer says court decisions were searched when that search failed or its source was down.
49
+ - New: Global preliminary, multi-country and full country searches now search the web in more depth and keep more results from each search.
50
+ - Fixed: opening a report no longer contacts any font service; the report carries its typeface, now Plus Jakarta Sans, inside itself.
51
+ - Fixed: searches no longer reuse earlier results.
52
+ - Fixed: skipped searches are no longer shown as unfinished in the report.
53
+ - For operators: each stage's AI program now receives only the settings it needs, and never the key that signs access keys.
54
+ - Fixed: The audit workbook now lists, on Coverage & gaps, any part of a report that could not be completed on the run. A knockout's Audit Trail also lists an owner lookup that got no answer.
55
+ - Fixed: on the OpenAI engine, a Full country search now reads case law from CourtListener and Legal Data Hunter, not EUR-Lex alone.
56
+ - Fixed: setup, `clearotron doctor` and the start of every search now check that the engine can write a file where a search writes its results.
57
+ - New: The meaning and reputation search now asks the questions the matter's framing names, in the languages whose markets matter. Where the framing names none, the report says no meaning search ran.
58
+ - For operators: the old default agent name is gone from the product. An install upgrading from a version before 0.2.2 must set its own agent name to keep seeing its earlier runs.
59
+ - Fixed: The operator tool no longer accepts instructions, which never reached the run; a call sending them is refused.
60
+ - Fixed: on the OpenAI engine, the tool that reads web pages now refuses addresses on the server's own network, such as cloud metadata addresses.
61
+ - For operators: the background worker no longer receives the key that signs access keys. Run `clearotron start --background` once to update an installed worker.
62
+
3
63
  ## 0.4.0-beta.1
4
64
 
5
65
  ### Patch Changes
@@ -428,6 +428,8 @@ export function deriveAsks({
428
428
  }
429
429
 
430
430
  // 9 ── cross-checks: the common-law→register recovery nets (xcheck + recall) — directives carry
431
+ // (the recall receipt exists only on runs from before the recall store's removal, 2026-09-24;
432
+ // on a newer run `recall` is null and this family lists the cross-checks alone)
431
433
  // qids (computed executed); over-cap rows are real unanswered questions (OPEN → closure/lawyer).
432
434
  for (const [name, doc] of [["xcheck", xcheck], ["recall", recall]]) {
433
435
  for (const d of doc?.directives ?? []) {
@@ -44,17 +44,97 @@
44
44
  // set-GID scar on the pool root). The existing detect-and-journal sweep STAYS as corroboration; its
45
45
  // never-kill-a-run property is sound and survives.
46
46
 
47
- import { resolve, sep } from "node:path";
47
+ import { realpathSync, statSync } from "node:fs";
48
+ import { resolve, win32, posix } from "node:path";
48
49
  import { driverDir } from "../shared/driver-dir.mjs"; //
49
50
 
50
- /** Is `child` inside `root` — by path segment, never by string prefix? PURE. */
51
- export function isInside(root, child) {
52
- const r = resolve(String(root ?? ""));
53
- const c = resolve(String(child ?? ""));
51
+ /**
52
+ * Is `child` inside `root` — by path segment, never by string prefix? PURE.
53
+ *
54
+ * ON WINDOWS BY WINDOWS RULES. There a path may use either separator and a name matches in any case, so
55
+ * `c:/users/x/SKILLS/a.md` is a file inside `C:\Users\x\skills`. Compared as Linux compares, the
56
+ * write lands in the protected tree and the boundary lets it through. `platform` is a parameter so that
57
+ * branch runs on a Linux CI. `foldCase` says whether to ignore letter case: always on Windows, and
58
+ * elsewhere as foldsCaseAt finds the protected folder's disk, which denyReason asks for each tree.
59
+ */
60
+ export function isInside(root, child, { platform = process.platform, foldCase = platform === "win32" } = {}) {
61
+ const P = platform === "win32" ? win32 : posix;
62
+ const fold = foldCase ? (s) => s.toLowerCase() : (s) => s;
63
+ const r = fold(P.resolve(String(root ?? "")));
64
+ const c = fold(P.resolve(String(child ?? "")));
54
65
  if (!r || r === "." || !c) return false;
55
66
  // `/a/skills-backup` is NOT inside `/a/skills`. A `startsWith` without the separator says it is, and
56
67
  // that is the classic form of this check being wrong in the direction that blocks real work.
57
- return c === r || c.startsWith(r.endsWith(sep) ? r : r + sep);
68
+ return c === r || c.startsWith(r.endsWith(P.sep) ? r : r + P.sep);
69
+ }
70
+
71
+ /**
72
+ * The one spelling of a Windows path: no device prefix, and the long names Windows itself reports.
73
+ *
74
+ * WINDOWS REACHES ONE FILE BY SEVERAL NAMES, and the boundary compared the one it was handed. Measured on
75
+ * a Windows runner, 2026-09-23: a write spelled `\\?\C:\…\skills\x.md`, and a write spelled with the
76
+ * long name of a folder the policy had recorded by its 8.3 short name (`RUNNER~1` against
77
+ * `runneradmin`), both reached the protected folder and were allowed. The device prefix (`\\?\`,
78
+ * `\\.\`) is taken off, and the longest part of the path that exists is asked of Windows itself, which
79
+ * answers with the long names and follows a junction, so a junction pointing into a protected folder is
80
+ * inside it too. The part that does not exist yet is joined back as written. `realpath` is injected so
81
+ * this runs on Linux.
82
+ */
83
+ export function canonicalWindowsPath(p, { realpath = realpathSync.native } = {}) {
84
+ const bare = String(p ?? "").replace(/^[\\/]{2}[?.][\\/]UNC[\\/]/i, "\\\\").replace(/^[\\/]{2}[?.][\\/]/, "");
85
+ const abs = win32.resolve(bare);
86
+ const rest = [];
87
+ for (let cur = abs; ;) {
88
+ try { return win32.join(realpath(cur), ...rest); }
89
+ catch {
90
+ const parent = win32.dirname(cur);
91
+ if (parent === cur) return abs;
92
+ rest.unshift(win32.basename(cur));
93
+ cur = parent;
94
+ }
95
+ }
96
+ }
97
+
98
+ const swapCase = (s) => [...s].map((ch) => (ch === ch.toLowerCase() ? ch.toUpperCase() : ch.toLowerCase())).join("");
99
+ const foldMemo = new Map();
100
+
101
+ /**
102
+ * Does the disk holding `root` ignore letter case? Asked of THAT folder, never of the platform.
103
+ *
104
+ * A Mac's default volume ignores case, so `P/SKILLS/x.md` is a file inside `P/skills`, and a boundary
105
+ * comparing letter for letter let the write through: measured on the macOS runner, 2026-09-23. But a Mac
106
+ * volume can be formatted to mind case, and a Linux folder can be set to ignore it, so the platform does
107
+ * not answer. The folder is stat'ed under its own name and under that name with every letter's case
108
+ * swapped: the same file under both means this disk ignores case. It writes nothing, so it adds nothing to
109
+ * a protected tree.
110
+ *
111
+ * WINDOWS ALWAYS FOLDS, as it did before this was asked, because NTFS ignores case unless a folder is set
112
+ * otherwise. Where the question cannot be put (the folder does not exist yet, or its name has no letters)
113
+ * the answer is the platform's default disk, Windows and macOS folding, so a Mac errs toward refusing. The
114
+ * answer is kept per folder for the life of the process. `stat` is injected so every branch runs on Linux.
115
+ */
116
+ export function foldsCaseAt(root, { platform = process.platform, stat = statSync } = {}) {
117
+ if (platform === "win32") return true;
118
+ const abs = posix.resolve(String(root ?? ""));
119
+ const key = `${platform}|${abs}`;
120
+ if (foldMemo.has(key) && stat === statSync) return foldMemo.get(key);
121
+ const byDefault = platform === "darwin";
122
+ const name = posix.basename(abs);
123
+ let answer = byDefault;
124
+ const other = swapCase(name);
125
+ if (name && other !== name) {
126
+ try {
127
+ const own = stat(abs);
128
+ try {
129
+ const swapped = stat(posix.join(posix.dirname(abs), other));
130
+ answer = own.dev === swapped.dev && own.ino === swapped.ino;
131
+ } catch (e) {
132
+ answer = e?.code === "ENOENT" || e?.code === "ENOTDIR" ? false : byDefault;
133
+ }
134
+ } catch { answer = byDefault; }
135
+ }
136
+ if (stat === statSync) foldMemo.set(key, answer);
137
+ return answer;
58
138
  }
59
139
 
60
140
  /**
@@ -87,11 +167,13 @@ export function authorityTrees({ skillsRoots = [], profilesDir = null, runDir =
87
167
  * why it is refused, that a retry gets the same answer, and where stage output lives. No second person, no
88
168
  * imperative. The seat decides what to do with a fact, which is the whole difference.
89
169
  */
90
- export function denyReason(targetPath, trees) {
170
+ export function denyReason(targetPath, trees, { platform = process.platform, foldsCase = (root) => foldsCaseAt(root, { platform }),
171
+ canonical = platform === "win32" ? (p) => canonicalWindowsPath(p) : (p) => p } = {}) {
91
172
  const t = String(targetPath ?? "").trim();
92
173
  if (!t) return null;
174
+ const target = canonical(t);
93
175
  for (const tree of trees ?? []) {
94
- if (!isInside(tree.path, t)) continue;
176
+ if (!isInside(canonical(tree.path), target, { platform, foldCase: foldsCase(tree.path) })) continue;
95
177
  return `REFUSED by the driver's write boundary: ${t} is inside ${tree.why} (${tree.path}). `
96
178
  + `That tree is authored at deploy time and never by a running stage, so the refusal is configuration `
97
179
  + `rather than a permission prompt, and a retry of the same path returns this same answer. Stage `
@@ -130,7 +130,7 @@ export function classifyRecord(markText, targets) {
130
130
  if (!norm) {
131
131
  // ── — A MARK WITH NO LATIN SKELETON CAN STILL BE IDENTICAL, AND IT WAS (2026-08-14) ──────────
132
132
  //
133
- // R1 retrieved TEN exact-match 色度 registrations — four REGISTERED, three in class 9, all in the
133
+ // R1 retrieved TEN exact-match 澜珀 registrations — four REGISTERED, three in class 9, all in the
134
134
  // matter's own classes — and the delivered report did not mention the token once. This line is
135
135
  // where they went. `normalizeElement` keeps only Latin characters, so a Han mark normalises to the
136
136
  // empty string, fell straight to `unclassifiable`, and `unclassifiable` is not one of the two tiers
@@ -139,7 +139,7 @@ export function classifyRecord(markText, targets) {
139
139
  // weighed. Retrieved, banded, positioned, and never an obligation.
140
140
  //
141
141
  // The classifier could not COMPARE them; that is true, and it is not the same as could not MATCH
142
- // them. 色度 equals 色度 by inspection. The precedent is in this repo already: reference-score's
142
+ // them. 澜珀 equals 澜珀 by inspection. The precedent is in this repo already: reference-score's
143
143
  // matchesReference makes RAW comparison its rule 1 for exactly this case, because normalizeElement
144
144
  // keeps only [a-z0-9] and a CJK mark folds to the empty string that every Latin rule would match
145
145
  // against everything. The scorer has had that rule since R1's Chinese registration was specced. The
@@ -147,13 +147,13 @@ export function classifyRecord(markText, targets) {
147
147
  //
148
148
  // EQUALITY ONLY, DELIBERATELY. The floors are mechanical obligations a lawyer must answer row by
149
149
  // row, so every member has to be defensible without judgment: raw-equal after NFKC (which folds
150
- // full-width and compatibility forms of the same characters) is that. Containment is not — 色度計
151
- // contains 色度 and is a different mark — and whether it matters is judgment's call, reached
150
+ // full-width and compatibility forms of the same characters) is that. Containment is not — 澜珀計
151
+ // contains 澜珀 and is a different mark — and whether it matters is judgment's call, reached
152
152
  // through the same-family tiers and the crowd descriptors, not by widening a floor.
153
153
  //
154
154
  // THIS PARAGRAPH USED TO SAY "narrower than the scorer's rule", and it was right when it was
155
155
  // written: matchesReference's script rule was unowned containment in both directions until,
156
- // which scored gold 色度 as retrieved off a different proprietor's 色度花间. The scorer now takes
156
+ // which scored gold 澜珀 as retrieved off a different proprietor's longer mark that contains it. The scorer now takes
157
157
  // equality too, with one owner-gated containment escape the floors deliberately do not have. The
158
158
  // two agree; neither is the looser one any more.
159
159
  const raw = text.normalize("NFKC").trim();
@@ -36,7 +36,18 @@
36
36
  // "no node imports → tests offline" invariant, exactly as connotation-search.mjs already does.
37
37
  import { abbrev } from "./repair-contract.mjs";
38
38
 
39
- export const CASE_LAW_LEDGER_SCHEMA_VERSION = 1;
39
+ // Version 2 is the one whose instruction tells the model to write `null` for a query it could not send,
40
+ // and never 0. At version 1 a 0 could mean either, so a reader may not count one as a search that ran.
41
+ export const CASE_LAW_LEDGER_SCHEMA_VERSION = 2;
42
+
43
+ // WHAT A RECORD THAT NAMES NO VERSION IS. The oldest, never the newest. Defaulting an unversioned record
44
+ // to whatever this build happens to be at hands it every promise the current instruction makes, which is
45
+ // exactly backwards: the records that name no version are the ones written before the field was asked
46
+ // for, and their zeros are the ambiguous kind. Read low, and a record only gains a promise by claiming it.
47
+ export const CASE_LAW_LEDGER_OLDEST_SCHEMA = 1;
48
+
49
+ /** Version 2 and up separate "sent and returned nothing" (0) from "never sent" (null). */
50
+ export const ledgerSeparatesUnsentQueries = (ledger) => Number(ledger?.schema_version) >= 2;
40
51
 
41
52
  /**
42
53
  * How far a proceeding got. `read` is the only state that discharges a depth dive, and the distinction
@@ -91,11 +102,16 @@ export function parseCaseLawLedger(raw) {
91
102
  return {
92
103
  error: null,
93
104
  ledger: {
94
- schema_version: Number(parsed.schema_version) || CASE_LAW_LEDGER_SCHEMA_VERSION,
105
+ schema_version: Number(parsed.schema_version) || CASE_LAW_LEDGER_OLDEST_SCHEMA,
95
106
  queries: queries.filter((q) => q && typeof q === "object").map((q) => ({
96
107
  query: str(q.query),
97
108
  jurisdiction: str(q.jurisdiction),
98
- results: Number.isFinite(Number(q.results)) ? Number(q.results) : null,
109
+ // NULL STAYS NULL, and this is load-bearing from version 2 on. `Number(null)` is 0 and
110
+ // `Number("")` is 0, both finite, so the obvious coercion turns the record's "I could not send
111
+ // this query" into "I sent it and got nothing" — the very two the version exists to separate,
112
+ // collapsed by the parser before any reader sees them. Only a value that genuinely is a number
113
+ // becomes one; everything else is null, which is the record declining to give a count.
114
+ results: q.results == null || q.results === "" || Number.isNaN(Number(q.results)) ? null : Number(q.results),
99
115
  })),
100
116
  citations: citations.filter((c) => c && typeof c === "object").map((c) => ({
101
117
  proceeding: str(c.proceeding),
@@ -35,6 +35,7 @@ import { readFileSync, readdirSync, statSync } from "node:fs";
35
35
  import { spawnSync } from "node:child_process"; // — the birth stamp where there is no /proc
36
36
  import { join } from "node:path";
37
37
  import { config } from "./driver.config.mjs";
38
+ import { windowsProcessScript, parseWindowsRows, defaultRunPowerShell } from "../shared/process-table.mjs"; // the birth stamp on Windows
38
39
 
39
40
  // ── B2 — fail-safe claim liveness ────────────────────────────────────────────────────────────────────
40
41
  // The .pid sidecar records "<pid>:<starttime>" (starttime = field 22 of /proc/<pid>/stat, the kernel's
@@ -42,7 +43,7 @@ import { config } from "./driver.config.mjs";
42
43
  // impersonate a dead claimer and rot its `.processing` forever. Legacy bare-pid sidecars still parse
43
44
  // (starttime null → pid-aliveness is all we have, today's behavior). All exported for unit tests.
44
45
  export function procStarttime(pid, readStat = undefined,
45
- { platform = process.platform, readPsStart = defaultReadPsStart } = {}) {
46
+ { platform = process.platform, readPsStart = defaultReadPsStart, runPowerShell = defaultRunPowerShell } = {}) {
46
47
  // AN INJECTED READER IS THE CALLER'S STATEMENT ABOUT HOW TO READ, and it outranks the platform.
47
48
  //
48
49
  // Measured on macOS (the verification run): the arm that pins the field-22
@@ -61,6 +62,7 @@ export function procStarttime(pid, readStat = undefined,
61
62
  return stat.slice(stat.lastIndexOf(")") + 2).trim().split(/\s+/)[19] ?? null;
62
63
  } catch { return null; }
63
64
  }
65
+ if (platform === "win32") return windowsStarttime(pid, runPowerShell);
64
66
  // ── — THE SAME DEFENCE WHERE THERE IS NO /proc ─────────────────────────
65
67
  //
66
68
  // Returning null here was not a neutral degradation. `claimToken` announces it and falls back to a
@@ -82,6 +84,33 @@ export function procStarttime(pid, readStat = undefined,
82
84
  } catch { return null; }
83
85
  }
84
86
 
87
+ // ── WINDOWS: NEITHER /proc NOR ps ────────────────────────────────────────────────────────────────────
88
+ //
89
+ // Without a stamp on Windows the portal said nothing was draining the queue while the worker ran, which
90
+ // is the false alarm described above, and a claim held by a dead runner stayed held until the max-claim-age
91
+ // ceiling. That second cost is larger on Windows than anywhere else, because Windows hands a freed pid to
92
+ // the next process soon after. So the stamp is read from the system's process list, through PowerShell:
93
+ // the creation time as epoch milliseconds, the same reader `processTable` uses, so a stamp written from
94
+ // one is compared with a stamp read by the same code.
95
+ //
96
+ // ONE PROCESS IS REMEMBERED: this one. Its start cannot change while it runs, and the heartbeat and the
97
+ // status stamp ask for it on every tick, each a PowerShell start. Every other pid is read afresh, for the
98
+ // reason above: a remembered stamp is the dead process's, handed to whatever now wears its number.
99
+ let ownWindowsStamp;
100
+ function windowsStarttime(pid, runPowerShell) {
101
+ if (!Number.isInteger(pid) || pid <= 0) return null;
102
+ const own = pid === process.pid && runPowerShell === defaultRunPowerShell;
103
+ if (own && ownWindowsStamp) return ownWindowsStamp;
104
+ let row = null;
105
+ try {
106
+ const r = runPowerShell(windowsProcessScript(pid));
107
+ if (r && r.status === 0) row = parseWindowsRows(r.stdout).find((p) => p.pid === pid) ?? null;
108
+ } catch { return null; }
109
+ const stamp = row && Number.isFinite(row.startedAt) ? String(row.startedAt) : null;
110
+ if (own && stamp) ownWindowsStamp = stamp;
111
+ return stamp;
112
+ }
113
+
85
114
  /** `ps` is POSIX and is on macOS. Empty stdout (no such pid) parses to NaN above, which is `null`. */
86
115
  function defaultReadPsStart(pid) {
87
116
  const r = spawnSync("ps", ["-o", "lstart=", "-p", String(pid)], { encoding: "utf8" });
@@ -130,8 +130,15 @@ export function renderClearanceVariants(model, scopeRows) {
130
130
  const out = ["# Variant manifest", ""];
131
131
  out.push(`## Mark: ${model.mark}`, "", `Dominant element: ${model.dominant_element}`, "");
132
132
 
133
- out.push("### Elements", "", "| Value | Kind |", "|---|---|");
134
- for (const e of model.elements) out.push(`| ${e.value} | ${e.kind} |`);
133
+ // The famous-mark column appears only when an element carries the flag, so a manifest with none reads
134
+ // exactly as it did before the flag existed.
135
+ if (model.elements.some((e) => e.famous_mark_flag)) {
136
+ out.push("### Elements", "", "| Value | Kind | Famous-mark check |", "|---|---|---|");
137
+ for (const e of model.elements) out.push(`| ${e.value} | ${e.kind} | ${e.famous_mark_flag ? "yes" : ""} |`);
138
+ } else {
139
+ out.push("### Elements", "", "| Value | Kind |", "|---|---|");
140
+ for (const e of model.elements) out.push(`| ${e.value} | ${e.kind} |`);
141
+ }
135
142
  out.push("");
136
143
 
137
144
  out.push(`### Variants (${model.variants.length})`, "", "| Value | Category | Romanisation | Rationale |", "|---|---|---|---|");
@@ -172,7 +179,7 @@ export function renderClearanceVariants(model, scopeRows) {
172
179
  /** The shape this tool declares, at every depth — what the ACCEPTOR enforces. */
173
180
  const DECLARED = Object.freeze({
174
181
  "": ["mark", "dominant_element", "elements", "variants", "incumbent_classes", "search_floor", "watchlist_owners", "goods_words", "scope_ledger"],
175
- elements: ["value", "kind"],
182
+ elements: ["value", "kind", "famous_mark_flag"],
176
183
  variants: ["value", "category", "rationale", "romanization"],
177
184
  scope_ledger: ["layer", "item", "status", "reason", "reopen_trigger"],
178
185
  });
@@ -30,6 +30,7 @@ export const MIN_CELLS_PER_VARIANT = 7;
30
30
  // It composes `queryKey`, which is the one author of what "the same text" means across a provider
31
31
  // boundary, and keeps its own wrapping-quote strip because a term arrives quoted here and does not there.
32
32
  import { queryKey } from "./connotation-search.mjs";
33
+ import { dictatedCells } from "../providers/perplexity/src/core.js"; // what the spec asks, product or blocks
33
34
 
34
35
  const norm = (s) => queryKey(String(s || "").trim().replace(/^["'`\u2018\u2019\u201c\u201d]+|["'`\u2018\u2019\u201c\u201d]+$/g, ""));
35
36
 
@@ -285,6 +286,30 @@ export function findGridLedgerViolations(manifestOrTerms, ledgerRaw, { minCellsP
285
286
  return shortVariants(variants, (term) => platforms.get(norm(term))?.size || 0, minCellsPerVariant);
286
287
  }
287
288
 
289
+ /**
290
+ * — THE RECEIPTS JOIN FOR A GRID IN BLOCKS: every cell the spec dictated ran — a cell or a recorded gap in
291
+ * the ledger — judged cell by cell. The count and identity joins below assume every term owes every
292
+ * platform, which a grid the matter frame decided does not ask. Same {variant, cells, expected} shape as
293
+ * findGridLedgerViolations, plus the platforms still owed, so the `grid_join_missing` token is unchanged.
294
+ * Throws on an unparseable ledger.
295
+ *
296
+ * @returns {Array<{variant:string, cells:number, expected:number, missing:string[]}>}
297
+ */
298
+ export function findUnranDictatedCells(spec, ledgerRaw) {
299
+ const map = parseGridLedger(ledgerRaw);
300
+ const owed = new Map(); // term → [platform, ran?]
301
+ for (const [term, platform] of dictatedCells(spec)) {
302
+ if (!owed.has(term)) owed.set(term, []);
303
+ owed.get(term).push([platform, map.get(norm(term))?.has(norm(platform)) === true]);
304
+ }
305
+ const out = [];
306
+ for (const [variant, rows] of owed) {
307
+ const missing = rows.filter(([, ran]) => !ran).map(([p]) => p);
308
+ if (missing.length) out.push({ variant, cells: rows.length - missing.length, expected: rows.length, missing });
309
+ }
310
+ return out;
311
+ }
312
+
288
313
  // ── WS-B: platform-identity join (per-customer profiles) ────────────────────────────────────────────────
289
314
  // The count floor proves HOW MANY distinct platforms each variant accounted for — never WHICH. With a
290
315
  // dictated per-customer platform list, a worker on gaming habit could sweep the wrong marketplaces and
@@ -674,6 +699,10 @@ export function splitGridSpec(spec, { outputPaths = {}, dispositionsPaths = {} }
674
699
  // The meaning seat sweeps no cells. `terms: []` is what makes its dispatch the meaning work and
675
700
  // nothing else, and it is why the grid halves keep an untouched 50/50 parity split.
676
701
  terms: isMeaning ? [] : terms[h],
702
+ // A grid in blocks keeps its blocks, each cut to this half's terms (a term owns all its cells in one half).
703
+ ...(Array.isArray(spec.grids) ? { grids: spec.grids.map((g) => ({ terms: g.terms.filter((t) => !isMeaning && terms[h].includes(t)), platforms: g.platforms })).filter((g) => g.terms.length) } : {}),
704
+ // The famous-mark record names only the cells this seat runs.
705
+ ...(Array.isArray(spec.famous) ? { famous: spec.famous.filter((t) => !isMeaning && terms[h].includes(t)) } : {}),
677
706
  output_path: outputPaths[h] ?? spec.output_path,
678
707
  // The disposition_required stamp still rides BOTH halves: it is the receipt-presence arm, and a
679
708
  // stray pr_risk block in the non-owning half must still be judged rather than waved through.
@@ -714,7 +743,8 @@ export function splitGridSpec(spec, { outputPaths = {}, dispositionsPaths = {} }
714
743
  * batch has since closed is dropped). This is the never-thinner guarantee: every dictated cell is
715
744
  * accounted as a real cell or an HONEST gap, so a half failure can never yield a ledger that is
716
745
  * thinner but still valid. A half's honestly-recorded gap for a cell OUTSIDE the spec (a re-keyed
717
- * variant / an extra swept platform) is UNIONED in too — the spec-only recompute would erase it.
746
+ * variant / an extra swept platform) is UNIONED in too — the spec-only recompute would erase it,
747
+ * except a meaning query's gap row when the merged receipts answer that query.
718
748
  * - extras.pr_risk[]: concatenated (a then b), deduped by trimmed query — restores the full dictated
719
749
  * meaning-receipt set from the per-half partitions. Other extras keys: arrays concatenate,
720
750
  * anything else first-defined wins.
@@ -773,23 +803,32 @@ export function mergeGrids(a, b, { spec, halfErrors = {} } = {}) {
773
803
  // gaps: recomputed against the FULL spec
774
804
  const halfTerms = splitGridTerms(spec?.terms ?? []);
775
805
  const gaps = [];
776
- for (const term of spec?.terms ?? []) {
777
- for (const platform of spec?.platforms ?? []) {
778
- if (seen.has(key(term, platform))) continue;
779
- const owner = halfOfTerm(halfTerms, term);
780
- const error = gapErr.get(key(term, platform))?.error
781
- || (owner && halfErrors[owner]) || "cell not accounted by either half-grid";
782
- gaps.push({ term, platform, error });
783
- }
806
+ // Over the cells the spec DICTATES, never terms × platforms: a grid the frame decided is blocks, and the
807
+ // product of its unions would make a gap of every cell nobody asked for, which the closure pass would run.
808
+ for (const [term, platform] of spec ? dictatedCells(spec) : []) {
809
+ if (seen.has(key(term, platform))) continue;
810
+ const owner = halfOfTerm(halfTerms, term);
811
+ const error = gapErr.get(key(term, platform))?.error
812
+ || (owner && halfErrors[owner]) || "cell not accounted by either half-grid";
813
+ gaps.push({ term, platform, error });
784
814
  }
785
815
  // UNION honestly-recorded NON-SPEC gap rows: a half may record a gap for a cell OUTSIDE the canonical
786
816
  // spec (a legitimately re-keyed variant / an extra platform a half swept). The spec-only recompute above
787
817
  // never emits those, silently erasing an honestly-recorded coverage gap — union them so an honest gap
788
818
  // survives the merge. A cell present in cells[] still wins (a stale gap a supplementary batch has since
789
819
  // closed is dropped exactly as for spec cells), and a gap already emitted by the spec loop is not doubled.
820
+ //
821
+ // A MEANING QUERY THE MERGED RECEIPTS ANSWER KEEPS NO GAP ROW. The grid tool records one for each dictated
822
+ // query its program did not return, and the engine's re-issue can answer that query later in the same
823
+ // ledger. Kept beside the answer, the row says a search did not complete when it did, and the closure pass
824
+ // takes every gap row for a store cell, so it searches the query again on a site called "connotation".
825
+ // Queries are matched as the receipts are (norm).
826
+ const answered = new Set((Array.isArray(extras.pr_risk) ? extras.pr_risk : [])
827
+ .map((e) => (typeof e?.query === "string" ? norm(e.query) : "")).filter(Boolean));
790
828
  const gappedKeys = new Set(gaps.map((g) => key(g.term, g.platform)));
791
829
  for (const [k, g] of gapErr) {
792
830
  if (seen.has(k) || gappedKeys.has(k)) continue;
831
+ if (String(g.platform).toLowerCase() === "connotation" && answered.has(norm(g.term))) continue;
793
832
  gaps.push({ term: g.term, platform: g.platform, error: g.error || "cell not accounted by either half-grid" });
794
833
  }
795
834
  return { cells, extras, gaps };
@@ -926,7 +965,16 @@ export function findSimilarListingSignals(findingsContent) {
926
965
  const signals = [];
927
966
  const OWNER_RE = /(?:developer|publisher|seller|owner)(?:\s+|_)of(?:\s+|_)record\W{0,5}([^\n|]+)/i;
928
967
  const OWNER_SKIP_RE = /not extracted|unknown|n\/a|none|^[\s—–-]*$/i;
968
+ // A table whose header lists the of-record fields matches OWNER_RE on its first label and captures the
969
+ // next one, so a label reached the register as an owner's name ("publisherofrecord", once its
970
+ // underscores were stripped). A label is never a name.
971
+ const OWNER_LABEL_RE = /^(?:developer|publisher|seller|owner)[\s_]*of[\s_]*record\b/i;
929
972
  const URL_RE = /https?:\/\/[^\s)|\]">]+/;
973
+ // A TABLE'S HEADER ROW NAMES COLUMNS, NEVER AN OWNER, whatever column follows an of-record label ("URL",
974
+ // "Store"). It is the row a separator row follows, and both are table rows: a bare "---" under an owner's
975
+ // line is a rule, not a separator, and the owner stands.
976
+ const SEPARATOR_RE = /^\s*\|?\s*:?-{3,}:?\s*(?:\|\s*:?-{3,}:?\s*)*\|?\s*$/;
977
+ const headerRow = (ln, next) => ln.includes("|") && String(next ?? "").includes("|") && SEPARATOR_RE.test(next);
930
978
  let inMatrix = false, inFindings = false;
931
979
  let block = null; // { markText, owner, url }
932
980
  const flush = () => {
@@ -935,7 +983,9 @@ export function findSimilarListingSignals(findingsContent) {
935
983
  }
936
984
  block = null;
937
985
  };
938
- for (const ln of (findingsContent || "").split("\n")) {
986
+ const lines = (findingsContent || "").split("\n");
987
+ for (let i = 0; i < lines.length; i++) {
988
+ const ln = lines[i];
939
989
  const h = ln.match(/^(#{1,6})\s+(.*)/);
940
990
  if (h) {
941
991
  const title = h[2];
@@ -958,10 +1008,10 @@ export function findSimilarListingSignals(findingsContent) {
958
1008
  continue;
959
1009
  }
960
1010
  if (inFindings && block) {
961
- const om = ln.match(OWNER_RE);
1011
+ const om = headerRow(ln, lines[i + 1]) ? null : ln.match(OWNER_RE);
962
1012
  if (om && !block.owner) {
963
1013
  const owner = om[1].replace(/[*_`]/g, "").trim();
964
- if (owner && !OWNER_SKIP_RE.test(owner)) block.owner = owner.slice(0, 120);
1014
+ if (owner && !OWNER_SKIP_RE.test(owner) && !OWNER_LABEL_RE.test(om[1].trim())) block.owner = owner.slice(0, 120);
965
1015
  }
966
1016
  const um = ln.match(URL_RE);
967
1017
  if (um && !block.url) block.url = um[0];
@@ -39,7 +39,7 @@
39
39
  // ── THE NON-LATIN TRAP IS ANSWERED, NOT AVOIDED ──────────────────────────────────────────────────────
40
40
  //
41
41
  // `normalizeJoinText` folds every non-[A-Za-z0-9] run to a space, so it returns the EMPTY STRING for
42
- // 色度, 冰沙, 瓦波冰沙, ワボスラッシュ and 와보 슬러시. A mark-keyed trace is structurally blind to
42
+ // 澜珀, 冰沙, 瓦波冰沙, ワボスラッシュ and 와보 슬러시. A mark-keyed trace is structurally blind to
43
43
  // every one of them.
44
44
  //
45
45
  // NOTHING HERE TOUCHES IT. A Han-script candidate joins on its ASCII/percent-encoded URL. A Han-script