@expo/code-review-cli 0.6.0 → 0.8.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 (54) hide show
  1. package/README.md +151 -25
  2. package/build/cli.js +7 -0
  3. package/build/commands/ci.js +307 -36
  4. package/build/commands/dismiss.js +6 -0
  5. package/build/commands/doctor.js +170 -33
  6. package/build/commands/feedback.js +433 -0
  7. package/build/commands/init.js +231 -15
  8. package/build/commands/review.js +191 -51
  9. package/build/commands/setup-auth.js +86 -11
  10. package/build/commands/verify-config.js +3 -0
  11. package/build/config/load.js +39 -0
  12. package/build/config/routing.js +7 -0
  13. package/build/config/schema.js +99 -3
  14. package/build/core/adjudicate.js +194 -0
  15. package/build/core/auth.js +127 -10
  16. package/build/core/claude-code.js +691 -0
  17. package/build/core/context-file.js +42 -0
  18. package/build/core/coordinator.js +2 -2
  19. package/build/core/diff.js +1 -0
  20. package/build/core/exec.js +282 -9
  21. package/build/core/log.js +1 -0
  22. package/build/core/noise.js +5 -0
  23. package/build/core/opencode.js +117 -15
  24. package/build/core/prompts.js +330 -5
  25. package/build/core/render.js +274 -45
  26. package/build/core/responses.js +158 -0
  27. package/build/core/review.js +447 -39
  28. package/build/core/schema.js +219 -3
  29. package/build/core/scrub.js +63 -1
  30. package/build/core/stack-confirm.js +137 -0
  31. package/build/core/stack.js +25 -0
  32. package/build/core/step-summary.js +1 -0
  33. package/build/core/suppress.js +2 -0
  34. package/build/core/throttle.js +12 -0
  35. package/build/core/util.js +18 -0
  36. package/build/core/verify.js +18 -1
  37. package/build/reporters/github.js +544 -44
  38. package/build/reporters/terminal.js +2 -0
  39. package/build/sources/github-pr.js +286 -7
  40. package/build/sources/local-git.js +6 -2
  41. package/build/sources/source.js +35 -0
  42. package/package.json +4 -3
  43. package/templates/agents/consistency.md +2 -0
  44. package/templates/agents/correctness.md +2 -0
  45. package/templates/agents/security.md +3 -0
  46. package/templates/atlantis.yml +123 -0
  47. package/templates/command.yml +4 -0
  48. package/templates/config.jsonc +71 -4
  49. package/templates/coordinator.md +34 -9
  50. package/templates/dismiss.yml +4 -0
  51. package/templates/routing.jsonc +3 -0
  52. package/templates/scope-config.jsonc +1 -0
  53. package/templates/shared.md +124 -1
  54. package/templates/workflow.yml +5 -0
@@ -0,0 +1,691 @@
1
+ // @ref LLP 0003#claude-code-cli-containment [implements] — argv/env hardening for the claude -p subprocess
2
+ // @ref LLP 0003#two-engines-per-agent-dispatch [implements] — anthropic/* routing and the per-agent engine map
3
+ import { tmpdir } from "node:os";
4
+ import path from "node:path";
5
+ import { checkAuthEntry } from "./auth.js";
6
+ import { pathInside, resolveOnPath, run } from "./exec.js";
7
+ import { addTokenUsage, AgentTimeoutError, CLAUDE_CODE_ENGINE, CROSS_CUTTING_AGENT, STACK_VERIFIER_AGENT, VERIFIER_AGENT, withTransientRetry, } from "./opencode.js";
8
+ import { RateLimitWatch } from "./throttle.js";
9
+ /** Coarse per-pass wander bound; the review's own maxWaitMs is the real ceiling. */
10
+ const CLAUDE_MAX_TURNS = 60;
11
+ /** Fallback per-pass ceiling when a caller passes no maxWaitMs. */
12
+ const DEFAULT_MAX_WAIT_MS = 8 * 60 * 1000;
13
+ /**
14
+ * A stateless `claude -p` pass emits no incremental tool lines to the caller, so a
15
+ * long pass would look hung. Emit a "still working" heartbeat this often (matching
16
+ * opencode.ts's HEARTBEAT_MS) to keep the progress signal alive.
17
+ */
18
+ const CLAUDE_HEARTBEAT_MS = 45_000;
19
+ /**
20
+ * OpenCode read-tool name → Claude Code tool name. These three are the only tools
21
+ * this engine ever grants; write/exec/net tools are always denied (review is
22
+ * read-only). `list` has no scoped Claude equivalent (Glob covers discovery) and is
23
+ * ignored.
24
+ */
25
+ const READ_TOOL_MAP = {
26
+ read: "Read",
27
+ grep: "Grep",
28
+ glob: "Glob",
29
+ };
30
+ const ALL_READ_TOOLS = ["Read", "Grep", "Glob"];
31
+ // @ref LLP 0003#claude-code-cli-containment [implements] — deny enumeration (not allow-only) because an empty/absent --allowedTools list default-ALLOWS reads; verified against claude 2.1.212, revisit on every CLI version bump
32
+ /**
33
+ * Tools never available to a review pass, whatever the role. A DENY enumeration is
34
+ * the only workable containment: permission rules cannot fail closed here — reads
35
+ * inside the workspace are default-ALLOWED even when an allow list is present but
36
+ * unmatched, and a `*` deny breaks tool calling outright (both verified against
37
+ * claude 2.1.212). The residual risk — a FUTURE CLI version shipping a new
38
+ * read-capable tool this list doesn't name — is bounded by pinning the CLI version
39
+ * (the scaffolded workflow installs an exact @anthropic-ai/claude-code version;
40
+ * bump it deliberately and revisit this list). Unknown names are ignored by the
41
+ * CLI, so denying tools that don't exist in a given version is harmless.
42
+ */
43
+ const ALWAYS_DENIED_TOOLS = [
44
+ "Bash",
45
+ "Edit",
46
+ "Write",
47
+ "NotebookEdit",
48
+ "NotebookRead",
49
+ "WebFetch",
50
+ "WebSearch",
51
+ "Task",
52
+ "TodoWrite",
53
+ "BashOutput",
54
+ "KillShell",
55
+ "ExitPlanMode",
56
+ ];
57
+ /**
58
+ * Env vars forwarded to the `claude` child — what a CLI needs to run (PATH,
59
+ * locale, tmp, proxies, its own config dir) and nothing else. See startClaudeCode
60
+ * for why this is an allowlist.
61
+ */
62
+ const CHILD_ENV_ALLOWLIST = [
63
+ "PATH",
64
+ "HOME",
65
+ "USER",
66
+ "LOGNAME",
67
+ "SHELL",
68
+ "TERM",
69
+ "LANG",
70
+ "LC_ALL",
71
+ "LC_CTYPE",
72
+ "TZ",
73
+ "TMPDIR",
74
+ "TEMP",
75
+ "TMP",
76
+ "XDG_CONFIG_HOME",
77
+ "XDG_DATA_HOME",
78
+ "XDG_CACHE_HOME",
79
+ "XDG_STATE_HOME",
80
+ "HTTP_PROXY",
81
+ "HTTPS_PROXY",
82
+ "NO_PROXY",
83
+ "http_proxy",
84
+ "https_proxy",
85
+ "no_proxy",
86
+ "CLAUDE_CONFIG_DIR",
87
+ // Windows equivalents of the above.
88
+ "SYSTEMROOT",
89
+ "SYSTEMDRIVE",
90
+ "USERPROFILE",
91
+ "APPDATA",
92
+ "LOCALAPPDATA",
93
+ "PROGRAMFILES",
94
+ "COMSPEC",
95
+ "PATHEXT",
96
+ ];
97
+ const MISSING_CLI_MESSAGE = "The `claude` CLI is not installed. Install Claude Code (npm i -g " +
98
+ "@anthropic-ai/claude-code) and run `claude setup-token` on a Max/Team " +
99
+ "subscription, then `ecr doctor`.";
100
+ /**
101
+ * Infer ONE agent's engine from its resolved model alone: an `anthropic/…` model
102
+ * runs through the Claude Code CLI, any other provider through OpenCode. The engine
103
+ * is a pure function of the model id — no auth, no run-level state — so a single run
104
+ * may drive BOTH engines at once (per agent, by model). ALL anthropic models are
105
+ * served by the CLI; the retired anthropic-via-OpenCode x-api-key path no longer
106
+ * exists (the CLI accepts an API key too).
107
+ */
108
+ // @ref LLP 0003#two-engines-per-agent-dispatch [implements] — engine choice is a pure function of the model id's provider prefix, no run-level auth-mode switch
109
+ export function engineForModel(model) {
110
+ const slash = model.indexOf("/");
111
+ const provider = slash > 0 ? model.slice(0, slash) : model;
112
+ return provider === "anthropic" ? CLAUDE_CODE_ENGINE : "opencode";
113
+ }
114
+ /**
115
+ * Map every dispatchable agent id → its engine + model: each reviewer id, plus the
116
+ * fixed cross-cutting / verifier / coordinator roles. This modelOf is the single
117
+ * source for which model backs each id — startClaudeCode (below) consumes it directly
118
+ * instead of rebuilding it, and buildOpencodeConfig (opencode.ts) folds the same ids
119
+ * into its richer per-role agent records. The per-model inference converges to one
120
+ * engine automatically when every model is identical (e.g. under REVIEWER_MODEL), so
121
+ * no run-level convergence code is needed.
122
+ *
123
+ * `agents` scopes the reviewer ids to a specific run's SELECTED agents (an explicit
124
+ * `--agents` subset); it defaults to the full roster. usesOpencode/usesClaude then
125
+ * report only the engines that run actually drives, so a subset whose passes never
126
+ * touch Claude doesn't force startClaudeCode (missing CLI/token) for nothing. The
127
+ * fixed roles always run, so the verifier/cross-cutting shared model and the
128
+ * coordinator model stay on the FULL roster (config.agents[0] / coordinator) — those
129
+ * passes use them regardless of which reviewers were selected.
130
+ */
131
+ export function buildEngineMap(config, agents = config.agents) {
132
+ const modelOf = {};
133
+ for (const agent of agents) {
134
+ modelOf[agent.id] = agent.model;
135
+ }
136
+ const shared = config.agents[0]?.model ?? config.coordinator.model;
137
+ modelOf[CROSS_CUTTING_AGENT] = shared;
138
+ modelOf[VERIFIER_AGENT] = shared;
139
+ // @ref LLP 0010#patch-level-confirmation-v2 [constrained-by] — the id MUST live in modelOf/engineOf or a claude-routed run dispatches against an undefined handle and crashes
140
+ modelOf[STACK_VERIFIER_AGENT] = shared;
141
+ modelOf["coordinator"] = config.coordinator.model;
142
+ const engineOf = {};
143
+ for (const [id, model] of Object.entries(modelOf)) {
144
+ engineOf[id] = engineForModel(model);
145
+ }
146
+ const engines = new Set(Object.values(engineOf));
147
+ return {
148
+ engineOf,
149
+ modelOf,
150
+ usesOpencode: engines.has("opencode"),
151
+ usesClaude: engines.has(CLAUDE_CODE_ENGINE),
152
+ };
153
+ }
154
+ /** Strip a leading `provider/` segment for the CLI's `--model` flag. */
155
+ export function claudeModelId(configModel) {
156
+ const slash = configModel.indexOf("/");
157
+ return slash >= 0 ? configModel.slice(slash + 1) : configModel;
158
+ }
159
+ /**
160
+ * Whether a configured model id and the model that actually answered are the same
161
+ * family, ignoring a trailing dated suffix (`claude-haiku-4-5-20251001` matches
162
+ * `claude-haiku-4-5` / `anthropic/claude-haiku-4-5`). A plain fallback within the
163
+ * family reports the CONFIGURED id (no spurious substitution note); a real swap to
164
+ * a different family reports the actual id so the substitution surfaces.
165
+ */
166
+ export function claudeModelMatches(requested, actualKey) {
167
+ const normalize = (id) => claudeModelId(id).replace(/-\d{8}$/, "");
168
+ return normalize(requested) === normalize(actualKey);
169
+ }
170
+ /**
171
+ * The read-only, trust-isolated, subscription-forced argv (minus the leading
172
+ * binary). Task text is fed on stdin, not here. NOT `--bare` (bare mode ignores
173
+ * CLAUDE_CODE_OAUTH_TOKEN/keychain OAuth); `--safe-mode` disables
174
+ * CLAUDE.md/hooks/MCP/plugins while KEEPING OAuth.
175
+ *
176
+ * The granted read tools vary by role (see the `tools` option): a reviewer gets its
177
+ * configured read/grep/glob, the cross-file and verifier passes get read+grep only
178
+ * (Glob withheld — directory crawling is what made them wander), and the coordinator
179
+ * plus the no-tools fallback get none. Whatever the role, every GRANTED read tool is
180
+ * path-scoped to the review tree (`//<cwd>/**`, Claude Code's absolute-path rule)
181
+ * with `dontAsk` denying any call that matches no allow rule. This narrows the
182
+ * prompt-injection exfil path for DIRECT out-of-tree reads: untrusted PR content is
183
+ * the review input and findings are posted as PR comments, so any unscoped
184
+ * read-capable tool (a bare `Grep` no less than a bare `Read`) would let an injected
185
+ * instruction read `~/.claude/.credentials.json` (the subscription token this engine
186
+ * authenticates with), `/proc/self/environ`, `.env*`, or SSH keys and emit them into
187
+ * a finding. Verified empirically against the installed CLI: in-tree Read/Grep
188
+ * succeed, out-of-tree Read/Grep/Glob (`/etc`, `~/.zshrc`) are denied by the
189
+ * unmatched-rule denial; a withheld read tool is denied BY NAME because an EMPTY
190
+ * allow list default-allows reads. NO scoped deny rules: `Read(//**)` would deny the
191
+ * tree itself (paths resolve to absolute), and `Read(~/**)` denies the whole tree
192
+ * whenever the repo lives under the home directory — the common case.
193
+ *
194
+ * This is NOT, by itself, a boundary against a symlink committed inside the PR-head
195
+ * tree (e.g. `docs/notes.md -> ~/.claude/.credentials.json`): the permission rule
196
+ * matches the literal path ARGUMENT, which is in-tree, but Read/Grep then follow the
197
+ * symlink via fs and return the out-of-tree target's contents. That gap is closed
198
+ * UPSTREAM of this argv, where there is still a filesystem to preflight: read-root
199
+ * materialization strips symlinks that resolve outside the tree
200
+ * (removeEscapingSymlinks in scrub.ts, run by prepareReadRootAsync). Runs whose read
201
+ * root is the user's own checkout (local diffs) don't get the sweep — the user is
202
+ * the trust principal for their own tree's symlinks.
203
+ */
204
+ export function buildClaudeArgs(opts) {
205
+ // Permission rules are gitignore-style with forward slashes; a Windows cwd
206
+ // (`C:\Users\dev\repo`) must be normalized or every rule silently matches
207
+ // nothing and dontAsk denies all reads.
208
+ const scopeRoot = opts.cwd.replace(/\\/g, "/");
209
+ const scope = (tool) => `${tool}(/${scopeRoot}/**)`;
210
+ const requested = opts.tools ?? ["read", "grep", "glob"];
211
+ const enabled = ALL_READ_TOOLS.filter((claudeName) => requested.some((name) => READ_TOOL_MAP[name] === claudeName));
212
+ // Read tools NOT granted are denied by name (see the `tools` doc above) — the
213
+ // scoped allow rules alone don't deny them when the allow list is empty.
214
+ const deniedReadTools = ALL_READ_TOOLS.filter((tool) => !enabled.includes(tool));
215
+ return [
216
+ "-p",
217
+ "--output-format",
218
+ "json",
219
+ "--model",
220
+ opts.model,
221
+ "--append-system-prompt",
222
+ opts.system,
223
+ ...(enabled.length > 0 ? ["--allowedTools", ...enabled.map(scope)] : []),
224
+ "--disallowedTools",
225
+ ...deniedReadTools,
226
+ ...ALWAYS_DENIED_TOOLS,
227
+ "--permission-mode",
228
+ "dontAsk",
229
+ "--strict-mcp-config",
230
+ "--safe-mode",
231
+ "--max-turns",
232
+ String(opts.maxTurns ?? CLAUDE_MAX_TURNS),
233
+ ];
234
+ }
235
+ /**
236
+ * The model that actually answered, out of the result's modelUsage keys. The CLI
237
+ * also bills its own internal helper calls there (a haiku entry appears alongside
238
+ * the main model, often FIRST — key order is meaningless), so prefer the key
239
+ * matching the requested family and fall back to the largest output-token count
240
+ * (the main model dominates output; helpers emit a trickle).
241
+ */
242
+ export function pickAnsweringModel(requested, modelOutputTokens) {
243
+ const keys = Object.keys(modelOutputTokens);
244
+ const familyMatch = keys.find((key) => claudeModelMatches(requested, key));
245
+ if (familyMatch) {
246
+ return familyMatch;
247
+ }
248
+ return keys.sort((a, b) => (modelOutputTokens[b] ?? 0) - (modelOutputTokens[a] ?? 0))[0];
249
+ }
250
+ /**
251
+ * Parse the `--output-format json` result object. Keys off `is_error` / a parse
252
+ * failure, NOT `subtype` — `subtype` stays `"success"` on some API errors.
253
+ */
254
+ export function parseClaudeResult(stdout) {
255
+ let parsed;
256
+ try {
257
+ parsed = JSON.parse(stdout);
258
+ }
259
+ catch {
260
+ return {
261
+ text: "",
262
+ cost: 0,
263
+ tokens: {},
264
+ modelOutputTokens: {},
265
+ isError: true,
266
+ errorText: stdout.trim(),
267
+ };
268
+ }
269
+ const usage = (parsed.usage ?? {});
270
+ const num = (value) => typeof value === "number" ? value : undefined;
271
+ const tokens = {
272
+ input: num(usage.input_tokens),
273
+ output: num(usage.output_tokens),
274
+ cache: {
275
+ write: num(usage.cache_creation_input_tokens),
276
+ read: num(usage.cache_read_input_tokens),
277
+ },
278
+ };
279
+ const modelUsage = (parsed.modelUsage ?? {});
280
+ const modelOutputTokens = {};
281
+ for (const [key, value] of Object.entries(modelUsage)) {
282
+ modelOutputTokens[key] = num(value?.outputTokens) ?? 0;
283
+ }
284
+ const result = typeof parsed.result === "string" ? parsed.result : "";
285
+ const isError = parsed.is_error === true;
286
+ return {
287
+ text: result,
288
+ cost: num(parsed.total_cost_usd) ?? 0,
289
+ tokens,
290
+ modelOutputTokens,
291
+ isError,
292
+ errorText: isError ? result || stdout.trim() : "",
293
+ };
294
+ }
295
+ /** Classify a Claude Code failure so the caller can pick backoff vs. hard fail. */
296
+ export function classifyClaudeError(errorText, apiStatus) {
297
+ if (apiStatus === 401 || apiStatus === 403) {
298
+ return "auth";
299
+ }
300
+ if (apiStatus === 429) {
301
+ return "rate-limit";
302
+ }
303
+ if (/authentication_failed|oauth_org_not_allowed|invalid.?api.?key|\b401\b/i.test(errorText)) {
304
+ return "auth";
305
+ }
306
+ if (/usage limit reached/i.test(errorText)) {
307
+ return "usage-limit";
308
+ }
309
+ if (/\b429\b|rate.?limit|too many requests|overloaded|quota/i.test(errorText)) {
310
+ return "rate-limit";
311
+ }
312
+ return "other";
313
+ }
314
+ /**
315
+ * The reset epoch (ms) a `usage limit reached|<epoch>` message carries, or null.
316
+ * Parsed defensively — the trailing `|<epoch>` is folklore, in seconds or ms.
317
+ */
318
+ export function usageLimitResetMs(errorText) {
319
+ const match = /\|\s*(\d{6,})/.exec(errorText);
320
+ if (!match) {
321
+ return null;
322
+ }
323
+ const value = Number(match[1]);
324
+ if (!Number.isFinite(value) || value <= 0) {
325
+ return null;
326
+ }
327
+ // A 10-digit value is epoch seconds; a 13-digit is already ms.
328
+ return value < 1e12 ? value * 1000 : value;
329
+ }
330
+ /**
331
+ * The thrown-error text for a usage-limit hit. Deliberately phrased so
332
+ * isTransientApiError MISSES it — no "rate limit"/"429"/"too many requests"/
333
+ * "overloaded" — because a subscription usage cap resets hours later, not in
334
+ * seconds: without this it matched the 429 pattern and burned the whole
335
+ * RATE_LIMIT_BACKOFF schedule on three doomed retries. Failing fast surfaces the
336
+ * reset time instead. The interpolated reset epoch is a long digit run with no
337
+ * internal word boundary, so it can't spuriously match `\b429\b`/`\b50x\b`.
338
+ */
339
+ // @ref LLP 0003#retry-taxonomy [constrained-by] — message text deliberately avoids matching isTransientApiError's regex so a subscription cap fails fast rather than retrying
340
+ export function usageLimitMessage(errorText) {
341
+ const resetMs = usageLimitResetMs(errorText);
342
+ const when = resetMs ? new Date(resetMs).toISOString() : "later";
343
+ return (`Claude Code usage limit reached; resets ${when}. This is a subscription usage cap, ` +
344
+ `not a transient throttle — retrying will not clear it. (${errorText})`);
345
+ }
346
+ /** Agent/coordinator default temperatures (mirrors load.ts resolveTemp fallbacks). */
347
+ const DEFAULT_AGENT_TEMPERATURE = 0.1;
348
+ const DEFAULT_COORDINATOR_TEMPERATURE = 0;
349
+ /**
350
+ * A one-time run note when a config sets a NON-default temperature under the
351
+ * claude-code engine: the `claude` CLI exposes no temperature flag, so every
352
+ * configured temperature is silently dropped. Only non-default values are flagged (a
353
+ * config left on the default never expected an effect), so a plain setup stays quiet.
354
+ * Returns null when there is nothing to surface.
355
+ */
356
+ export function claudeTemperatureNote(config, engineOf) {
357
+ // Only CLAUDE-ROUTED passes drop their temperature; in a mixed run an
358
+ // OpenCode-routed agent's tuned temperature IS honored and must not be flagged.
359
+ const tuned = config.agents.some((agent) => engineOf[agent.id] === CLAUDE_CODE_ENGINE &&
360
+ agent.temperature !== DEFAULT_AGENT_TEMPERATURE) ||
361
+ (engineOf["coordinator"] === CLAUDE_CODE_ENGINE &&
362
+ config.coordinator.temperature !== DEFAULT_COORDINATOR_TEMPERATURE);
363
+ return tuned
364
+ ? "temperature settings are not supported by the claude-code engine and were ignored " +
365
+ "(for the claude-routed passes)"
366
+ : null;
367
+ }
368
+ /**
369
+ * One prompt → text/cost/tokens/model, as a single `claude -p` subprocess (the
370
+ * Claude analogue of OpenCode's promptAgent; no sessions/polling).
371
+ */
372
+ export async function runClaudePrompt(handle, args) {
373
+ const maxWaitMs = args.maxWaitMs ?? DEFAULT_MAX_WAIT_MS;
374
+ const configuredModel = handle.models[args.agent] ?? handle.defaultModel;
375
+ // Per-role tools mirror buildOpencodeConfig (reviewers → configured set;
376
+ // cross-cutting/verifier → read+grep; coordinator → none). maxToolCalls:0 is
377
+ // review.ts's no-tools-fallback tripwire — deny every tool for that pass.
378
+ const configuredTools = handle.tools[args.agent] ?? ["read", "grep", "glob"];
379
+ const tools = args.maxToolCalls === 0 ? [] : configuredTools;
380
+ // A soft tool-call ceiling doubles as the CLI's per-pass turn bound (the closest
381
+ // stateless analogue of OpenCode's mid-run tool-call cap).
382
+ const maxTurns = args.maxToolCalls != null && args.maxToolCalls > 0 ? args.maxToolCalls : undefined;
383
+ // A stateless `claude -p` pass streams nothing back, so emit a heartbeat while it
384
+ // runs or a long pass looks hung (cleared in finally, whatever the outcome).
385
+ const heartbeatStart = Date.now();
386
+ const heartbeat = args.onActivity
387
+ ? setInterval(() => {
388
+ args.onActivity?.(`still working… ${Math.round((Date.now() - heartbeatStart) / 1000)}s elapsed`);
389
+ }, CLAUDE_HEARTBEAT_MS)
390
+ : undefined;
391
+ heartbeat?.unref?.();
392
+ let result;
393
+ try {
394
+ result = await run(handle.cliPath, buildClaudeArgs({
395
+ model: claudeModelId(configuredModel),
396
+ system: args.system,
397
+ cwd: process.cwd(),
398
+ tools,
399
+ maxTurns,
400
+ }), {
401
+ input: args.text,
402
+ env: handle.childEnv,
403
+ cwd: process.cwd(),
404
+ timeout: maxWaitMs,
405
+ check: false,
406
+ });
407
+ }
408
+ finally {
409
+ if (heartbeat) {
410
+ clearInterval(heartbeat);
411
+ }
412
+ }
413
+ // Our own deadline fired and killed the child. A killed child can't wrap up,
414
+ // so there is no finalize salvage here.
415
+ if (result.timedOut) {
416
+ throw new AgentTimeoutError(args.agent, Math.round(maxWaitMs / 60000), 0, undefined, "time");
417
+ }
418
+ // A non-timeout signal is a crash (SIGSEGV, OOM SIGKILL, external kill), not a
419
+ // timeout — surface it as a hard error rather than the subdivide/retry path.
420
+ if (result.signal) {
421
+ throw new Error(`Claude Code was killed by signal ${result.signal}: ${result.stderr.trim() || "(no output)"}`);
422
+ }
423
+ // Truncated output can't be parsed as JSON; report the cause plainly instead of
424
+ // letting it fall through as a generic parse failure.
425
+ if (result.overflowed) {
426
+ throw new Error("claude output exceeded the 64MB buffer and was truncated");
427
+ }
428
+ const parsed = parseClaudeResult(result.stdout);
429
+ if (parsed.isError) {
430
+ const kind = classifyClaudeError(parsed.errorText);
431
+ if (kind === "rate-limit" || kind === "usage-limit") {
432
+ handle.rateLimit.note();
433
+ if (kind === "usage-limit") {
434
+ // Non-transient by construction (see usageLimitMessage): fail fast with the
435
+ // reset time instead of retrying a cap that won't clear for hours.
436
+ throw new Error(usageLimitMessage(parsed.errorText));
437
+ }
438
+ throw new Error(`Claude Code rate limit hit. (${parsed.errorText})`);
439
+ }
440
+ if (kind === "auth") {
441
+ // Non-transient (see isTransientApiError): must propagate, not retry.
442
+ throw new Error(`Claude Code authentication failed (${parsed.errorText}). Re-mint with ` +
443
+ "`claude setup-token`, set CLAUDE_CODE_OAUTH_TOKEN, and check `claude auth status` / " +
444
+ "`ecr doctor`.");
445
+ }
446
+ throw new Error(parsed.errorText ||
447
+ `claude exited with code ${result.code}: ${result.stderr.trim() || result.stdout.trim() || "(no output)"}`);
448
+ }
449
+ const answered = pickAnsweringModel(configuredModel, parsed.modelOutputTokens);
450
+ const model = answered
451
+ ? claudeModelMatches(configuredModel, answered)
452
+ ? configuredModel
453
+ : `anthropic/${answered}`
454
+ : configuredModel;
455
+ return { text: parsed.text, cost: parsed.cost, sessionID: "", tokens: parsed.tokens, model };
456
+ }
457
+ /**
458
+ * The claude analogue of opencode.ts's CORRECTIVE. NOT shared: that one says
459
+ * "your previous reply could not be parsed", which is true in OpenCode's
460
+ * same-session follow-up but false here — each `claude -p` invocation is a fresh
461
+ * stateless process with no previous reply to reference.
462
+ */
463
+ const CLAUDE_CORRECTIVE = "\n\nIMPORTANT: reply with ONLY the single JSON object described above — no prose, " +
464
+ "no code fences, no partial output.";
465
+ /**
466
+ * Prompt via the Claude Code CLI and parse the reply, mirroring OpenCode's
467
+ * promptAndParse: transient retry on the first call, then one corrective re-run
468
+ * (a fresh process — the diff is inlined, so re-read is a cache hit).
469
+ */
470
+ export async function claudeCodePromptAndParse(handle, args, parse) {
471
+ let cost = 0;
472
+ let model;
473
+ const tokens = {};
474
+ const record = (result) => {
475
+ cost += result.cost;
476
+ addTokenUsage(tokens, result.tokens);
477
+ model = result.model ?? model;
478
+ };
479
+ const first = await withTransientRetry(`Agent "${args.agent}"`, args.onActivity, () => runClaudePrompt(handle, args));
480
+ record(first);
481
+ try {
482
+ return { value: parse(first.text), cost, truncated: false, tokens, model };
483
+ }
484
+ catch {
485
+ const retry = await runClaudePrompt(handle, { ...args, text: args.text + CLAUDE_CORRECTIVE });
486
+ record(retry);
487
+ try {
488
+ return { value: parse(retry.text), cost, truncated: false, tokens, model };
489
+ }
490
+ catch (finalError) {
491
+ throw new Error(`Agent "${args.agent}" did not return parseable JSON after retries: ${finalError instanceof Error ? finalError.message : String(finalError)}`);
492
+ }
493
+ }
494
+ }
495
+ /**
496
+ * Preflight: the CLI must exist, and every configured model must be an Anthropic
497
+ * id — a non-anthropic provider prefix (e.g. a leftover `openai/gpt-…` agent or
498
+ * coordinator frontmatter) would otherwise fail every pass routed to it at
499
+ * request time. Model-id validity within Anthropic is left to per-call
500
+ * `is_error` (Claude validates at request time).
501
+ */
502
+ export async function assertClaudeModels(handle, models) {
503
+ const foreign = [...new Set(models)].filter((model) => {
504
+ const slash = model.indexOf("/");
505
+ return slash > 0 && model.slice(0, slash) !== "anthropic";
506
+ });
507
+ if (foreign.length > 0) {
508
+ throw new Error(`The claude-code engine can only run anthropic/… models, but the config resolves ` +
509
+ `to: ${foreign.join(", ")}. Point every agent AND the coordinator (frontmatter in ` +
510
+ `coordinator.md) at anthropic/… model ids.`);
511
+ }
512
+ const { code } = await run(handle.cliPath, ["--version"], {
513
+ env: handle.childEnv,
514
+ check: false,
515
+ });
516
+ if (code !== 0) {
517
+ throw new Error(MISSING_CLI_MESSAGE);
518
+ }
519
+ }
520
+ /** A `claude auth status --text` line that indicates a usable Max/Team login. */
521
+ const SUBSCRIPTION_STATUS_RE = /max|team|subscription|logged in/i;
522
+ /**
523
+ * Resolve the host `claude` binary the way this engine trusts it: a PATH lookup from
524
+ * a trusted cwd (resolveOnPath, never the inherited one) and a refusal of any binary
525
+ * that resolves INSIDE the current tree. Null when unresolved or in-tree.
526
+ *
527
+ * Every `claude` spawn goes through a resolved-and-checked absolute path, never a
528
+ * bare name: the process may have chdir'd into an untrusted PR-head tree (a review)
529
+ * and doctor/setup-auth may run inside a cloned untrusted repo, so a bare name lets a
530
+ * PR-committed `claude` shim win the lookup and run with ambient secrets in its env.
531
+ * startClaudeCode keeps its own inline resolution (it needs to distinguish "missing"
532
+ * from "in-tree" for its error messages); the read-only callers use this.
533
+ */
534
+ export async function resolveClaudeCli() {
535
+ const cliPath = await resolveOnPath("claude");
536
+ if (!cliPath || pathInside(cliPath, process.cwd())) {
537
+ return null;
538
+ }
539
+ return cliPath;
540
+ }
541
+ /**
542
+ * Whether a Claude Max/Team subscription login is active locally. Shared by
543
+ * startClaudeCode, `ecr doctor`, and `ecr setup-auth` so they agree on the
544
+ * load-bearing regex.
545
+ *
546
+ * SECURITY: this may run AFTER the process has chdir'd into the untrusted PR-head
547
+ * tree (startClaudeCode calls it mid-run), and doctor/setup-auth may themselves run
548
+ * inside a cloned untrusted repo. So it must never spawn a BARE `claude` with the
549
+ * inherited cwd — on Windows (and some PATH setups) that resolves the current
550
+ * directory first, letting a PR-committed `claude` shim run with ambient secrets in
551
+ * its env. startClaudeCode passes the CLI it already resolved and pathInside-checked
552
+ * plus its allowlisted childEnv; doctor/setup-auth pass nothing and get the same
553
+ * trusted resolution internally. Either way the probe runs from tmpdir(), never cwd.
554
+ */
555
+ export async function claudeSubscriptionActive(cli = {}) {
556
+ const cliPath = cli.cliPath ?? (await resolveClaudeCli());
557
+ if (!cliPath) {
558
+ return false;
559
+ }
560
+ const status = await run(cliPath, ["auth", "status", "--text"], {
561
+ check: false,
562
+ cwd: tmpdir(),
563
+ env: cli.env,
564
+ });
565
+ return status.code === 0 && SUBSCRIPTION_STATUS_RE.test(status.stdout);
566
+ }
567
+ /**
568
+ * The forwardable Claude credential for the anthropic auth entry, or undefined.
569
+ * Resolution order: the entry's tokenEnv value when set, else an ambient
570
+ * CLAUDE_CODE_OAUTH_TOKEN (the var `ecr setup-auth`/`claude setup-token` export and
571
+ * the child-env allowlist otherwise drops), else undefined (the local `claude` login
572
+ * covers the run). Ambient ANTHROPIC_API_KEY is deliberately NOT consulted unless it
573
+ * is the configured tokenEnv — config wins over ambient env.
574
+ *
575
+ * The value is classified by shape: an "sk-ant-oat…" subscription OAuth token is
576
+ * forwarded as CLAUDE_CODE_OAUTH_TOKEN; any other value (an "sk-ant-api…" Console
577
+ * key) as ANTHROPIC_API_KEY. Shared by startClaudeCode (what it forwards) and `ecr
578
+ * doctor` (what it reports) so the fail-fast check and the doctor verdict never drift.
579
+ */
580
+ export function claudeTokenCredential(entry, env = process.env) {
581
+ const value = (entry?.tokenEnv ? env[entry.tokenEnv] : undefined) ?? env.CLAUDE_CODE_OAUTH_TOKEN;
582
+ if (!value) {
583
+ return undefined;
584
+ }
585
+ return { value, kind: value.startsWith("sk-ant-oat") ? "oauth" : "api-key" };
586
+ }
587
+ // @ref LLP 0003#credential-resolution-and-forwarding [implements] — re-runs checkAuthEntry at the forwarding site because REVIEWER_MODEL bypasses prepareAuth/checkProviderAuth entirely
588
+ /** Start the Claude Code engine: resolve the CLI and build the subscription env. */
589
+ export async function startClaudeCode(config) {
590
+ const cliPath = await resolveOnPath("claude");
591
+ if (!cliPath) {
592
+ throw new Error(MISSING_CLI_MESSAGE);
593
+ }
594
+ // SECURITY backstop to resolveOnPath's trusted-cwd lookup: by the time this runs
595
+ // the process is chdir'd into the untrusted PR-head tree, and executing a binary
596
+ // that lives INSIDE that tree would hand the reviewed PR arbitrary code execution
597
+ // with the engine credential in its environment. Never run an in-tree `claude`.
598
+ if (pathInside(cliPath, process.cwd())) {
599
+ throw new Error(`refusing to run a \`claude\` binary found inside the reviewed tree (${cliPath}) — ` +
600
+ `install Claude Code on the host (npm i -g @anthropic-ai/claude-code).`);
601
+ }
602
+ // The child env is an ALLOWLIST, never a copy of process.env: the review runs
603
+ // over untrusted PR content, so ambient secrets (GH_TOKEN, CI tokens…) must not
604
+ // exist in the child's environment at all — and Anthropic's documented
605
+ // precedence lets ANTHROPIC_API_KEY/AUTH_TOKEN override the subscription OAuth,
606
+ // so leaving them out also forces the subscription. Never log values.
607
+ const childEnv = {};
608
+ for (const name of CHILD_ENV_ALLOWLIST) {
609
+ if (process.env[name] !== undefined) {
610
+ childEnv[name] = process.env[name];
611
+ }
612
+ }
613
+ const entry = config.auth.find((auth) => auth.provider === "anthropic");
614
+ if (entry) {
615
+ // Re-run the deny-list AT THE FORWARDING SITE: prepareAuth/checkProviderAuth
616
+ // are bypassed entirely under REVIEWER_MODEL, and this is the one code path
617
+ // that still forwards a config-named secret in that case. Without this, a
618
+ // config could point tokenEnv at GITHUB_TOKEN (FORBIDDEN_TOKEN_ENVS) or a
619
+ // non-anthropic provider's key and ship it to Anthropic as the bearer.
620
+ const readiness = checkAuthEntry(entry);
621
+ if (!readiness.ok) {
622
+ throw new Error(readiness.detail);
623
+ }
624
+ }
625
+ // Forward the resolved credential: the configured tokenEnv's value, or an ambient
626
+ // CLAUDE_CODE_OAUTH_TOKEN when no anthropic entry names one (the var the allowlist
627
+ // otherwise drops, so without this the token `ecr setup-auth` tells users to export
628
+ // is a no-op and a headless run still fails). An "sk-ant-oat…" subscription token
629
+ // goes in as CLAUDE_CODE_OAUTH_TOKEN; an Anthropic API key as ANTHROPIC_API_KEY —
630
+ // the CLI reads either, and setting one never sets the other. See
631
+ // claudeTokenCredential — doctor mirrors it.
632
+ const credential = claudeTokenCredential(entry);
633
+ if (credential) {
634
+ if (credential.kind === "oauth") {
635
+ childEnv.CLAUDE_CODE_OAUTH_TOKEN = credential.value;
636
+ }
637
+ else {
638
+ childEnv.ANTHROPIC_API_KEY = credential.value;
639
+ }
640
+ }
641
+ // Fail fast with the fix in hand, before spending any pass budget: with no
642
+ // credential of any kind AND no local `claude` login, every pass would fail
643
+ // identically.
644
+ if (!childEnv.CLAUDE_CODE_OAUTH_TOKEN &&
645
+ !childEnv.ANTHROPIC_API_KEY &&
646
+ !(await claudeSubscriptionActive({ cliPath, env: childEnv }))) {
647
+ throw new Error("No Claude credential found: " +
648
+ (entry?.tokenEnv
649
+ ? `token env "${entry.tokenEnv}" is not set and no \`claude\` login is active. `
650
+ : "no `claude` login is active. ") +
651
+ "Run `claude setup-token` (Max/Team) and export the token, or log in with `claude`.");
652
+ }
653
+ // The id→model map is exactly buildEngineMap's modelOf (same reviewer ids plus the
654
+ // fixed cross-cutting / verifier / coordinator roles and their fallback), so derive
655
+ // it there rather than re-deriving it here — one source for which model backs each id.
656
+ const { modelOf: models } = buildEngineMap(config);
657
+ const tools = {};
658
+ for (const agent of config.agents) {
659
+ // A reviewer's configured tool map → the OpenCode tool names it enables
660
+ // (buildClaudeArgs keeps only the read-capable subset and scopes it).
661
+ tools[agent.id] = Object.entries(agent.tools)
662
+ .filter(([, enabled]) => enabled)
663
+ .map(([name]) => name);
664
+ }
665
+ // Mirror buildOpencodeConfig's fixed roles: the cross-file and verifier passes get
666
+ // read+grep (Glob withheld — crawling is what made them wander); the coordinator
667
+ // consolidates findings and needs no repo tools.
668
+ tools[CROSS_CUTTING_AGENT] = ["read", "grep"];
669
+ tools[VERIFIER_AGENT] = ["read", "grep"];
670
+ // No tools: the addressing PR's patch is inlined into the task, so the stack
671
+ // verifier never reads the disk (mirrors the coordinator's empty list).
672
+ tools[STACK_VERIFIER_AGENT] = [];
673
+ tools["coordinator"] = [];
674
+ const defaultModel = config.agents[0]?.model ?? config.coordinator.model;
675
+ return {
676
+ client: undefined,
677
+ url: "",
678
+ close: () => { },
679
+ // NOT the default watch file: that is the host's real OpenCode log, which this
680
+ // engine never writes — stale 429s from unrelated OpenCode use would be counted
681
+ // as evidence for this run. A nonexistent path keeps check() at zero; evidence
682
+ // for this engine arrives via note() in runClaudePrompt.
683
+ rateLimit: new RateLimitWatch(path.join(tmpdir(), `ecr-claude-${process.pid}-norate.log`)),
684
+ engine: CLAUDE_CODE_ENGINE,
685
+ models,
686
+ tools,
687
+ defaultModel,
688
+ cliPath,
689
+ childEnv,
690
+ };
691
+ }