clearotron 0.3.2-beta.6 → 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 (98) 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 +82 -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 +83 -0
  11. package/driver/band-size.mjs +59 -0
  12. package/driver/config-inventory.mjs +112 -9
  13. package/driver/connotation-search.mjs +45 -0
  14. package/driver/contract-arm2-baseline.json +1 -3
  15. package/driver/contract-e3-backlog.mjs +29 -29
  16. package/driver/contract-vocabulary.mjs +59 -25
  17. package/driver/door-gates.mjs +41 -7
  18. package/driver/driver.config.mjs +272 -59
  19. package/driver/engine/CONTRACT.md +10 -3
  20. package/driver/engine/README.md +2 -2
  21. package/driver/engine/anthropic-agent.mjs +77 -21
  22. package/driver/engine/auth.mjs +129 -10
  23. package/driver/engine/jx-turn.mjs +7 -6
  24. package/driver/engine/mcp/recording-server.mjs +13 -0
  25. package/driver/engine/openai-agent.mjs +4 -2
  26. package/driver/engine/probe.mjs +110 -23
  27. package/driver/findings-model.mjs +1 -1
  28. package/driver/flag-snapshot.mjs +28 -5
  29. package/driver/gateway.mjs +30 -21
  30. package/driver/jx-lanes.mjs +21 -2
  31. package/driver/jx-units.mjs +6 -3
  32. package/driver/jx.mjs +4 -2
  33. package/driver/matter-frame-record.mjs +90 -1
  34. package/driver/named-band.mjs +34 -2
  35. package/driver/package.json +1 -1
  36. package/driver/pipeline.mjs +391 -26
  37. package/driver/portal-config-view.mjs +30 -1
  38. package/driver/portal-report.mjs +15 -1
  39. package/driver/portal-service.mjs +46 -6
  40. package/driver/predelivery-lint.mjs +12 -2
  41. package/driver/publish/index.mjs +46 -5
  42. package/driver/publish/knockout.mjs +10 -1
  43. package/driver/publish/render-knockout.mjs +69 -7
  44. package/driver/publish/render.mjs +170 -59
  45. package/driver/publish/report-data.mjs +4 -1
  46. package/driver/publish/report-topbar.mjs +58 -0
  47. package/driver/publish/templates/report.css +28 -2
  48. package/driver/publish/xlsx.mjs +13 -1
  49. package/driver/register-availability.mjs +2 -2
  50. package/driver/register-coverage.mjs +94 -1
  51. package/driver/register-digest-record.mjs +236 -11
  52. package/driver/register-plan.mjs +170 -0
  53. package/driver/result-noun-fields.mjs +7 -4
  54. package/driver/run-economics.mjs +41 -10
  55. package/driver/run-requirements.mjs +173 -9
  56. package/driver/runner.mjs +3 -3
  57. package/driver/stages.mjs +12 -8
  58. package/driver/suite-census.json +162 -72
  59. package/driver/systemd/README.md +7 -4
  60. package/driver/terminal-clamp.mjs +107 -1
  61. package/driver/tokens.mjs +169 -3
  62. package/driver/unit-environment.mjs +42 -15
  63. package/driver/unit-inventory.mjs +19 -2
  64. package/driver/verify.mjs +50 -5
  65. package/mcp-server/CHANGELOG.md +8 -0
  66. package/mcp-server/http-server.mjs +4 -0
  67. package/mcp-server/lib/audit.mjs +11 -1
  68. package/mcp-server/lib/http-handler.mjs +6 -2
  69. package/mcp-server/package.json +1 -1
  70. package/mcp-server/server.mjs +16 -2
  71. package/package.json +1 -1
  72. package/portal-ui/dist/assets/{index-5UyqAyNM.js → index-6jzO9HiX.js} +155 -79
  73. package/portal-ui/dist/index.html +1 -1
  74. package/portal-ui/package.json +1 -1
  75. package/providers/clarivate/src/capabilities.js +5 -5
  76. package/providers/clarivate/src/core.js +1 -1
  77. package/providers/corsearch/src/core.js +2 -2
  78. package/providers/jx/README.md +2 -1
  79. package/providers/jx/src/turn-envelope.mjs +8 -3
  80. package/providers/oauth-mcp-bridge/CHANGELOG.md +8 -0
  81. package/providers/oauth-mcp-bridge/package.json +1 -1
  82. package/providers/perplexity/src/core.js +3 -3
  83. package/providers/signa/src/capabilities.js +5 -6
  84. package/providers/signa/src/core.js +1 -1
  85. package/providers/uspto-local/README.md +1 -1
  86. package/scripts/authority-boundary-probe.mjs +4 -2
  87. package/scripts/env-audit.mjs +12 -6
  88. package/scripts/freeze-example-run.mjs +49 -16
  89. package/scripts/generated-files-are-current.mjs +69 -4
  90. package/scripts/release-duplicate-notes.mjs +246 -0
  91. package/scripts/release-publish-guard.mjs +64 -6
  92. package/scripts/report-print-check.mjs +194 -0
  93. package/scripts/settings-render-check.mjs +75 -2
  94. package/scripts/test-full.mjs +96 -3
  95. package/scripts/test-run.mjs +10 -0
  96. package/shared/deployment-box.mjs +7 -2
  97. package/shared/driver-dir.mjs +1 -1
  98. package/shared/names-in-force.mjs +1 -1
@@ -6,7 +6,7 @@
6
6
  //
7
7
  // Contract: engine/CONTRACT.md. runTurn() returns the registry-standard normalized tuple
8
8
  // (`{code, killed, wall, stdout, stderr, laneWaitMs, json, usage, reads, readsTruncated, modelWire,
9
- // sessionRef, signals}`), with a SYNTHESIZED `json` envelope in the classifier's shape so every downstream
9
+ // providerWire, sessionRef, signals}`), with a SYNTHESIZED `json` envelope in the classifier's shape so every downstream
10
10
  // classifier in gateway.mjs (payloadText, json.status check, isEmbeddedFallback, isTimeout,
11
11
  // isLaneWedge) works unchanged.
12
12
  //
@@ -21,12 +21,18 @@ import { tmpdir } from "node:os";
21
21
  import { join, dirname } from "node:path";
22
22
  import { fileURLToPath } from "node:url";
23
23
  import { resolveSpawnCwd, spawnGraceMs } from "./common.mjs";
24
- import { envFrom } from "../../shared/env-aliases.mjs"; // — resolves EITHER spelling; names the retired one because that is the live-writable half
24
+ import { resolveEngineProgram } from "../driver.config.mjs"; // — the one place that finds the program; it reads every spelling of the setting
25
25
  import { authorityTrees } from "../authority-trees.mjs";
26
26
  import { recordEngineChild, clearEngineChild } from "./child-record.mjs"; //
27
+ import { billingMode } from "./auth.mjs"; // — the one parse of the billing word (see spawnEnv)
27
28
 
28
29
  // Read per-call (not module-level) so tests can drive a short stall timeout / a mock binary.
29
- const claudeBin = () => envFrom(process.env, "CLEAROTRON_CLAUDE_PATH") || "claude";
30
+ // ONE place knows how to find the program (driver.config.mjs resolveEngineProgram): the explicit setting,
31
+ // then PATH, then the copy Clearotron installed. What it found is spawned by ABSOLUTE path, because a
32
+ // bare word lets spawn(2) walk PATH on its own and never reach the installed copy. When nothing resolved,
33
+ // what was asked for is spawned unchanged, so that failure reads exactly as it always has; the run door
34
+ // (preflightEngineBinary) refuses that case before any stage runs.
35
+ const claudeBin = () => { const r = resolveEngineProgram("anthropic-agent"); return r.resolved ?? r.bin; };
30
36
 
31
37
  // AUTH TOGGLE (config, not code). The subscription path is the cost-saving default: claude -p with NO
32
38
  // ANTHROPIC_API_KEY in its env falls back to the OAuth subscription credentials (apiKeySource:"none" →
@@ -34,10 +40,15 @@ const claudeBin = () => envFrom(process.env, "CLEAROTRON_CLAUDE_PATH") || "claud
34
40
  // .env, and a present API key OVERRIDES the subscription — so we must STRIP it from the claude subprocess
35
41
  // env. CLEAROTRON_AI_BILLING=api-key keeps the key (the standing fallback for when the subscription is
36
42
  // revoked — Anthropic's advance notice = today's per-call API cost). Default = subscription.
43
+ //
44
+ // `cloud` strips it too, as that mode's acceptance asks: a key has no part in a turn the vendor's switches
45
+ // send to the reader's cloud account, and dropping it means a leftover key can never be what bills. A
46
+ // gateway's credential is its own ANTHROPIC_AUTH_TOKEN, which rides through like every other name. The
47
+ // word is parsed by auth.mjs (billingMode), the one place that reads it. This never validates and never
48
+ // throws, because the doors that do (the top of runStage, the probe, the jx runner) have already run.
37
49
  export function spawnEnv(base = process.env) {
38
50
  const env = { ...base };
39
- const mode = (base.CLEAROTRON_AI_BILLING || "subscription").toLowerCase();
40
- if (mode !== "api-key") delete env.ANTHROPIC_API_KEY; // subscription: force OAuth/subscription billing
51
+ if (billingMode(base) !== "api-key") delete env.ANTHROPIC_API_KEY; // subscription and cloud
41
52
  return env;
42
53
  }
43
54
  // 120s of ZERO streamed output = the silent-provider-stall abort. A healthy turn
@@ -110,15 +121,20 @@ const killEscalateMs = () => Math.max(50, Number(process.env.CLEAROTRON_KILL_ESC
110
121
  // clock, so the watchdog never trips). Default 64MB (gateway parity); CLEAROTRON_ENGINE_MAX_BUFFER shrinks it for tests.
111
122
  const engineMaxBufferChars = () => Math.max(1024, Number(process.env.CLEAROTRON_ENGINE_MAX_BUFFER || 64 * 1024 * 1024));
112
123
 
113
- // tier/alias → claude -p model alias. haiku passes straight through (claude understands the alias —
114
- // the 2026-06-16 capture used `--model haiku`). opus and sonnet are PINNED to their full model names
115
- // (claude-opus-5 / claude-sonnet-5) rather than the bare "opus"/"sonnet" aliases, so they no longer
116
- // silently drift to whatever Anthropic/the CLI currently calls "opus"/"sonnet" — matching the driver's
117
- // reproducibility conventions (warm-resume same-model, PURE-FILE replay). opus was bumped off the
118
- // floating "opus" alias to the pinned claude-opus-5 on 2026-07-27: the bare alias still resolved to
119
- // claude-opus-4-8 on the live CLI (2.1.209) at the time, so this is a real, GRADE-MOVING model change
120
- // validated in the paid A/B (CONTRACT §3), never on $0 replay — same price as 4.8 ($5/$25). The
121
- // non-anthropic tiers (gemini skeptic, deepseek refutation, azure) have no claude equivalent →
124
+ // tier/alias → claude -p model alias. EVERY TIER GOES AS THE VENDOR'S OWN ALIAS — opus, sonnet, haiku,
125
+ // fable — so the CLI serves the newest model of that family, and a new one arrives with no edit here.
126
+ // opus and sonnet were pinned to claude-opus-5 / claude-sonnet-5 from 2026-07-27, when the bare "opus"
127
+ // still resolved to Opus 4.8 on the live CLI (2.1.209). The pin was reversed on 2026-09-14, for two
128
+ // reasons. A hand-pinned id is a silent downgrade on every clearance from the day a better model ships.
129
+ // And on Bedrock, Vertex and Foundry the CLI resolves an alias through the vendor's own
130
+ // ANTHROPIC_DEFAULT_OPUS_MODEL / _SONNET_MODEL / _HAIKU_MODEL / _FABLE_MODEL, which an exact id bypasses:
131
+ // a cloud with no deployment of that exact name refuses the turn. The cost is that a model can move under a
132
+ // clearance without a test; the witness is the id the CLI reports, recorded on every attempt row
133
+ // (`modelActual`) and on the published run. To hold a tier still, set the vendor's variable in the env file
134
+ // (ANTHROPIC_DEFAULT_OPUS_MODEL=<id>): the stage's environment is the driver's, so it reaches the CLI
135
+ // with no setting of Clearotron's own. ANTHROPIC_DEFAULT_FABLE_MODEL holds fable, which no stage asks for
136
+ // unless an override names it, as CLEAROTRON_SYNTHESIS_MODEL=fable does. A catalog id a caller names
137
+ // (anthropic/claude-opus-5) still goes as that exact id. The non-anthropic tiers (gemini skeptic, deepseek refutation, azure) have no claude equivalent →
122
138
  // substituted with an anthropic model (also GRADE-MOVING, A/B-only); their bare-alias substitutes
123
139
  // (e.g. deepseek → "opus") are legacy aliases no stage names today, intentionally left un-pinned. They
124
140
  // stay registered so a stage that names one is SUBSTITUTED loudly rather than caught by the regex
@@ -141,7 +157,7 @@ const engineMaxBufferChars = () => Math.max(1024, Number(process.env.CLEAROTRON_
141
157
  // non-GPT id. That is the issue's requirement in one line: an unhonoured model override is an error,
142
158
  // not a substitution.
143
159
  const CLAUDE_MODEL = {
144
- opus: "claude-opus-5", sonnet: "claude-sonnet-5", haiku: "haiku", fable: "fable",
160
+ opus: "opus", sonnet: "sonnet", haiku: "haiku", fable: "fable",
145
161
  "anthropic/claude-opus-5": "claude-opus-5", "anthropic/claude-sonnet-5": "claude-sonnet-5",
146
162
  "anthropic/claude-sonnet-4-6": "sonnet", "anthropic/claude-haiku-4-5": "haiku",
147
163
  };
@@ -552,7 +568,16 @@ export const anthropicAgentEngine = {
552
568
  // It never falls back to the requested alias — a record that says "actual: <what we asked for>"
553
569
  // when nothing was observed is precisely the absence-read-as-a-pass this issue exists to end. The
554
570
  // comparison and the policy live in gateway.mjs; this reports, it does not judge.
555
- let wireModelInit = null, wireModelAssistant = null;
571
+ //
572
+ // A MESSAGE THE CLI WROTE ITSELF NAMES NO MODEL. The CLI labels such a message `<synthetic>` in the
573
+ // model field, measured in testing on Azure Foundry (2026-09-14): with the opus pin naming a
574
+ // deployment that did not exist, the turn exited 1 with the CLI's own error and its assistant event
575
+ // said `<synthetic>`. No model served that turn, so the label is never taken as a served id, and
576
+ // `answeredItself` stops init's answer from standing in for one: init says what the session was
577
+ // configured with, and naming it here would name a model for a turn no model served. A real id on
578
+ // an earlier assistant event of the same turn now stands, because that model did serve a call;
579
+ // before the label was refused, the label that followed overwrote it.
580
+ let wireModelInit = null, wireModelAssistant = null, answeredItself = false;
556
581
  // READS GAUGE (AD-4, 2026-07-30 addendum): which files this turn actually OPENED, from the stream's
557
582
  // completed Read tool_use blocks. The stage prompt OFFERS a set of documents (declared inputs +
558
583
  // skill refs); nothing recorded whether the turn could and did read them — and one review
@@ -703,11 +728,14 @@ export const anthropicAgentEngine = {
703
728
  else if (ev.type === "rate_limit_event") rateLimitEvent = ev;
704
729
  else if (ev.type === "system" && ev.subtype === "init") {
705
730
  // MODEL GAUGE — the session's configured model, the earliest wire statement of what will run.
706
- if (typeof ev.model === "string" && ev.model) wireModelInit ??= ev.model;
731
+ if (typeof ev.model === "string" && ev.model && !isCliLabel(ev.model)) wireModelInit ??= ev.model;
707
732
  }
708
733
  else if (ev.type === "assistant") {
709
734
  // MODEL GAUGE — the model that served THIS API call. Authoritative over init (see above).
710
- if (typeof ev.message?.model === "string" && ev.message.model) wireModelAssistant = ev.message.model;
735
+ if (typeof ev.message?.model === "string" && ev.message.model) {
736
+ if (isCliLabel(ev.message.model)) answeredItself = true;
737
+ else wireModelAssistant = ev.message.model;
738
+ }
711
739
  // THINKING GAUGE — block presence + signature, never the text (display defaults to "omitted",
712
740
  // so an engaged block carries a zero-length `thinking` string). See the declaration above.
713
741
  if (!thought && ev.message?.content?.some?.((b) => b?.type === "thinking")) thought = true;
@@ -1077,8 +1105,13 @@ export const anthropicAgentEngine = {
1077
1105
  toolWaitUnmeasurable: [...unmeasurable],
1078
1106
  // MODEL GAUGE: the id the WIRE reported, or null when the stream never said. Assistant
1079
1107
  // message first (what served the call), init second (what the session was configured with).
1080
- // Never the requested alias — see the declaration above.
1081
- modelWire: wireModelAssistant ?? wireModelInit ?? null,
1108
+ // Never the requested alias, and never init's answer for a turn only the CLI answered — see the
1109
+ // declaration above.
1110
+ modelWire: wireModelAssistant ?? (answeredItself ? null : wireModelInit),
1111
+ // PROVIDER GAUGE: the program's own word for who served the turn, read from the result's per-model
1112
+ // usage ("firstParty" on Anthropic's own API and "foundry" on Azure Foundry, measured 2026-09-14),
1113
+ // or null when the stream never said or its models disagree. Recorded, never inferred from config.
1114
+ providerWire: providerOf(resultEvent),
1082
1115
  sessionRef: resultEvent?.session_id ?? resumeRef ?? null,
1083
1116
  // The raw result event's total_cost_usd is a provider-side field and stays in the provider's
1084
1117
  // own stream; the tuple carries no currency (tokens-only directive 2026-07-11) — `usage` is
@@ -1116,6 +1149,29 @@ function errResult(t0, e, resumeRef) {
1116
1149
  // reads: a spawn error means NO turn ran — [] is the true observation (nothing was read), not a gap.
1117
1150
  // modelWire: null for the opposite reason — no turn ran, so the wire said nothing about a model, and
1118
1151
  // the record must say UNKNOWN rather than inherit the alias that was asked for.
1119
- json: null, usage: null, reads: [], readsTruncated: false, modelWire: null, sessionRef: resumeRef ?? null,
1152
+ json: null, usage: null, reads: [], readsTruncated: false, modelWire: null, providerWire: null, sessionRef: resumeRef ?? null,
1120
1153
  };
1121
1154
  }
1155
+
1156
+ /**
1157
+ * The program's own word for which provider served a turn, from the result event's per-model usage:
1158
+ * `modelUsage[<model>].provider`, "firstParty" on Anthropic's own API and "foundry" on Azure Foundry
1159
+ * (measured 2026-09-14, CLI 2.1.263). One word when every model the turn used names the same provider;
1160
+ * null when none does or they disagree, because a single word would then be a guess.
1161
+ */
1162
+ export function providerOf(resultEvent) {
1163
+ const words = new Set();
1164
+ for (const u of Object.values(resultEvent?.modelUsage ?? {})) {
1165
+ const w = typeof u?.provider === "string" ? u.provider.trim() : "";
1166
+ if (w) words.add(w);
1167
+ }
1168
+ return words.size === 1 ? [...words][0] : null;
1169
+ }
1170
+
1171
+ /**
1172
+ * Whether a model field holds one of the CLI's own bracketed labels, such as `<synthetic>` on a message
1173
+ * it wrote itself, rather than the id of a model. The reports skip the same shape when they name models.
1174
+ */
1175
+ function isCliLabel(id) {
1176
+ return /^<.*>$/.test(String(id).trim());
1177
+ }
@@ -20,28 +20,147 @@
20
20
  // spelling list let the OpenAI half decide how an Anthropic run bills. deleted the old names, so
21
21
  // there is one variable, only the selected engine is ever consulted, and the hazard has no route left.
22
22
  //
23
- // And deliberately written OUT at each site rather than through a helper: the guard in
24
- // `env-governance.test.mjs` finds a product read by the literal `env.NAME`, so a helper taking the
25
- // name as an argument makes both reads invisible to it — measured, it turned them harness-only and
26
- // put both names on the "no longer read by product code" list. The repetition is what keeps them
27
- // visible to the census that has to see them.
23
+ // And deliberately written OUT as a literal rather than passed to a helper as an argument: the env
24
+ // audit finds a product read by the literal `env.NAME`, so a helper taking the name as an argument makes
25
+ // the read invisible to it — measured, it turned the reads harness-only and put the name on the "no
26
+ // longer read by product code" list. `billingMode` below spells the name out, which keeps it visible.
27
+
28
+ // THREE MODES FOR CLAUDE, AND NOTHING ELSE IS A MODE. `cloud` bills Claude through the reader's own
29
+ // Google, Microsoft or Amazon account, or through a gateway in front of one (ANTHROPIC_BASE_URL). The
30
+ // vendor's program already routes on its own switches, which reach it because the stage environment is
31
+ // the driver's; what was missing was a billing word that says so. Without it a cloud machine had two
32
+ // choices and both were wrong: `api-key` refused for want of an Anthropic key the machine does not have,
33
+ // and `subscription` ran and stamped every row as billed to a subscription nobody was paying.
34
+ //
35
+ // AN UNKNOWN WORD IS REFUSED. It used to run as `subscription` on both engines, so a typo in the one
36
+ // setting that decides who pays was a quiet bill to the wrong account. It refuses here, at the top of
37
+ // runStage, in the probe and in the jx runner, before any turn runs.
38
+ export const BILLING_MODES = Object.freeze(["subscription", "api-key", "cloud"]);
39
+
40
+ /**
41
+ * The billing word as the environment writes it, normalised and NOT validated: unset or blank reads as
42
+ * the default. The one parse of the word. `resolveAuthMode` validates it; the anthropic adapter's
43
+ * `spawnEnv`, which must never throw, reads it through here rather than parsing it a second way.
44
+ */
45
+ export function billingMode(env = process.env) {
46
+ return String(env.CLEAROTRON_AI_BILLING ?? "").trim().toLowerCase() || "subscription";
47
+ }
48
+
49
+ // The vendor's own switches, read the way its program reads them: "1", "true", "yes" or "on" switches
50
+ // one on. Spelled out one per line for the reason given above. The order is only the order a refusal
51
+ // names them in.
52
+ const switchedOn = (v) => ["1", "true", "yes", "on"].includes(String(v ?? "").trim().toLowerCase());
53
+ export const CLOUD_SWITCH = Object.freeze({ vertex: "CLAUDE_CODE_USE_VERTEX", foundry: "CLAUDE_CODE_USE_FOUNDRY", bedrock: "CLAUDE_CODE_USE_BEDROCK" });
54
+
55
+ // EVERY NAME THE PROGRAM READS TO REACH AND PAY A CLOUD, as setup writes them and the install page lists
56
+ // them: each cloud's switch and its least settings, the gateway pair, and the four model pins a cloud
57
+ // deployment is named by. A run takes every line of its settings file, so it has these already. Setup's
58
+ // proof turn and doctor read the file name by name, and carry these so a check proves the account a run
59
+ // bills rather than whatever the shell happened to hold.
60
+ //
61
+ // THE FABLE PIN IS ON IT, THOUGH SETUP NEVER ASKS FOR IT. The program reads a pin for every tier it takes as
62
+ // an alias, fable included, and no stage asks for fable unless an override names it. On Foundry that
63
+ // alias resolves to nothing unless the pin names a deployment, so a reader who sets the override sets the
64
+ // pin by hand, and doctor, setup's proof turn and a background start must carry it like the other three.
65
+ //
66
+ // THE STANDARD AWS KEY VARIABLES ARE ON IT. A machine with no AWS profile and no instance role keeps its
67
+ // Amazon keys in the settings file, and a search reads them from there. Without these three names doctor's
68
+ // proof turn ran without the keys and reported a fault on a machine whose searches worked.
69
+ export const CLOUD_SETTINGS = Object.freeze([
70
+ ...Object.values(CLOUD_SWITCH),
71
+ "ANTHROPIC_VERTEX_PROJECT_ID", "CLOUD_ML_REGION", "GOOGLE_APPLICATION_CREDENTIALS",
72
+ "ANTHROPIC_FOUNDRY_RESOURCE", "ANTHROPIC_FOUNDRY_API_KEY",
73
+ "AWS_REGION", "AWS_PROFILE", "AWS_ACCESS_KEY_ID", "AWS_SECRET_ACCESS_KEY", "AWS_SESSION_TOKEN",
74
+ "ANTHROPIC_BASE_URL", "ANTHROPIC_AUTH_TOKEN",
75
+ "ANTHROPIC_DEFAULT_OPUS_MODEL", "ANTHROPIC_DEFAULT_SONNET_MODEL", "ANTHROPIC_DEFAULT_HAIKU_MODEL", "ANTHROPIC_DEFAULT_FABLE_MODEL",
76
+ ]);
77
+
78
+ // THE ONES THAT HOLD A SECRET, by name. Wherever a cloud setting is shown, one of these is shown as set and
79
+ // never with its value. Named rather than matched by suffix: AWS_ACCESS_KEY_ID ends like the Google project
80
+ // id beside it, and only one of the two is a credential.
81
+ export const CLOUD_SECRETS = Object.freeze([
82
+ "ANTHROPIC_FOUNDRY_API_KEY", "AWS_ACCESS_KEY_ID", "AWS_SECRET_ACCESS_KEY", "AWS_SESSION_TOKEN", "ANTHROPIC_AUTH_TOKEN",
83
+ ]);
84
+
85
+ // WHAT A READER CHECKS WHEN A CLOUD REFUSES THE CREDENTIALS, per cloud: who refused, by the name a reader
86
+ // knows it by, and the settings and sign-in that decide it. Names only, never a value. The remedy for a
87
+ // subscription, run the program once and sign in, means nothing on a machine that pays through a cloud, and
88
+ // it was the only remedy the checks gave. Every setting named here is on CLOUD_SETTINGS, so doctor and
89
+ // setup's proof turn carry what this tells a reader to check.
90
+ export const CLOUD_CREDENTIAL_CHECK = Object.freeze({
91
+ vertex: Object.freeze({ who: "Google Cloud",
92
+ check: "ANTHROPIC_VERTEX_PROJECT_ID, CLOUD_ML_REGION, and the Google sign-in on this machine (gcloud's, or the key GOOGLE_APPLICATION_CREDENTIALS names)" }),
93
+ foundry: Object.freeze({ who: "Microsoft Azure",
94
+ check: "ANTHROPIC_FOUNDRY_API_KEY, or the Azure sign-in on this machine, and ANTHROPIC_FOUNDRY_RESOURCE" }),
95
+ bedrock: Object.freeze({ who: "Amazon Bedrock",
96
+ check: "AWS_REGION and the AWS credentials on this machine (a profile, an instance role, or AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY)" }),
97
+ gateway: Object.freeze({ who: "the gateway", check: "ANTHROPIC_BASE_URL and ANTHROPIC_AUTH_TOKEN" }),
98
+ });
99
+
100
+ export function cloudsSwitchedOn(env = process.env) {
101
+ const on = [];
102
+ if (switchedOn(env.CLAUDE_CODE_USE_VERTEX)) on.push("vertex");
103
+ if (switchedOn(env.CLAUDE_CODE_USE_FOUNDRY)) on.push("foundry");
104
+ if (switchedOn(env.CLAUDE_CODE_USE_BEDROCK)) on.push("bedrock");
105
+ return on;
106
+ }
107
+
108
+ // Every refusal below carries `billingRefusal: true`, so a reader classifies it by what it is rather than
109
+ // by its wording: the probe does, and a sign-in error that also names this setting is not one of these.
110
+ const refuse = (message) => Object.assign(new Error(message), { billingRefusal: true });
111
+ const notAMode = (mode, modes) => refuse(
112
+ `CLEAROTRON_AI_BILLING=${mode} is not a billing mode — refusing to guess, because the guess would bill ` +
113
+ `the subscription. One of: ${modes.join(", ")}.`);
28
114
 
29
115
  export function resolveAuthMode({ engineName, env = process.env } = {}) {
30
116
  const name = String(engineName || "").toLowerCase();
31
117
 
32
118
  if (name === "anthropic-agent") {
33
- const mode = (env.CLEAROTRON_AI_BILLING || "subscription").toLowerCase() === "api-key" ? "api-key" : "subscription";
119
+ const mode = billingMode(env);
34
120
  if (mode === "api-key" && !env.ANTHROPIC_API_KEY)
35
- throw new Error(
121
+ throw refuse(
36
122
  `CLEAROTRON_AI_BILLING=api-key but ANTHROPIC_API_KEY is not set — refusing to silently bill the ` +
37
123
  `subscription instead. Set the key, or use CLEAROTRON_AI_BILLING=subscription.`);
38
- return { provider: "anthropic", mode, apiBilled: mode === "api-key" };
124
+ const on = cloudsSwitchedOn(env);
125
+ // A cloud's switch sends the program to that cloud whatever the billing word says. Measured on Foundry,
126
+ // 2026-09-14: with the switch on and the word unset, the program reported Foundry as its provider and
127
+ // the row was stamped as the subscription's. So a switch beside `subscription` or `api-key` is refused,
128
+ // after a missing key, which is the fault the config page names first.
129
+ if ((mode === "subscription" || mode === "api-key") && on.length)
130
+ throw refuse(
131
+ `${on.map((c) => CLOUD_SWITCH[c]).join(" and ")} ${on.length > 1 ? "are" : "is"} on, which sends Claude to ` +
132
+ `that cloud account, while CLEAROTRON_AI_BILLING says ${mode} — refusing rather than record the wrong ` +
133
+ `account. Use CLEAROTRON_AI_BILLING=cloud, or ${on.length > 1 ? "turn them off" : "turn the switch off"}.`);
134
+ if (mode === "subscription") return { provider: "anthropic", mode, apiBilled: false };
135
+ if (mode === "api-key") return { provider: "anthropic", mode, apiBilled: true };
136
+ if (mode === "cloud") {
137
+ if (on.length > 1)
138
+ throw refuse(
139
+ `CLEAROTRON_AI_BILLING=cloud but more than one cloud is switched on ` +
140
+ `(${on.map((c) => CLOUD_SWITCH[c]).join(", ")}) — set exactly one, so the run can say which account it bills.`);
141
+ // A switch names the cloud. ANTHROPIC_BASE_URL alone is the gateway form: a cloud reached through the
142
+ // reader's own proxy. With a switch also set, the switch is what the program routes on.
143
+ const cloud = on[0] ?? (env.ANTHROPIC_BASE_URL ? "gateway" : null);
144
+ if (!cloud)
145
+ throw refuse(
146
+ `CLEAROTRON_AI_BILLING=cloud but none of CLAUDE_CODE_USE_VERTEX, CLAUDE_CODE_USE_FOUNDRY, ` +
147
+ `CLAUDE_CODE_USE_BEDROCK or ANTHROPIC_BASE_URL is set — refusing to silently bill the subscription ` +
148
+ `instead. Set the one for your cloud (INSTALL.md), or use CLEAROTRON_AI_BILLING=subscription.`);
149
+ return { provider: "anthropic", mode, apiBilled: true, cloud };
150
+ }
151
+ throw notAMode(mode, BILLING_MODES);
39
152
  }
40
153
 
41
154
  if (name === "openai-agent") {
42
- const mode = (env.CLEAROTRON_AI_BILLING || "subscription").toLowerCase() === "api-key" ? "api-key" : "subscription";
155
+ const mode = billingMode(env);
156
+ if (mode === "cloud")
157
+ throw refuse(
158
+ `CLEAROTRON_AI_BILLING=cloud bills Claude through a cloud account, and this machine runs the Codex ` +
159
+ `engine — refusing rather than billing the ChatGPT subscription instead. Use subscription or api-key ` +
160
+ `with Codex, or CLEAROTRON_AI=anthropic-agent for a cloud account.`);
161
+ if (mode !== "subscription" && mode !== "api-key") throw notAMode(mode, ["subscription", "api-key"]);
43
162
  if (mode === "api-key" && !env.CODEX_API_KEY)
44
- throw new Error(
163
+ throw refuse(
45
164
  `CLEAROTRON_AI_BILLING=api-key but CODEX_API_KEY is not set — refusing to silently bill the ChatGPT ` +
46
165
  `subscription instead. Set the key, or use CLEAROTRON_AI_BILLING=subscription.`);
47
166
  return { provider: "openai", mode, apiBilled: mode === "api-key" };
@@ -68,7 +68,7 @@ export function turnText(tuple) {
68
68
  * Read one normalized tuple into the shape the jx lanes consume. PURE, so every branch is assertable
69
69
  * from a literal rather than from a spawned CLI.
70
70
  */
71
- export function readJxTuple(tuple, { vendor, authMode, engine }) {
71
+ export function readJxTuple(tuple, { vendor, authMode, cloud = null, engine }) {
72
72
  // CANONICAL Usage, whole (engine/CONTRACT.md §2: {input, output, cacheRead, cacheWrite, total}). The old
73
73
  // Messages-API rows carried input/output only because that is all the API returned; keeping only those
74
74
  // two now would drop cache and total tokens from the rollup on the very lanes this change puts on the
@@ -82,7 +82,7 @@ export function readJxTuple(tuple, { vendor, authMode, engine }) {
82
82
  // the receipt names who did the native-language work, and an alias does not name anyone. Both adapters
83
83
  // populate it (anthropic-agent from the assistant/init events, openai-agent from `ev.model`).
84
84
  const model = tuple?.modelWire ?? null;
85
- const base = { model, vendor, authMode, usage, engine };
85
+ const base = { model, vendor, authMode, cloud, usage, engine };
86
86
  // WHETHER TRUNCATION IS OBSERVABLE IS A FACT ABOUT THE ADAPTER, NOT ABOUT THIS TURN. It is keyed on the
87
87
  // engine deliberately: `anthropic-agent` writes `stopReason: r?.stop_reason` unconditionally, so the
88
88
  // KEY is present on every one of its turns whether or not the wire said anything — testing for the key
@@ -108,7 +108,7 @@ export function readJxTuple(tuple, { vendor, authMode, engine }) {
108
108
  /**
109
109
  * A `turn` runner for the jx lanes, bound to the run's engine and billing mode.
110
110
  *
111
- * Returns `{ turn, vendor, authMode, engine }`, or `{ error }` when the configuration refuses — the
111
+ * Returns `{ turn, vendor, authMode, cloud, engine }`, or `{ error }` when the configuration refuses — the
112
112
  * caller degrades the lane with that cause rather than this throwing into a pipeline stage.
113
113
  */
114
114
  export async function makeJxTurnRunner({
@@ -149,16 +149,17 @@ export async function makeJxTurnRunner({
149
149
 
150
150
  const vendor = auth.provider;
151
151
  const authMode = auth.mode;
152
+ const cloud = auth.cloud ?? null; // which cloud account bills, under the cloud mode; null otherwise
152
153
  return {
153
- vendor, authMode, engine: id,
154
+ vendor, authMode, cloud, engine: id,
154
155
  async turn({ prompt }) {
155
156
  let tuple;
156
157
  try { tuple = await runTurn({ message: prompt, model, thinking, timeoutSec, stallSec }); }
157
158
  catch (e) {
158
159
  return { ok: false, cause: `the engine turn threw: ${String(e?.message ?? e).slice(0, 200)}`,
159
- model: null, vendor, authMode, engine: id, usage: { input: 0, output: 0 }, truncationObservable: false };
160
+ model: null, vendor, authMode, cloud, engine: id, usage: { input: 0, output: 0 }, truncationObservable: false };
160
161
  }
161
- return readJxTuple(tuple, { vendor, authMode, engine: id });
162
+ return readJxTuple(tuple, { vendor, authMode, cloud, engine: id });
162
163
  },
163
164
  };
164
165
  }
@@ -627,6 +627,19 @@ serve({
627
627
  },
628
628
  },
629
629
  },
630
+ batch: {
631
+ type: "integer",
632
+ minimum: 1,
633
+ description:
634
+ "The batch of records this call accounts for, when the dispatch splits the band into " +
635
+ "batches. Send one call per batch, carrying its number. Every record in THAT batch must end " +
636
+ "in this call — a findings row, an incumbent row, a Negative-results drop, or a " +
637
+ "Disagreement resolution — and the call is refused naming any that end nowhere; the records " +
638
+ "in every other batch are not this call's business. A batch call MERGES onto what you have " +
639
+ "already recorded, so earlier batches are kept without re-sending them, and a record ended " +
640
+ "under one batch cannot be ended again under another. Omit it only when you are sending the " +
641
+ "whole band in one call, which the dispatch tells you when it is.",
642
+ },
630
643
  patch: {
631
644
  type: "boolean",
632
645
  description:
@@ -29,9 +29,11 @@ import { join } from "node:path";
29
29
  import { runStreamingChild, absolutizeSkillRefs, WRITE_DISCIPLINE, buildEnvelope, resolveSpawnCwd } from "./common.mjs";
30
30
  import { renderCodexConfigToml } from "./mcp/codex-config.mjs";
31
31
  import { resolveAuthMode } from "./auth.mjs";
32
- import { envFrom } from "../../shared/env-aliases.mjs"; // — advice names the name in force
32
+ import { resolveEngineProgram } from "../driver.config.mjs"; // — the one place that finds the program; it reads every spelling of the setting
33
33
 
34
- const codexBin = () => envFrom(process.env, "CLEAROTRON_CODEX_PATH") || "codex";
34
+ // The same one resolver as the claude adapter (driver.config.mjs resolveEngineProgram), for the same reason:
35
+ // the absolute path it found, or what was asked for when it found nothing.
36
+ const codexBin = () => { const r = resolveEngineProgram("openai-agent"); return r.resolved ?? r.bin; };
35
37
 
36
38
  // tier/alias → codex `-m` model id. opus/sonnet/haiku are the driver's abstract tiers (CONTRACT §3). The
37
39
  // GPT ids are ENV-OVERRIDABLE and default to three DISTINCT rungs of the codex ladder — `gpt-5.6-sol`,