clearotron 0.3.2-beta.7 → 0.3.2-beta.8

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 (85) hide show
  1. package/.env.example +24 -23
  2. package/INSTALL.md +142 -75
  3. package/README.md +3 -3
  4. package/bin/onboard.mjs +637 -216
  5. package/bin/start.mjs +133 -23
  6. package/bin/update.mjs +58 -11
  7. package/build-info.json +2 -2
  8. package/docs/architecture/04-configuration-reference.md +26 -11
  9. package/docs/architecture/05-config-governance.md +17 -7
  10. package/driver/CHANGELOG.md +76 -0
  11. package/driver/band-size.mjs +59 -0
  12. package/driver/config-inventory.mjs +112 -9
  13. package/driver/contract-arm2-baseline.json +1 -3
  14. package/driver/contract-e3-backlog.mjs +26 -26
  15. package/driver/contract-vocabulary.mjs +44 -10
  16. package/driver/door-gates.mjs +41 -7
  17. package/driver/driver.config.mjs +272 -59
  18. package/driver/engine/CONTRACT.md +10 -3
  19. package/driver/engine/README.md +2 -2
  20. package/driver/engine/anthropic-agent.mjs +77 -21
  21. package/driver/engine/auth.mjs +129 -10
  22. package/driver/engine/jx-turn.mjs +7 -6
  23. package/driver/engine/mcp/recording-server.mjs +13 -0
  24. package/driver/engine/openai-agent.mjs +4 -2
  25. package/driver/engine/probe.mjs +110 -23
  26. package/driver/findings-model.mjs +1 -1
  27. package/driver/flag-snapshot.mjs +28 -5
  28. package/driver/gateway.mjs +24 -18
  29. package/driver/jx-lanes.mjs +21 -2
  30. package/driver/jx-units.mjs +6 -3
  31. package/driver/jx.mjs +4 -2
  32. package/driver/matter-frame-record.mjs +90 -1
  33. package/driver/named-band.mjs +34 -2
  34. package/driver/package.json +1 -1
  35. package/driver/pipeline.mjs +200 -23
  36. package/driver/portal-config-view.mjs +30 -1
  37. package/driver/portal-report.mjs +15 -1
  38. package/driver/portal-service.mjs +46 -6
  39. package/driver/predelivery-lint.mjs +12 -2
  40. package/driver/publish/index.mjs +46 -5
  41. package/driver/publish/knockout.mjs +10 -1
  42. package/driver/publish/render-knockout.mjs +69 -7
  43. package/driver/publish/render.mjs +170 -59
  44. package/driver/publish/report-data.mjs +4 -1
  45. package/driver/publish/report-topbar.mjs +58 -0
  46. package/driver/publish/templates/report.css +18 -1
  47. package/driver/publish/xlsx.mjs +13 -1
  48. package/driver/register-availability.mjs +2 -2
  49. package/driver/register-coverage.mjs +94 -1
  50. package/driver/register-digest-record.mjs +236 -11
  51. package/driver/register-plan.mjs +170 -0
  52. package/driver/result-noun-fields.mjs +2 -2
  53. package/driver/run-economics.mjs +41 -10
  54. package/driver/run-requirements.mjs +173 -9
  55. package/driver/runner.mjs +3 -3
  56. package/driver/stages.mjs +12 -8
  57. package/driver/suite-census.json +142 -64
  58. package/driver/systemd/README.md +7 -4
  59. package/driver/terminal-clamp.mjs +107 -1
  60. package/driver/tokens.mjs +169 -3
  61. package/driver/unit-environment.mjs +42 -15
  62. package/driver/unit-inventory.mjs +19 -2
  63. package/driver/verify.mjs +27 -0
  64. package/mcp-server/CHANGELOG.md +4 -0
  65. package/mcp-server/package.json +1 -1
  66. package/mcp-server/server.mjs +15 -1
  67. package/package.json +1 -1
  68. package/portal-ui/dist/assets/{index-5UyqAyNM.js → index-6jzO9HiX.js} +155 -79
  69. package/portal-ui/dist/index.html +1 -1
  70. package/portal-ui/package.json +1 -1
  71. package/providers/jx/README.md +2 -1
  72. package/providers/jx/src/turn-envelope.mjs +8 -3
  73. package/providers/oauth-mcp-bridge/CHANGELOG.md +4 -0
  74. package/providers/oauth-mcp-bridge/package.json +1 -1
  75. package/providers/uspto-local/README.md +1 -1
  76. package/scripts/authority-boundary-probe.mjs +4 -2
  77. package/scripts/env-audit.mjs +12 -6
  78. package/scripts/freeze-example-run.mjs +49 -16
  79. package/scripts/generated-files-are-current.mjs +69 -4
  80. package/scripts/settings-render-check.mjs +75 -2
  81. package/scripts/test-full.mjs +96 -3
  82. package/scripts/test-run.mjs +10 -0
  83. package/shared/deployment-box.mjs +7 -2
  84. package/shared/driver-dir.mjs +1 -1
  85. package/shared/names-in-force.mjs +1 -1
@@ -41,7 +41,9 @@
41
41
  import { resolveEffectiveProfile, recipeProseGuard, platformEntryErrors } from "./profiles.mjs";
42
42
  import { wantsPortalRoute, PORTAL_ROUTE_UNAVAILABLE } from "./enqueue-schema.mjs";
43
43
  import { gateResolvedPolicy, loadRecipes } from "./search-policy.mjs";
44
- import { readFlagSnapshot, registerTerritoriesFor } from "./flag-snapshot.mjs";
44
+ import { resolveTerritories } from "./effective-scope.mjs";
45
+ import { uncoveredTerritories, registerReachRefusal } from "./register-coverage.mjs";
46
+ import { readFlagSnapshot, registerTerritoriesFor, registerLabelFor } from "./flag-snapshot.mjs";
45
47
  import { config } from "./driver.config.mjs";
46
48
  import { resolveRequest } from "./resolve-request.mjs";
47
49
  import { checkResolvedProduct } from "./scope-rules.mjs";
@@ -56,10 +58,14 @@ import { checkResolvedProduct } from "./scope-rules.mjs";
56
58
  * `readable` says whether the profile store answered at all — the input `checkClearanceScopeRules`
57
59
  * needs to tell "this account has no default territories" apart from "we could not read the account".
58
60
  */
59
- /** The wired register's covered territories, read once per gate call. Never throws: a door that cannot
60
- * read the snapshot must still open. */
61
- function snapshotTerritories() {
62
- try { return registerTerritoriesFor(readFlagSnapshot(config.poolRootOrNull)); } catch { return undefined; }
61
+ /** The wired register as a door needs it: what it covers, and what to CALL it in a sentence a client
62
+ * reads. One read, both answers. Never throws — a door that cannot read the snapshot must still open,
63
+ * and `territories: undefined` is the fail-open answer every layer below already speaks. */
64
+ function snapshotRegister() {
65
+ try {
66
+ const snap = readFlagSnapshot(config.poolRootOrNull);
67
+ return { territories: registerTerritoriesFor(snap), label: registerLabelFor(snap) };
68
+ } catch { return { territories: undefined, label: null }; }
63
69
  }
64
70
 
65
71
  export function resolveForDoor(job) {
@@ -92,8 +98,12 @@ export function resolveForDoor(job) {
92
98
  * products.mjs and search-policy.mjs, for every door.
93
99
  */
94
100
  export function gateResolvedRequest({ job = null, profile = null, resolved = null, readable = true } = {},
95
- { availability = true, registerTerritories = undefined } = {}) {
101
+ { availability = true, registerTerritories = undefined, registerLabel = undefined } = {}) {
96
102
  const out = { errors: [], warnings: [], byCheck: {} };
103
+ // Read at most once per gate call, and not at all when both arms were given their answer — a test
104
+ // driving a Signa-shaped deployment must not need a snapshot on disk to do it.
105
+ let snapshot;
106
+ const register = () => (snapshot ??= snapshotRegister());
97
107
  // A resolution that could not be taken is not a refusal — see the fail-open note in the header.
98
108
  if (!resolved) return out;
99
109
  // A CLARIFY IS RELAYED VERBATIM. It is already an actionable sentence naming the selector that could
@@ -114,10 +124,34 @@ export function gateResolvedRequest({ job = null, profile = null, resolved = nul
114
124
  //
115
125
  // `undefined` is the fail-open answer at every layer below, so an unreadable or absent snapshot
116
126
  // leaves this arm silent and the runner's wall still decides — the door rule stated in the header.
117
- const terr = registerTerritories !== undefined ? registerTerritories : snapshotTerritories();
127
+ const terr = registerTerritories !== undefined ? registerTerritories : register().territories;
118
128
  const gateMsg = gateResolvedPolicy(resolved, { registerTerritories: terr });
119
129
  if (gateMsg) { out.errors.push(gateMsg); return out; }
120
130
  }
131
+ // ── A TERRITORY THIS REQUEST NAMES THAT THE WIRED REGISTER CANNOT SEARCH ──────────────────────────
132
+ //
133
+ // OUTSIDE the availability block, and that placement is the whole correctness of this arm. The portal
134
+ // and plan_run pass `availability:false` because they word an unavailable PRODUCT in their own words
135
+ // (the header's staff-prose split) — so an arm written inside that block cannot fire at the two doors
136
+ // a client actually orders through, and it would fail to fire SILENTLY, green, on the case the ruling
137
+ // of 2026-09-17 is about. This refusal carries no switch name and no internal key, so unlike the
138
+ // availability twin it is the same sentence on every surface and needs no client-facing rewording.
139
+ //
140
+ // THE TERRITORIES ARE RESOLVED HERE, NOT THREADED IN. `resolveTerritories` is the same ladder
141
+ // effective-scope.mjs runs for the scope a door prints beside this refusal, so the two cannot
142
+ // disagree. Asking each of the six doors to pass its own scope would let one forget the argument and
143
+ // fail open in silence — the exact shape `register-coverage-doors.test.mjs` exists to catch one layer
144
+ // up. One call, in the one gate they all share, covers every door by construction.
145
+ //
146
+ // Fail-open on a throw, the door's rule: an unreadable profile store must not stop somebody searching.
147
+ {
148
+ let named = [];
149
+ try { named = resolveTerritories(job ?? {}, profile, resolved?.recipeScope ?? null).jurisdictions ?? []; } catch { named = []; }
150
+ const terr = registerTerritories !== undefined ? registerTerritories : register().territories;
151
+ const label = registerLabel !== undefined ? registerLabel : register().label;
152
+ const refusal = registerReachRefusal(uncoveredTerritories(named, terr), label);
153
+ if (refusal) { out.errors.push(refusal); return out; }
154
+ }
121
155
  const gates = checkResolvedProduct({ job, profile, resolved, profileReadable: profile !== null && readable });
122
156
  out.errors.push(...gates.errors);
123
157
  out.warnings.push(...gates.warnings);
@@ -7,10 +7,11 @@
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
@@ -616,12 +617,12 @@ export const MODELS = {
616
617
  gemini: "google/gemini-3.1-pro-preview",
617
618
  "gemini-flash": "google/gemini-3-flash-preview",
618
619
  "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",
620
+ // azure = a legacy catalogue entry for an Azure GPT deployment. NO STAGE NAMES IT AND NO ENGINE RUNS IT:
621
+ // the Claude adapter refuses the alias and the codex adapter maps no tier onto it. So its target is a
622
+ // constant. The setting that used to override it was retired on 2026-09-15 and nothing reads it any
623
+ // more; the configuration reference lists it under the settings that do nothing. It is named only
624
+ // there, in prose, because a name written in a source file here counts as a name this build reads.
625
+ azure: "azure-openai/gpt-5.4",
625
626
  };
626
627
 
627
628
  // alias → full id; a value that's already a full provider/model id (contains "/") passes through.
@@ -659,6 +660,13 @@ export function resolveModel(model) {
659
660
  * never a default. A null on either side makes the comparison UNKNOWN, and an unknown must never be
660
661
  * recorded as a match — that is the absence-read-as-a-pass class this whole issue is about.
661
662
  */
663
+ // FABLE IS NOT A FAMILY HERE, ON PURPOSE. Nobody has seen what the wire reports for a fable turn, so an id
664
+ // naming fable stays unknown and its comparison can never manufacture a mismatch. Placing it was tried
665
+ // (2026-09-15) and refused turns that had always run: a fable request served by claude-sonnet-5, by
666
+ // claude-opus-5, by a deployment named after another tier or by a GPT id, an opus request served as
667
+ // claude-fable-5-1, and any tier served under a name beginning with the word. It was taken out again the
668
+ // same day. The report's model line still names Fable: servedModels (tokens.mjs) reads a fable request's tier
669
+ // itself, where only the report sees it, and never through this comparison.
662
670
  const MODEL_FAMILY_RE = /(?:^|\/)(?:claude-)?(opus|sonnet|haiku)(?:[-.]|$)/i;
663
671
 
664
672
  // — THE OPENAI SIDE, added when the codex path could first answer "what ran".
@@ -1910,26 +1918,32 @@ export const ENGINE_BINARIES = {
1910
1918
  // `vendor` is the one word a person needs — the staff config page answers "which engine is
1911
1919
  // running the searches", and `label` below is the MECHANISM, which is what took off that page.
1912
1920
  vendor: "Anthropic",
1921
+ // `product` is the name a reader knows the program by, beside the vendor in setup's engine question
1922
+ // ("Claude, by Anthropic"). Not `fallback`, which is the command word and lower-case.
1923
+ product: "Claude",
1913
1924
  env: "CLEAROTRON_CLAUDE_PATH", fallback: "claude",
1925
+ // The npm package that carries this program, and the oldest version setup installs: "this version or
1926
+ // newer", with no ceiling. Setup installs it into the engines folder (enginesFolder, below the table)
1927
+ // when the reader picks this engine, and the resolver uses it only when the machine has no copy of its
1928
+ // own. The package's own `bin` field names the program, so no path inside it is written down here.
1929
+ package: "@anthropic-ai/claude-code", floor: "2.1.270",
1930
+ // WHAT THE INSTALL TAKES ON DISK, in MB, which setup states before it asks to install. MEASURED, not
1931
+ // declared by the vendor: the engines folder after a fresh install of this package into an empty
1932
+ // folder, on npm 10.9.8 and on 11.19.1, 2026-09-14. A later release can be larger or smaller, so setup
1933
+ // says "about". Re-measure when the floor moves.
1934
+ installMB: 214,
1935
+ // The licence setup states wherever it tells a reader what they are about to install or use, as the
1936
+ // vendor's package declares it: "SEE LICENSE IN README.md", Anthropic's own terms.
1937
+ licence: "proprietary third-party software",
1914
1938
  label: "Anthropic — each stage runs as a headless `claude -p` turn",
1915
1939
  module: "engine/anthropic-agent.mjs", adapter: "anthropicAgentEngine",
1916
1940
  signIn: "run `claude` once in a terminal and complete the sign-in",
1917
1941
  authEnv: "CLEAROTRON_AI_BILLING", apiKeyEnv: "ANTHROPIC_API_KEY",
1918
1942
  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" },
1943
+ // `install`, the command that puts this program in the engines folder, is written in below the table
1944
+ // from `package` and `floor`, so the command a reader is shown and the one setup runs cannot drift.
1945
+ // npm, NOT the vendor's shell installer: a command this product executes on someone's box has to be
1946
+ // one they can read in full before they answer, and a piped remote script is not.
1933
1947
  // — the documented headless ending. `claude setup-token` walks the sign-in and
1934
1948
  // prints a long-lived token; the stage subprocess env is a spread of the driver's — spawnEnv
1935
1949
  // strips ONLY the API key under subscription — so a token in the env file reaches the CLI
@@ -1940,23 +1954,66 @@ export const ENGINE_BINARIES = {
1940
1954
  },
1941
1955
  "openai-agent": {
1942
1956
  vendor: "OpenAI",
1957
+ product: "Codex",
1943
1958
  env: "CLEAROTRON_CODEX_PATH", fallback: "codex",
1959
+ package: "@openai/codex", floor: "0.154.0",
1960
+ installMB: 324, // measured the same way and on the same day as Claude's, above
1961
+ licence: "third-party software under the Apache-2.0 licence", // the package's own "license" field
1944
1962
  label: "OpenAI — each stage runs as a headless `codex exec` turn",
1945
1963
  module: "engine/openai-agent.mjs", adapter: "openaiAgentEngine",
1946
1964
  signIn: "run `codex login`",
1947
1965
  authEnv: "CLEAROTRON_AI_BILLING", apiKeyEnv: "CODEX_API_KEY",
1948
1966
  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
1967
  // — codex's headless ending writes its own ~/.codex/auth.json; there is no
1955
1968
  // token to capture into an env file, and inventing one would be a route nobody has driven.
1956
1969
  headless: { cmd: "codex login --device-auth", tokenEnv: null },
1957
1970
  },
1958
1971
  };
1959
1972
 
1973
+ // ── WHERE SETUP INSTALLS AN ENGINE'S PROGRAM, AND THE COMMAND THAT DOES IT ─────────────────────────────
1974
+ //
1975
+ // Nothing is bundled into the package. Setup installs the ONE program the reader's engine runs, when they
1976
+ // say yes, as an ordinary npm project in a folder Clearotron owns under their home directory: npm then
1977
+ // fetches that platform's binary and nothing else, and needs no root. The folder is not on PATH, so a copy
1978
+ // the machine installs itself is found first (resolveEngineProgram). `clearotron update` runs the same
1979
+ // install again, which moves the program to the newest its vendor publishes: measured on npm 10.9.8 and
1980
+ // 11.19.1, re-running an install with a `>=` range over an older copy moves it to the newest, where
1981
+ // `npm update` would stop at the caret npm writes into the folder's package.json.
1982
+
1983
+ /** Where to install, and look for, the engine programs instead of the default folder. An EMPTY directory means none. */
1984
+ export const ENGINES_DIR_ENV = "CLEAROTRON_ENGINES_DIR";
1985
+
1986
+ /** The default engines folder as a reader types it. */
1987
+ const ENGINES_FOLDER_TYPED = "~/.local/share/clearotron/engines";
1988
+
1989
+ /**
1990
+ * The folder setup installs engine programs into and the resolver's last step reads: ENGINES_DIR_ENV from
1991
+ * this process when set, otherwise `~/.local/share/clearotron/engines`. Under the home directory rather
1992
+ * than the install's own tree, because an update replaces that tree, and because every service runs as a
1993
+ * user unit under the same home as the setup that installed it. XDG_DATA_HOME is not consulted: a shell's
1994
+ * value does not reach the services, and the two would then look in different folders.
1995
+ */
1996
+ export function enginesFolder({ env = process.env, home = homedir() } = {}) {
1997
+ return String(env[ENGINES_DIR_ENV] ?? "").trim() || join(home, ".local", "share", "clearotron", "engines");
1998
+ }
1999
+
2000
+ /** The npm arguments that install, or refresh, an engine's program in `dir`: "this version or newer". */
2001
+ export function engineInstallArgs(spec, dir = enginesFolder()) {
2002
+ return ["install", "--prefix", dir, "--no-fund", "--no-audit", `${spec.package}@>=${spec.floor}`];
2003
+ }
2004
+
2005
+ /** A shell word as a reader pastes it: quoted when it must be, because a bare `>=` is a redirection. */
2006
+ const shellWord = (w) => (/^[\w@%+=:,./~-]+$/.test(w) ? w : `'${String(w).replace(/'/g, "'\\''")}'`);
2007
+
2008
+ /** The same install as a command a reader can paste. The default folder is written the way they type it. */
2009
+ export function engineInstallCommand(spec, dir = ENGINES_FOLDER_TYPED) {
2010
+ return ["npm", ...engineInstallArgs(spec, dir)].map(shellWord).join(" ");
2011
+ }
2012
+
2013
+ // The printable command each engine carries, for the readers that show one without a folder to hand (the
2014
+ // portal's engine page, doctor's way out of demo mode), made from the same parts as the install setup runs.
2015
+ for (const spec of Object.values(ENGINE_BINARIES)) spec.install = engineInstallCommand(spec);
2016
+
1960
2017
  /** The production default, in ONE place rather than a literal repeated at every reader. */
1961
2018
  export const DEFAULT_ENGINE_ID = "anthropic-agent";
1962
2019
 
@@ -1971,48 +2028,190 @@ export function engineAdapterSpecifier(engine) {
1971
2028
  return spec ? pathToFileURL(join(DRIVER_DIR, spec.module)).href : null;
1972
2029
  }
1973
2030
 
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;
2031
+ // ── WHERE AN ENGINE'S PROGRAM IS FOUND: ONE PLACE, AND EVERY READER ASKS IT ─────────────────────────────
2032
+ //
2033
+ // The run door, the inventory the portal reads, the wizard, doctor and both adapters all ask this one
2034
+ // function. Before it there were four answers: this file's PATH walk, the wizard's own walk (the only one
2035
+ // that passed over a Windows copy under WSL), and each adapter handing spawn(2) a bare word so the OS made
2036
+ // its own choice. They agreed only while every copy lived on PATH.
2037
+ //
2038
+ // THE ORDER, and why the machine's own copy wins:
2039
+ // 1. The explicit setting (`CLEAROTRON_CLAUDE_PATH` / `CLEAROTRON_CODEX_PATH`). A value that is set and
2040
+ // unusable is REPORTED, never overruled: the reader stated it, and quietly resolving somewhere else
2041
+ // would run a program nobody chose. The engine's own fallback word (`claude`, `codex`) is the default
2042
+ // spelled out, which is how the shipped example files write it, so it means exactly what unset means.
2043
+ // 2. The program on PATH: the machine's own install, which keeps updating itself.
2044
+ // 3. The copy setup installed in the engines folder (enginesFolder, above), only when the machine has
2045
+ // none. That folder is not on PATH. If a reader puts its node_modules/.bin there, a PATH hit that IS
2046
+ // that copy is passed over in step 2 and taken for what it is in step 3, so it is never reported, or
2047
+ // written into a settings file, as the machine's own.
2048
+ //
2049
+ // FILESYSTEM ONLY, like the rest of this door (see the header above ENGINE_BINARIES): nothing is spawned.
2050
+
2051
+ /** A path on a Windows drive as WSL mounts it. */
2052
+ export const ON_A_WINDOWS_DRIVE = /^\/mnt\/[a-z]\//i;
2053
+
2054
+ const isExecFile = (p) => { try { return statSync(p).isFile() && (accessSync(p, X_OK), true); } catch { return false; } };
2055
+ const realOrNull = (p) => { try { return realpathSync(p); } catch { return null; } };
2056
+ const readPackage = (dir) => { try { return JSON.parse(readFileSync(join(dir, "package.json"), "utf8")); } catch { return null; } };
2057
+
2058
+ /**
2059
+ * Whether the kernel runs this file ITSELF: a native executable (ELF, Mach-O, a Windows PE) or a `#!` script.
2060
+ *
2061
+ * Asked only of a file inside the vendor's own package, for one measured reason. The Claude package ships
2062
+ * `bin/claude.exe` as a 500-byte shell placeholder and puts the real program over it in its install step.
2063
+ * With `--ignore-scripts`, or with the platform's native package missing, the placeholder stays. It is a
2064
+ * regular file with the execute bit, so the executable check accepts it, and spawn runs it through `sh`:
2065
+ * it prints "claude native binary not installed" and exits 1, at every stage. It has neither `#!` nor a
2066
+ * binary header, so this refuses it at the door instead. A file anywhere else is judged as before; a
2067
+ * reader's own wrapper script is not ours to second-guess.
2068
+ */
2069
+ function runsDirectly(file) {
2070
+ const head = Buffer.alloc(4);
2071
+ let fd = null, n = 0;
2072
+ try { fd = openSync(file, "r"); n = readSync(fd, head, 0, 4, 0); }
2073
+ catch { return false; }
2074
+ finally { if (fd !== null) { try { closeSync(fd); } catch { /* nothing left to release */ } } }
2075
+ if (n >= 2 && head[0] === 0x23 && head[1] === 0x21) return true; // #!
2076
+ if (n >= 2 && head[0] === 0x4d && head[1] === 0x5a) return true; // MZ
2077
+ if (n < 4) return false;
2078
+ const word = head.readUInt32BE(0);
2079
+ return word === 0x7f454c46 // ELF
2080
+ || [0xfeedface, 0xfeedfacf, 0xcefaedfe, 0xcffaedfe, 0xcafebabe].includes(word); // Mach-O, thin and universal
2081
+ }
2082
+
2083
+ /** The npm package a file belongs to: the nearest package.json within a few levels of its real path. */
2084
+ function owningPackage(file) {
2085
+ let d = dirname(realOrNull(file) ?? file);
2086
+ for (let i = 0; i < 4; i++) {
2087
+ const pkg = readPackage(d);
2088
+ if (pkg) return { name: pkg.name ?? null, version: pkg.version ?? null };
2089
+ const up = dirname(d);
2090
+ if (up === d) break;
2091
+ d = up;
1982
2092
  }
1983
2093
  return null;
1984
2094
  }
1985
2095
 
2096
+ /** The directory the installed copy of `spec.package` lives in under the engines folder `root`, or null. */
2097
+ function installedPackageDir(spec, root) {
2098
+ if (!spec.package || !root) return null;
2099
+ const dir = join(root, "node_modules", ...spec.package.split("/"));
2100
+ return existsSync(join(dir, "package.json")) ? dir : null;
2101
+ }
2102
+
2103
+ /** The program that installed copy declares for this engine, by the package's own `bin` field, or null. */
2104
+ function installedProgram(spec, root) {
2105
+ const dir = installedPackageDir(spec, root);
2106
+ if (!dir) return null;
2107
+ const pkg = readPackage(dir);
2108
+ const rel = typeof pkg?.bin === "string" ? pkg.bin : pkg?.bin?.[spec.fallback];
2109
+ return rel ? join(dir, rel) : null;
2110
+ }
2111
+
2112
+ /** Can this candidate be spawned as the engine? `{ok: true, version}` or `{ok: false, why}`. */
2113
+ function engineCandidate(p, spec) {
2114
+ 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)" };
2115
+ const own = owningPackage(p);
2116
+ const vendor = Boolean(spec.package) && own?.name === spec.package;
2117
+ if (vendor && !runsDirectly(p)) {
2118
+ return { ok: false, why: `the placeholder ${spec.package} leaves when its install step did not run (npm was given `
2119
+ + "--ignore-scripts, or the platform's native package is missing), so every stage would print an error and "
2120
+ + "exit. Reinstall without --ignore-scripts" };
2121
+ }
2122
+ return { ok: true, version: vendor ? own.version : null };
2123
+ }
2124
+
2125
+ /**
2126
+ * Find the program an engine spawns. Never throws: refusing is the caller's decision (preflightEngineBinary).
2127
+ *
2128
+ * Returns `{engine, binEnv, bin, explicit, relative, resolved, source, version, windowsShim, skipped, rejected}`.
2129
+ * `resolved` is an absolute path or null. `source` is "explicit" | "path" | "installed" | null. `version` is
2130
+ * read from the copy's own package.json when npm installed it, and null otherwise, because nothing is
2131
+ * spawned to ask. `skipped` lists Windows copies passed over under WSL; `rejected` lists candidates that
2132
+ * could not run, each with its reason and the step that found it (`source`).
2133
+ *
2134
+ * `enginesDir`: undefined reads the engines folder (enginesFolder: ENGINES_DIR_ENV from THIS process, then
2135
+ * the default under the home directory); a directory looks there; null looks nowhere. It is read from the
2136
+ * process rather than from `env` because it describes this user's install, not the configuration being
2137
+ * asked about: doctor asks about the units' environment from a shell, and the folder is the same either
2138
+ * way, because the services run as user units under the same home. `wsl` and `onWindowsDrive` are
2139
+ * injectable for the reason shared/wsl.mjs gives.
2140
+ */
2141
+ export function resolveEngineProgram(engine, { env = process.env, enginesDir = undefined, wsl = null, onWindowsDrive = null } = {}) {
2142
+ const id = String(engine ?? "").trim().toLowerCase();
2143
+ const spec = ENGINE_BINARIES[id];
2144
+ const out = { engine: id, binEnv: spec?.env ?? null, bin: null, explicit: false, relative: false,
2145
+ resolved: null, source: null, version: null, windowsShim: false, skipped: [], rejected: [] };
2146
+ if (!spec) return out;
2147
+ const set = String(envFrom(env, spec.env) ?? "").trim();
2148
+ out.explicit = Boolean(set) && set !== spec.fallback;
2149
+ out.bin = out.explicit ? set : spec.fallback;
2150
+ const underWsl = wsl ?? isWsl({ env });
2151
+ const onDrive = onWindowsDrive ?? ((p) => ON_A_WINDOWS_DRIVE.test(p));
2152
+ const take = (p, source) => {
2153
+ const c = engineCandidate(p, spec);
2154
+ if (!c.ok) { out.rejected.push({ path: p, why: c.why, source }); return false; }
2155
+ Object.assign(out, { resolved: p, source, version: c.version });
2156
+ return true;
2157
+ };
2158
+
2159
+ if (out.explicit && out.bin.includes("/")) {
2160
+ if (!isAbsolute(out.bin)) { out.relative = true; return out; }
2161
+ out.windowsShim = underWsl && onDrive(out.bin);
2162
+ take(out.bin, "explicit");
2163
+ return out;
2164
+ }
2165
+
2166
+ // THE PATH WALK splits on the platform's own delimiter. This file's walk used to split on ":", which
2167
+ // tears a Windows PATH at every drive letter; the wizard's walk already used the delimiter.
2168
+ const installed = out.explicit ? null : installedProgram(spec, enginesDir !== undefined ? enginesDir : enginesFolder());
2169
+ const installedReal = installed ? realOrNull(installed) : null;
2170
+ for (const dir of String(env.PATH ?? "").split(delimiter).filter(Boolean)) {
2171
+ const p = join(dir, out.bin);
2172
+ if (!isExecFile(p)) continue;
2173
+ // UNDER WSL THE WINDOWS PATH IS APPENDED TO THIS ONE, so `claude` on a fresh WSL2 Ubuntu resolves to the
2174
+ // Windows build before any Linux install. It is executable, and it fails the proof turn as "not signed
2175
+ // in" because the credential it looks for is the Linux one. Passed over, and named for the caller to say.
2176
+ if (underWsl && onDrive(p)) { out.skipped.push(p); continue; }
2177
+ if (installedReal && realOrNull(p) === installedReal) continue;
2178
+ if (take(p, out.explicit ? "explicit" : "path")) return out;
2179
+ }
2180
+ if (installed) take(installed, "installed");
2181
+ return out;
2182
+ }
2183
+
1986
2184
  /**
1987
- * Refuse a run whose engine binary is missing, unexecutable, or written as a relative path.
2185
+ * Refuse a run whose engine program cannot be found or run, or is written as a relative path.
1988
2186
  *
1989
2187
  * 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.
2188
+ * context is built. Returns {engine, binEnv, bin, resolved, source, version} when the program is usable:
2189
+ * the answer of resolveEngineProgram above, which every other reader asks too.
1991
2190
  *
1992
2191
  * An UNKNOWN CLEAROTRON_AI returns without checking rather than throwing a second, differently-worded
1993
2192
  * version of gateway.selectEngine's error. One definition of "that is not an engine", and it is the
1994
2193
  * registry's.
1995
2194
  */
1996
- export function preflightEngineBinary(env = process.env, { platform = process.platform } = {}) {
2195
+ export function preflightEngineBinary(env = process.env, { platform = process.platform, enginesDir = undefined, wsl = null, onWindowsDrive = null } = {}) {
1997
2196
  // item 3 — NATIVE WINDOWS REFUSES BY NAME, BEFORE ANYTHING READS PATH.
1998
2197
  //
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.
2198
+ // INSTALL.md promises a native-Windows run "refuses at preflight". Nothing implemented it, so what a
2199
+ // Windows user actually got was a PATH walk that split on ":", tore `C:\Users\…` in half at the drive
2200
+ // letter, and reported their `claude.cmd` as "not on PATH". The walk now splits on the platform's own
2201
+ // delimiter, and the refusal stands anyway, because its real grounds were never the lookup: a stage runs
2202
+ // as its own process group and is stopped by signalling that group, an immediate stop identifies the
2203
+ // process from /proc or ps, and the write-boundary hook is quoted for a POSIX shell. Native Windows has
2204
+ // none of that, and where the program is found changes none of it.
2004
2205
  //
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.
2206
+ // This fires FIRST so that no message about a PATH reaches a reader whose platform is the answer.
2007
2207
  //
2008
2208
  // `platform` is injectable so the refusal is testable off win32 — the population this protects is the
2009
2209
  // one that cannot run this suite to find out.
2010
2210
  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).");
2211
+ throw new Error("[preflight] this engine does not run on native Windows. Each stage runs as its own process "
2212
+ + "group and is stopped by signalling that group, which native Windows cannot do, so a stopped stage would "
2213
+ + "leave the tools it started still running. Run it under WSL2, or in the devcontainer (.devcontainer/), "
2214
+ + "where the documented install path applies unchanged.");
2016
2215
  }
2017
2216
  const engine = (env.CLEAROTRON_AI || DEFAULT_ENGINE_ID).trim().toLowerCase();
2018
2217
  const spec = ENGINE_BINARIES[engine];
@@ -2033,24 +2232,38 @@ export function preflightEngineBinary(env = process.env, { platform = process.pl
2033
2232
  // `envFrom` is therefore BELT-AND-BRACES, not the repair: it makes this site correct on its own terms
2034
2233
  // rather than correct because something upstream normalised the environment first — a coupling
2035
2234
  // 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)) {
2235
+ const r = resolveEngineProgram(engine, { env, enginesDir, wsl, onWindowsDrive }); // injectable for the reason it gives
2236
+ const bin = r.bin;
2237
+ const setTo = String(envFrom(env, spec.env) ?? "").trim();
2238
+ const where = `${spec.env}${r.explicit ? "" : setTo
2239
+ ? ` (set to its default "${spec.fallback}": the one on PATH, then the copy Clearotron installed)`
2240
+ : ` (unset — defaulting to "${spec.fallback}" on PATH, then the copy Clearotron installed)`}`;
2241
+
2242
+ if (r.relative) {
2040
2243
  throw new Error(`[preflight] ${where} is the RELATIVE path "${bin}", which cannot work: the engine is `
2041
2244
  + "spawned with the RUN DIRECTORY as its cwd (#524), not the repo, so a relative command is looked "
2042
2245
  + `for inside the run. Give an absolute path — e.g. ${join(REPO_ROOT, bin)} — or a bare name on PATH.`);
2043
2246
  }
2044
2247
 
2045
- const resolved = resolveExecutable(bin, env);
2046
- if (!resolved) {
2248
+ if (!r.resolved) {
2249
+ const passedOver = r.rejected.map((x) => `${x.path} is ${x.why}`).join("; ");
2250
+ // "None installed" only when none was. An installed copy that cannot run is named in the passed-over
2251
+ // list with its reason, and the claim beside it would send the reader to install it again.
2252
+ const installedRefused = r.rejected.some((x) => x.source === "installed");
2253
+ // Under WSL the Windows copies on the appended PATH were passed over on purpose, and the reader's own
2254
+ // `which` still prints them, so the refusal names them and says why.
2255
+ const windows = r.skipped.length
2256
+ ? `. Passed over because they sit on a Windows drive, and a Windows build cannot run a stage here: ${r.skipped.join(", ")}` : "";
2047
2257
  throw new Error(`[preflight] the ${engine} engine cannot run: ${where} names "${bin}", which is `
2048
2258
  + (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)"})`)
2259
+ ? (r.rejected[0]?.why ?? "not an executable file (it is missing, is a directory, or lacks the execute bit for this user)")
2260
+ : `not on PATH as an executable file (PATH=${env.PATH || "(empty)"})`
2261
+ + (r.explicit || installedRefused ? "" : ", and Clearotron has not installed one")
2262
+ + (passedOver ? `. Passed over: ${passedOver}` : "")
2263
+ + windows)
2051
2264
  + ". Every stage of a run spawns it, so the run is refused now rather than at the first stage.");
2052
2265
  }
2053
- return { engine, binEnv: spec.env, bin, resolved };
2266
+ return { engine, binEnv: spec.env, bin, resolved: r.resolved, source: r.source, version: r.version };
2054
2267
  }
2055
2268
 
2056
2269
  // Report which deployment hostnames are unset, so the runner can say so out loud at activation.
@@ -41,6 +41,8 @@ ladder consumes it without knowing which engine produced it ([gateway.mjs](../ga
41
41
  sessionRef: string | null, // opaque resume handle (claude session_id | codex thread_id)
42
42
  modelWire: string | null, // MODEL GAUGE — the served model id this turn observed (§3);
43
43
  // null = nothing was observed, never the requested alias
44
+ providerWire: string | null, // PROVIDER GAUGE — the program's own word for who served the turn
45
+ // ("firstParty", "foundry"); null = not said, or said inconsistently
44
46
  signals: { stalled?, noProgress?, hardWall?, rateLimited?, rateLimitBasis?, resetsAt?,
45
47
  resetsAtBasis?, usageStreamed?, noStreamEvents?, thought: bool|null },
46
48
  // THINKING GAUGE — see below.
@@ -139,8 +141,8 @@ Stages must name **abstract tiers**, not provider aliases. Per-engine maps:
139
141
 
140
142
  | tier | role (stage examples) | anthropic-agent (claude alias) | openai-agent (codex `-m`) |
141
143
  |---|---|---|---|
142
- | `judgment` | matter-frame, register-digest, synthesis, narrative-refutation | `claude-opus-5` (pinned) | `$CLEAROTRON_OPENAI_MODEL_JUDGMENT` |
143
- | `sweep` | register-unit, case-law, skeptic, report-overview, report-card | `claude-sonnet-5` (pinned) | `$CLEAROTRON_OPENAI_MODEL_SWEEP` |
144
+ | `judgment` | matter-frame, register-digest, synthesis, narrative-refutation | `opus` | `$CLEAROTRON_OPENAI_MODEL_JUDGMENT` |
145
+ | `sweep` | register-unit, case-law, skeptic, report-overview, report-card | `sonnet` | `$CLEAROTRON_OPENAI_MODEL_SWEEP` |
144
146
  | `cheap` | saturation-probe | `haiku` | `$CLEAROTRON_OPENAI_MODEL_CHEAP` |
145
147
 
146
148
  **AN UNHONOURED OVERRIDE IS AN ERROR, NOT A SUBSTITUTION** ( corruption 3, 2026-08-03). This
@@ -150,7 +152,12 @@ anthropic engine, "grade-moving, validated only in the paid A/B". The substituti
150
152
  and could not be: the telemetry logged the alias that was ASKED FOR, so an arm run at gemini reported
151
153
  gemini and ran sonnet. Both tiers are gone — the failover chain was deleted in and both stages
152
154
  declare an anthropic tier in `STAGES` — and every engine's model map now **refuses** an alias it cannot
153
- run (`claudeModel`, `openaiModel`). A concrete provider id passes through; anything else throws.
155
+ run (`claudeModel`, `openaiModel`). On the anthropic engine a tier goes as the vendor's alias, a catalog
156
+ id in the table (`anthropic/claude-opus-5`) goes as itself, a bare or dated `claude-*` id goes as its
157
+ family's alias, and anything else throws. To hold a tier on one model, set the vendor's own
158
+ `ANTHROPIC_DEFAULT_OPUS_MODEL` / `_SONNET_MODEL` / `_HAIKU_MODEL`, or `ANTHROPIC_DEFAULT_FABLE_MODEL` for
159
+ `fable`, which no stage asks for unless an override names it, as `CLEAROTRON_SYNTHESIS_MODEL=fable` does; each
160
+ reaches the CLI through the stage's environment.
154
161
 
155
162
  **Model provenance — two fields, never collapsed.** Every dispatch row (`_driver/<stage>.jsonl`) and
156
163
  every `attempt` row (`_driver/run.jsonl`) carries:
@@ -13,7 +13,7 @@ does not exist — that is the design, and [`CONTRACT.md`](CONTRACT.md) is the d
13
13
  | [`CONTRACT.md`](CONTRACT.md) | **The adapter contract.** What an engine must implement, the model-tier map, and what a turn is allowed to assume. Read this before either adapter |
14
14
  | `anthropic-agent.mjs` | Spawns `claude -p`. Skill-reference absolutization, `--add-dir` grants, rate-limit and no-progress handling |
15
15
  | `openai-agent.mjs` | Spawns `codex exec`. A per-run `CODEX_HOME` carrying a rendered `config.toml`, and the session-rollout reader that recovers the turn's usage |
16
- | `auth.mjs` | `resolveAuthMode()` — subscription or API key, resolved once per turn and stamped on the telemetry |
16
+ | `auth.mjs` | `resolveAuthMode()` — subscription, API key or (Claude only) cloud account, resolved once per turn and stamped on the telemetry |
17
17
  | `probe.mjs` | Drives one cheap turn through whichever adapter is configured, to prove the engine can complete a turn at all. What `npm run setup` spends |
18
18
  | `common.mjs` | Helpers both adapters share |
19
19
  | `deny-authority-write.mjs` | A PreToolUse hook. `--add-dir` has no read-only form, so the read-only intent over the skills tree is enforced here |
@@ -27,7 +27,7 @@ succeeding on the other one. A run's manifest records which engine served it.
27
27
 
28
28
  ## Billing mode is resolved here, and it fails loud
29
29
 
30
- `resolveAuthMode()` is the single place "subscription or API key" is decided, and it is deliberately
30
+ `resolveAuthMode()` is the single place a turn's billing mode (subscription, API key or cloud account) is decided, and it is deliberately
31
31
  unforgiving in one direction:
32
32
 
33
33
  ```