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
@@ -1,20 +1,21 @@
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
- // Central paths + tunables for the prelim-search deterministic driver.
3
+ // Central paths + tunables for the clearance-search deterministic driver.
4
4
  //
5
5
  // The driver runs as an ordinary UNIX service account (launched by systemd), NOT as an LLM agent.
6
6
  // The agent exec-deny is a gateway agent-tool restriction; it does not apply to this OS process.
7
7
  // Every value is env-overridable so the identical code runs from a developer's shell and from the
8
8
  // systemd unit on a deployed host.
9
9
 
10
- import { join, dirname, isAbsolute } from "node:path";
10
+ import { join, dirname, basename, isAbsolute, delimiter } from "node:path";
11
11
  import { fileURLToPath, pathToFileURL } from "node:url";
12
- import { readdirSync, existsSync, accessSync, statSync, statfsSync, constants as FS } from "node:fs";
12
+ import { readdirSync, existsSync, accessSync, statSync, statfsSync, readFileSync, realpathSync, openSync, readSync, closeSync, constants as FS } from "node:fs";
13
13
  import { homedir } from "node:os";
14
+ import { isWsl } from "../shared/wsl.mjs"; // — the one answer to "is this Linux under Windows", which decides the /mnt/<drive> skip
14
15
  import { envFrom } from "../shared/env-aliases.mjs"; // — an operator-facing name is the one an operator sets, and it has to work where they set it; — envFrom is the resolver that reads every spelling of it
15
16
  import { invoke } from "../shared/invocation.mjs"; // — name a command the reader can actually type
16
17
  import { envFileRead } from "../shared/env-local.mjs"; // — WHICH file to set it in, measured; null for a service that read none
17
- import { numericSetting, resolveNumericSetting } from "./numeric-setting.mjs"; // — a number, or a refusal that names the variable; never NaN
18
+ import { numericSetting, resolveNumericSetting } from "./numeric-setting.mjs"; import { STUDIO_SEGMENTS, STUDIO_SEGMENT_RE, studioSegmentFor } from "../shared/pre-rename-spellings.mjs"; export { STUDIO_SEGMENTS, STUDIO_SEGMENT_RE, studioSegmentFor }; // the studio segment an install keeps its runs under // — a number, or a refusal that names the variable; never NaN
18
19
 
19
20
  const { X_OK } = FS;
20
21
 
@@ -110,6 +111,23 @@ export const envGateOn = (name) => {
110
111
  // so import-time captures silently pinned every test to the FIRST test's env (workspace root, pool,
111
112
  // retries) — the root of the intermittent cross-test contamination flake. Getters make "set env, then
112
113
  // run" mean what it says, in tests and in prod alike.
114
+ // The four skill folders the identifier rename moved. A store outside the product may still hold them,
115
+ // and a profile may still name them, under the old spelling.
116
+ const RENAMED_SKILL_DIRS = Object.freeze([
117
+ ["clearance-search", "prelim-search"], ["clearance-register", "prelim-register"],
118
+ ["clearance-common-law", "prelim-common-law"], ["clearance-variants", "prelim-variants"],
119
+ ]);
120
+ /** `[rel, the same path under the folder's other spelling | null]`. PURE. */
121
+ export function skillSpellings(rel) {
122
+ const m = /^skills\/([a-z-]+)(\/.*)?$/.exec(String(rel ?? ""));
123
+ if (!m) return [rel, null];
124
+ for (const [now, before] of RENAMED_SKILL_DIRS) {
125
+ if (m[1] === now) return [rel, `skills/${before}${m[2] ?? ""}`];
126
+ if (m[1] === before) return [rel, `skills/${now}${m[2] ?? ""}`];
127
+ }
128
+ return [rel, null];
129
+ }
130
+
113
131
  export const config = {
114
132
  // / — A BLANK VALUE IS NOT A CONFIGURED VALUE. `process.env.X || default` treats " " as
115
133
  // configured, because a whitespace-only string is TRUTHY in JavaScript — so a variable set to spaces
@@ -211,21 +229,8 @@ export const config = {
211
229
  * still fails loudly against the canonical location rather than silently against the overlay.
212
230
  */
213
231
  resolveSkillPath(relFromSkillsRoot) {
214
- const rel = String(relFromSkillsRoot ?? "").replace(/^\/+/, "");
215
- const overlay = this.skillsOverlayDir;
216
- if (overlay) {
217
- // FAIL LOUD ON AN UNREADABLE OVERLAY. existsSync() answers false for a permission error just as it
218
- // does for a missing file, so a config store the process cannot read would silently resolve EVERY
219
- // file to the repo — swapping a customer's own risk framework for the Generic default with nothing
220
- // in the log to say so. A configured-but-unreadable overlay is a deploy defect, not a fallback.
221
- if (!existsSync(overlay))
222
- throw new Error(`skills_overlay_unreadable:${overlay} (CLEAROTRON_INSTRUCTIONS_DIR names it, set by the operator or derived by the portal from PROFILE_REPO_ROOT, but this process cannot see it — customer-specific skills would silently fall back to the repo defaults)`);
223
- const p = join(dirname(overlay), rel);
224
- if (existsSync(p)) return p;
225
- }
226
- return join(dirname(this.skillsBaseDir), rel);
232
+ return this.resolveSkillPathReport(relFromSkillsRoot).path;
227
233
  },
228
-
229
234
  /**
230
235
  * WHICH LAYER ANSWERED, as a fact rather than a path — `resolveSkillPath` with its reasoning shown.
231
236
  *
@@ -251,13 +256,24 @@ export const config = {
251
256
  */
252
257
  resolveSkillPathReport(relFromSkillsRoot) {
253
258
  const rel = String(relFromSkillsRoot ?? "").replace(/^\/+/, "");
254
- const basePath = join(dirname(this.skillsBaseDir), rel);
259
+ // EITHER SPELLING OF A RENAMED SKILL FOLDER, in either direction. A store that has not moved its
260
+ // folders still holds `skills/prelim-search/…`, and a profile written before the rename still NAMES
261
+ // it; the product ships only the new names. Tried in the overlay first — a client's own doctrine must
262
+ // never be silently replaced by ours because a folder name changed — and the base answers only under
263
+ // the name it ships.
264
+ const [asNamed, other] = skillSpellings(rel);
265
+ const shipped = rel === asNamed && other && /^skills\/prelim-/.test(asNamed) ? other : asNamed;
266
+ const basePath = join(dirname(this.skillsBaseDir), shipped);
255
267
  const overlay = this.skillsOverlayDir;
256
268
  if (!overlay) return { path: basePath, rel, layer: existsSync(basePath) ? "base-only" : "missing", overlayPath: null, basePath };
257
269
  if (!existsSync(overlay))
258
270
  throw new Error(`skills_overlay_unreadable:${overlay} (CLEAROTRON_INSTRUCTIONS_DIR names it, set by the operator or derived by the portal from PROFILE_REPO_ROOT, but this process cannot see it — customer-specific skills would silently fall back to the repo defaults)`);
259
- const overlayPath = join(dirname(overlay), rel);
271
+ const overlayPath = join(dirname(overlay), asNamed);
260
272
  if (existsSync(overlayPath)) return { path: overlayPath, rel, layer: "overlay", overlayPath, basePath };
273
+ if (other) {
274
+ const otherPath = join(dirname(overlay), other);
275
+ if (existsSync(otherPath)) return { path: otherPath, rel, layer: "overlay", overlayPath: otherPath, basePath };
276
+ }
261
277
  return { path: basePath, rel, layer: existsSync(basePath) ? "base" : "missing", overlayPath, basePath };
262
278
  },
263
279
 
@@ -275,7 +291,7 @@ export const config = {
275
291
  return roots;
276
292
  },
277
293
 
278
- // The base that a profile's "skills/prelim-search/<file>.md" path is relative to — i.e. the PARENT
294
+ // The base that a profile's "skills/clearance-search/<file>.md" path is relative to — i.e. the PARENT
279
295
  // of skillsDir. Everything the DRIVER reads itself (framework manifests, band-meaning extraction)
280
296
  // must join against this, exactly as the agent resolves the same relative paths against the
281
297
  // skillsDir it is handed (gateway.mjs engineSkillsDir).
@@ -304,15 +320,17 @@ export const config = {
304
320
  },
305
321
  // Escaped prefix for the reverse regexes below (a custom prefix may carry regex metachars).
306
322
  get workspacePrefixRe() { return this.workspacePrefix.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); },
307
- // `prelim-search` IS NOT A PRODUCT NAME HERE and does not follow the product rename. It is a
308
- // directory segment on disk, and every archived run — its slug dirs, its `archive/`, its matter
309
- // ledger — was written under it. Renaming the segment does not move those runs; it points the
310
- // reader somewhere empty, and an empty directory reads as "no runs" rather than as an error.
311
- // The rule the tree already enforces elsewhere: a token that is read back out of an archive keeps
312
- // its old spelling, or the code refuses its own archive. Thirteen sites compute this segment and
313
- // all thirteen stay.
323
+ // THE STUDIO SEGMENT IS NOT A PRODUCT NAME and does not follow the product rename. It is a directory
324
+ // segment on disk, and every archived run — its slug dirs, its `archive/`, its queue, its matter ledger —
325
+ // was written under it. Renaming it does not move those runs; it points the reader somewhere empty, and
326
+ // an empty directory reads as "no runs" rather than as an error. The identifier rename rewrote this
327
+ // comment together with the thirteen sites it protected, and an upgraded install lost sight of its whole
328
+ // archive. So an install keeps the segment it has: `prelim-search` wherever that directory exists, and
329
+ // `clearance-search` only for an install that has no other. Every site asks `studioSegmentFor`.
330
+ studioSegment(workspaceDir) { return studioSegmentFor(workspaceDir); },
314
331
  studioRootForAgent(agentId) {
315
- return join(this.workspaceRoot, this.workspaceDirName(agentId), "studio", "prelim-search");
332
+ const ws = join(this.workspaceRoot, this.workspaceDirName(agentId));
333
+ return join(ws, "studio", studioSegmentFor(ws));
316
334
  },
317
335
  queueDirForAgent(agentId) {
318
336
  return join(this.studioRootForAgent(agentId), "queue");
@@ -320,9 +338,10 @@ export const config = {
320
338
  archiveRootForAgent(agentId) {
321
339
  return join(this.studioRootForAgent(agentId), "archive");
322
340
  },
323
- // …/<prefix><id>/studio/prelim-search/queue → "<id>"; null if the path isn't an agent queue dir.
341
+ // …/<prefix><id>/studio/<segment>/queue → "<id>"; null if the path isn't an agent queue dir. Either
342
+ // spelling of the segment, because an install keeps the one it has.
324
343
  agentIdFromQueueDir(qdir) {
325
- const m = new RegExp(`(?:^|/)${this.workspacePrefixRe}([^/]+)/studio/prelim-search/queue/?$`).exec(qdir);
344
+ const m = new RegExp(`(?:^|/)${this.workspacePrefixRe}([^/]+)/studio/${STUDIO_SEGMENT_RE}/queue/?$`).exec(qdir);
326
345
  return m ? m[1] : null;
327
346
  },
328
347
  // Every agent workspace's clearotron queue. The systemd `.path` watches these and the runner drains ALL of
@@ -342,14 +361,18 @@ export const config = {
342
361
  try {
343
362
  for (const name of readdirSync(root)) {
344
363
  if (this.agentIdFromWorkspaceName(name) == null) continue;
345
- const q = join(root, name, "studio", "prelim-search", "queue");
346
- if (existsSync(q)) dirs.push(q);
364
+ // BOTH spellings are drained where both exist: a job queued under either is a job somebody is
365
+ // waiting on, and an unwatched queue looks exactly like an empty one.
366
+ for (const seg of STUDIO_SEGMENTS) {
367
+ const q = join(root, name, "studio", seg, "queue");
368
+ if (existsSync(q)) dirs.push(q);
369
+ }
347
370
  }
348
371
  } catch { /* workspaceRoot may not exist in some test envs — fall through to the canonical queue */ }
349
372
  // THE ONE AGENT NAME NO CONFIGURATION REMOVES, and it is deliberate. row 5.
350
373
  //
351
374
  // `this.queueDir` is `queueDirForAgent("clawdi")` — a LITERAL, not `defaultAgent` — so every
352
- // deployment, however configured, watches `<workspacePrefix>clawdi/studio/prelim-search/queue`.
375
+ // deployment, however configured, watches `<workspacePrefix>clawdi/studio/clearance-search/queue`.
353
376
  // Setting CLEAROTRON_DEFAULT_AGENT does not remove it (prod runs `ops`, dev runs `dev`, and both still
354
377
  // watch this); nor does CLEAROTRON_WORKSPACE_PREFIX. An installer who copies `.env.example` gets a
355
378
  // neutral default agent AND this directory.
@@ -539,7 +562,7 @@ export const config = {
539
562
  // directory, so renaming the default moves the install to an empty one and nothing migrates. Here
540
563
  // the orphaned files are run-slot locks, so a live run's slot goes unseen and the global cap is
541
564
  // silently exceeded rather than enforced. Ruling.
542
- get runLockDir() { return this.envValue("CLEAROTRON_RUN_LOCK_DIR") || join(this.workspaceRoot, "prelim-run-locks"); },
565
+ get runLockDir() { return this.envValue("CLEAROTRON_RUN_LOCK_DIR") || [join(this.workspaceRoot, "prelim-run-locks"), join(this.workspaceRoot, "clearance-run-locks")].find((d, i) => i === 1 || existsSync(d)); }, // the name the install already has wins
543
566
 
544
567
  // Delivery outbox (Workstream B). On a handoff-mode finish the driver drops <runId>.pending here (naming
545
568
  // the forwarder agent); the systemd-user prelim-outbox.path unit fires an INSTANT clearotron-deliver wake off
@@ -551,7 +574,31 @@ export const config = {
551
574
  // directory, so renaming the default moves the install to an empty one and nothing migrates. Here
552
575
  // the orphaned files are requester-facing events — delivered, run-failed, intake-rejected — so the
553
576
  // visible failure is a requester never told their run finished. Ruling.
554
- get outboxDir() { return this.envValue("CLEAROTRON_OUTBOX_DIR") || join(this.workspaceRoot, "prelim-outbox"); },
577
+ get outboxDir() { return this.envValue("CLEAROTRON_OUTBOX_DIR") || join(this.workspaceRoot, "clearance-outbox"); },
578
+
579
+ // ── THE DIRECTORY THIS DEFAULT USED TO NAME, AND WHY IT IS STILL READ ──────────────────────────────
580
+ //
581
+ // This default was `clearance-outbox` until the identifier was renamed. A deployment that never pinned
582
+ // `CLEAROTRON_OUTBOX_DIR` has its `<runId>.pending` markers sitting in the old directory, and moving
583
+ // the default without reading the old one orphans every one of them. That failure is silent and it is
584
+ // the expensive kind: a marker is a report a client is OWED, an unread outbox is indistinguishable
585
+ // from nothing to send, and the run that produced it has already recorded itself as delivered-pending.
586
+ //
587
+ // NULL WHEN THE VARIABLE IS SET, because then the operator named the directory and there is no old
588
+ // default in play. Writers use `outboxDir` alone; only readers consult this, which is the same posture
589
+ // the run records take — new work uses the new name, old work is still understood.
590
+ //
591
+ // THE OLD SPELLING IS THE WHOLE POINT OF THIS ACCESSOR, and a sweep took it. The identifier rename
592
+ // moved the directory from `prelim-outbox` to `clearance-outbox`, and the follow-up that renamed the
593
+ // directory's 85 occurrences rewrote this literal along with them — leaving the accessor that exists to
594
+ // name the OLD directory naming the new one. Both getters then answered the same path: the drain read
595
+ // the new directory twice, never read the old one, and every marker under it was listed TWICE. So a box
596
+ // that never pinned the variable got exactly the failure the block above describes — orphaned markers,
597
+ // silently — plus duplicate work on the ones it could see. It is written once, here, and pinned by a
598
+ // test that asserts the two are different.
599
+ get legacyOutboxDir() {
600
+ return this.envValue("CLEAROTRON_OUTBOX_DIR") ? null : join(this.workspaceRoot, "prelim-outbox");
601
+ },
555
602
 
556
603
  // ── Delivery/comms (Phase 2, standalone product) ─────────────────────────────────────────────────
557
604
  // THERE IS ONE MODE AND IT IS NOT A SETTING. The driver SENDS NOTHING: every requester-facing event
@@ -569,7 +616,7 @@ export const config = {
569
616
  // LOCATION (see studioRootForAgent / agentIdFromQueueDir above).
570
617
  //
571
618
  // THE DEFAULT IS PART OF A PATH, so changing it moves where an install looks for its own runs:
572
- // every run dir is `<workspaceRoot>/workspace-<agent>/studio/prelim-search/…`. An install created
619
+ // every run dir is `<workspaceRoot>/workspace-<agent>/studio/clearance-search/…`. An install created
573
620
  // before this default changed keeps its runs under the old id and must pin it — both spellings,
574
621
  // because the gather servers read their own variable:
575
622
  //
@@ -616,12 +663,12 @@ export const MODELS = {
616
663
  gemini: "google/gemini-3.1-pro-preview",
617
664
  "gemini-flash": "google/gemini-3-flash-preview",
618
665
  "deepseek-v4-pro": "together/deepseek-ai/DeepSeek-V4-Pro",
619
- // azure = the proven-working Azure GPT-5.4 (api: openai-completions). It REPLACED the dead
620
- // azure-openai-pro/gpt-5.4-pro (api: azure-openai-responses), which rejected every payload at the
621
- // provider level — "provider rejected the request schema or tool payload" — and was retired
622
- // 2026-06-08 (live-probed both: 5.4-pro exit 1, 5.4 completions clean). Env-overridable for dev/prod
623
- // parity; default is the rendered $AZURE_OPENAI_DEPLOYMENT catalog id.
624
- azure: process.env.CLEAROTRON_AZURE_MODEL || "azure-openai/gpt-5.4",
666
+ // azure = a legacy catalogue entry for an Azure GPT deployment. NO STAGE NAMES IT AND NO ENGINE RUNS IT:
667
+ // the Claude adapter refuses the alias and the codex adapter maps no tier onto it. So its target is a
668
+ // constant. The setting that used to override it was retired on 2026-09-15 and nothing reads it any
669
+ // more; the configuration reference lists it under the settings that do nothing. It is named only
670
+ // there, in prose, because a name written in a source file here counts as a name this build reads.
671
+ azure: "azure-openai/gpt-5.4",
625
672
  };
626
673
 
627
674
  // alias → full id; a value that's already a full provider/model id (contains "/") passes through.
@@ -659,6 +706,13 @@ export function resolveModel(model) {
659
706
  * never a default. A null on either side makes the comparison UNKNOWN, and an unknown must never be
660
707
  * recorded as a match — that is the absence-read-as-a-pass class this whole issue is about.
661
708
  */
709
+ // FABLE IS NOT A FAMILY HERE, ON PURPOSE. Nobody has seen what the wire reports for a fable turn, so an id
710
+ // naming fable stays unknown and its comparison can never manufacture a mismatch. Placing it was tried
711
+ // (2026-09-15) and refused turns that had always run: a fable request served by claude-sonnet-5, by
712
+ // claude-opus-5, by a deployment named after another tier or by a GPT id, an opus request served as
713
+ // claude-fable-5-1, and any tier served under a name beginning with the word. It was taken out again the
714
+ // same day. The report's model line still names Fable: servedModels (tokens.mjs) reads a fable request's tier
715
+ // itself, where only the report sees it, and never through this comparison.
662
716
  const MODEL_FAMILY_RE = /(?:^|\/)(?:claude-)?(opus|sonnet|haiku)(?:[-.]|$)/i;
663
717
 
664
718
  // — THE OPENAI SIDE, added when the codex path could first answer "what ran".
@@ -931,7 +985,7 @@ export const PROVIDERS = {
931
985
  id: "corsearch",
932
986
  label: "Corsearch",
933
987
  credEnv: "CORSEARCH_SESSION_KEY",
934
- skillDoc: "skills/prelim-register/providers/corsearch.md",
988
+ skillDoc: "skills/clearance-register/providers/corsearch.md",
935
989
  hasPublicRecordUrl: true,
936
990
  // WP-receipts W2: the public per-record origin (publicRecordOrigin + /mark/<jur>/<id> is a working
937
991
  // link) — replaces the fragile resolved-link-origin inference at render for receipt-carrying runs.
@@ -1005,7 +1059,7 @@ export const PROVIDERS = {
1005
1059
  id: "clarivate",
1006
1060
  label: "Clarivate Compumark",
1007
1061
  credEnv: "CLARIVATE_API_KEY",
1008
- skillDoc: "skills/prelim-register/providers/clarivate.md",
1062
+ skillDoc: "skills/clearance-register/providers/clarivate.md",
1009
1063
  hasPublicRecordUrl: false, // Compumark Content has no public record URL — cite the office register
1010
1064
  //, ruling 2026-08-20 — WHAT A CARD SHOWS WHERE A LINK CANNOT GO. A UI exists for this
1011
1065
  // provider and we do not know its per-record URL, so the card says so and says it is unfinished.
@@ -1147,7 +1201,7 @@ export const PROVIDERS = {
1147
1201
  id: "signa",
1148
1202
  label: "Signa",
1149
1203
  credEnv: "SIGNA_API_KEY",
1150
- skillDoc: "skills/prelim-register/providers/signa.md",
1204
+ skillDoc: "skills/clearance-register/providers/signa.md",
1151
1205
  hasPublicRecordUrl: false, // Signa exposes no per-record public URL — cite the office register
1152
1206
  //, ruling 2026-08-20 — no register UI exists to link to at all, so the card points at
1153
1207
  // the artifact that DOES carry the record: the audit workbook. Naming it is the whole of this
@@ -1202,8 +1256,12 @@ export const PROVIDERS = {
1202
1256
  // on the search response, so this no longer has to answer `present` and nothing else. It is
1203
1257
  // still null whenever the vendor would only approximate it — and null there means UNKNOWN,
1204
1258
  // which is the whole reason the field may never be filled in with a figure from anywhere else.
1259
+ // `floor` rides beside `approximate` because the two are one fact: the register answered, and the
1260
+ // answer is "more than this". Without the number the disclosure is not usable — "approximate"
1261
+ // alone says no more than "unknown" does, which is the state this replaces.
1205
1262
  return { ok: true, total: Number.isFinite(p.total_hits) ? p.total_hits : null,
1206
- approximate: p.total_approximate === true, present: p.present === true, note: p.note };
1263
+ approximate: p.total_approximate === true, floor: Number.isFinite(p.total_floor) ? p.total_floor : null,
1264
+ present: p.present === true, note: p.note };
1207
1265
  } catch (e) { return { ok: false, cause: `countHits threw: ${e.message}` }; }
1208
1266
  },
1209
1267
  // `reason`, not `cause`, on every refusal: the listing reads `reason` (register-records.mjs), so a
@@ -1293,7 +1351,7 @@ export const PROVIDERS = {
1293
1351
  // list — without this, an instance holding the id and no secret passes preflight and dies on the
1294
1352
  // first token request, after model spend and reported as a provider fault.
1295
1353
  credEnvAlso: ["EUIPO_CLIENT_SECRET"],
1296
- skillDoc: "skills/prelim-register/providers/euipo.md",
1354
+ skillDoc: "skills/clearance-register/providers/euipo.md",
1297
1355
  hasPublicRecordUrl: true,
1298
1356
  publicRecordOrigin: "https://euipo.europa.eu",
1299
1357
  async recordFetch(uri, { agentId, sessionKey, recordLog = null }) {
@@ -1359,7 +1417,7 @@ export const PROVIDERS = {
1359
1417
  id: "uspto-local",
1360
1418
  label: "USPTO (local index)",
1361
1419
  credEnv: "USPTO_LOCAL_DB",
1362
- skillDoc: "skills/prelim-register/providers/uspto-local.md",
1420
+ skillDoc: "skills/clearance-register/providers/uspto-local.md",
1363
1421
  hasPublicRecordUrl: true,
1364
1422
  // TSDR publishes a page per serial, so a finding can cite an address the reader can open. The
1365
1423
  // record ref is /mark/us/<serial>, and the core builds the full statusSearch link on the record.
@@ -1463,7 +1521,7 @@ export const PROVIDERS = {
1463
1521
  label: "Free tier (EUIPO + USPTO local index)",
1464
1522
  credEnv: "EUIPO_CLIENT_ID",
1465
1523
  credEnvAlso: ["EUIPO_CLIENT_SECRET"],
1466
- skillDoc: "skills/prelim-register/providers/free-tier.md",
1524
+ skillDoc: "skills/clearance-register/providers/free-tier.md",
1467
1525
  hasPublicRecordUrl: true,
1468
1526
  // NULL, deliberately: the two members have DIFFERENT public origins (euipo.europa.eu and the USPTO),
1469
1527
  // so a single origin string here would stamp one office's host onto the other's citations. The
@@ -1813,7 +1871,7 @@ export function preflightCredentials(env = process.env) {
1813
1871
  // paid for. This door is that same failure, moved in front of the spend.
1814
1872
  //
1815
1873
  // GATED ON THE COMPONENT, NEVER ON THE PIPELINE. `pipeline === "clearance"` is the wrong predicate and
1816
- // fails in the expensive direction: `prelim-register-only` is a clearance that carries
1874
+ // fails in the expensive direction: `clearance-register-only` is a clearance that carries
1817
1875
  // `commonLawGrid: false`, searches no unregistered-use half by design, and would be refused for a
1818
1876
  // credential its lane never reads. The component IS the question — search-policy.mjs calls it "the
1819
1877
  // clearance's unregistered-use half" in as many words.
@@ -1910,26 +1968,32 @@ export const ENGINE_BINARIES = {
1910
1968
  // `vendor` is the one word a person needs — the staff config page answers "which engine is
1911
1969
  // running the searches", and `label` below is the MECHANISM, which is what took off that page.
1912
1970
  vendor: "Anthropic",
1971
+ // `product` is the name a reader knows the program by, beside the vendor in setup's engine question
1972
+ // ("Claude, by Anthropic"). Not `fallback`, which is the command word and lower-case.
1973
+ product: "Claude",
1913
1974
  env: "CLEAROTRON_CLAUDE_PATH", fallback: "claude",
1975
+ // The npm package that carries this program, and the oldest version setup installs: "this version or
1976
+ // newer", with no ceiling. Setup installs it into the engines folder (enginesFolder, below the table)
1977
+ // when the reader picks this engine, and the resolver uses it only when the machine has no copy of its
1978
+ // own. The package's own `bin` field names the program, so no path inside it is written down here.
1979
+ package: "@anthropic-ai/claude-code", floor: "2.1.270",
1980
+ // WHAT THE INSTALL TAKES ON DISK, in MB, which setup states before it asks to install. MEASURED, not
1981
+ // declared by the vendor: the engines folder after a fresh install of this package into an empty
1982
+ // folder, on npm 10.9.8 and on 11.19.1, 2026-09-14. A later release can be larger or smaller, so setup
1983
+ // says "about". Re-measure when the floor moves.
1984
+ installMB: 214,
1985
+ // The licence setup states wherever it tells a reader what they are about to install or use, as the
1986
+ // vendor's package declares it: "SEE LICENSE IN README.md", Anthropic's own terms.
1987
+ licence: "proprietary third-party software",
1914
1988
  label: "Anthropic — each stage runs as a headless `claude -p` turn",
1915
1989
  module: "engine/anthropic-agent.mjs", adapter: "anthropicAgentEngine",
1916
1990
  signIn: "run `claude` once in a terminal and complete the sign-in",
1917
1991
  authEnv: "CLEAROTRON_AI_BILLING", apiKeyEnv: "ANTHROPIC_API_KEY",
1918
1992
  subscriptionHow: "sign in once with `claude`",
1919
- // — HERE RATHER THAN IN THE WIZARD, for the same reason `authEnv` is: this table is what the
1920
- // wizard and the run-door preflight both read, and an install command living in the wizard would be
1921
- // a second place an engine is described. Verified against the registry 2026-08-24: 2.1.241.
1922
- //
1923
- // npm, NOT the vendor's shell installer. `curl … | bash` is the other documented route for this CLI
1924
- // and the wizard will not run one: a command this product executes on someone's box has to be one
1925
- // they can read in full before they answer, and a piped remote script is not.
1926
- install: "npm install -g @anthropic-ai/claude-code",
1927
- // — THE NO-ROOT ROUTE, NAMED AND NEVER EXECUTED. The stance above holds: this
1928
- // product does not run a piped remote script. But on a box whose npm prefix needs root, the npm
1929
- // route CANNOT work as this user, and offering only it was a dead end the owner hit. The wizard
1930
- // prints this for the reader to run BY THEIR OWN HAND in another terminal — their shell, their
1931
- // eyes, their decision — and says where it lands so the path answer afterwards is not a guess.
1932
- installNoRoot: { cmd: "curl -fsSL https://claude.ai/install.sh | bash", lands: "~/.local/bin" },
1993
+ // `install`, the command that puts this program in the engines folder, is written in below the table
1994
+ // from `package` and `floor`, so the command a reader is shown and the one setup runs cannot drift.
1995
+ // npm, NOT the vendor's shell installer: a command this product executes on someone's box has to be
1996
+ // one they can read in full before they answer, and a piped remote script is not.
1933
1997
  // — the documented headless ending. `claude setup-token` walks the sign-in and
1934
1998
  // prints a long-lived token; the stage subprocess env is a spread of the driver's — spawnEnv
1935
1999
  // strips ONLY the API key under subscription — so a token in the env file reaches the CLI
@@ -1940,23 +2004,66 @@ export const ENGINE_BINARIES = {
1940
2004
  },
1941
2005
  "openai-agent": {
1942
2006
  vendor: "OpenAI",
2007
+ product: "Codex",
1943
2008
  env: "CLEAROTRON_CODEX_PATH", fallback: "codex",
2009
+ package: "@openai/codex", floor: "0.154.0",
2010
+ installMB: 324, // measured the same way and on the same day as Claude's, above
2011
+ licence: "third-party software under the Apache-2.0 licence", // the package's own "license" field
1944
2012
  label: "OpenAI — each stage runs as a headless `codex exec` turn",
1945
2013
  module: "engine/openai-agent.mjs", adapter: "openaiAgentEngine",
1946
2014
  signIn: "run `codex login`",
1947
2015
  authEnv: "CLEAROTRON_AI_BILLING", apiKeyEnv: "CODEX_API_KEY",
1948
2016
  subscriptionHow: "sign in once with `codex login` — the adapter reads ~/.codex/auth.json and refuses before spending if it is absent",
1949
- // Verified against the registry 2026-08-24: 0.149.1.
1950
- install: "npm install -g @openai/codex",
1951
- // — no vendor shell installer exists for this CLI; the no-root answer on a
1952
- // root-only prefix is npm's own prefix move, which the wizard names the same way.
1953
- installNoRoot: null,
1954
2017
  // — codex's headless ending writes its own ~/.codex/auth.json; there is no
1955
2018
  // token to capture into an env file, and inventing one would be a route nobody has driven.
1956
2019
  headless: { cmd: "codex login --device-auth", tokenEnv: null },
1957
2020
  },
1958
2021
  };
1959
2022
 
2023
+ // ── WHERE SETUP INSTALLS AN ENGINE'S PROGRAM, AND THE COMMAND THAT DOES IT ─────────────────────────────
2024
+ //
2025
+ // Nothing is bundled into the package. Setup installs the ONE program the reader's engine runs, when they
2026
+ // say yes, as an ordinary npm project in a folder Clearotron owns under their home directory: npm then
2027
+ // fetches that platform's binary and nothing else, and needs no root. The folder is not on PATH, so a copy
2028
+ // the machine installs itself is found first (resolveEngineProgram). `clearotron update` runs the same
2029
+ // install again, which moves the program to the newest its vendor publishes: measured on npm 10.9.8 and
2030
+ // 11.19.1, re-running an install with a `>=` range over an older copy moves it to the newest, where
2031
+ // `npm update` would stop at the caret npm writes into the folder's package.json.
2032
+
2033
+ /** Where to install, and look for, the engine programs instead of the default folder. An EMPTY directory means none. */
2034
+ export const ENGINES_DIR_ENV = "CLEAROTRON_ENGINES_DIR";
2035
+
2036
+ /** The default engines folder as a reader types it. */
2037
+ const ENGINES_FOLDER_TYPED = "~/.local/share/clearotron/engines";
2038
+
2039
+ /**
2040
+ * The folder setup installs engine programs into and the resolver's last step reads: ENGINES_DIR_ENV from
2041
+ * this process when set, otherwise `~/.local/share/clearotron/engines`. Under the home directory rather
2042
+ * than the install's own tree, because an update replaces that tree, and because every service runs as a
2043
+ * user unit under the same home as the setup that installed it. XDG_DATA_HOME is not consulted: a shell's
2044
+ * value does not reach the services, and the two would then look in different folders.
2045
+ */
2046
+ export function enginesFolder({ env = process.env, home = homedir() } = {}) {
2047
+ return String(env[ENGINES_DIR_ENV] ?? "").trim() || join(home, ".local", "share", "clearotron", "engines");
2048
+ }
2049
+
2050
+ /** The npm arguments that install, or refresh, an engine's program in `dir`: "this version or newer". */
2051
+ export function engineInstallArgs(spec, dir = enginesFolder()) {
2052
+ return ["install", "--prefix", dir, "--no-fund", "--no-audit", `${spec.package}@>=${spec.floor}`];
2053
+ }
2054
+
2055
+ /** A shell word as a reader pastes it: quoted when it must be, because a bare `>=` is a redirection. */
2056
+ const shellWord = (w) => (/^[\w@%+=:,./~-]+$/.test(w) ? w : `'${String(w).replace(/'/g, "'\\''")}'`);
2057
+
2058
+ /** The same install as a command a reader can paste. The default folder is written the way they type it. */
2059
+ export function engineInstallCommand(spec, dir = ENGINES_FOLDER_TYPED) {
2060
+ return ["npm", ...engineInstallArgs(spec, dir)].map(shellWord).join(" ");
2061
+ }
2062
+
2063
+ // The printable command each engine carries, for the readers that show one without a folder to hand (the
2064
+ // portal's engine page, doctor's way out of demo mode), made from the same parts as the install setup runs.
2065
+ for (const spec of Object.values(ENGINE_BINARIES)) spec.install = engineInstallCommand(spec);
2066
+
1960
2067
  /** The production default, in ONE place rather than a literal repeated at every reader. */
1961
2068
  export const DEFAULT_ENGINE_ID = "anthropic-agent";
1962
2069
 
@@ -1971,48 +2078,190 @@ export function engineAdapterSpecifier(engine) {
1971
2078
  return spec ? pathToFileURL(join(DRIVER_DIR, spec.module)).href : null;
1972
2079
  }
1973
2080
 
1974
- /** Resolve `name` the way spawn(2) would, or null. No separator ⇒ a PATH walk; otherwise the path itself. */
1975
- function resolveExecutable(name, env) {
1976
- const executable = (p) => { try { return statSync(p).isFile() && (accessSync(p, X_OK), true); } catch { return null; } };
1977
- if (name.includes("/")) return executable(name) ? name : null;
1978
- for (const dir of String(env.PATH ?? "").split(":")) {
1979
- if (!dir) continue;
1980
- const p = join(dir, name);
1981
- if (executable(p)) return p;
2081
+ // ── WHERE AN ENGINE'S PROGRAM IS FOUND: ONE PLACE, AND EVERY READER ASKS IT ─────────────────────────────
2082
+ //
2083
+ // The run door, the inventory the portal reads, the wizard, doctor and both adapters all ask this one
2084
+ // function. Before it there were four answers: this file's PATH walk, the wizard's own walk (the only one
2085
+ // that passed over a Windows copy under WSL), and each adapter handing spawn(2) a bare word so the OS made
2086
+ // its own choice. They agreed only while every copy lived on PATH.
2087
+ //
2088
+ // THE ORDER, and why the machine's own copy wins:
2089
+ // 1. The explicit setting (`CLEAROTRON_CLAUDE_PATH` / `CLEAROTRON_CODEX_PATH`). A value that is set and
2090
+ // unusable is REPORTED, never overruled: the reader stated it, and quietly resolving somewhere else
2091
+ // would run a program nobody chose. The engine's own fallback word (`claude`, `codex`) is the default
2092
+ // spelled out, which is how the shipped example files write it, so it means exactly what unset means.
2093
+ // 2. The program on PATH: the machine's own install, which keeps updating itself.
2094
+ // 3. The copy setup installed in the engines folder (enginesFolder, above), only when the machine has
2095
+ // none. That folder is not on PATH. If a reader puts its node_modules/.bin there, a PATH hit that IS
2096
+ // that copy is passed over in step 2 and taken for what it is in step 3, so it is never reported, or
2097
+ // written into a settings file, as the machine's own.
2098
+ //
2099
+ // FILESYSTEM ONLY, like the rest of this door (see the header above ENGINE_BINARIES): nothing is spawned.
2100
+
2101
+ /** A path on a Windows drive as WSL mounts it. */
2102
+ export const ON_A_WINDOWS_DRIVE = /^\/mnt\/[a-z]\//i;
2103
+
2104
+ const isExecFile = (p) => { try { return statSync(p).isFile() && (accessSync(p, X_OK), true); } catch { return false; } };
2105
+ const realOrNull = (p) => { try { return realpathSync(p); } catch { return null; } };
2106
+ const readPackage = (dir) => { try { return JSON.parse(readFileSync(join(dir, "package.json"), "utf8")); } catch { return null; } };
2107
+
2108
+ /**
2109
+ * Whether the kernel runs this file ITSELF: a native executable (ELF, Mach-O, a Windows PE) or a `#!` script.
2110
+ *
2111
+ * Asked only of a file inside the vendor's own package, for one measured reason. The Claude package ships
2112
+ * `bin/claude.exe` as a 500-byte shell placeholder and puts the real program over it in its install step.
2113
+ * With `--ignore-scripts`, or with the platform's native package missing, the placeholder stays. It is a
2114
+ * regular file with the execute bit, so the executable check accepts it, and spawn runs it through `sh`:
2115
+ * it prints "claude native binary not installed" and exits 1, at every stage. It has neither `#!` nor a
2116
+ * binary header, so this refuses it at the door instead. A file anywhere else is judged as before; a
2117
+ * reader's own wrapper script is not ours to second-guess.
2118
+ */
2119
+ function runsDirectly(file) {
2120
+ const head = Buffer.alloc(4);
2121
+ let fd = null, n = 0;
2122
+ try { fd = openSync(file, "r"); n = readSync(fd, head, 0, 4, 0); }
2123
+ catch { return false; }
2124
+ finally { if (fd !== null) { try { closeSync(fd); } catch { /* nothing left to release */ } } }
2125
+ if (n >= 2 && head[0] === 0x23 && head[1] === 0x21) return true; // #!
2126
+ if (n >= 2 && head[0] === 0x4d && head[1] === 0x5a) return true; // MZ
2127
+ if (n < 4) return false;
2128
+ const word = head.readUInt32BE(0);
2129
+ return word === 0x7f454c46 // ELF
2130
+ || [0xfeedface, 0xfeedfacf, 0xcefaedfe, 0xcffaedfe, 0xcafebabe].includes(word); // Mach-O, thin and universal
2131
+ }
2132
+
2133
+ /** The npm package a file belongs to: the nearest package.json within a few levels of its real path. */
2134
+ function owningPackage(file) {
2135
+ let d = dirname(realOrNull(file) ?? file);
2136
+ for (let i = 0; i < 4; i++) {
2137
+ const pkg = readPackage(d);
2138
+ if (pkg) return { name: pkg.name ?? null, version: pkg.version ?? null };
2139
+ const up = dirname(d);
2140
+ if (up === d) break;
2141
+ d = up;
1982
2142
  }
1983
2143
  return null;
1984
2144
  }
1985
2145
 
2146
+ /** The directory the installed copy of `spec.package` lives in under the engines folder `root`, or null. */
2147
+ function installedPackageDir(spec, root) {
2148
+ if (!spec.package || !root) return null;
2149
+ const dir = join(root, "node_modules", ...spec.package.split("/"));
2150
+ return existsSync(join(dir, "package.json")) ? dir : null;
2151
+ }
2152
+
2153
+ /** The program that installed copy declares for this engine, by the package's own `bin` field, or null. */
2154
+ function installedProgram(spec, root) {
2155
+ const dir = installedPackageDir(spec, root);
2156
+ if (!dir) return null;
2157
+ const pkg = readPackage(dir);
2158
+ const rel = typeof pkg?.bin === "string" ? pkg.bin : pkg?.bin?.[spec.fallback];
2159
+ return rel ? join(dir, rel) : null;
2160
+ }
2161
+
2162
+ /** Can this candidate be spawned as the engine? `{ok: true, version}` or `{ok: false, why}`. */
2163
+ function engineCandidate(p, spec) {
2164
+ if (!isExecFile(p)) return { ok: false, why: "not an executable file (it is missing, is a directory, or lacks the execute bit for this user)" };
2165
+ const own = owningPackage(p);
2166
+ const vendor = Boolean(spec.package) && own?.name === spec.package;
2167
+ if (vendor && !runsDirectly(p)) {
2168
+ return { ok: false, why: `the placeholder ${spec.package} leaves when its install step did not run (npm was given `
2169
+ + "--ignore-scripts, or the platform's native package is missing), so every stage would print an error and "
2170
+ + "exit. Reinstall without --ignore-scripts" };
2171
+ }
2172
+ return { ok: true, version: vendor ? own.version : null };
2173
+ }
2174
+
2175
+ /**
2176
+ * Find the program an engine spawns. Never throws: refusing is the caller's decision (preflightEngineBinary).
2177
+ *
2178
+ * Returns `{engine, binEnv, bin, explicit, relative, resolved, source, version, windowsShim, skipped, rejected}`.
2179
+ * `resolved` is an absolute path or null. `source` is "explicit" | "path" | "installed" | null. `version` is
2180
+ * read from the copy's own package.json when npm installed it, and null otherwise, because nothing is
2181
+ * spawned to ask. `skipped` lists Windows copies passed over under WSL; `rejected` lists candidates that
2182
+ * could not run, each with its reason and the step that found it (`source`).
2183
+ *
2184
+ * `enginesDir`: undefined reads the engines folder (enginesFolder: ENGINES_DIR_ENV from THIS process, then
2185
+ * the default under the home directory); a directory looks there; null looks nowhere. It is read from the
2186
+ * process rather than from `env` because it describes this user's install, not the configuration being
2187
+ * asked about: doctor asks about the units' environment from a shell, and the folder is the same either
2188
+ * way, because the services run as user units under the same home. `wsl` and `onWindowsDrive` are
2189
+ * injectable for the reason shared/wsl.mjs gives.
2190
+ */
2191
+ export function resolveEngineProgram(engine, { env = process.env, enginesDir = undefined, wsl = null, onWindowsDrive = null } = {}) {
2192
+ const id = String(engine ?? "").trim().toLowerCase();
2193
+ const spec = ENGINE_BINARIES[id];
2194
+ const out = { engine: id, binEnv: spec?.env ?? null, bin: null, explicit: false, relative: false,
2195
+ resolved: null, source: null, version: null, windowsShim: false, skipped: [], rejected: [] };
2196
+ if (!spec) return out;
2197
+ const set = String(envFrom(env, spec.env) ?? "").trim();
2198
+ out.explicit = Boolean(set) && set !== spec.fallback;
2199
+ out.bin = out.explicit ? set : spec.fallback;
2200
+ const underWsl = wsl ?? isWsl({ env });
2201
+ const onDrive = onWindowsDrive ?? ((p) => ON_A_WINDOWS_DRIVE.test(p));
2202
+ const take = (p, source) => {
2203
+ const c = engineCandidate(p, spec);
2204
+ if (!c.ok) { out.rejected.push({ path: p, why: c.why, source }); return false; }
2205
+ Object.assign(out, { resolved: p, source, version: c.version });
2206
+ return true;
2207
+ };
2208
+
2209
+ if (out.explicit && out.bin.includes("/")) {
2210
+ if (!isAbsolute(out.bin)) { out.relative = true; return out; }
2211
+ out.windowsShim = underWsl && onDrive(out.bin);
2212
+ take(out.bin, "explicit");
2213
+ return out;
2214
+ }
2215
+
2216
+ // THE PATH WALK splits on the platform's own delimiter. This file's walk used to split on ":", which
2217
+ // tears a Windows PATH at every drive letter; the wizard's walk already used the delimiter.
2218
+ const installed = out.explicit ? null : installedProgram(spec, enginesDir !== undefined ? enginesDir : enginesFolder());
2219
+ const installedReal = installed ? realOrNull(installed) : null;
2220
+ for (const dir of String(env.PATH ?? "").split(delimiter).filter(Boolean)) {
2221
+ const p = join(dir, out.bin);
2222
+ if (!isExecFile(p)) continue;
2223
+ // UNDER WSL THE WINDOWS PATH IS APPENDED TO THIS ONE, so `claude` on a fresh WSL2 Ubuntu resolves to the
2224
+ // Windows build before any Linux install. It is executable, and it fails the proof turn as "not signed
2225
+ // in" because the credential it looks for is the Linux one. Passed over, and named for the caller to say.
2226
+ if (underWsl && onDrive(p)) { out.skipped.push(p); continue; }
2227
+ if (installedReal && realOrNull(p) === installedReal) continue;
2228
+ if (take(p, out.explicit ? "explicit" : "path")) return out;
2229
+ }
2230
+ if (installed) take(installed, "installed");
2231
+ return out;
2232
+ }
2233
+
1986
2234
  /**
1987
- * Refuse a run whose engine binary is missing, unexecutable, or written as a relative path.
2235
+ * Refuse a run whose engine program cannot be found or run, or is written as a relative path.
1988
2236
  *
1989
2237
  * Throws, like preflightCredentials, and is called at the same door — pipelineInner, before the run
1990
- * context is built. Returns {engine, binEnv, bin, resolved} when the binary is usable.
2238
+ * context is built. Returns {engine, binEnv, bin, resolved, source, version} when the program is usable:
2239
+ * the answer of resolveEngineProgram above, which every other reader asks too.
1991
2240
  *
1992
2241
  * An UNKNOWN CLEAROTRON_AI returns without checking rather than throwing a second, differently-worded
1993
2242
  * version of gateway.selectEngine's error. One definition of "that is not an engine", and it is the
1994
2243
  * registry's.
1995
2244
  */
1996
- export function preflightEngineBinary(env = process.env, { platform = process.platform } = {}) {
2245
+ export function preflightEngineBinary(env = process.env, { platform = process.platform, enginesDir = undefined, wsl = null, onWindowsDrive = null } = {}) {
1997
2246
  // item 3 — NATIVE WINDOWS REFUSES BY NAME, BEFORE ANYTHING READS PATH.
1998
2247
  //
1999
- // INSTALL.md promises a native-Windows run "refuses at preflight" and names the reason. Nothing
2000
- // implemented it, so what a Windows user actually got was the PATH resolver below — which splits on
2001
- // ":" and therefore tears `C:\Users\…` in half at the drive letter. The refusal then told them their
2002
- // `claude.cmd` was "not on PATH as an executable file" and printed a PATH that had been mangled on the
2003
- // way to saying so. A true statement about a false premise, and a wild-goose chase for the reader.
2248
+ // INSTALL.md promises a native-Windows run "refuses at preflight". Nothing implemented it, so what a
2249
+ // Windows user actually got was a PATH walk that split on ":", tore `C:\Users\…` in half at the drive
2250
+ // letter, and reported their `claude.cmd` as "not on PATH". The walk now splits on the platform's own
2251
+ // delimiter, and the refusal stands anyway, because its real grounds were never the lookup: a stage runs
2252
+ // as its own process group and is stopped by signalling that group, an immediate stop identifies the
2253
+ // process from /proc or ps, and the write-boundary hook is quoted for a POSIX shell. Native Windows has
2254
+ // none of that, and where the program is found changes none of it.
2004
2255
  //
2005
- // This fires FIRST for that reason: any message mentioning PATH on win32 is misleading whatever else
2006
- // it says, because the value it quotes has already been destroyed by the split.
2256
+ // This fires FIRST so that no message about a PATH reaches a reader whose platform is the answer.
2007
2257
  //
2008
2258
  // `platform` is injectable so the refusal is testable off win32 — the population this protects is the
2009
2259
  // one that cannot run this suite to find out.
2010
2260
  if (platform === "win32") {
2011
- throw new Error("[preflight] this engine does not run on native Windows. Stage subprocesses are spawned "
2012
- + "with POSIX path and process semantics, and the PATH resolution below splits on \":\", which cuts a "
2013
- + "Windows path at its drive letter — so any message it produced about your engine binary would be "
2014
- + "about a mangled path. Run it under WSL2, or in the devcontainer (.devcontainer/), where the "
2015
- + "documented install path applies unchanged (#1149 item 3).");
2261
+ throw new Error("[preflight] this engine does not run on native Windows. Each stage runs as its own process "
2262
+ + "group and is stopped by signalling that group, which native Windows cannot do, so a stopped stage would "
2263
+ + "leave the tools it started still running. Run it under WSL2, or in the devcontainer (.devcontainer/), "
2264
+ + "where the documented install path applies unchanged.");
2016
2265
  }
2017
2266
  const engine = (env.CLEAROTRON_AI || DEFAULT_ENGINE_ID).trim().toLowerCase();
2018
2267
  const spec = ENGINE_BINARIES[engine];
@@ -2033,24 +2282,38 @@ export function preflightEngineBinary(env = process.env, { platform = process.pl
2033
2282
  // `envFrom` is therefore BELT-AND-BRACES, not the repair: it makes this site correct on its own terms
2034
2283
  // rather than correct because something upstream normalised the environment first — a coupling
2035
2284
  // nothing at this site declares and nothing here could notice breaking.
2036
- const bin = String(envFrom(env, spec.env) ?? "").trim() || spec.fallback;
2037
- const where = `${spec.env}${envFrom(env, spec.env) ? "" : ` (unset — defaulting to "${spec.fallback}")`}`;
2038
-
2039
- if (bin.includes("/") && !isAbsolute(bin)) {
2285
+ const r = resolveEngineProgram(engine, { env, enginesDir, wsl, onWindowsDrive }); // injectable for the reason it gives
2286
+ const bin = r.bin;
2287
+ const setTo = String(envFrom(env, spec.env) ?? "").trim();
2288
+ const where = `${spec.env}${r.explicit ? "" : setTo
2289
+ ? ` (set to its default "${spec.fallback}": the one on PATH, then the copy Clearotron installed)`
2290
+ : ` (unset — defaulting to "${spec.fallback}" on PATH, then the copy Clearotron installed)`}`;
2291
+
2292
+ if (r.relative) {
2040
2293
  throw new Error(`[preflight] ${where} is the RELATIVE path "${bin}", which cannot work: the engine is `
2041
2294
  + "spawned with the RUN DIRECTORY as its cwd (#524), not the repo, so a relative command is looked "
2042
2295
  + `for inside the run. Give an absolute path — e.g. ${join(REPO_ROOT, bin)} — or a bare name on PATH.`);
2043
2296
  }
2044
2297
 
2045
- const resolved = resolveExecutable(bin, env);
2046
- if (!resolved) {
2298
+ if (!r.resolved) {
2299
+ const passedOver = r.rejected.map((x) => `${x.path} is ${x.why}`).join("; ");
2300
+ // "None installed" only when none was. An installed copy that cannot run is named in the passed-over
2301
+ // list with its reason, and the claim beside it would send the reader to install it again.
2302
+ const installedRefused = r.rejected.some((x) => x.source === "installed");
2303
+ // Under WSL the Windows copies on the appended PATH were passed over on purpose, and the reader's own
2304
+ // `which` still prints them, so the refusal names them and says why.
2305
+ const windows = r.skipped.length
2306
+ ? `. Passed over because they sit on a Windows drive, and a Windows build cannot run a stage here: ${r.skipped.join(", ")}` : "";
2047
2307
  throw new Error(`[preflight] the ${engine} engine cannot run: ${where} names "${bin}", which is `
2048
2308
  + (bin.includes("/")
2049
- ? "not an executable file (it is missing, is a directory, or lacks the execute bit for this user)"
2050
- : `not on PATH as an executable file (PATH=${env.PATH || "(empty)"})`)
2309
+ ? (r.rejected[0]?.why ?? "not an executable file (it is missing, is a directory, or lacks the execute bit for this user)")
2310
+ : `not on PATH as an executable file (PATH=${env.PATH || "(empty)"})`
2311
+ + (r.explicit || installedRefused ? "" : ", and Clearotron has not installed one")
2312
+ + (passedOver ? `. Passed over: ${passedOver}` : "")
2313
+ + windows)
2051
2314
  + ". Every stage of a run spawns it, so the run is refused now rather than at the first stage.");
2052
2315
  }
2053
- return { engine, binEnv: spec.env, bin, resolved };
2316
+ return { engine, binEnv: spec.env, bin, resolved: r.resolved, source: r.source, version: r.version };
2054
2317
  }
2055
2318
 
2056
2319
  // Report which deployment hostnames are unset, so the runner can say so out loud at activation.
@@ -2119,7 +2382,7 @@ export function preflightDeploymentUrls(env = process.env) {
2119
2382
  const RUN_FREE_BYTES_FLOOR = 500e6;
2120
2383
 
2121
2384
  /** Nearest ancestor of `p` that exists. statfs needs a real path, and on a first run NONE of
2122
- * …/workspace-<agent>/studio/prelim-search exists yet — measuring the leaf would throw ENOENT and land
2385
+ * …/workspace-<agent>/studio/clearance-search exists yet — measuring the leaf would throw ENOENT and land
2123
2386
  * in the unmeasurable branch, which would disable this check on exactly the fresh installs it is for. */
2124
2387
  function nearestExistingDir(p) {
2125
2388
  let dir = p;
@@ -2161,7 +2424,7 @@ export function freeSpacePlan({ freeBytes, needBytes, path }) {
2161
2424
  * shape of run that can proceed without one, so there is no exemption to write.
2162
2425
  *
2163
2426
  * MEASURES THE FILESYSTEM THAT WILL HOLD THE BYTES, which is the workspace root's, not `/` and not the
2164
- * repo's. Run directories live under config.studioRoot (…/workspace-<agent>/studio/prelim-search); the
2427
+ * repo's. Run directories live under config.studioRoot (…/workspace-<agent>/studio/clearance-search); the
2165
2428
  * published report goes to poolRoot and the packet to outboxDir, which on a laptop are different
2166
2429
  * filesystems again. A check aimed at the wrong mount passes while the right one is full, which is the
2167
2430
  * silent-pass this exists to prevent — so the path is taken from the caller's studioRoot when the runner