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
@@ -29,9 +29,11 @@ import { join } from "node:path";
29
29
  import { runStreamingChild, absolutizeSkillRefs, WRITE_DISCIPLINE, buildEnvelope, resolveSpawnCwd } from "./common.mjs";
30
30
  import { renderCodexConfigToml } from "./mcp/codex-config.mjs";
31
31
  import { resolveAuthMode } from "./auth.mjs";
32
- import { envFrom } from "../../shared/env-aliases.mjs"; // — advice names the name in force
32
+ import { resolveEngineProgram } from "../driver.config.mjs"; // — the one place that finds the program; it reads every spelling of the setting
33
33
 
34
- const codexBin = () => envFrom(process.env, "CLEAROTRON_CODEX_PATH") || "codex";
34
+ // The same one resolver as the claude adapter (driver.config.mjs resolveEngineProgram), for the same reason:
35
+ // the absolute path it found, or what was asked for when it found nothing.
36
+ const codexBin = () => { const r = resolveEngineProgram("openai-agent"); return r.resolved ?? r.bin; };
35
37
 
36
38
  // tier/alias → codex `-m` model id. opus/sonnet/haiku are the driver's abstract tiers (CONTRACT §3). The
37
39
  // GPT ids are ENV-OVERRIDABLE and default to three DISTINCT rungs of the codex ladder — `gpt-5.6-sol`,
@@ -54,8 +54,8 @@
54
54
  // The one place the real adapter is exercised, it is pointed at `driver/test/mock-claude.mjs` through
55
55
  // `CLEAROTRON_CLAUDE_PATH` — the same offline fixture the engine tests already spawn.
56
56
 
57
- import { ENGINE_BINARIES, DEFAULT_ENGINE_ID, engineAdapterSpecifier } from "../driver.config.mjs";
58
- import { resolveAuthMode } from "./auth.mjs";
57
+ import { ENGINE_BINARIES, DEFAULT_ENGINE_ID, engineAdapterSpecifier, resolveEngineProgram } from "../driver.config.mjs";
58
+ import { resolveAuthMode, CLOUD_SETTINGS, CLOUD_CREDENTIAL_CHECK } from "./auth.mjs";
59
59
 
60
60
  /** Six words. Short enough to be free in practice, and it still requires a real completed turn. */
61
61
  export const PROBE_PROMPT = "Reply with the single word: ok.";
@@ -90,44 +90,97 @@ const tail = (s) => {
90
90
  return t.length > DETAIL_CHARS ? `…${t.slice(-DETAIL_CHARS)}` : t;
91
91
  };
92
92
 
93
+ /**
94
+ * An engine instruction that names its program (`run \`claude\` once…`, `claude setup-token`), rewritten to
95
+ * name the copy that will actually run when that copy is the one Clearotron installed. That copy is not on
96
+ * PATH, so for it the bare word is a command the reader's shell cannot
97
+ * find, at the one step nobody can do for them. Any other copy is on PATH or named by path already, and
98
+ * the text is returned unchanged.
99
+ *
100
+ * HERE, AND RE-EXPORTED BY SETUP. Setup's own screens used it and the probe's sign-in advice, which doctor
101
+ * and the run door print, did not, so after setup's install doctor told the reader to run a `claude` their
102
+ * shell does not have. The probe cannot import the wizard, so the one copy lives on this side.
103
+ */
104
+ export function namingProgram(text, eng, bin) {
105
+ if (!text || bin?.source !== "installed" || !bin.path) return text;
106
+ const program = /\s/.test(bin.path) ? `"${bin.path}"` : bin.path;
107
+ return String(text).replace(new RegExp(`(^|\`)${eng.fallback}(?=[\\s\`]|$)`, "g"), (_, before) => `${before}${program}`);
108
+ }
109
+
93
110
  // THE HEADLESS ROUTE, WHERE THE ENGINE HAS ONE. The interactive sign-in is the one thing a server with no
94
111
  // browser cannot do, and it was the only remedy this offered — including to a box that had configured the
95
112
  // route built for servers. The engine table already carries that route; this reads it rather than a copy.
96
- const signInLine = (engine) => {
113
+ //
114
+ // `program` is the copy that ran ({ source, path }, or null), and every command run ON THIS MACHINE is named
115
+ // through `namingProgram`. The token route's command can run on any machine, so it keeps the bare word,
116
+ // with this machine's copy named beside it, as setup names it.
117
+ const signInLine = (engine, program = null) => {
97
118
  const spec = ENGINE_BINARIES[engine];
98
- const base = spec?.signIn ?? "sign the CLI in";
119
+ const base = spec?.signIn ? namingProgram(spec.signIn, spec, program) : "sign the CLI in";
99
120
  const h = spec?.headless;
100
121
  // Both forms the wizard already offers, read off the same table: a TOKEN route is run elsewhere and
101
- // carried here by variable; a DEVICE route is run on this box and signs it in directly.
122
+ // carried here by variable; a DEVICE route is run on this machine and signs it in directly. The route is
123
+ // named by where a sign-in can be completed, as INSTALL.md's sign-in table names it.
102
124
  if (!h?.cmd) return base;
125
+ const here = namingProgram(h.cmd, spec, program);
103
126
  return h.tokenEnv
104
- ? `${base} — or, on a box with no browser, run \`${h.cmd}\` on any machine you can sign in on and set the token it prints as ${h.tokenEnv} in this install's environment file`
105
- : `${base} — or, on a box with no browser, run \`${h.cmd}\` here`;
127
+ ? `${base} — or, on a machine you cannot complete a sign-in on, run \`${h.cmd}\` on any machine you can sign in on${here !== h.cmd ? ` (on this one, \`${here}\`)` : ""} and set the token it prints as ${h.tokenEnv} in this install's environment file`
128
+ : `${base} — or, on a machine you cannot complete a sign-in on, run \`${here}\` here`;
106
129
  };
107
130
 
131
+ /**
132
+ * Who refused the credentials and what to check, for a turn paid through an API key or a cloud account:
133
+ * `{ who, what, check }`, or null on a subscription, whose remedy is the sign-in above. `auth` is the
134
+ * resolver's answer for the turn ({ mode, cloud }). Names only, never a value.
135
+ *
136
+ * THE SIGN-IN IS A SUBSCRIPTION'S REMEDY AND NO OTHER'S. A cloud that refuses the credentials, or a key the
137
+ * vendor refuses, answers 401 or 403 like a signed-out program, and the advice was to run the program once
138
+ * and sign in: nothing to sign in to on a cloud, and a sign-in the adapter would not use under a key.
139
+ */
140
+ export function credentialCheck(engine, auth) {
141
+ if (auth?.mode === "cloud") {
142
+ const c = CLOUD_CREDENTIAL_CHECK[auth.cloud];
143
+ return c ? { who: c.who, what: "the credentials", check: c.check } : null;
144
+ }
145
+ const spec = ENGINE_BINARIES[engine];
146
+ if (auth?.mode === "api-key" && spec?.apiKeyEnv) return { who: spec.vendor, what: "the API key", check: spec.apiKeyEnv };
147
+ return null;
148
+ }
149
+ const capitalised = (s) => s.charAt(0).toUpperCase() + s.slice(1);
150
+
108
151
  /**
109
152
  * One verdict from one turn. PURE — no clock, no filesystem, no process.
110
153
  *
111
154
  * `tuple` is the engine's normalized return (engine/CONTRACT.md §1); `error` is a THROW, which is a
112
155
  * distinct class and not a returned failure: `openai-agent.runTurn` throws for both of its auth shapes
113
156
  * (resolveAuthMode on api-key-without-key, and the auth.json refusal) rather than settling a tuple.
157
+ *
158
+ * `auth` is how the turn was paid for (resolveAuthMode's `{ mode, cloud }`) and `program` the copy that ran
159
+ * (`{ source, path }`); both only shape the advice, never the mode, so the run door refuses exactly what it
160
+ * refused before. Absent, the advice is the subscription's, naming the bare program word.
114
161
  */
115
- export function classifyProbe({ engine, tuple = null, error = null, timeoutSec = PROBE_TIMEOUT_SEC } = {}) {
162
+ export function classifyProbe({ engine, tuple = null, error = null, timeoutSec = PROBE_TIMEOUT_SEC, auth = null, program = null } = {}) {
116
163
  const id = String(engine ?? "").trim().toLowerCase();
117
164
  const v = (mode, basis, headline, fix, extra = {}) =>
118
165
  ({ ok: false, engine: id, mode, basis, headline, fix, detail: null, ...extra });
119
166
 
120
167
  // ── a THROW ────────────────────────────────────────────────────────────────────────────────────────
121
- // The thrown text is relayed VERBATIM as the fix wherever the thrower already says what to do. Those
122
- // messages were written by the module that owns the decision (auth.mjs owns the billing refusal, the
123
- // codex adapter owns `codex login`); paraphrasing them here creates a second wording that drifts.
168
+ // The thrown text is relayed as the fix wherever the thrower already says what to do. Those messages
169
+ // were written by the module that owns the decision (auth.mjs owns the billing refusal, the codex
170
+ // adapter owns `codex login`); paraphrasing them here creates a second wording that drifts.
171
+ //
172
+ // ONE CHANGE ONLY, AND ONLY TO A SIGN-IN: the program's name. The codex adapter refuses a subscription
173
+ // with no sign-in by throwing "run `codex login`" before it starts anything, which is the commonest way a
174
+ // signed-out Codex reaches this line. Relayed as thrown, that told a reader whose only copy is the one
175
+ // setup installed, which is not on PATH, to run a command their shell does not have. `namingProgram`
176
+ // rewrites the bare word for that copy and leaves every other copy's text as it was thrown.
124
177
  if (error) {
125
178
  const msg = String(error?.message ?? error);
126
- if (/=api-key but/i.test(msg))
179
+ if (error?.billingRefusal === true || /=api-key but/i.test(msg)) // auth.mjs marks every billing refusal
127
180
  return v("auth-misconfigured", "config",
128
- `${id} cannot start: the billing mode this box declares has no key`, msg, { detail: null });
181
+ `${id} cannot start: ${/=api-key but/i.test(msg) ? "the billing mode this box declares has no key" : "the billing setting this box declares is refused"}`, msg, { detail: null });
129
182
  if (SIGNED_OUT_RE.test(msg))
130
- return v("signed-out", "config", `${id} is not signed in`, msg);
183
+ return v("signed-out", "config", `${id} is not signed in`, ENGINE_BINARIES[id] ? namingProgram(msg, ENGINE_BINARIES[id], program) : msg);
131
184
  if (TIER_RE.test(msg))
132
185
  return v("tier-unavailable", "config", `${id} cannot reach the model it was asked for`, tierFix(id, msg));
133
186
  return v("failed", "throw", `${id} could not run a turn`, msg);
@@ -144,7 +197,11 @@ export function classifyProbe({ engine, tuple = null, error = null, timeoutSec =
144
197
  // which pipe this happened to look at.
145
198
  const detail = tail(tuple.stderr) ?? tail(tuple.stdout);
146
199
 
147
- if (tuple.code === 0) return { ok: true, engine: id, mode: "ok", basis: "completed-turn", headline: `${id} completed a turn`, fix: null, detail: null };
200
+ // A completed turn names what served it, the model and the provider as the program reported them, and
201
+ // null where it named neither, so a proof says which model and whose account it proved.
202
+ if (tuple.code === 0) return { ok: true, engine: id, mode: "ok", basis: "completed-turn", headline: `${id} completed a turn`, fix: null, detail: null,
203
+ served: typeof tuple.modelWire === "string" && tuple.modelWire ? tuple.modelWire : null,
204
+ provider: typeof tuple.providerWire === "string" && tuple.providerWire ? tuple.providerWire : null };
148
205
 
149
206
  // spawn itself failed. The filesystem preflight normally catches this first; when it does not, say so
150
207
  // in the binary's own vocabulary rather than as a mysterious engine fault.
@@ -163,9 +220,15 @@ export function classifyProbe({ engine, tuple = null, error = null, timeoutSec =
163
220
  { resetsAt: s.resetsAt ?? null, detail });
164
221
  }
165
222
 
223
+ // THE MODE STAYS `signed-out` UNDER EVERY WAY OF PAYING, because the run door refuses on the mode and a
224
+ // refused credential is as much this machine's to fix as a signed-out program. Only the words follow how
225
+ // the turn is paid for.
226
+ const refused = credentialCheck(id, auth);
166
227
  if (SIGNED_OUT_RE.test(text))
167
- return v("signed-out", "text-match", `${id} is not signed in`,
168
- `Sign in: ${signInLine(id)}, then run this again. Setup does not do it for you — the CLI owns its own login.`, { detail });
228
+ return refused
229
+ ? v("signed-out", "text-match", `${capitalised(refused.who)} refused ${refused.what}`, `check ${refused.check}, then run this again.`, { detail })
230
+ : v("signed-out", "text-match", `${id} is not signed in`,
231
+ `Sign in: ${signInLine(id, program)}, then run this again. Setup does not do it for you — the CLI owns its own login.`, { detail });
169
232
 
170
233
  if (TIER_RE.test(text))
171
234
  return v("tier-unavailable", "text-match", `${id} cannot reach the model it was asked for`, tierFix(id, text), { detail });
@@ -179,7 +242,9 @@ export function classifyProbe({ engine, tuple = null, error = null, timeoutSec =
179
242
  // from the shape rather than read from a message.
180
243
  if (s.noStreamEvents)
181
244
  return v("signed-out", "startup-class", `${id} exited before it produced anything`,
182
- `That is the signed-out shape, so start there: ${signInLine(id)}, then run this again. The engine's stderr below is the diagnosis if it is something else.`, { detail });
245
+ refused
246
+ ? `That is what refused credentials look like, so start there: check ${refused.check}, then run this again. The engine's stderr below is the diagnosis if it is something else.`
247
+ : `That is the signed-out shape, so start there: ${signInLine(id, program)}, then run this again. The engine's stderr below is the diagnosis if it is something else.`, { detail });
183
248
 
184
249
  return v("failed", "nonzero-exit", `${id} ran but the turn failed (exit ${tuple.code})`,
185
250
  "The engine's stderr below is the whole story; a turn that starts and fails is not a configuration this check can name.", { detail });
@@ -292,11 +357,15 @@ export function probeWeatherWarning(verdict) {
292
357
  * and a caller building the environment it hands this module fills from it. `doctor` kept a second copy
293
358
  * that had dropped both credentials, so it filled a probe environment without the very token it then
294
359
  * reported missing. Exported so there is nothing left to copy.
360
+ *
361
+ * The cloud a Claude turn is sent to and paid through is on it too (`CLOUD_SETTINGS`, from auth.mjs).
362
+ * Without those names a cloud answered in setup would pass the resolver, which reads the caller's
363
+ * environment, and never reach the turn, which reads this process's: a proof of an account no run bills.
295
364
  */
296
365
  export function engineEnvKeys() {
297
366
  return [...new Set(["CLEAROTRON_AI", ...Object.values(ENGINE_BINARIES)
298
367
  .flatMap((s) => [s.env, s.authEnv, s.apiKeyEnv, s.headless?.tokenEnv])
299
- .filter(Boolean)])];
368
+ .filter(Boolean), ...CLOUD_SETTINGS])];
300
369
  }
301
370
 
302
371
  function applyEngineEnv(env) {
@@ -346,6 +415,12 @@ async function defaultLoadAdapter(engine) {
346
415
  /**
347
416
  * Run the probe and return a verdict. Never throws for a configuration fault — a caller that wants a
348
417
  * refusal calls `preflightEngineTurn`, and a caller that wants to report calls this.
418
+ *
419
+ * `program` is the copy the caller has already found and is running (`{ source, path }`), for a caller
420
+ * that knows more about it than its setting says. Setup pins the program setting to the absolute path of
421
+ * the copy it proves, so the turn runs exactly that copy; resolved from that setting alone, the copy setup
422
+ * installed reads as a path the reader set, and the advice then named the bare word that copy does not
423
+ * answer to. Without it, the probe resolves the copy itself.
349
424
  */
350
425
  export async function probeEngineTurn({
351
426
  env = process.env,
@@ -354,6 +429,7 @@ export async function probeEngineTurn({
354
429
  loadAdapter = defaultLoadAdapter,
355
430
  timeoutSec = PROBE_TIMEOUT_SEC,
356
431
  stallSec = PROBE_STALL_SEC,
432
+ program: knownProgram = null,
357
433
  } = {}) {
358
434
  const id = String(env.CLEAROTRON_AI || DEFAULT_ENGINE_ID).trim().toLowerCase();
359
435
  if (!ENGINE_BINARIES[id]) {
@@ -366,8 +442,10 @@ export async function probeEngineTurn({
366
442
  };
367
443
  }
368
444
 
369
- // The billing-mode door, before anything spawns — a fail-loud config error must not cost a turn.
370
- try { resolveAuthMode({ engineName: id, env }); }
445
+ // The billing-mode door, before anything spawns — a fail-loud config error must not cost a turn. Its
446
+ // answer is kept: a refusal from a cloud or of a key is advised on differently from a signed-out program.
447
+ let auth;
448
+ try { auth = resolveAuthMode({ engineName: id, env }); }
371
449
  catch (e) { return classifyProbe({ engine: id, error: e, timeoutSec }); }
372
450
 
373
451
  let turn = injectedRunTurn;
@@ -377,11 +455,20 @@ export async function probeEngineTurn({
377
455
  }
378
456
 
379
457
  const restore = applyEngineEnv(env);
458
+ let program = null;
380
459
  try {
460
+ // THE COPY THAT RUNS, resolved the way the adapter resolves it: by the one resolver, inside the
461
+ // environment the turn runs in. The sign-in advice names it when it is the copy Clearotron installed,
462
+ // which is not on PATH. AFTER applyEngineEnv, NEVER BEFORE: the resolver reads this process's
463
+ // environment, and the caller's program setting is only there from that line until `restore()`, so above
464
+ // it this would name whatever copy the shell happened to point at. Inside the try, so the `finally` puts
465
+ // the environment back whatever it does. A resolver that cannot answer leaves the bare word.
466
+ if (knownProgram?.path) program = { source: knownProgram.source ?? null, path: knownProgram.path };
467
+ else try { const r = resolveEngineProgram(id); program = r.resolved ? { source: r.source, path: r.resolved } : null; } catch { /* the bare word */ }
381
468
  const tuple = await turn({ message: PROBE_PROMPT, model: PROBE_MODEL, thinking: PROBE_THINKING, timeoutSec, stallSec });
382
- return classifyProbe({ engine: id, tuple, timeoutSec });
469
+ return classifyProbe({ engine: id, tuple, timeoutSec, auth, program });
383
470
  } catch (e) {
384
- return classifyProbe({ engine: id, error: e, timeoutSec });
471
+ return classifyProbe({ engine: id, error: e, timeoutSec, auth, program });
385
472
  } finally {
386
473
  restore();
387
474
  }
@@ -1,6 +1,6 @@
1
1
  // SPDX-License-Identifier: AGPL-3.0-only
2
2
  // Copyright 2026 Cordillera Sàrl. Additional terms under section 7 of the AGPL-3.0 apply — see ADDITIONAL-TERMS.md
3
- // Job-file shape for studio/prelim-search/queue/<id>.json (written by email-loop on a prelim-search request).
3
+ // Job-file shape for studio/clearance-search/queue/<id>.json (written by email-loop on a clearance-search request).
4
4
  // The job id = sanitized email message-id so a re-delivered webhook overwrites the same file (no duplicate run).
5
5
  //
6
6
  // Blocking semantics follow change-spec v3 §B2: the ONLY content reason a search may not start is the
@@ -10,7 +10,7 @@
10
10
  // with a `noref<hash>` slug (see phase0.mjs deriveSlug). The intake confirmation brief (email-loop §6)
11
11
  // resolves ambiguity BEFORE enqueue; this validator is the runner-side mechanical backstop.
12
12
 
13
- import { loadProfiles, loadProjects, resolveProfile, applicantMatchesProfile, recipeProseGuard, platformEntryErrors } from "./profiles.mjs";
13
+ import { loadProfiles, loadProjects, resolveProfile, applicantMatchesProfile, recipeProseGuard, platformEntryErrors , unreadableProfiles } from "./profiles.mjs";
14
14
  import { demoRunShape } from "./demo-run-agreement.mjs";
15
15
  import { ORDERABLE_PRODUCTS, policyFor, checkMarkBudget, checkScopeAgainstPolicy, loadRecipes, kebabCollisions, resolveSearchPolicy } from "./search-policy.mjs";
16
16
  // — the §B2 gate resolves the subject through the SAME ladder the run uses. See the gate itself.
@@ -776,6 +776,10 @@ export function validateJob(job, { atClaim = false } = {}) {
776
776
  profiles = loadProfiles({ force: true });
777
777
  known = profiles.has(key);
778
778
  }
779
+ // A company whose own file would not load is a known company that could not be READ — the same
780
+ // failure to look as a store that cannot be read, and handled the same way below, never "no such
781
+ // customer". The file's reason travels with the run's own refusal (resolveProfile).
782
+ if (!known) known = new Set(unreadableProfiles(profiles).map((u) => u.key)).has(key);
779
783
  roster = [...profiles.keys()].sort();
780
784
  } catch { known = true; }
781
785
  // NAME THE ROSTER THIS PROCESS CAN SEE, always.
@@ -590,8 +590,11 @@ export function riskStatement({ tier, verdict, reasons, basis, clauses } = {}) {
590
590
  const basisNote = registerOnly ? " Register findings only — no common-law or marketplace search was run." : "";
591
591
  if (v === "BLOCKING") return `On hold — the reviewing lawyer's open questions must be resolved before any recommendation.${basisNote}`;
592
592
  if (v === "CONDITIONAL") {
593
- const conds = (Array.isArray(reasons) ? reasons : []).map((r) => String(r ?? "").trim()).filter(Boolean);
594
- const cls = (Array.isArray(clauses) ? clauses : []).map((c) => String(c ?? "").trim()).filter(Boolean);
593
+ // A clause stored as explicit null is a condition ruled to the run record alone: it is not the lede,
594
+ // it is not counted, and its reason is never the fallback text. `undefined` (legacy/short) is not null.
595
+ const cs = Array.isArray(clauses) ? clauses : [];
596
+ const conds = (Array.isArray(reasons) ? reasons : []).filter((r, i) => cs[i] !== null).map((r) => String(r ?? "").trim()).filter(Boolean);
597
+ const cls = cs.map((c) => String(c ?? "").trim()).filter(Boolean);
595
598
  const lede = cls[0] ?? conds[0] ?? "the open conditions carried in the report";
596
599
  const first = clipClause(sentenceCaseLead(lede), STATEMENT_CLAUSE_MAX);
597
600
  const n = conds.length || cls.length;
@@ -1732,7 +1735,7 @@ function validateNet(f, ord, mode) {
1732
1735
  // This file's header says `findings_` = a top-level shape defect, `finding_` = a specific finding/field,
1733
1736
  // and by that rule this token would be `finding_net_chained` alongside finding_net_missing /
1734
1737
  // finding_net_invalid / finding_net_prescriptive. It is `findings_net_chained` instead, because the
1735
- // convention is about ROUTING and routing disagrees. pipeline.mjs:3161 reads:
1738
+ // convention is about ROUTING and routing disagrees. pipeline.mjs reads:
1736
1739
  //
1737
1740
  // const eligible = /^invalid_file:/.test(fail) && /:finding_[a-z]/.test(fail) && !/:findings_/.test(fail);
1738
1741
  //
@@ -41,7 +41,7 @@
41
41
  // than being unable to ask. Failing closed here would mean a file-read error takes the whole portal
42
42
  // down — trading a rare wrong-greyed-out option for a total outage.
43
43
 
44
- import { writeFileSync, readFileSync, mkdirSync } from "node:fs";
44
+ import { writeFileSync, readFileSync, mkdirSync, existsSync } from "node:fs";
45
45
  import { dirname, join } from "node:path";
46
46
  import { BUILT } from "./search-policy.mjs";
47
47
  import { isEntrypoint } from "../shared/is-entrypoint.mjs"; // — one entry-point test, all spellings
@@ -129,7 +129,7 @@ const truthy = (v) => ["1", "true", "yes", "on"].includes(String(v ?? "").trim()
129
129
  * `capturedAt` is supplied rather than read from the clock so this stays testable and so a caller can
130
130
  * stamp it from the same instant it stamps everything else.
131
131
  */
132
- export function buildFlagSnapshot(env, { capturedAt, registerProvider = null, registerCanCount = null, registerTerritories = undefined, engine = undefined, providers = undefined }) {
132
+ export function buildFlagSnapshot(env, { capturedAt, registerProvider = null, registerLabel = null, registerCanCount = null, registerTerritories = undefined, engine = undefined, providers = undefined }) {
133
133
  const flags = {};
134
134
  // Written unconditionally, true or false to a count: a reader must be able to tell "this snapshot
135
135
  // tracks no flags" from "this snapshot lost its flags", and `flags: {}` alone cannot say which.
@@ -181,6 +181,12 @@ export function buildFlagSnapshot(env, { capturedAt, registerProvider = null, re
181
181
  register: registerProvider
182
182
  ? {
183
183
  provider: registerProvider,
184
+ // The register's own DISPLAY label ("Signa"), beside the key the engine switches on ("signa").
185
+ // A door that names the register to a client must not print the key, and capabilities — where
186
+ // the label lives — is a provider module this process may not be able to import. Same split as
187
+ // `territories` directly below: the writer runs in the engine environment and resolves it once.
188
+ // Omitted rather than guessed when the writer had none, and every reader falls back to the key.
189
+ ...(registerLabel ? { label: registerLabel } : {}),
184
190
  canCount: registerCanCount,
185
191
  ...(registerTerritories === undefined ? {} : { territories: registerTerritories }),
186
192
  }
@@ -335,9 +341,9 @@ export function postureDisagreement(snapshot, live) {
335
341
  // the better answer for a reader: "found" against "not found" says it without a legend.
336
342
  const found = (v) => (v === true ? "found" : v === false ? "not found" : null);
337
343
  differ("engine program", found(snapshot.engine?.binaryPresent), found(live.engine?.binaryPresent),
338
- "whether a NEW search can start — the engine that last ran and this deployment do not agree that the "
339
- + "engine program can be found, so one screen offers a search the other refuses. Restart the engine "
340
- + "service so it re-reads its PATH, or install the CLI where the service can see it");
344
+ "whether a NEW search can start — the services, when they last started, and this deployment do not agree "
345
+ + "that the engine program can be found, so one screen offers a search the other refuses. Restart the "
346
+ + "services so they look again; if they still disagree, `clearotron doctor` says which side to fix and how");
341
347
 
342
348
  // Flags: compare only names BOTH sides declare, for the same reason `differ` skips absent values —
343
349
  // a build that adds a flag must not read as every older capture disagreeing with it.
@@ -363,7 +369,7 @@ export function postureDisagreement(snapshot, live) {
363
369
 
364
370
  /** Where the snapshot lives. Beside the pool, so it shares the pool's lifecycle and backup. */
365
371
  export function snapshotPath(poolRoot) {
366
- return join(poolRoot, "_state", "prelim-flag-snapshot.json");
372
+ return join(poolRoot, "_state", "clearance-flag-snapshot.json");
367
373
  }
368
374
 
369
375
  /**
@@ -377,7 +383,10 @@ export function readFlagSnapshot(poolRoot) {
377
383
  // reads as intentional.
378
384
  if (!poolRoot) return null;
379
385
  try {
380
- const raw = JSON.parse(readFileSync(snapshotPath(poolRoot), "utf8"));
386
+ // The file's pre-rename name is read when the new one is not there yet, so an upgraded install does
387
+ // not read as "no snapshot" until its first write under the new name.
388
+ const legacy = join(poolRoot, "_state", "prelim-flag-snapshot.json");
389
+ const raw = JSON.parse(readFileSync(existsSync(snapshotPath(poolRoot)) || !existsSync(legacy) ? snapshotPath(poolRoot) : legacy, "utf8"));
381
390
  if (!raw || typeof raw !== "object" || typeof raw.flags !== "object") return null;
382
391
  return raw;
383
392
  } catch {
@@ -425,6 +434,22 @@ export function registerTerritoriesFor(snapshot) {
425
434
  return Array.isArray(v) ? v.filter((n) => typeof n === "string") : undefined;
426
435
  }
427
436
 
437
+ /**
438
+ * The wired register's DISPLAY label, for a sentence a client reads — `null` when the snapshot does not
439
+ * carry one, which every snapshot written before this shipped does not.
440
+ *
441
+ * FALLS BACK TO THE PROVIDER KEY rather than to nothing: a door that names the register is better off
442
+ * saying "signa" than saying nothing at all, and the caller decides whether a key is good enough to
443
+ * print. It is deliberately NOT title-cased on the way out — "uspto-local" title-cased is worse prose
444
+ * than the key, and inventing a display name is the provider module's job, not this reader's.
445
+ */
446
+ export function registerLabelFor(snapshot) {
447
+ const l = snapshot?.register?.label;
448
+ if (typeof l === "string" && l.trim()) return l.trim();
449
+ const p = snapshot?.register?.provider;
450
+ return typeof p === "string" && p.trim() ? p.trim() : null;
451
+ }
452
+
428
453
  /**
429
454
  * What this instance searches. THREE answers, exactly as `registerTerritoriesFor` above:
430
455
  *
@@ -535,11 +560,12 @@ export async function livePosture({ env = process.env } = {}) {
535
560
  const { REGISTER_PROVIDER } = await import("./driver.config.mjs");
536
561
  const { capabilitiesFor } = await import("./register-capabilities.mjs");
537
562
  const canCount = (() => { try { return capabilitiesFor(REGISTER_PROVIDER).countProbe !== "none"; } catch { return null; } })();
563
+ const label = (() => { try { return capabilitiesFor(REGISTER_PROVIDER).label ?? null; } catch { return null; } })();
538
564
  const { coveredTerritoryNames } = await import("./register-coverage.mjs");
539
565
  const territories = await (async () => { try { return await coveredTerritoryNames(capabilitiesFor(REGISTER_PROVIDER)); } catch { return undefined; } })();
540
566
  const { engineInventory, providerInventory } = await import("./config-inventory.mjs");
541
567
  return buildFlagSnapshot(env, {
542
- capturedAt: new Date().toISOString(), registerProvider: REGISTER_PROVIDER, registerCanCount: canCount,
568
+ capturedAt: new Date().toISOString(), registerProvider: REGISTER_PROVIDER, registerLabel: label, registerCanCount: canCount,
543
569
  registerTerritories: territories,
544
570
  engine: engineInventory(env), providers: providerInventory(env),
545
571
  });
@@ -84,6 +84,36 @@ export function editNeighbourhood(element) {
84
84
  return [...out].sort();
85
85
  }
86
86
 
87
+ // ── A one-letter neighbour that is an ordinary word with a DIFFERENT SOUND is not searched ────────────
88
+ //
89
+ // THE REVIEWING LAWYER'S TEST IS CONFUSING SIMILARITY, and edit-1 over a short ordinary word is mostly other
90
+ // ordinary words: CARE, CODE, CORD, BORE, MORE beside CORE, each a common mark with its own crowd. Measured
91
+ // on a delivered four-letter run, 2026-09-18: 1,037 of 2,098 records (49%) were reached only by the one-letter
92
+ // lists; on the dense production matter of 2026-09-16, 1,154 of 2,146 (54%). Conceptually different words are
93
+ // not confused by consumers, so fetching them is cost and not coverage. A respelling that sounds the same —
94
+ // KORE for CORE — is exactly what a search must find, and so is every neighbour of a made-up word, because two
95
+ // unknown words that sound alike have nothing conceptual to keep them apart (MALENA beside VALENA).
96
+ //
97
+ // So a neighbour is dropped only when BOTH hold: it is in the ordinary-word list, AND no Double-Metaphone key
98
+ // of it matches any key of the element. Everything else is dispatched exactly as before. There is no judgment
99
+ // of the MARK here — coined or ordinary — only of each neighbour, so a coined element keeps its whole list
100
+ // except where a neighbour is a different-sounding real word. The list only ever removes queries, and a word
101
+ // missing from it is searched: over-search is the safe direction.
102
+ //
103
+ // DOUBLE METAPHONE KEEPS VOWELS ONLY AT THE START, so a vowel change inside the word does not change the key:
104
+ // CARE, CURE and GORE all key KR, as CORE does, and are KEPT. That is the rule as specified erring towards the
105
+ // search, and it is recorded here so nobody reads those three as a defect of the list.
106
+ //
107
+ // `ordinaryWords` is a Set of lowercase words, or null. Null drops nothing — the behaviour before this rule —
108
+ // so a caller that does not load the list (every test that predates it) is unchanged. PURE.
109
+ export function ordinaryWordDifferentSound(element, ordinaryWords) {
110
+ const el = normalizeElement(element);
111
+ if (!el || !(ordinaryWords instanceof Set) || !ordinaryWords.size) return [];
112
+ const own = new Set(doubleMetaphone(el).filter(Boolean));
113
+ return editNeighbourhood(el).filter((t) => ordinaryWords.has(t)
114
+ && !doubleMetaphone(t).filter(Boolean).some((k) => own.has(k)));
115
+ }
116
+
87
117
  // ── Consonant skeleton + wildcard patterns — retrieve the phonetic VOWEL family in one bounded vendor query ──
88
118
  // VALENA → consonants V,L,N → skeleton "VLN"; vowel-slot wildcard "V?L?N?"-style patterns the vendor's Lucene
89
119
  // `?`/`*` supports (live-confirmed). This is the tractable, complete way to reach VYLONA/VILINA/VILENA without
@@ -229,13 +259,18 @@ export function transliterations(element, { scripts = SUPPORTED_SCRIPTS } = {})
229
259
  // droppedVariantFamilies). The floor stays exhaustive WITHIN the families judgment kept — this is the funnel
230
260
  // honouring a scope decision that was already written and, until 2026-07-18, ignored. edit-1 is never
231
261
  // droppable: it is the doctrine floor (radiusFor), not a family.
232
- export function formNeighbourhood(element, { markets = [], scripts = SUPPORTED_SCRIPTS, droppedAxes = [] } = {}) {
262
+ export function formNeighbourhood(element, { markets = [], scripts = SUPPORTED_SCRIPTS, droppedAxes = [], ordinaryWords = null } = {}) {
233
263
  const radius = radiusFor(element);
234
264
  const el = radius.element;
235
- if (!el) return { element: "", radius, exactQueries: [], wildcardPatterns: [], phoneticKeys: [], confusables: [], transliterations: [], ledger: { disclosed: radius.note, axes: [] } };
265
+ if (!el) return { element: "", radius, exactQueries: [], wildcardPatterns: [], phoneticKeys: [], confusables: [], transliterations: [], ordinaryWordDifferentSound: [], ledger: { disclosed: radius.note, axes: [] } };
236
266
 
237
267
  const drop = new Set(droppedAxes ?? []);
238
- const edits = editNeighbourhood(el);
268
+ const generatedEdits = editNeighbourhood(el);
269
+ // Only edit-1 is filtered. A term another generator also produces (a confusable, a transliteration) is
270
+ // still dispatched as that generator's — those families are unchanged by this rule.
271
+ const notSearched = ordinaryWordDifferentSound(el, ordinaryWords);
272
+ const skip = new Set(notSearched);
273
+ const edits = generatedEdits.filter((t) => !skip.has(t));
239
274
  const confs = drop.has("visual-confusable") ? [] : visualConfusables(el);
240
275
  const trans = drop.has("transliteration") ? [] : transliterations(el, { scripts });
241
276
  const wildcards = drop.has("phonetic-family") ? [] : skeletonPatterns(el);
@@ -261,11 +296,15 @@ export function formNeighbourhood(element, { markets = [], scripts = SUPPORTED_S
261
296
  phoneticKeys: keys,
262
297
  confusables: confs,
263
298
  transliterations: trans,
299
+ ordinaryWordDifferentSound: notSearched,
264
300
  ledger: {
265
301
  disclosed: radius.note,
266
302
  dropped_axes: [...drop].sort(),
267
303
  axes: [
268
- { axis: "edit-1", count: edits.length, mechanism: "Damerau-Levenshtein edit-1, exhaustive" },
304
+ { axis: "edit-1", count: edits.length, generated: generatedEdits.length, not_searched: notSearched.length,
305
+ mechanism: notSearched.length
306
+ ? "Damerau-Levenshtein edit-1, exhaustive, less the neighbours that are ordinary words with a different sound"
307
+ : "Damerau-Levenshtein edit-1, exhaustive" },
269
308
  // NO PATTERN is a THIRD state, and it is disclosed in the same voice as a judgment drop. An
270
309
  // element with too few consonants to anchor a skeleton wildcard (X, and anything normalizing to
271
310
  // one character) yields no retrieval pattern at all — see skeletonPatterns. Silence here would
@@ -349,7 +388,7 @@ export function coverageGaps(band, { dispatched = [], explained = [] } = {}) {
349
388
  // exhaustive edit-1 neighbourhood: 1,736 junk exact queries on AquaPlus 2026-07-17, 4,524 on the 07-16 run.
350
389
  // Measured 2026-07-18: 10 of 20 recent runs carried one of these. The JSON field is a validated scalar and
351
390
  // cannot swallow a sentence.
352
- export function renderFormNeighbourhoodJson(manifestMd, { markets = [], scripts = SUPPORTED_SCRIPTS, droppedAxes = [], model = null, mark = "" } = {}) {
391
+ export function renderFormNeighbourhoodJson(manifestMd, { markets = [], scripts = SUPPORTED_SCRIPTS, droppedAxes = [], model = null, mark = "", ordinaryWords = null } = {}) {
353
392
  const { seeds, seededFrom, rejected } = floorSeeds(manifestMd, { model, mark });
354
393
  // The reason travels WITH the throw. It used to say only "the job states no mark", which is one
355
394
  // of two causes and not the one that actually fires: a non-Latin mark states a mark perfectly well and
@@ -361,7 +400,7 @@ export function renderFormNeighbourhoodJson(manifestMd, { markets = [], scripts
361
400
  || "manifest names no Dominant element / Formative root, and the job states no mark";
362
401
  throw new Error(`form_neighbourhood_no_element: ${why} — nothing to seed the mechanical form band`);
363
402
  }
364
- const elements = seeds.map(({ element, role }) => ({ element, role, band: formNeighbourhood(element, { markets, scripts, droppedAxes }) }));
403
+ const elements = seeds.map(({ element, role }) => ({ element, role, band: formNeighbourhood(element, { markets, scripts, droppedAxes, ordinaryWords }) }));
365
404
  const families = variantFloorFamilies(elements, { mark, droppedAxes });
366
405
  return JSON.stringify({
367
406
  schema_version: 2,
@@ -596,10 +635,11 @@ export function spacingPunctuationForms(mark) {
596
635
  export function variantFloorFamilies(elements, { mark = "", droppedAxes = [] } = {}) {
597
636
  const drop = new Set(droppedAxes ?? []);
598
637
  const edit = new Set(), visual = new Set(), translit = new Set(), other = new Set();
599
- const wildcards = new Set(), keys = new Set();
638
+ const wildcards = new Set(), keys = new Set(), notSearched = new Set();
600
639
  for (const el of elements ?? []) {
601
640
  const band = el?.band;
602
641
  if (!band) continue;
642
+ for (const t of band.ordinaryWordDifferentSound ?? []) notSearched.add(t);
603
643
  const edits = new Set(editNeighbourhood(el.element));
604
644
  const confs = new Set(band.confusables ?? []);
605
645
  const trans = new Set(band.transliterations ?? []);
@@ -639,6 +679,12 @@ export function variantFloorFamilies(elements, { mark = "", droppedAxes = [] } =
639
679
  dropped: drop.has("phonetic-family"), terms: sorted(wildcards), phonetic_keys: sorted(keys) },
640
680
  ...(other.size ? [{ family: "other", category: "other", generator: "formNeighbourhood (dedupeOnFold residue)",
641
681
  enumeration: "a dispatched term no single generator claims — recorded rather than hidden", dropped: false, terms: sorted(other) }] : []),
682
+ // NOT SEARCHED, and listed in full so the plan says what it left out and why. Not a crowd and not a
683
+ // gap: a deliberate rule (ordinaryWordDifferentSound). `searched: false` keeps it out of the floor the
684
+ // merge below counts, so a model that proposes one of these words is recorded as its own addition.
685
+ ...(notSearched.size ? [{ family: "ordinary-word-different-sound", category: "phonetic", generator: "editNeighbourhood",
686
+ enumeration: "edit-1 neighbours that are ordinary English words and share no Double-Metaphone key with the element",
687
+ dispatch: "not searched — ordinary word, different sound", searched: false, dropped: true, terms: sorted(notSearched) }] : []),
642
688
  ];
643
689
  }
644
690
 
@@ -674,6 +720,7 @@ export function mergeVariantFloor(floorFamilies, modelVariants, { rejectedSeeds
674
720
  const families = Array.isArray(floorFamilies) ? floorFamilies : [];
675
721
  const floorByKey = new Map();
676
722
  for (const f of families) {
723
+ if (f?.searched === false) continue; // listed for disclosure, never searched — not floor
677
724
  for (const t of f?.terms ?? []) {
678
725
  const k = formKey(t);
679
726
  if (!k || floorByKey.has(k)) continue;
@@ -2,7 +2,7 @@
2
2
  // Copyright 2026 Cordillera Sàrl. Additional terms under section 7 of the AGPL-3.0 apply — see ADDITIONAL-TERMS.md
3
3
  // framework.mjs — the risk-framework MANIFEST layer (doc 50: the framework in force rates the matter).
4
4
  //
5
- // The framework itself is a PROSE deck (skills/prelim-search/risk-framework*.md) — the customer's own legal
5
+ // The framework itself is a PROSE deck (skills/clearance-search/risk-framework*.md) — the customer's own legal
6
6
  // judgment written down, which synthesis reads and reasons WITH. This module carries the small
7
7
  // machine-readable sidecar (<framework>.manifest.json) that lets validators, the renderer, the archive index
8
8
  // and the profile UI consume the framework's VOCABULARY — band words, severity order, entity label, source —
@@ -18,12 +18,12 @@ import { readFileSync } from "node:fs";
18
18
  import { join } from "node:path";
19
19
 
20
20
  // ── selection (the ?? fallback IS the "Generic default rates the matter" rule) ────────────────────────────
21
- export const DEFAULT_FRAMEWORK = "skills/prelim-search/risk-framework.md";
22
- export const DEFAULT_WORKED_EXAMPLES = "skills/prelim-search/worked-examples.md";
21
+ export const DEFAULT_FRAMEWORK = "skills/clearance-search/risk-framework.md";
22
+ export const DEFAULT_WORKED_EXAMPLES = "skills/clearance-search/worked-examples.md";
23
23
  export const frameworkFor = (profile) => profile?.frameworkPath ?? DEFAULT_FRAMEWORK;
24
24
  export const workedExamplesFor = (profile) => profile?.workedExamplesPath ?? DEFAULT_WORKED_EXAMPLES;
25
25
 
26
- /** skills/prelim-search/risk-framework-x.md → skills/prelim-search/risk-framework-x.manifest.json.
26
+ /** skills/clearance-search/risk-framework-x.md → skills/clearance-search/risk-framework-x.manifest.json.
27
27
  * The manifest path is DERIVED, never a profile knob — the profile names only the .md. */
28
28
  export const manifestPathFor = (fwPath) => String(fwPath).replace(/\.md$/, ".manifest.json");
29
29