@expo/code-review-cli 0.9.2 → 0.10.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.
@@ -0,0 +1,108 @@
1
+ // @ref LLP 0005#review-result-cache [implements] — automated CI reuses only a complete result whose full review input hashes identically
2
+ import { createHash } from "node:crypto";
3
+ import { createReadStream } from "node:fs";
4
+ import { lstat, readlink } from "node:fs/promises";
5
+ import { createRequire } from "node:module";
6
+ import path from "node:path";
7
+ import { pathInside } from "./exec.js";
8
+ const packageVersion = createRequire(import.meta.url)("../../package.json")
9
+ .version;
10
+ /**
11
+ * Version the cache-key contract independently of the embedded comment-state shape.
12
+ * Bump this whenever review inputs gain a new source that is not represented below.
13
+ */
14
+ const REVIEW_INPUT_HASH_VERSION = 1;
15
+ /** Canonical JSON: object-key order never turns the same input into a cache miss. */
16
+ function canonicalJson(value) {
17
+ if (value === null || typeof value !== "object") {
18
+ return JSON.stringify(value) ?? "null";
19
+ }
20
+ if (Array.isArray(value)) {
21
+ return `[${value.map(canonicalJson).join(",")}]`;
22
+ }
23
+ const record = value;
24
+ return `{${Object.keys(record)
25
+ .filter((key) => record[key] !== undefined)
26
+ .sort()
27
+ .map((key) => `${JSON.stringify(key)}:${canonicalJson(record[key])}`)
28
+ .join(",")}}`;
29
+ }
30
+ /**
31
+ * Hash every input that can change the code-review result while deliberately
32
+ * excluding checkout-local paths and commit OIDs. A restack changes OIDs; when the
33
+ * scoped changed-file contents, normalized patch, prompts/config and PR prose are
34
+ * byte-identical, that is the cache hit this key is meant to recognize.
35
+ */
36
+ function normalizedPatch(patch, binary) {
37
+ return (patch
38
+ .split("\n")
39
+ // Blob ids are volatile across a restack. A binary patch has no textual content
40
+ // to hash, so retain its index line as the only content identity available.
41
+ .filter((line) => binary || !line.startsWith("index "))
42
+ // Unrelated lines landing earlier in the file move hunk coordinates without
43
+ // changing the code under review. Preserve any trailing function heading.
44
+ .map((line) => line.replace(/^@@ -\d+(?:,\d+)? \+\d+(?:,\d+)? @@(.*)$/, "@@ @@$1"))
45
+ .join("\n"));
46
+ }
47
+ async function fileContentIdentity(root, file) {
48
+ const abs = path.resolve(root, file.path);
49
+ if (!pathInside(abs, root)) {
50
+ throw new Error(`review cache path escapes the PR tree: ${file.path}`);
51
+ }
52
+ let stat;
53
+ try {
54
+ stat = await lstat(abs);
55
+ }
56
+ catch (error) {
57
+ if (error.code === "ENOENT") {
58
+ return { kind: "missing" };
59
+ }
60
+ throw error;
61
+ }
62
+ if (stat.isSymbolicLink()) {
63
+ // Never follow a PR-controlled symlink out of the materialized tree.
64
+ return { kind: "symlink", target: await readlink(abs), mode: stat.mode & 0o777 };
65
+ }
66
+ if (!stat.isFile()) {
67
+ return { kind: "other", mode: stat.mode & 0o777 };
68
+ }
69
+ const hash = createHash("sha256");
70
+ for await (const chunk of createReadStream(abs)) {
71
+ hash.update(chunk);
72
+ }
73
+ return { kind: "file", digest: hash.digest("hex"), mode: stat.mode & 0o777 };
74
+ }
75
+ export async function reviewInputHash(options) {
76
+ const { configDir: _configDir, ...portableConfig } = options.config;
77
+ const sortedFiles = [...options.files].sort((a, b) => a.path.localeCompare(b.path) || (a.status ?? "").localeCompare(b.status ?? ""));
78
+ const files = [];
79
+ // Sequential reads avoid opening one descriptor per changed file on a very wide PR.
80
+ for (const file of sortedFiles) {
81
+ files.push({
82
+ path: file.path,
83
+ patch: normalizedPatch(file.patch, file.binary === true),
84
+ status: file.status ?? "",
85
+ binary: file.binary === true,
86
+ content: await fileContentIdentity(options.readRoot, file),
87
+ });
88
+ }
89
+ const input = {
90
+ version: REVIEW_INPUT_HASH_VERSION,
91
+ engineVersion: packageVersion,
92
+ files,
93
+ config: portableConfig,
94
+ metadata: options.metadata,
95
+ agents: options.agents ? [...options.agents].sort() : null,
96
+ route: options.route === true,
97
+ contextText: options.contextText ?? null,
98
+ };
99
+ return createHash("sha256").update(canonicalJson(input)).digest("hex");
100
+ }
101
+ /** Partial/failed reviews must be retried, never made durable by a cache hit. */
102
+ export function reviewCanBeReused(review) {
103
+ return review.couldNotComplete !== true && review.incomplete.length === 0;
104
+ }
105
+ /** Exact hash match plus the completeness guard used by every CI comment shape. */
106
+ export function reviewMatchesInput(review, storedHash, currentHash) {
107
+ return (storedHash === currentHash && /^[a-f0-9]{64}$/.test(storedHash) && reviewCanBeReused(review));
108
+ }
@@ -70,6 +70,12 @@ export function effectiveConcurrency(config, env = process.env) {
70
70
  }
71
71
  return 6;
72
72
  }
73
+ /** Prefix live model activity with its stable agent bucket and optional pass label. */
74
+ export function formatAgentActivity(agent, label, line) {
75
+ const pass = label === agent ? "" : label.startsWith(`${agent} `) ? label.slice(agent.length).trim() : label;
76
+ const activity = line.replace(/[\r\n]+/g, " ").trim();
77
+ return ` [${agent}] ${pass ? `${pass}: ` : ""}${activity}`;
78
+ }
73
79
  export async function runReview(source, options) {
74
80
  const { config } = options;
75
81
  const started = Date.now();
@@ -324,7 +330,7 @@ export async function runReview(source, options) {
324
330
  let selectedAgents = explicitAgents ?? config.agents;
325
331
  if (!explicitAgents && options.route) {
326
332
  progress("Routing: selecting relevant agents…");
327
- const routed = await routeAgents(handle, config, workspace.files);
333
+ const routed = await routeAgents(handle, config, workspace.files, (line) => progress(formatAgentActivity("router", "router", line)));
328
334
  selectedAgents = routed.agents;
329
335
  progress(routed.routed
330
336
  ? `Router selected: ${selectedAgents.map((a) => a.id).join(", ")}`
@@ -467,6 +473,7 @@ export async function runReview(source, options) {
467
473
  });
468
474
  let completedPasses = 0;
469
475
  let failedPasses = 0;
476
+ const taskProgress = (task, line) => progress(formatAgentActivity(task.bucket, task.label, line));
470
477
  // promptAndParse already retries internally (same-session corrective, then a
471
478
  // bounded fresh session). We do NOT wrap it in another retry loop. On a genuine
472
479
  // TIMEOUT, instead of dropping the work we break it into units that converge:
@@ -480,7 +487,7 @@ export async function runReview(source, options) {
480
487
  system: task.system,
481
488
  text: buildTaskText(task),
482
489
  title: task.title,
483
- onActivity: (line) => progress(` ${task.label}: ${line}`),
490
+ onActivity: (line) => taskProgress(task, line),
484
491
  maxWaitMs: task.maxWaitMs,
485
492
  maxToolCalls: task.maxToolCalls,
486
493
  finalizeOnTimeout: true,
@@ -497,7 +504,7 @@ export async function runReview(source, options) {
497
504
  }
498
505
  completedPasses++;
499
506
  if (truncated) {
500
- progress(` ${task.label}: hit its budget — returned partial findings`);
507
+ taskProgress(task, "hit its budget — returned partial findings");
501
508
  incomplete.push(`${capitalize(task.coverageLabel)} ran out of time; its findings may be incomplete.`);
502
509
  }
503
510
  return;
@@ -506,7 +513,7 @@ export async function runReview(source, options) {
506
513
  // Non-timeout errors are genuine failures — record and move on.
507
514
  if (!(error instanceof AgentTimeoutError)) {
508
515
  failedPasses++;
509
- progress(` ${task.label}: FAILED (${errorMessage(error)})`);
516
+ taskProgress(task, `FAILED (${errorMessage(error)})`);
510
517
  // An auth/permission failure hits every pass identically; push one shared,
511
518
  // actionable note (deduped into a single coverage line) instead of N generic
512
519
  // per-pass failures that bury the real, fixable cause.
@@ -531,7 +538,7 @@ export async function runReview(source, options) {
531
538
  const mid = Math.ceil(task.files.length / 2);
532
539
  const left = task.files.slice(0, mid);
533
540
  const right = task.files.slice(mid);
534
- progress(` ${task.label}: exceeded ${minutes}m — splitting into 2 smaller passes (${left.length} + ${right.length} files)`);
541
+ taskProgress(task, `exceeded ${minutes}m — splitting into 2 smaller passes (${left.length} + ${right.length} files)`);
535
542
  const over = { depth: task.depth + 1, maxWaitMs: childCap };
536
543
  enqueue(childTask(task, left, `↳${left.length}f`, over));
537
544
  enqueue(childTask(task, right, `↳${right.length}f`, over));
@@ -543,7 +550,7 @@ export async function runReview(source, options) {
543
550
  // without tools; it just can't open a caller outside the diff. A lighter
544
551
  // cross-file review beats the coverage gap it used to report.
545
552
  if (!task.fallback && remaining > FALLBACK_TIMEOUT_MS) {
546
- progress(` ${task.label}: exceeded ${minutes}m — retrying ${filesLabel(task.files)} with a fast no-tools pass`);
553
+ taskProgress(task, `exceeded ${minutes}m — retrying ${filesLabel(task.files)} with a fast no-tools pass`);
547
554
  enqueue(childTask(task, task.files, "(no-tools fallback)", {
548
555
  fallback: true,
549
556
  maxWaitMs: FALLBACK_TIMEOUT_MS,
@@ -561,7 +568,7 @@ export async function runReview(source, options) {
561
568
  failedPasses++;
562
569
  const couldStillReduce = (canSubdivide && task.depth < MAX_SUBDIVIDE_DEPTH) || !task.fallback;
563
570
  if (error.reason === "stall") {
564
- progress(` ${task.label}: its model requests went silent (stalled) and did not recover — ` +
571
+ taskProgress(task, `its model requests went silent (stalled) and did not recover — ` +
565
572
  `most likely provider rate limiting; reporting a coverage gap`);
566
573
  // Name the likely cause. OpenCode retries a 429 internally without surfacing
567
574
  // it, so provider throttling reaches us as pure silence — indistinguishable
@@ -573,11 +580,11 @@ export async function runReview(source, options) {
573
580
  `those changes were not fully reviewed.`);
574
581
  }
575
582
  else if (couldStillReduce) {
576
- progress(` ${task.label}: exceeded ${minutes}m and the run's time budget is spent — reporting a coverage gap`);
583
+ taskProgress(task, `exceeded ${minutes}m and the run's time budget is spent — reporting a coverage gap`);
577
584
  incomplete.push(`${capitalize(task.coverageLabel)} timed out and the overall review budget was exhausted before it could be broken down further; those changes were not fully reviewed.`);
578
585
  }
579
586
  else {
580
- progress(` ${task.label}: exceeded ${minutes}m even at its smallest reviewable unit — reporting a coverage gap`);
587
+ taskProgress(task, `exceeded ${minutes}m even at its smallest reviewable unit — reporting a coverage gap`);
581
588
  incomplete.push(`${capitalize(task.coverageLabel)} exceeded its time budget even after being reduced to its smallest reviewable unit; those changes were not fully reviewed.`);
582
589
  }
583
590
  }
@@ -634,7 +641,7 @@ export async function runReview(source, options) {
634
641
  progress("Coordinating findings…");
635
642
  let consolidated;
636
643
  try {
637
- const { output: rawOutput, cost, tokens: coordinatorTokens, truncated: coordinatorTruncated, model: coordinatorModel, } = await coordinate(handle, config, metadata, agentFindings, coverageNotes, stackManifest);
644
+ const { output: rawOutput, cost, tokens: coordinatorTokens, truncated: coordinatorTruncated, model: coordinatorModel, } = await coordinate(handle, config, metadata, agentFindings, coverageNotes, stackManifest, (line) => progress(formatAgentActivity("coordinator", "coordinator", line)));
638
645
  agentCosts["coordinator"] = cost;
639
646
  trackTokens("coordinator", coordinatorTokens);
640
647
  trackModel("coordinator", config.coordinator.model, coordinatorModel);
@@ -709,7 +716,7 @@ export async function runReview(source, options) {
709
716
  stackManifest &&
710
717
  grounded.some((finding) => finding.requalifiedBy)) {
711
718
  progress("Confirming stacked-PR requalifications against their patches…");
712
- const confirmation = await confirmStackRequalifications(grounded, options.stackConfirm.maxConfirmations, patchConfirmer(handle, source), progress);
719
+ const confirmation = await confirmStackRequalifications(grounded, options.stackConfirm.maxConfirmations, patchConfirmer(handle, source, (line) => progress(formatAgentActivity(STACK_VERIFIER_AGENT, STACK_VERIFIER_AGENT, line))), progress);
713
720
  grounded = confirmation.findings;
714
721
  requalificationStrips.push(...confirmation.strippedFindings);
715
722
  agentCosts[STACK_VERIFIER_AGENT] = confirmation.cost;
@@ -6,7 +6,7 @@ import { parseRouteOutput } from "./schema.js";
6
6
  * `alwaysRun` are unioned in regardless. Falls back to ALL agents if the router
7
7
  * returns nothing usable or errors — a review must never run with zero agents.
8
8
  */
9
- export async function routeAgents(handle, config, files) {
9
+ export async function routeAgents(handle, config, files, onActivity) {
10
10
  const always = config.agents.filter((agent) => agent.alwaysRun);
11
11
  try {
12
12
  const { value } = await promptAndParse(handle, {
@@ -14,6 +14,7 @@ export async function routeAgents(handle, config, files) {
14
14
  system: buildRouterSystem(),
15
15
  text: buildRouterTask(config.agents, files),
16
16
  title: "route",
17
+ onActivity,
17
18
  }, parseRouteOutput);
18
19
  const byId = new Map(config.agents.map((agent) => [agent.id, agent]));
19
20
  const picked = value.agents
@@ -66,9 +66,6 @@ export const VerdictSchema = z.object({
66
66
  verified: z.boolean(),
67
67
  reason: z.string().default(""),
68
68
  });
69
- export function parseVerdict(text) {
70
- return VerdictSchema.parse(extractJsonObject(text));
71
- }
72
69
  /**
73
70
  * A stack verifier's verdict on whether a later stacked PR's patch actually
74
71
  * addresses an absence-style finding (v2 patch confirmation). Fails toward
@@ -79,27 +76,27 @@ export const StackVerdictSchema = z.object({
79
76
  addressed: z.boolean(),
80
77
  reason: z.string().default(""),
81
78
  });
82
- export function parseStackVerdict(text) {
83
- return StackVerdictSchema.parse(extractJsonObject(text));
84
- }
85
79
  // @ref LLP 0011#attribution-and-identity [implements] — `agent` is engine-populated, so the model-facing schema drops it at the parse boundary instead of trusting call sites to strip it
86
80
  /**
87
81
  * The finding shape a MODEL may emit: `FindingSchema` minus the engine-only `agent`.
88
82
  * Both model outputs parse through this, so an `agent` a reviewer pass or the
89
83
  * coordinator invented is dropped where model JSON becomes typed data — the engine's own
90
- * fingerprint lookup is then the only thing that can set it. The transform re-widens the
91
- * result to `Finding` so downstream code (which does set `agent`) needs no change.
84
+ * fingerprint lookup is then the only thing that can set it. This schema deliberately
85
+ * has no Zod transform: the Claude runtime converts it to JSON Schema so the provider
86
+ * can enforce the same contract before the local parse boundary checks it again.
92
87
  */
93
- const ModelFindingSchema = FindingSchema.omit({ agent: true }).transform((finding) => finding);
88
+ const ModelFindingSchema = FindingSchema.omit({ agent: true });
94
89
  /** Shape each sub-reviewer must emit. */
95
90
  export const ReviewerOutputSchema = z.object({
96
91
  findings: z.array(ModelFindingSchema).default([]),
97
92
  });
98
93
  /** Mode-agnostic coordinator result; each Reporter decides how to render it. */
99
- export const CoordinatorOutputSchema = z.object({
94
+ const CoordinatorModelOutputSchema = z.object({
100
95
  decision: z.enum(DECISIONS),
101
96
  findings: z.array(ModelFindingSchema).default([]),
102
97
  summary: z.string(),
98
+ });
99
+ export const CoordinatorOutputSchema = CoordinatorModelOutputSchema.extend({
103
100
  /**
104
101
  * Human-readable notes about reduced coverage (e.g. a review pass that hit its
105
102
  * time limit and returned partial findings, or was skipped). Populated by the
@@ -271,9 +268,6 @@ export const AdjudicationSchema = z.object({
271
268
  verdict: z.enum(FEEDBACK_VERDICTS),
272
269
  reason: z.enum(FEEDBACK_REASONS).default("other"),
273
270
  });
274
- export function parseAdjudication(text) {
275
- return AdjudicationSchema.parse(extractJsonObject(text));
276
- }
277
271
  /** Minimum normalized evidence length to key a fingerprint on the code (below
278
272
  * this we fall back to the title). */
279
273
  const MIN_FP_EVIDENCE_LEN = 12;
@@ -344,12 +338,20 @@ export function extractJsonObject(text) {
344
338
  export const RouteOutputSchema = z.object({
345
339
  agents: z.array(z.string()).default([]),
346
340
  });
347
- export function parseRouteOutput(text) {
348
- return RouteOutputSchema.parse(extractJsonObject(text));
349
- }
350
- export function parseReviewerOutput(text) {
351
- return ReviewerOutputSchema.parse(extractJsonObject(text));
352
- }
353
- export function parseCoordinatorOutput(text) {
354
- return CoordinatorOutputSchema.parse(extractJsonObject(text));
341
+ /**
342
+ * Bind local Zod validation to the draft-07 JSON Schema Claude Code consumes.
343
+ * Local parsing remains authoritative; provider-side validation is a reliability
344
+ * layer that repairs malformed output before it reaches this trust boundary.
345
+ */
346
+ // @ref LLP 0003#retry-taxonomy [implements] — Claude receives the same contract as the local parser and repairs mismatches in-session
347
+ function structuredParser(parseSchema, outputSchema = parseSchema) {
348
+ const parser = ((text) => parseSchema.parse(extractJsonObject(text)));
349
+ parser.jsonSchema = z.toJSONSchema(outputSchema, { target: "draft-7" });
350
+ return parser;
355
351
  }
352
+ export const parseVerdict = structuredParser(VerdictSchema);
353
+ export const parseStackVerdict = structuredParser(StackVerdictSchema);
354
+ export const parseAdjudication = structuredParser(AdjudicationSchema);
355
+ export const parseRouteOutput = structuredParser(RouteOutputSchema);
356
+ export const parseReviewerOutput = structuredParser(ReviewerOutputSchema);
357
+ export const parseCoordinatorOutput = structuredParser(CoordinatorOutputSchema, CoordinatorModelOutputSchema);
@@ -103,7 +103,7 @@ export async function confirmStackRequalifications(findings, maxConfirmations, c
103
103
  * it into the no-tools stack verifier, and require `addressed: true`. The patch is
104
104
  * inlined, never materialized — there is no disk read and no tool use at all.
105
105
  */
106
- export function patchConfirmer(handle, source) {
106
+ export function patchConfirmer(handle, source, onActivity) {
107
107
  // Per-run patch memo: distinct findings citing the same (prNumber, file) each get
108
108
  // their own verdict but share one gh fetch. The source fails open to null (never
109
109
  // rejects), so memoizing the promise is safe.
@@ -131,6 +131,7 @@ export function patchConfirmer(handle, source) {
131
131
  // A timeout here throws AgentTimeoutError, which the caller catches and STRIPS
132
132
  // (fail toward blocking) — so no finalize salvage, unlike the main verifier.
133
133
  finalizeOnTimeout: false,
134
+ onActivity: (line) => onActivity?.(`PR #${prNumber}: ${line}`),
134
135
  }, parseStackVerdict);
135
136
  return { addressed: value.addressed === true, cost, tokens, model };
136
137
  };
@@ -123,6 +123,7 @@ export async function verifyFindings(handle, findings, cwd, onProgress) {
123
123
  title: `verify-${index}`,
124
124
  maxWaitMs: VERIFY_TIMEOUT_MS,
125
125
  finalizeOnTimeout: true,
126
+ onActivity: (line) => onProgress?.(` [verifier] #${index + 1}: ${line}`),
126
127
  }, parseVerdict);
127
128
  cost += verifyCost;
128
129
  addTokenUsage(tokens, verifyTokens);
@@ -314,7 +314,7 @@ export class GitHubReporter {
314
314
  * (annotate mode). Either way the feedback path fails soft — it never blocks the
315
315
  * comment from being posted.
316
316
  */
317
- async report(review, feedback) {
317
+ async report(review, feedback, inputHash) {
318
318
  // Carry forward any per-PR dismissals recorded in the existing comment so they
319
319
  // survive re-reviews (a dismissed finding stays in the collapsed section).
320
320
  const existing = await this.findExistingComment();
@@ -332,7 +332,7 @@ export class GitHubReporter {
332
332
  ? applyPins(feedback, pinsIn)
333
333
  : await this.computeFeedback(withFp, priorRecords, pinsIn);
334
334
  const link = await this.linkContextAsync();
335
- await this.upsertComment(renderMarkdown(review, this.options.commentTag, dismissed, link, records, pins));
335
+ await this.upsertComment(renderMarkdown(review, this.options.commentTag, dismissed, link, records, pins, inputHash));
336
336
  }
337
337
  /** Post/update the aggregate multi-scope comment (comment:'single' mode). */
338
338
  async reportAggregate(results, unmatchedFiles, feedback) {
@@ -599,7 +599,7 @@ export class GitHubReporter {
599
599
  const link = await this.linkContextAsync();
600
600
  const body = isAggregate
601
601
  ? renderAggregateMarkdown(state.scopes, this.options.commentTag, next.dismissed, link, undefined, next.feedback, next.pins)
602
- : renderMarkdown(state.review, this.options.commentTag, next.dismissed, link, next.feedback, next.pins);
602
+ : renderMarkdown(state.review, this.options.commentTag, next.dismissed, link, next.feedback, next.pins, state.inputHash);
603
603
  await this.patchComment(existing.id, body);
604
604
  return {
605
605
  dismissedCount: next.dismissed.length,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@expo/code-review-cli",
3
- "version": "0.9.2",
3
+ "version": "0.10.0",
4
4
  "description": "Generic, config-driven AI code reviewer engine. Repos supply their agents via .expo-code-review/.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -3,11 +3,11 @@
3
3
  description: Security and secrets. Injection, credential or secret leakage, unsafe shell/child-process use, missing validation at trust boundaries.
4
4
  alwaysRun: true
5
5
  # Security is the highest-stakes agent and benefits most from stronger threat-model
6
- # reasoning, so it runs on the pro tier even though the other specialists use the
6
+ # reasoning, so it runs on the Opus tier even though the other specialists use the
7
7
  # default model. Scoped to this one agent to limit the extra latency/rate-limit cost;
8
- # subdivide-on-timeout + the per-fetch deadline keep a slow pro pass from hanging.
8
+ # subdivide-on-timeout + the per-fetch deadline keep a slow Opus pass from hanging.
9
9
  # @ref LLP 0009#config-and-prompt-templates [implements]
10
- model: openai/gpt-5.5-pro
10
+ model: anthropic/claude-opus-5
11
11
  ---
12
12
 
13
13
  # Security & secrets
@@ -81,6 +81,11 @@ jobs:
81
81
  # disable setup-node's auto package-manager cache.
82
82
  package-manager-cache: false
83
83
 
84
+ # The anthropic/… models run through the Claude Code CLI (claude-code
85
+ # engine). Pinned exactly — see workflow.yml (expo-code-review.yml).
86
+ - name: Install Claude Code CLI
87
+ run: npm install -g @anthropic-ai/claude-code@2.1.212
88
+
84
89
  # SECURITY: the base-ref checkout includes every .expo-code-review/ config,
85
90
  # whose auth.tokenEnv names the forwarded model credential. `ecr verify-config`
86
91
  # sweeps every config and refuses unless tokenEnv appears exactly once, in a
@@ -89,7 +94,7 @@ jobs:
89
94
  # @ref LLP 0009#guard-step-ordering-and-job-budgets [implements] — same $ECR_VERSION feeds guard and review
90
95
  - name: Guard config tokenEnv (root + routing + all scopes)
91
96
  env:
92
- ECR_EXPECTED_TOKEN_ENV: ${{ vars.ECR_EXPECTED_TOKEN_ENV || 'OPENAI_API_KEY' }}
97
+ ECR_EXPECTED_TOKEN_ENV: ${{ vars.ECR_EXPECTED_TOKEN_ENV || 'CLAUDE_CODE_REVIEW_SHARED_API_TOKEN' }}
93
98
  run: npx --yes -p "@expo/code-review-cli@$ECR_VERSION" ecr verify-config
94
99
 
95
100
  # Running via `issue_comment` makes this a manual /review, which the CLI detects
@@ -101,10 +106,11 @@ jobs:
101
106
  env:
102
107
  GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
103
108
  # Layer-1 auth lock: keep in sync with the guard's EXPECTED.
104
- ECR_EXPECTED_TOKEN_ENV: ${{ vars.ECR_EXPECTED_TOKEN_ENV || 'OPENAI_API_KEY' }}
105
- # OpenAI API key — the env var named by auth.tokenEnv in config.jsonc.
106
- OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
107
- # Optional: override the model for every agent (uses your OpenCode login).
109
+ ECR_EXPECTED_TOKEN_ENV: ${{ vars.ECR_EXPECTED_TOKEN_ENV || 'CLAUDE_CODE_REVIEW_SHARED_API_TOKEN' }}
110
+ # Anthropic review credential — the env var named by auth.tokenEnv in
111
+ # config.jsonc (see workflow.yml for the accepted token shapes).
112
+ CLAUDE_CODE_REVIEW_SHARED_API_TOKEN: ${{ secrets.CLAUDE_CODE_REVIEW_SHARED_API_TOKEN }}
113
+ # Optional: override the model for every agent.
108
114
  REVIEWER_MODEL: ${{ vars.REVIEWER_MODEL }}
109
115
  run: |
110
116
  # Build the flags as a bash array so the path never word-splits.
@@ -111,6 +111,12 @@ jobs:
111
111
  # to save an empty cache and error).
112
112
  package-manager-cache: false
113
113
 
114
+ # The anthropic/… models run through the Claude Code CLI (claude-code
115
+ # engine). Pinned exactly — see workflow.yml (expo-code-review.yml).
116
+ - name: Install Claude Code CLI
117
+ if: steps.cmd.outputs.run == 'true'
118
+ run: npm install -g @anthropic-ai/claude-code@2.1.212
119
+
114
120
  # SECURITY: the base-ref checkout above includes every .expo-code-review/
115
121
  # config.jsonc + routing.jsonc, whose auth.tokenEnv names the env var the CLI
116
122
  # forwards as the model credential. The canonical guard ships with the CLI:
@@ -126,7 +132,7 @@ jobs:
126
132
  if: steps.cmd.outputs.run == 'true'
127
133
  env:
128
134
  # (Comma-separated set for a multi-credential auth.providers config.)
129
- ECR_EXPECTED_TOKEN_ENV: ${{ vars.ECR_EXPECTED_TOKEN_ENV || 'OPENAI_API_KEY' }}
135
+ ECR_EXPECTED_TOKEN_ENV: ${{ vars.ECR_EXPECTED_TOKEN_ENV || 'CLAUDE_CODE_REVIEW_SHARED_API_TOKEN' }}
130
136
  run: npx --yes -p "@expo/code-review-cli@$ECR_VERSION" ecr verify-config
131
137
 
132
138
  - name: Run AI review
@@ -136,12 +142,13 @@ jobs:
136
142
  GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
137
143
  # Layer-1 auth lock: the CLI refuses to run when the tokenEnv it would honor
138
144
  # differs from this. Keep it in sync with the guard's EXPECTED.
139
- ECR_EXPECTED_TOKEN_ENV: ${{ vars.ECR_EXPECTED_TOKEN_ENV || 'OPENAI_API_KEY' }}
140
- # OpenAI API key — the env var named by auth.tokenEnv in config.jsonc.
141
- # Store it as a repo secret; a project-scoped key restricted to model
142
- # inference (with a spend limit) is all the reviewer needs.
143
- OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
144
- # Optional: override the model for every agent (uses your OpenCode login).
145
+ ECR_EXPECTED_TOKEN_ENV: ${{ vars.ECR_EXPECTED_TOKEN_ENV || 'CLAUDE_CODE_REVIEW_SHARED_API_TOKEN' }}
146
+ # Anthropic review credential — the env var named by auth.tokenEnv in
147
+ # config.jsonc. Store it as a repo secret: an `sk-ant-oat…` token minted
148
+ # by `claude setup-token`, or an `sk-ant-api…` Console key (the Claude
149
+ # Code CLI reads either).
150
+ CLAUDE_CODE_REVIEW_SHARED_API_TOKEN: ${{ secrets.CLAUDE_CODE_REVIEW_SHARED_API_TOKEN }}
151
+ # Optional: override the model for every agent.
145
152
  REVIEWER_MODEL: ${{ vars.REVIEWER_MODEL }}
146
153
  AGENTS: ${{ steps.cmd.outputs.agents }}
147
154
  ROUTE: ${{ steps.cmd.outputs.route }}
@@ -1,15 +1,17 @@
1
1
  // @ref LLP 0009#config-and-prompt-templates — root config: agent roster by filename, phase-1 defaults, auth
2
2
  {
3
- // Default model for every agent. Override per-agent via frontmatter in the
4
- // agent's markdown, or at runtime with the REVIEWER_MODEL env var
5
- // (e.g. REVIEWER_MODEL=openai/gpt-5.4-mini).
6
- "model": "openai/gpt-5.5",
3
+ // Default model for every agent. Anthropic models run through the Claude Code
4
+ // CLI engine (`claude -p`), inferred per agent from the model id — see `auth`
5
+ // below for the credential. Override per-agent via frontmatter in the agent's
6
+ // markdown, or at runtime with the REVIEWER_MODEL env var
7
+ // (e.g. REVIEWER_MODEL=anthropic/claude-haiku-4-5).
8
+ "model": "anthropic/claude-sonnet-5",
7
9
 
8
10
  // Agents: every markdown file in agents/ is one reviewer (id = filename).
9
11
  // Add or remove files to change the roster — no list needed here.
10
12
  // shared.md (prepended to every agent + coordinator) and coordinator.md are
11
13
  // reserved filenames. Per-agent overrides go in each file's YAML frontmatter,
12
- // e.g. `---\nmodel: openai/gpt-5.5-pro\n---`.
14
+ // e.g. `---\nmodel: anthropic/claude-opus-5\n---`.
13
15
 
14
16
  // @ref LLP 0009#config-and-prompt-templates [implements] — suggestions off by default, not a schema limit
15
17
  "policy": {
@@ -46,49 +48,39 @@
46
48
  // HTML marker used to find + update the single PR comment. Keep it stable.
47
49
  "commentTag": "expo-ai-code-reviewer",
48
50
 
49
- // How model credentials are provided. Default: an OpenAI API key.
50
- // "api-key": tokenEnv names the env var holding a provider API key. In CI,
51
- // store the key as a repo secret and pass it under that env var
52
- // (the scaffolded workflow does). If you omit `auth` entirely,
53
- // OpenCode's own login / ambient provider env vars are used.
54
- // For Anthropic/Claude, see the dedicated block below (it now runs through the
55
- // Claude Code CLI). For another provider, omit `auth` and set REVIEWER_MODEL
56
- // after an `opencode auth login` for that provider.
51
+ // How model credentials are provided. Default: Anthropic via the Claude Code
52
+ // CLI. Anthropic models always run through that CLI the engine is inferred
53
+ // per agent from the `anthropic/…` model id, so this entry only supplies the
54
+ // credential. In CI the tokenEnv holds the review credential (an `sk-ant-oat…`
55
+ // token minted by `claude setup-token`, or an `sk-ant-api…` Console key — the
56
+ // CLI reads either); locally an active `claude` login is enough, and the entry
57
+ // could even be omitted. ECR_EXPECTED_TOKEN_ENV (repo variable / workflow
58
+ // fallback) must equal this tokenEnv. `ecr setup-auth` walks you through it.
57
59
  //
58
- // MIXED setup (a ChatGPT/Codex subscription for the default models, plus a
59
- // metered API key for pro-tier models the subscription doesn't offer): use the
60
- // per-provider map instead, reference `openai-api/...` models in the frontmatter
61
- // of the agents that need the pro tier, and set ECR_EXPECTED_TOKEN_ENV in the
62
- // workflow to the comma-separated set of both env names.
63
- // "auth": { "providers": {
64
- // "openai": { "mode": "oauth", "tokenEnv": "CODEX_OAUTH_ACCESS_TOKEN" },
65
- // "openai-api": { "mode": "api-key", "tokenEnv": "OPENAI_API_KEY", "upstream": "openai" }
66
- // } }
67
- // (openai oauth: tokenEnv holds the ACCESS token from an `opencode auth login`
68
- // ChatGPT sign-in — `ecr setup-auth` extracts it. NEVER share the refresh
69
- // token: it is single-use and dies on first rotation.)
60
+ // Other providers run through OpenCode:
61
+ // "api-key": tokenEnv names the env var holding a provider API key. In CI,
62
+ // store the key as a repo secret and pass it under that env var.
63
+ // "auth": { "mode": "api-key", "provider": "openai", "tokenEnv": "OPENAI_API_KEY" }
70
64
  //
71
- // Anthropic runs through the Claude Code CLI, inferred from the model — set your
72
- // models to "anthropic/…" (e.g. anthropic/claude-opus-5, anthropic/claude-sonnet-5)
73
- // and run `claude setup-token`, exporting the token as CLAUDE_CODE_OAUTH_TOKEN (CI)
74
- // or rely on your local `claude` login. An auth entry is OPTIONAL (tokenEnv just
75
- // names the credential env); `ecr setup-auth` walks you through it.
65
+ // ChatGPT/Codex subscription ("oauth"): tokenEnv holds the ACCESS token from
66
+ // an `opencode auth login` ChatGPT sign-in — `ecr setup-auth` extracts it.
67
+ // NEVER share the refresh token: it is single-use and dies on first rotation.
76
68
  // "auth": { "providers": {
77
- // "anthropic": { "tokenEnv": "CLAUDE_CODE_OAUTH_TOKEN" }
69
+ // "openai": { "mode": "oauth", "tokenEnv": "CODEX_OAUTH_ACCESS_TOKEN" }
78
70
  // } }
79
71
  //
80
- // MIXING engines is supported: the engine is inferred per agent from its model, so
81
- // an anthropic entry may coexist with an openai (or any other) OpenCode provider.
82
- // Each agent's `model` selects its engine — an `anthropic/…` agent runs through the
83
- // Claude Code CLI while an `openai/…` agent runs through OpenCode, in the SAME run.
72
+ // MIXING engines is supported: the engine is inferred per agent from its model,
73
+ // so this anthropic entry may coexist with an openai (or any other) OpenCode
74
+ // provider — an `anthropic/…` agent runs through the Claude Code CLI while an
75
+ // `openai/…` agent runs through OpenCode, in the SAME run:
84
76
  // "auth": { "providers": {
85
- // "anthropic": { "tokenEnv": "CLAUDE_CODE_OAUTH_TOKEN" },
77
+ // "anthropic": { "tokenEnv": "CLAUDE_CODE_REVIEW_SHARED_API_TOKEN" },
86
78
  // "openai": { "mode": "oauth", "tokenEnv": "CODEX_OAUTH_ACCESS_TOKEN" }
87
79
  // } }
88
80
  "auth": {
89
- "mode": "api-key",
90
- "provider": "openai",
91
- "tokenEnv": "OPENAI_API_KEY"
81
+ "providers": {
82
+ "anthropic": { "tokenEnv": "CLAUDE_CODE_REVIEW_SHARED_API_TOKEN" }
83
+ }
92
84
  },
93
85
 
94
86
  // Stack-aware requalification (ROOT-ONLY; off by default). When on, `ecr ci` walks
@@ -1,11 +1,11 @@
1
1
  <!-- @ref LLP 0009#config-and-prompt-templates — pro tier pinned on purpose: consolidation quality over serial-tail latency -->
2
2
  ---
3
3
  # The coordinator makes the final call — de-duping, re-judging severity, and
4
- # deciding — so it runs on the pro tier: consolidation quality matters more here
4
+ # deciding — so it runs on the Opus tier: consolidation quality matters more here
5
5
  # than the small serial-tail latency it adds (no repo tools, one bounded pass).
6
6
  # Override with a cheaper model if you'd rather trade decision quality for latency.
7
7
  # @ref LLP 0009#config-and-prompt-templates [implements]
8
- model: openai/gpt-5.5-pro
8
+ model: anthropic/claude-opus-5
9
9
  ---
10
10
 
11
11
  # Coordinator — consolidation & decision