@lcv-ideas-software/cross-review 4.0.0

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 (122) hide show
  1. package/CHANGELOG.md +2568 -0
  2. package/LICENSE +201 -0
  3. package/NOTICE +26 -0
  4. package/README.md +208 -0
  5. package/SECURITY.md +52 -0
  6. package/dist/scripts/api-streaming-smoke.d.ts +1 -0
  7. package/dist/scripts/api-streaming-smoke.js +78 -0
  8. package/dist/scripts/api-streaming-smoke.js.map +1 -0
  9. package/dist/scripts/runtime-default-smoke.d.ts +1 -0
  10. package/dist/scripts/runtime-default-smoke.js +88 -0
  11. package/dist/scripts/runtime-default-smoke.js.map +1 -0
  12. package/dist/scripts/runtime-smoke.d.ts +1 -0
  13. package/dist/scripts/runtime-smoke.js +148 -0
  14. package/dist/scripts/runtime-smoke.js.map +1 -0
  15. package/dist/scripts/smoke.d.ts +1 -0
  16. package/dist/scripts/smoke.js +6156 -0
  17. package/dist/scripts/smoke.js.map +1 -0
  18. package/dist/src/core/cache-manifest.d.ts +22 -0
  19. package/dist/src/core/cache-manifest.js +133 -0
  20. package/dist/src/core/cache-manifest.js.map +1 -0
  21. package/dist/src/core/caller-tokens.d.ts +32 -0
  22. package/dist/src/core/caller-tokens.js +240 -0
  23. package/dist/src/core/caller-tokens.js.map +1 -0
  24. package/dist/src/core/config.d.ts +9 -0
  25. package/dist/src/core/config.js +643 -0
  26. package/dist/src/core/config.js.map +1 -0
  27. package/dist/src/core/convergence.d.ts +5 -0
  28. package/dist/src/core/convergence.js +186 -0
  29. package/dist/src/core/convergence.js.map +1 -0
  30. package/dist/src/core/cost.d.ts +59 -0
  31. package/dist/src/core/cost.js +359 -0
  32. package/dist/src/core/cost.js.map +1 -0
  33. package/dist/src/core/file-config.d.ts +316 -0
  34. package/dist/src/core/file-config.js +490 -0
  35. package/dist/src/core/file-config.js.map +1 -0
  36. package/dist/src/core/orchestrator.d.ts +199 -0
  37. package/dist/src/core/orchestrator.js +3430 -0
  38. package/dist/src/core/orchestrator.js.map +1 -0
  39. package/dist/src/core/prompt-parts.d.ts +58 -0
  40. package/dist/src/core/prompt-parts.js +122 -0
  41. package/dist/src/core/prompt-parts.js.map +1 -0
  42. package/dist/src/core/relator-lottery.d.ts +23 -0
  43. package/dist/src/core/relator-lottery.js +112 -0
  44. package/dist/src/core/relator-lottery.js.map +1 -0
  45. package/dist/src/core/reports.d.ts +2 -0
  46. package/dist/src/core/reports.js +82 -0
  47. package/dist/src/core/reports.js.map +1 -0
  48. package/dist/src/core/session-store.d.ts +149 -0
  49. package/dist/src/core/session-store.js +1923 -0
  50. package/dist/src/core/session-store.js.map +1 -0
  51. package/dist/src/core/status.d.ts +61 -0
  52. package/dist/src/core/status.js +249 -0
  53. package/dist/src/core/status.js.map +1 -0
  54. package/dist/src/core/timeouts.d.ts +2 -0
  55. package/dist/src/core/timeouts.js +3 -0
  56. package/dist/src/core/timeouts.js.map +1 -0
  57. package/dist/src/core/types.d.ts +604 -0
  58. package/dist/src/core/types.js +36 -0
  59. package/dist/src/core/types.js.map +1 -0
  60. package/dist/src/dashboard/server.d.ts +2 -0
  61. package/dist/src/dashboard/server.js +339 -0
  62. package/dist/src/dashboard/server.js.map +1 -0
  63. package/dist/src/mcp/server.d.ts +54 -0
  64. package/dist/src/mcp/server.js +1584 -0
  65. package/dist/src/mcp/server.js.map +1 -0
  66. package/dist/src/observability/logger.d.ts +9 -0
  67. package/dist/src/observability/logger.js +24 -0
  68. package/dist/src/observability/logger.js.map +1 -0
  69. package/dist/src/peers/anthropic.d.ts +14 -0
  70. package/dist/src/peers/anthropic.js +290 -0
  71. package/dist/src/peers/anthropic.js.map +1 -0
  72. package/dist/src/peers/base.d.ts +72 -0
  73. package/dist/src/peers/base.js +416 -0
  74. package/dist/src/peers/base.js.map +1 -0
  75. package/dist/src/peers/deepseek.d.ts +12 -0
  76. package/dist/src/peers/deepseek.js +246 -0
  77. package/dist/src/peers/deepseek.js.map +1 -0
  78. package/dist/src/peers/errors.d.ts +2 -0
  79. package/dist/src/peers/errors.js +185 -0
  80. package/dist/src/peers/errors.js.map +1 -0
  81. package/dist/src/peers/gemini.d.ts +13 -0
  82. package/dist/src/peers/gemini.js +215 -0
  83. package/dist/src/peers/gemini.js.map +1 -0
  84. package/dist/src/peers/grok.d.ts +17 -0
  85. package/dist/src/peers/grok.js +346 -0
  86. package/dist/src/peers/grok.js.map +1 -0
  87. package/dist/src/peers/model-selection.d.ts +4 -0
  88. package/dist/src/peers/model-selection.js +260 -0
  89. package/dist/src/peers/model-selection.js.map +1 -0
  90. package/dist/src/peers/openai.d.ts +14 -0
  91. package/dist/src/peers/openai.js +299 -0
  92. package/dist/src/peers/openai.js.map +1 -0
  93. package/dist/src/peers/perplexity.d.ts +18 -0
  94. package/dist/src/peers/perplexity.js +375 -0
  95. package/dist/src/peers/perplexity.js.map +1 -0
  96. package/dist/src/peers/registry.d.ts +3 -0
  97. package/dist/src/peers/registry.js +77 -0
  98. package/dist/src/peers/registry.js.map +1 -0
  99. package/dist/src/peers/retry.d.ts +2 -0
  100. package/dist/src/peers/retry.js +36 -0
  101. package/dist/src/peers/retry.js.map +1 -0
  102. package/dist/src/peers/stub.d.ts +13 -0
  103. package/dist/src/peers/stub.js +344 -0
  104. package/dist/src/peers/stub.js.map +1 -0
  105. package/dist/src/peers/text.d.ts +18 -0
  106. package/dist/src/peers/text.js +39 -0
  107. package/dist/src/peers/text.js.map +1 -0
  108. package/dist/src/security/redact.d.ts +2 -0
  109. package/dist/src/security/redact.js +128 -0
  110. package/dist/src/security/redact.js.map +1 -0
  111. package/docs/api-keys.md +34 -0
  112. package/docs/architecture.md +118 -0
  113. package/docs/caching.md +135 -0
  114. package/docs/costs.md +40 -0
  115. package/docs/evidence-preflight.md +88 -0
  116. package/docs/github-security-baseline.md +32 -0
  117. package/docs/model-selection.md +105 -0
  118. package/docs/reports/cross-review-v2-api-capability-smoke-2026-04-30.md +354 -0
  119. package/docs/reports/cross-review-v2-format-recovery-findings-2026-04-28.md +223 -0
  120. package/docs/reports/cross-review-v2-official-provider-docs-refresh-2026-05-05.md +60 -0
  121. package/docs/reports/cross-review-v2-token-streaming-smoke-2026-04-30.md +119 -0
  122. package/package.json +88 -0
@@ -0,0 +1,643 @@
1
+ import path from "node:path";
2
+ import os from "node:os";
3
+ import { execFileSync } from "node:child_process";
4
+ import { fileURLToPath } from "node:url";
5
+ import { applyFileConfigToEnv } from "./file-config.js";
6
+ // v2.4.0 / audit closure (P3.12): tilde expansion for env-provided paths.
7
+ // `path.resolve` does NOT expand `~` to the user's home directory on any
8
+ // platform — operators routinely write `~/sessions` in env files and end
9
+ // up with a literal `~` directory. We honor `~`, `~/...`, and `~\...`
10
+ // (Windows) before resolving. The shell's `~user` syntax is intentionally
11
+ // NOT supported because it would require a passwd lookup.
12
+ function expandHome(rawPath) {
13
+ if (rawPath === "~")
14
+ return os.homedir();
15
+ if (rawPath.startsWith("~/") || rawPath.startsWith("~\\")) {
16
+ return path.join(os.homedir(), rawPath.slice(2));
17
+ }
18
+ return rawPath;
19
+ }
20
+ export const VERSION = "4.0.0";
21
+ export const RELEASE_DATE = "2026-05-15";
22
+ export const DEFAULT_MAX_OUTPUT_TOKENS = 20_000;
23
+ const COST_RATE_ENV_PREFIX = {
24
+ codex: "CROSS_REVIEW_OPENAI",
25
+ claude: "CROSS_REVIEW_ANTHROPIC",
26
+ gemini: "CROSS_REVIEW_GEMINI",
27
+ deepseek: "CROSS_REVIEW_DEEPSEEK",
28
+ // v2.14.0: Grok pricing via env (no hardcoded defaults; operator
29
+ // populates `CROSS_REVIEW_GROK_INPUT_USD_PER_MILLION` +
30
+ // `CROSS_REVIEW_GROK_OUTPUT_USD_PER_MILLION`).
31
+ grok: "CROSS_REVIEW_GROK",
32
+ // v3.0.0: Perplexity pricing via env. Sonar API bills both per-token
33
+ // (INPUT/OUTPUT) AND per-1000-requests where the fee scales with
34
+ // search_context_size (REQUEST_FEE_LOW/MEDIUM/HIGH). Sonar Deep
35
+ // Research model additionally bills citation_tokens, reasoning_tokens
36
+ // and search_queries — those fields are optional and left undefined
37
+ // for the other Perplexity models.
38
+ perplexity: "CROSS_REVIEW_PERPLEXITY",
39
+ };
40
+ const __filename = fileURLToPath(import.meta.url);
41
+ const __dirname = path.dirname(__filename);
42
+ const RUNTIME_ROOT = path.resolve(__dirname, "..", "..");
43
+ const PROJECT_ROOT = path.basename(RUNTIME_ROOT).toLowerCase() === "dist"
44
+ ? path.resolve(RUNTIME_ROOT, "..")
45
+ : RUNTIME_ROOT;
46
+ // v2.28.0 (cold-start hardening Part 3): single bulk read of the Windows
47
+ // registry environment scopes at first miss, then pure Map lookups. The
48
+ // previous per-var `reg query <root> /v NAME` design fired 2 subprocesses
49
+ // per missing env var (HKCU + HKLM, ~30 ms each). With ~140 config env
50
+ // vars consulted per `loadConfig()` and only some present in
51
+ // `process.env`, the missing-var fallback alone consumed 3-7 seconds of
52
+ // boot time on Windows — the dominant cost outside MCP SDK module load.
53
+ // Caching makes the cost O(1 + 2 registry reads) instead of O(N missing
54
+ // × 2 spawns). The cache is populated on the first call that would have
55
+ // gone to the registry; if `process.env` already has every var, it is
56
+ // never populated. Cross-process cache isolation is preserved (each Node
57
+ // process has its own).
58
+ let _winRegistryEnvCache = null;
59
+ // v3.7.0 (AUDIT-6, Codex super-audit 2026-05-14): cross-review's
60
+ // "API-only" claim means it does NOT execute caller-supplied shell or
61
+ // repo commands — it is not a CLI runner and never shells out on behalf
62
+ // of a caller. It DOES make a small number of fixed, internal process
63
+ // calls with constant arguments (this `reg query` for the Windows
64
+ // env-var fallback; `tasklist` for process-tree introspection in
65
+ // caller-tokens). Those args are constants or PID-derived, never
66
+ // caller-influenced. The precise statement is "no caller-supplied
67
+ // shell/repo execution", not "no child processes at all".
68
+ function loadWindowsRegistryEnvCache() {
69
+ if (_winRegistryEnvCache)
70
+ return _winRegistryEnvCache;
71
+ const cache = new Map();
72
+ const roots = [
73
+ "HKCU\\Environment",
74
+ "HKLM\\SYSTEM\\CurrentControlSet\\Control\\Session Manager\\Environment",
75
+ ];
76
+ // HKCU wins over HKLM on collision (per Windows env-resolution order),
77
+ // so populate HKLM first and overwrite with HKCU last.
78
+ for (const root of [roots[1], roots[0]]) {
79
+ try {
80
+ const output = execFileSync("reg", ["query", root], {
81
+ encoding: "utf8",
82
+ stdio: ["ignore", "pipe", "ignore"],
83
+ windowsHide: true,
84
+ });
85
+ // `reg query <root>` emits one line per value:
86
+ // <whitespace><Name><whitespace>REG_<TYPE><whitespace><Value>
87
+ // Header lines (the root path itself) and blank lines are
88
+ // filtered by the REG_<TYPE> token requirement.
89
+ for (const line of output.split(/\r?\n/)) {
90
+ const match = line.match(/^\s*([^\s]+)\s+REG_(?:SZ|EXPAND_SZ|MULTI_SZ|DWORD|QWORD|BINARY)\s+(.*)$/);
91
+ if (!match)
92
+ continue;
93
+ const [, name, rawValue] = match;
94
+ if (!name || rawValue == null)
95
+ continue;
96
+ cache.set(name, rawValue.trim());
97
+ }
98
+ }
99
+ catch {
100
+ // Missing root is unusual but not fatal — env-var lookups simply
101
+ // fall back to `undefined` after the cache miss.
102
+ }
103
+ }
104
+ _winRegistryEnvCache = cache;
105
+ return cache;
106
+ }
107
+ function readWindowsRegistryEnv(name) {
108
+ if (process.platform !== "win32")
109
+ return undefined;
110
+ return loadWindowsRegistryEnvCache().get(name);
111
+ }
112
+ function envValue(name) {
113
+ const processValue = process.env[name];
114
+ if (processValue)
115
+ return processValue;
116
+ const registryValue = readWindowsRegistryEnv(name);
117
+ if (registryValue) {
118
+ process.env[name] = registryValue;
119
+ return registryValue;
120
+ }
121
+ return undefined;
122
+ }
123
+ function boolEnv(name, fallback = false) {
124
+ const value = envValue(name);
125
+ if (value == null || value === "")
126
+ return fallback;
127
+ return /^(1|true|yes|on)$/i.test(value);
128
+ }
129
+ function intEnv(name, fallback) {
130
+ const parsed = Number.parseInt(envValue(name) ?? "", 10);
131
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
132
+ }
133
+ function numberEnv(name) {
134
+ const parsed = Number.parseFloat(envValue(name) ?? "");
135
+ return Number.isFinite(parsed) && parsed >= 0 ? parsed : undefined;
136
+ }
137
+ function listEnv(name) {
138
+ return (envValue(name) ?? "")
139
+ .split(",")
140
+ .map((value) => value.trim())
141
+ .filter(Boolean);
142
+ }
143
+ function keyForPeer(peer) {
144
+ switch (peer) {
145
+ case "codex":
146
+ return envValue("OPENAI_API_KEY");
147
+ case "claude":
148
+ return envValue("ANTHROPIC_API_KEY");
149
+ case "gemini":
150
+ return envValue("GEMINI_API_KEY");
151
+ case "deepseek":
152
+ return envValue("DEEPSEEK_API_KEY");
153
+ // v2.14.0: Grok auth via GROK_API_KEY (canonical, operator-corrected
154
+ // 2026-05-04 — peer name is "grok" not "xai", env var follows).
155
+ case "grok":
156
+ return envValue("GROK_API_KEY");
157
+ // v3.0.0: Perplexity auth via PERPLEXITY_API_KEY (canonical per
158
+ // docs.perplexity.ai). The Sonar API accepts the key as a Bearer
159
+ // token in the Authorization header.
160
+ case "perplexity":
161
+ return envValue("PERPLEXITY_API_KEY");
162
+ }
163
+ }
164
+ function reasoningEffort(name, fallback) {
165
+ const value = envValue(name);
166
+ if (!value)
167
+ return fallback;
168
+ if (/^(none|minimal|low|medium|high|xhigh|max)$/i.test(value)) {
169
+ return value.toLowerCase();
170
+ }
171
+ return fallback;
172
+ }
173
+ // v3.1.0: telemetry of the file-config load attempt; readers can call
174
+ // `getLastFileConfigResult()` to surface a boot notice or expose it via
175
+ // server_info. Not part of AppConfig (intentionally) so existing
176
+ // snapshots remain backward-compatible.
177
+ let LAST_FILE_CONFIG_RESULT;
178
+ export function getLastFileConfigResult() {
179
+ return LAST_FILE_CONFIG_RESULT;
180
+ }
181
+ export function loadConfig() {
182
+ const configuredDataDir = envValue("CROSS_REVIEW_DATA_DIR");
183
+ const dataDir = configuredDataDir
184
+ ? path.resolve(expandHome(configuredDataDir))
185
+ : path.join(PROJECT_ROOT, "data");
186
+ // v3.1.0 central config file: hydrate `process.env` with values from
187
+ // `${dataDir}/config.json` (or path overridden via
188
+ // CROSS_REVIEW_CONFIG_FILE) BEFORE any of the per-field readers
189
+ // below consult envValue(). The file's contribution is a default
190
+ // layer: env (process.env + Windows registry) wins, file second,
191
+ // hardcoded defaults last. See src/core/file-config.ts for the
192
+ // mapping table from structured JSON fields to flat env-var names.
193
+ LAST_FILE_CONFIG_RESULT = applyFileConfigToEnv(dataDir, envValue);
194
+ return {
195
+ version: VERSION,
196
+ data_dir: dataDir,
197
+ log_level: envValue("CROSS_REVIEW_LOG_LEVEL") || "info",
198
+ stub: boolEnv("CROSS_REVIEW_STUB", false),
199
+ dashboard_port: intEnv("CROSS_REVIEW_DASHBOARD_PORT", 4588),
200
+ retry: {
201
+ max_attempts: intEnv("CROSS_REVIEW_RETRY_ATTEMPTS", 3),
202
+ base_delay_ms: intEnv("CROSS_REVIEW_RETRY_BASE_MS", 1000),
203
+ max_delay_ms: intEnv("CROSS_REVIEW_RETRY_MAX_MS", 30000),
204
+ timeout_ms: intEnv("CROSS_REVIEW_TIMEOUT_MS", 30 * 60 * 1000),
205
+ },
206
+ budget: {
207
+ max_session_cost_usd: numberEnv("CROSS_REVIEW_MAX_SESSION_COST_USD"),
208
+ until_stopped_max_cost_usd: numberEnv("CROSS_REVIEW_UNTIL_STOPPED_MAX_COST_USD"),
209
+ preflight_max_round_cost_usd: numberEnv("CROSS_REVIEW_PREFLIGHT_MAX_ROUND_COST_USD"),
210
+ require_rates_for_budget: true,
211
+ // v2.5.0: configurable fallback for run_until_unanimous when the
212
+ // caller does not pass `max_rounds` and `until_stopped` is false.
213
+ // The MCP zod schema caps caller-supplied `max_rounds` at 1000
214
+ // (v3.7.0 / AUDIT-5: corrected stale "32" in this comment); this
215
+ // controls the SERVER-side default (previously hardcoded to 8 in
216
+ // orchestrator.ts). Values <=0 fall back to 8.
217
+ default_max_rounds: intEnv("CROSS_REVIEW_DEFAULT_MAX_ROUNDS", 8),
218
+ // v2.25.0 (circular mode): maximum number of full rotations
219
+ // permitted in a `mode: "circular"` session before the runtime
220
+ // aborts with `circular_max_rotations_exceeded`. A "rotation" is
221
+ // `rotation_order.length` rounds (one turn per non-caller peer).
222
+ // Default 3 maps to 12 rounds for a 4-peer panel (caller=peer)
223
+ // or 15 rounds for a 5-peer panel (caller=operator) — large
224
+ // enough that a well-behaved artifact converges, small enough
225
+ // that runaway revisions abort within reasonable budget.
226
+ // Empirical anchor: maestro-app circular sessions historically
227
+ // converged within 2 rotations; 3 gives one safety margin.
228
+ circular_max_rotations: intEnv("CROSS_REVIEW_CIRCULAR_MAX_ROTATIONS", 3),
229
+ },
230
+ prompt: {
231
+ max_task_chars: intEnv("CROSS_REVIEW_MAX_TASK_CHARS", 8_000),
232
+ max_review_focus_chars: intEnv("CROSS_REVIEW_MAX_REVIEW_FOCUS_CHARS", 2_000),
233
+ max_history_chars: intEnv("CROSS_REVIEW_MAX_HISTORY_CHARS", 20_000),
234
+ max_draft_chars: intEnv("CROSS_REVIEW_MAX_DRAFT_CHARS", 40_000),
235
+ max_prior_rounds: intEnv("CROSS_REVIEW_MAX_PRIOR_ROUNDS", 5),
236
+ max_peer_requests: intEnv("CROSS_REVIEW_MAX_PEER_REQUESTS", 8),
237
+ // v2.14.0 (path-A structural fix): see AppConfig type docs.
238
+ // v2.26.1 (2026-05-12): default raised 80_000 → 200_000 after the
239
+ // stepsecurity v0.2.0 ship empirically demonstrated that 80K is
240
+ // too low for multi-file evidence sets. session-store.ts:1507
241
+ // computes `perFileCap = max(2_000, floor(totalCap * 0.6))`, then
242
+ // each attachment consumes `min(perFileCap, totalCap - used)`. With
243
+ // 5 attachments totaling ~95KB, the 4th+ attachments got truncated
244
+ // because the budget was already exhausted (peers reported
245
+ // `truncated to 33273 of 38412 bytes` while the file content
246
+ // had legitimate 38KB). 200_000 default accommodates ~5 attachments
247
+ // averaging ~30KB each before any per-file truncation. Operator
248
+ // can still tune via CROSS_REVIEW_MAX_ATTACHED_EVIDENCE_CHARS.
249
+ max_attached_evidence_chars: intEnv("CROSS_REVIEW_MAX_ATTACHED_EVIDENCE_CHARS", 200_000),
250
+ },
251
+ max_output_tokens: intEnv("CROSS_REVIEW_MAX_OUTPUT_TOKENS", DEFAULT_MAX_OUTPUT_TOKENS),
252
+ // v3.5.0 (CRV2-4): evidence preflight gate. Default ON — the check
253
+ // is conservative (only trips on a completed-work claim with zero
254
+ // evidence markers) and saves a full multi-round paid cross-review
255
+ // on under-evidenced submissions. Operators set
256
+ // CROSS_REVIEW_EVIDENCE_PREFLIGHT=off to disable.
257
+ evidence_preflight_enabled: boolEnv("CROSS_REVIEW_EVIDENCE_PREFLIGHT", true),
258
+ streaming: {
259
+ events: boolEnv("CROSS_REVIEW_STREAM_EVENTS", true),
260
+ tokens: boolEnv("CROSS_REVIEW_STREAM_TOKENS", true),
261
+ include_text: boolEnv("CROSS_REVIEW_STREAM_TEXT", false),
262
+ },
263
+ models: {
264
+ codex: envValue("CROSS_REVIEW_OPENAI_MODEL") || "gpt-5.5",
265
+ claude: envValue("CROSS_REVIEW_ANTHROPIC_MODEL") || "claude-opus-4-7",
266
+ gemini: envValue("CROSS_REVIEW_GEMINI_MODEL") || "gemini-2.5-pro",
267
+ deepseek: envValue("CROSS_REVIEW_DEEPSEEK_MODEL") || "deepseek-v4-pro",
268
+ // v3.7.2 (AUDIT-3 + operator directive 2026-05-14): grok default
269
+ // pinned to `grok-4-latest` — the operator's chosen "most advanced
270
+ // pro with reasoning" model for cross-review, superseding the
271
+ // prior `grok-4.20-multi-agent` default. The operator may still
272
+ // env-override via CROSS_REVIEW_GROK_MODEL to any xAI model
273
+ // (`grok-4.20-multi-agent` for explicit `reasoning.effort` control,
274
+ // `grok-4.3` / `grok-4.20-reasoning` etc.); the adapter detects the
275
+ // chosen model before deciding whether to send the reasoning field.
276
+ grok: envValue("CROSS_REVIEW_GROK_MODEL") || "grok-4-latest",
277
+ // v3.0.0 (operator directive 2026-05-12): Perplexity default
278
+ // `sonar-reasoning-pro` — reasoning + grounding + chain-of-thought,
279
+ // best fit for cross-review where the peer must reason about the
280
+ // attached draft (not just fact-lookup). Operator can override via
281
+ // CROSS_REVIEW_PERPLEXITY_MODEL to switch to `sonar`, `sonar-pro`,
282
+ // or `sonar-deep-research`. The adapter validates the chosen model
283
+ // against the documented allowlist at call time.
284
+ perplexity: envValue("CROSS_REVIEW_PERPLEXITY_MODEL") || "sonar-reasoning-pro",
285
+ },
286
+ fallback_models: {
287
+ codex: listEnv("CROSS_REVIEW_OPENAI_FALLBACK_MODELS"),
288
+ claude: listEnv("CROSS_REVIEW_ANTHROPIC_FALLBACK_MODELS"),
289
+ gemini: listEnv("CROSS_REVIEW_GEMINI_FALLBACK_MODELS"),
290
+ deepseek: listEnv("CROSS_REVIEW_DEEPSEEK_FALLBACK_MODELS"),
291
+ grok: listEnv("CROSS_REVIEW_GROK_FALLBACK_MODELS"),
292
+ perplexity: listEnv("CROSS_REVIEW_PERPLEXITY_FALLBACK_MODELS"),
293
+ },
294
+ reasoning_effort: {
295
+ codex: reasoningEffort("CROSS_REVIEW_OPENAI_REASONING_EFFORT", "xhigh"),
296
+ claude: reasoningEffort("CROSS_REVIEW_ANTHROPIC_REASONING_EFFORT", "xhigh"),
297
+ deepseek: reasoningEffort("CROSS_REVIEW_DEEPSEEK_REASONING_EFFORT", "max"),
298
+ grok: reasoningEffort("CROSS_REVIEW_GROK_REASONING_EFFORT", "xhigh"),
299
+ // v3.0.0: Perplexity Sonar API only accepts `minimal|low|medium|high`
300
+ // for sonar-reasoning-pro / sonar-deep-research (other models
301
+ // ignore the field entirely). Default `high` matches the
302
+ // canonical "max reasoning per peer" stance the other peers take
303
+ // (xhigh/max for OpenAI/Anthropic/Grok/DeepSeek). The adapter
304
+ // clamps internal scale (`xhigh`/`max`) to `high` for Perplexity.
305
+ perplexity: reasoningEffort("CROSS_REVIEW_PERPLEXITY_REASONING_EFFORT", "high"),
306
+ },
307
+ model_selection: {},
308
+ api_keys: {
309
+ codex: keyForPeer("codex"),
310
+ claude: keyForPeer("claude"),
311
+ gemini: keyForPeer("gemini"),
312
+ deepseek: keyForPeer("deepseek"),
313
+ grok: keyForPeer("grok"),
314
+ perplexity: keyForPeer("perplexity"),
315
+ },
316
+ cost_rates: {
317
+ codex: costRate(COST_RATE_ENV_PREFIX.codex),
318
+ claude: costRate(COST_RATE_ENV_PREFIX.claude),
319
+ gemini: costRate(COST_RATE_ENV_PREFIX.gemini),
320
+ deepseek: costRate(COST_RATE_ENV_PREFIX.deepseek),
321
+ grok: costRate(COST_RATE_ENV_PREFIX.grok),
322
+ perplexity: costRate(COST_RATE_ENV_PREFIX.perplexity),
323
+ },
324
+ evidence_judge_autowire: loadEvidenceJudgeAutowireConfig(),
325
+ peer_enabled: loadPeerEnabledConfig(),
326
+ cache: loadCacheConfig(),
327
+ perplexity: loadPerplexityConfig(),
328
+ };
329
+ }
330
+ // v3.0.0 (Perplexity 6th peer): per-call Perplexity-specific knobs.
331
+ // `search_context_size` controls the breadth of the web search and
332
+ // drives both quality AND per-1000-request fee (low=$5-6 / medium=$8-10
333
+ // / high=$12-14 depending on model). Default `low` minimizes noise
334
+ // and cost for cross-review use (peer reasons about attached draft;
335
+ // search is a fact-check overlay). `disable_search` turns off the
336
+ // web-search component entirely (peer becomes a pure LLM; pricing
337
+ // reduces to token-based only). Default `false` per operator directive
338
+ // 2026-05-12 — search-active is the differentiator versus the other 5
339
+ // peers.
340
+ function loadPerplexityConfig() {
341
+ const sizeRaw = (envValue("CROSS_REVIEW_PERPLEXITY_SEARCH_CONTEXT_SIZE") ?? "")
342
+ .trim()
343
+ .toLowerCase();
344
+ let searchContextSize = "low";
345
+ if (sizeRaw === "medium" || sizeRaw === "high") {
346
+ searchContextSize = sizeRaw;
347
+ }
348
+ else if (sizeRaw !== "" && sizeRaw !== "low") {
349
+ console.error(`[cross-review] notice: CROSS_REVIEW_PERPLEXITY_SEARCH_CONTEXT_SIZE="${sizeRaw}" not recognized; defaulting to "low". Recognized values: low, medium, high.`);
350
+ }
351
+ return {
352
+ search_context_size: searchContextSize,
353
+ disable_search: boolEnv("CROSS_REVIEW_PERPLEXITY_DISABLE_SEARCH", false),
354
+ };
355
+ }
356
+ // v2.21.0 (caching): config loader. Default ON; switch off via
357
+ // CROSS_REVIEW_DISABLE_CACHE=true (operator panic button when a
358
+ // provider misbehaves or the operator wants strictly-fresh runs for
359
+ // audit reproducibility). TTL options gated to the documented values
360
+ // to prevent typos silently sending nonsense to providers — Anthropic
361
+ // API rejects unknown ttl values with 400. OpenAI does NOT publish
362
+ // per-call retention values; we still parse the env so future
363
+ // migrations can flip the default without touching adapter code.
364
+ function loadCacheConfig() {
365
+ const enabled = !boolEnv("CROSS_REVIEW_DISABLE_CACHE", false);
366
+ const schemaVersion = (envValue("CROSS_REVIEW_CACHE_SCHEMA_VERSION") ?? "v1").trim() || "v1";
367
+ const anthropicTtl = parseTtlEnv("CROSS_REVIEW_CACHE_TTL_ANTHROPIC", "1h");
368
+ const openaiTtl = parseTtlEnv("CROSS_REVIEW_CACHE_TTL_OPENAI", "1h");
369
+ // v3.7.5 (A3, logs+sessions study 2026-05-15): per-provider cache
370
+ // disable. Default for Anthropic (claude) is `true` (cache off) based
371
+ // on empirical $1.18 wasted to save $0.0035 over 244 sessions
372
+ // (0.3% hit-rate). All other providers default `false` (cache on,
373
+ // preserving v2.21.0 behavior). Operators may flip any per-provider
374
+ // flag via `CROSS_REVIEW_DISABLE_CACHE_<PROVIDER>` (`true|false`).
375
+ // Recognized truthy values match the parser used by peer_enabled:
376
+ // on/true/1/yes/enabled (case-insensitive). Anything else is "off".
377
+ // v3.7.5 (A3): env vars use PROVIDER names (ANTHROPIC/OPENAI/...) matching
378
+ // the v2.21.0 TTL convention (`CROSS_REVIEW_CACHE_TTL_ANTHROPIC` +
379
+ // `CROSS_REVIEW_CACHE_TTL_OPENAI`). Internal `disable_per_peer` is
380
+ // keyed by PeerId (claude/codex/...). Mapping below is the only place
381
+ // provider names cross with peer ids.
382
+ const disablePerPeer = {
383
+ codex: parseDisableCacheEnv("CROSS_REVIEW_DISABLE_CACHE_OPENAI", false),
384
+ claude: parseDisableCacheEnv("CROSS_REVIEW_DISABLE_CACHE_ANTHROPIC", true),
385
+ gemini: parseDisableCacheEnv("CROSS_REVIEW_DISABLE_CACHE_GEMINI", false),
386
+ deepseek: parseDisableCacheEnv("CROSS_REVIEW_DISABLE_CACHE_DEEPSEEK", false),
387
+ grok: parseDisableCacheEnv("CROSS_REVIEW_DISABLE_CACHE_GROK", false),
388
+ perplexity: parseDisableCacheEnv("CROSS_REVIEW_DISABLE_CACHE_PERPLEXITY", false),
389
+ };
390
+ return {
391
+ schema_version: schemaVersion,
392
+ enabled,
393
+ ttl: {
394
+ anthropic: anthropicTtl,
395
+ openai: openaiTtl,
396
+ },
397
+ disable_per_peer: disablePerPeer,
398
+ };
399
+ }
400
+ // v3.7.5 (A3): per-provider cache-disable env var parser. Same shape as
401
+ // the peer_enabled parser but with a per-call default since Anthropic
402
+ // defaults true and others default false.
403
+ function parseDisableCacheEnv(name, fallback) {
404
+ const raw = (envValue(name) ?? "").trim().toLowerCase();
405
+ if (raw === "")
406
+ return fallback;
407
+ if (/^(on|true|1|yes|enabled)$/i.test(raw))
408
+ return true;
409
+ if (/^(off|false|0|no|disabled)$/i.test(raw))
410
+ return false;
411
+ console.error(`[cross-review] notice: ${name}="${raw}" is not recognized; defaulting to "${fallback ? "on" : "off"}". Recognized values: on/true/1/yes/enabled vs off/false/0/no/disabled.`);
412
+ return fallback;
413
+ }
414
+ function parseTtlEnv(name, fallback) {
415
+ const raw = (envValue(name) ?? "").trim().toLowerCase();
416
+ if (raw === "5m" || raw === "1h")
417
+ return raw;
418
+ if (raw !== "") {
419
+ console.error(`[cross-review] notice: ${name}="${raw}" not recognized; defaulting to "${fallback}". Recognized values: 5m, 1h.`);
420
+ }
421
+ return fallback;
422
+ }
423
+ // v2.14.0 (operator directive 2026-05-04): per-peer enable/disable
424
+ // parser. Default `on` for every peer. Recognized truthy values:
425
+ // "on", "true", "1", "yes", "enabled". Recognized falsy: "off",
426
+ // "false", "0", "no", "disabled". Unrecognized values fall back to
427
+ // `on` with a stderr warning so a typo never silently disables a peer.
428
+ // Boot-time minimum-2-enabled validation lives at the boundary
429
+ // (orchestrator construction) — keeping the parser pure makes it easy
430
+ // to test in isolation.
431
+ function loadPeerEnabledConfig() {
432
+ const peers = ["codex", "claude", "gemini", "deepseek", "grok", "perplexity"];
433
+ const result = {};
434
+ for (const peer of peers) {
435
+ const envName = `CROSS_REVIEW_PEER_${peer.toUpperCase()}`;
436
+ const raw = (envValue(envName) ?? "").trim().toLowerCase();
437
+ if (raw === "") {
438
+ result[peer] = true;
439
+ continue;
440
+ }
441
+ if (/^(on|true|1|yes|enabled)$/i.test(raw)) {
442
+ result[peer] = true;
443
+ }
444
+ else if (/^(off|false|0|no|disabled)$/i.test(raw)) {
445
+ result[peer] = false;
446
+ }
447
+ else {
448
+ console.error(`[cross-review] notice: ${envName}="${raw}" is not recognized; defaulting to "on". Recognized values: on/true/1/yes/enabled vs off/false/0/no/disabled.`);
449
+ result[peer] = true;
450
+ }
451
+ }
452
+ return result;
453
+ }
454
+ // v2.12.0: parse the judge auto-wire env vars into a typed struct that
455
+ // server_info, the boot notice and the orchestrator share. Invalid
456
+ // values do NOT throw — `mode` keeps the literal string for the boot
457
+ // notice, `peer` is undefined when not in PEERS, `active` is true iff
458
+ // the runtime will actually emit shadow_decision events.
459
+ function loadEvidenceJudgeAutowireConfig() {
460
+ const rawMode = (process.env.CROSS_REVIEW_EVIDENCE_JUDGE_AUTOWIRE_MODE ?? "")
461
+ .trim()
462
+ .toLowerCase();
463
+ const rawPeer = (process.env.CROSS_REVIEW_EVIDENCE_JUDGE_AUTOWIRE_PEER ?? "").trim();
464
+ const rawConsensusPeers = (process.env.CROSS_REVIEW_EVIDENCE_JUDGE_AUTOWIRE_CONSENSUS_PEERS ?? "").trim();
465
+ const peerKnown = ["codex", "claude", "gemini", "deepseek", "grok", "perplexity"];
466
+ const peer = peerKnown.includes(rawPeer) ? rawPeer : undefined;
467
+ // v2.15.0 (item 1): parse consensus peers list. Comma-separated; only
468
+ // peers that are members of PEERS are kept. Need >=2 valid entries
469
+ // for consensus to apply (orchestrator guard); below 2 falls back to
470
+ // single-peer autowire.
471
+ const consensusPeers = rawConsensusPeers
472
+ ? rawConsensusPeers
473
+ .split(",")
474
+ .map((entry) => entry.trim())
475
+ .filter((entry) => peerKnown.includes(entry))
476
+ .map((entry) => entry)
477
+ : [];
478
+ const mode = rawMode === "" ? "off" : rawMode;
479
+ // v2.14.0 (item 2): "active" promoted to first-class autowire mode.
480
+ // v2.15.0 (item 1): consensus path also activates `active` when
481
+ // consensus_peers >= 2 (single-peer field becomes optional in that case).
482
+ const active = (mode === "shadow" || mode === "active") && (peer !== undefined || consensusPeers.length >= 2);
483
+ // v2.12.0: preserve EXACT pre-v2.12 semantics. The legacy inline read
484
+ // was `Number.parseInt(env ?? "8", 10) || 8` — this lets negative
485
+ // values flow through (because `-5 || 8 === -5` in JS) so the
486
+ // orchestrator's `Math.max(1, Math.min(100, cap))` clamps -5 to 1, NOT 8.
487
+ // We don't use `intEnv` here because that helper has a `parsed > 0`
488
+ // filter, which would change the consumer's clamp result for negatives.
489
+ // codex R1 ship-review of v2.12.0 caught the divergence.
490
+ // v2.18.4 / Codex audit 2026-05-07 P1.4: defensive cap reduction
491
+ // 8 → 4. Math: with default consensus_peers=4 (codex+gemini+
492
+ // deepseek+grok), worst-case round fires `consensus_peers ×
493
+ // max_items_per_pass = 4 × 8 = 32` paid judge calls per round.
494
+ // Lowering the default to 4 puts the worst case at `4 × 4 = 16`
495
+ // paid calls, halving the budget exposure without a code change.
496
+ // Operators wanting the prior 8 (or higher) set the env-var
497
+ // explicitly. Single-peer mode goes from 1×8=8 to 1×4=4 — a coverage
498
+ // reduction, but the operator can always raise via env-var. This
499
+ // is a *default* change, not a hard cap.
500
+ const rawCap = Number.parseInt(process.env.CROSS_REVIEW_EVIDENCE_JUDGE_MAX_ITEMS_PER_PASS ?? "4", 10);
501
+ const maxItemsPerPass = Number.isFinite(rawCap) && rawCap !== 0 ? rawCap : 4;
502
+ return {
503
+ mode,
504
+ peer,
505
+ active,
506
+ max_items_per_pass: maxItemsPerPass,
507
+ configured_mode_raw: rawMode,
508
+ configured_peer_raw: rawPeer,
509
+ consensus_peers: consensusPeers,
510
+ configured_consensus_peers_raw: rawConsensusPeers,
511
+ };
512
+ }
513
+ export function missingFinancialControlVars(config, peers, options = {}) {
514
+ const missing = new Set();
515
+ if (config.budget.max_session_cost_usd == null) {
516
+ missing.add("CROSS_REVIEW_MAX_SESSION_COST_USD");
517
+ }
518
+ if (config.budget.preflight_max_round_cost_usd == null) {
519
+ missing.add("CROSS_REVIEW_PREFLIGHT_MAX_ROUND_COST_USD");
520
+ }
521
+ if (options.untilStopped && config.budget.until_stopped_max_cost_usd == null) {
522
+ missing.add("CROSS_REVIEW_UNTIL_STOPPED_MAX_COST_USD");
523
+ }
524
+ for (const peer of peers) {
525
+ if (config.cost_rates[peer])
526
+ continue;
527
+ const prefix = COST_RATE_ENV_PREFIX[peer];
528
+ missing.add(`${prefix}_INPUT_USD_PER_MILLION`);
529
+ missing.add(`${prefix}_OUTPUT_USD_PER_MILLION`);
530
+ }
531
+ // v3.0.0 (Perplexity 6th peer): when the perplexity peer is in scope
532
+ // AND search is enabled (default), the request fee for the configured
533
+ // search_context_size MUST be set — Perplexity bills BOTH per-token
534
+ // AND per-1000-requests, and the request fee is the only way the
535
+ // cost layer can account for the search cost dimension. When
536
+ // disable_search is true, the request fee is irrelevant (peer
537
+ // becomes pure-LLM and the missing fee is harmless). This preserves
538
+ // the v2.26.0 "no-hardcoded-financials" contract: every dimension of
539
+ // pricing that applies to the current call must be operator-
540
+ // configured before paid traffic is allowed.
541
+ if (peers.includes("perplexity") && !config.perplexity.disable_search) {
542
+ const rate = config.cost_rates.perplexity;
543
+ const size = config.perplexity.search_context_size;
544
+ const requestFeeField = size === "high"
545
+ ? "request_fee_high_per_1000"
546
+ : size === "medium"
547
+ ? "request_fee_medium_per_1000"
548
+ : "request_fee_low_per_1000";
549
+ const requestFeeEnvSuffix = size === "high"
550
+ ? "REQUEST_FEE_HIGH_USD_PER_1000_REQUESTS"
551
+ : size === "medium"
552
+ ? "REQUEST_FEE_MEDIUM_USD_PER_1000_REQUESTS"
553
+ : "REQUEST_FEE_LOW_USD_PER_1000_REQUESTS";
554
+ if (!rate || rate[requestFeeField] == null) {
555
+ missing.add(`${COST_RATE_ENV_PREFIX.perplexity}_${requestFeeEnvSuffix}`);
556
+ }
557
+ }
558
+ return [...missing].sort();
559
+ }
560
+ // v2.26.0: cost rate parser expanded to a complete pricing model.
561
+ // Required: _INPUT_USD_PER_MILLION + _OUTPUT_USD_PER_MILLION (backward
562
+ // compat). Optional extensions:
563
+ // - Extended tier (≤ vs > threshold): _INPUT_EXTENDED, _OUTPUT_EXTENDED
564
+ // - Cache pricing: _CACHE_READ, _CACHE_WRITE, _CACHE_READ_EXTENDED,
565
+ // _CACHE_WRITE_EXTENDED
566
+ // - Promo pricing (limited-time discount): _PROMO_INPUT, _PROMO_OUTPUT,
567
+ // _PROMO_INPUT_EXTENDED, _PROMO_OUTPUT_EXTENDED, _PROMO_CACHE_READ,
568
+ // _PROMO_CACHE_WRITE, _PROMO_CACHE_READ_EXTENDED,
569
+ // _PROMO_CACHE_WRITE_EXTENDED, _PROMO_EXPIRES_AT_UTC (ISO 8601)
570
+ // - Tier threshold: _THRESHOLD_TOKENS (e.g., 200000 for Gemini)
571
+ // Selection logic (in cost.ts selectRate()): if today < promo_expires_at
572
+ // AND a corresponding promo field is set, use promo. Else if total input
573
+ // tokens > threshold AND extended field is set, use extended. Else use
574
+ // base. Each category (input/output/cache_read/cache_write) selects
575
+ // independently.
576
+ function costRate(prefix) {
577
+ const input = numberEnv(`${prefix}_INPUT_USD_PER_MILLION`);
578
+ const output = numberEnv(`${prefix}_OUTPUT_USD_PER_MILLION`);
579
+ if (input == null || output == null)
580
+ return undefined;
581
+ const opt = (suffix) => numberEnv(`${prefix}_${suffix}`) ?? undefined;
582
+ const promoExpiresRaw = envValue(`${prefix}_PROMO_EXPIRES_AT_UTC`);
583
+ const thresholdTokensRaw = numberEnv(`${prefix}_THRESHOLD_TOKENS`);
584
+ const rate = {
585
+ input_per_million: input,
586
+ output_per_million: output,
587
+ };
588
+ const fields = [
589
+ ["input_extended_per_million", "INPUT_EXTENDED_USD_PER_MILLION"],
590
+ ["output_extended_per_million", "OUTPUT_EXTENDED_USD_PER_MILLION"],
591
+ ["cache_read_per_million", "CACHE_READ_USD_PER_MILLION"],
592
+ ["cache_write_per_million", "CACHE_WRITE_USD_PER_MILLION"],
593
+ ["cache_read_extended_per_million", "CACHE_READ_EXTENDED_USD_PER_MILLION"],
594
+ ["cache_write_extended_per_million", "CACHE_WRITE_EXTENDED_USD_PER_MILLION"],
595
+ ["promo_input_per_million", "PROMO_INPUT_USD_PER_MILLION"],
596
+ ["promo_output_per_million", "PROMO_OUTPUT_USD_PER_MILLION"],
597
+ ["promo_input_extended_per_million", "PROMO_INPUT_EXTENDED_USD_PER_MILLION"],
598
+ ["promo_output_extended_per_million", "PROMO_OUTPUT_EXTENDED_USD_PER_MILLION"],
599
+ ["promo_cache_read_per_million", "PROMO_CACHE_READ_USD_PER_MILLION"],
600
+ ["promo_cache_write_per_million", "PROMO_CACHE_WRITE_USD_PER_MILLION"],
601
+ ["promo_cache_read_extended_per_million", "PROMO_CACHE_READ_EXTENDED_USD_PER_MILLION"],
602
+ ["promo_cache_write_extended_per_million", "PROMO_CACHE_WRITE_EXTENDED_USD_PER_MILLION"],
603
+ // v3.0.0 (Perplexity 6th peer): Perplexity bills both per-token AND
604
+ // per-1000-requests where the request fee scales with
605
+ // `search_context_size`. Other peers' costRate calls leave these
606
+ // suffixes undefined; perplexity costRate (env prefix
607
+ // CROSS_REVIEW_PERPLEXITY) sets them when operator configures them.
608
+ ["request_fee_low_per_1000", "REQUEST_FEE_LOW_USD_PER_1000_REQUESTS"],
609
+ ["request_fee_medium_per_1000", "REQUEST_FEE_MEDIUM_USD_PER_1000_REQUESTS"],
610
+ ["request_fee_high_per_1000", "REQUEST_FEE_HIGH_USD_PER_1000_REQUESTS"],
611
+ // v3.0.0 (Perplexity Sonar Deep Research): three distinct line
612
+ // items billed separately from input/output. Other Sonar models
613
+ // (sonar / sonar-pro / sonar-reasoning-pro) leave these undefined.
614
+ ["citation_tokens_per_million", "CITATION_TOKENS_USD_PER_MILLION"],
615
+ [
616
+ "deep_research_reasoning_tokens_per_million",
617
+ "DEEP_RESEARCH_REASONING_TOKENS_USD_PER_MILLION",
618
+ ],
619
+ ["search_queries_per_1000", "SEARCH_QUERIES_USD_PER_1000_REQUESTS"],
620
+ ];
621
+ for (const [key, suffix] of fields) {
622
+ const value = opt(suffix);
623
+ if (value != null) {
624
+ rate[key] = value;
625
+ }
626
+ }
627
+ if (thresholdTokensRaw != null && thresholdTokensRaw > 0) {
628
+ rate.threshold_tokens = Math.floor(thresholdTokensRaw);
629
+ }
630
+ if (promoExpiresRaw && promoExpiresRaw.trim() !== "") {
631
+ const trimmed = promoExpiresRaw.trim();
632
+ // Validate ISO 8601 parseability so a typo doesn't silently disable promo.
633
+ const parsed = Date.parse(trimmed);
634
+ if (Number.isNaN(parsed)) {
635
+ console.error(`[cross-review] notice: ${prefix}_PROMO_EXPIRES_AT_UTC="${trimmed}" is not a valid ISO 8601 timestamp; promo rates will be ignored.`);
636
+ }
637
+ else {
638
+ rate.promo_expires_at = trimmed;
639
+ }
640
+ }
641
+ return rate;
642
+ }
643
+ //# sourceMappingURL=config.js.map