@expo/code-review-cli 0.9.2 → 0.11.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.
package/README.md CHANGED
@@ -48,21 +48,19 @@ npx @expo/code-review-cli setup-auth
48
48
  npx @expo/code-review-cli doctor
49
49
  ```
50
50
 
51
- `setup-auth` reads the repo's config and walks through each credential it needs:
52
- an OpenAI **API key** (the scaffolded default it prints where to create the key
53
- and the exact restricted permissions to grant), and/or a **ChatGPT/Codex
54
- subscription** sign-in (it runs OpenCode's browser login and extracts the token
55
- for you). `doctor` offers to run it whenever a credential is missing.
56
-
57
- In CI, store the same values as repo secrets (`OPENAI_API_KEY`; plus
58
- `CODEX_OAUTH_ACCESS_TOKEN` for the mixed setup) the scaffolded workflow
59
- forwards them.
60
-
61
- **Have a ChatGPT Plus/Pro (Codex) subscription? Use both.** The recommended
62
- production setup pairs the subscription (runs the default models at no marginal
63
- cost) with the usage-based key (covers only the pro-tier models the subscription
64
- excludes) — see [the mixed setup](#other-providers) below. Prefer
65
- **Anthropic/Claude** or another provider? Same section.
51
+ `setup-auth` reads the repo's config and walks through each credential it needs.
52
+ The scaffolded default is **Anthropic via the Claude Code CLI**: locally your
53
+ `claude` login is enough, and for CI it helps you mint a token with
54
+ `claude setup-token`. It also handles the alternatives (an OpenAI **API key**,
55
+ a **ChatGPT/Codex subscription** sign-in). `doctor` offers to run it whenever a
56
+ credential is missing.
57
+
58
+ In CI, store the credential as the repo secret the scaffolded workflow forwards
59
+ (`CLAUDE_CODE_REVIEW_SHARED_API_TOKEN` by default — an `sk-ant-oat…` token from
60
+ `claude setup-token`, or an `sk-ant-api…` Console key; the CLI reads either).
61
+
62
+ Prefer **OpenAI** (API key, or a ChatGPT/Codex subscription, or both mixed) or
63
+ another provider? See [Other providers & auth modes](#other-providers) below.
66
64
 
67
65
  ### Reviewing (already configured)
68
66
 
@@ -196,7 +194,7 @@ your-monorepo/
196
194
  // Central guardrails every scope inherits and CANNOT override.
197
195
  "defaults": {
198
196
  // The ONLY place auth/tokenEnv is honored (besides the root config.jsonc).
199
- "auth": { "mode": "api-key", "provider": "openai", "tokenEnv": "OPENAI_API_KEY" },
197
+ "auth": { "providers": { "anthropic": { "tokenEnv": "CLAUDE_CODE_REVIEW_SHARED_API_TOKEN" } } },
200
198
  "enforceAgents": ["security"], // always runs on every scope, roster or not
201
199
  "commentTag": "expo-ai-code-reviewer" // per-scope markers derive from this
202
200
  },
@@ -214,7 +212,7 @@ your-monorepo/
214
212
  // apps/api/.expo-code-review/config.jsonc (the api team owns this)
215
213
  {
216
214
  // NO "auth" block — locked centrally; a tokenEnv here is rejected by loader + CI guard.
217
- "model": "openai/gpt-5.5",
215
+ "model": "anthropic/claude-sonnet-5",
218
216
  "policy": { "includeSuggestions": false },
219
217
  "noise": { "additionalIgnores": ["apps/api/**/__generated__/**"] }
220
218
  // shared.md, coordinator.md, agents/*.md live beside this file — the api team's roster.
@@ -316,6 +314,11 @@ Ownership is enforced with CODEOWNERS: `/.expo-code-review/routing.jsonc @your-i
316
314
  - **Reporter** — posts/updates a single fingerprinted PR comment (CI), or prints
317
315
  a grouped summary (local). Findings below the configured severity floor are
318
316
  suppressed.
317
+ - **Whole-review reuse** — automated CI stores a hash of each review job's inputs
318
+ in the hidden state of that comment. If a restack leaves a scope's files and
319
+ review configuration unchanged, its complete prior result is reused. Manual
320
+ `/review`, partial/failed reviews, stack-aware review, and model-adjudicated
321
+ feedback always run fresh.
319
322
 
320
323
  Built on the [OpenCode](https://opencode.ai) SDK, which spawns the model provider
321
324
  and applies the provider's prompt caching automatically.
@@ -336,7 +339,18 @@ cache, in three places:
336
339
  run, so the step summary is where past runs' comments remain readable.
337
340
  - **`.expo-code-review/.runs/reviews.jsonl`** — one JSON line per run (uploaded as
338
341
  a CI artifact) with the same totals plus per-pass `agentTokens`, the raw
339
- per-agent findings, coverage notes, and what the verifier dropped.
342
+ per-agent findings, bounded reviewer traces, coverage notes, and what the verifier
343
+ dropped.
344
+
345
+ Each reviewer can also return a compact trace with up to three concrete checks and
346
+ two unresolved questions. The reporter stores it only inside the existing base64
347
+ `<!-- <commentTag>:state=… -->` comment marker as `review.reviewTrace`; it does not
348
+ render in the visible review. Agents and other machine consumers can decode that
349
+ state to see what a clean review covered. The payload declares
350
+ `trust: "unverified-model-diagnostics"`: it contains bounded conclusions, never a
351
+ raw transcript or chain-of-thought, and must not be treated as a verified finding.
352
+ The engine sorts agent ids and caps the complete decoded trace at 6 KB so this hidden
353
+ diagnostic cannot crowd visible findings out of GitHub's comment-size limit.
340
354
 
341
355
  **How the caching works.** Provider prompt caching is a *prefix match*: the
342
356
  provider caches the rendered prompt up to a point, and any byte change anywhere
@@ -387,7 +401,7 @@ show writes (there is nothing to re-read within the run).
387
401
  ---
388
402
  description: One line the router uses to decide relevance.
389
403
  alwaysRun: true # run even when the router would skip this agent
390
- model: openai/gpt-5.5-pro # override the default model
404
+ model: anthropic/claude-opus-5 # override the default model
391
405
  temperature: 0.1
392
406
  ---
393
407
 
@@ -396,14 +410,14 @@ temperature: 0.1
396
410
 
397
411
  For a real-world example, see eas-cli's
398
412
  [`.expo-code-review/`](https://github.com/expo/eas-cli/tree/main/.expo-code-review)
399
- — correctness/security/consistency agents, a stronger model for security + the
400
- coordinator, and per-repo `noise.additionalIgnores`.
413
+ — correctness/security/consistency agents, a stronger model for security, and
414
+ per-repo `noise.additionalIgnores`.
401
415
 
402
416
  `config.jsonc` (JSONC — comments + trailing commas supported):
403
417
 
404
418
  ```jsonc
405
419
  {
406
- "model": "openai/gpt-5.5", // default model for the specialists
420
+ "model": "anthropic/claude-sonnet-5", // default model for the specialists
407
421
  "policy": { "includeSuggestions": false }, // suppress suggestion-severity findings
408
422
  "chunk": { "maxChangedLines": 1000, "maxFiles": 20 }, // concurrency defaults: 6 (API key) / 3 (subscription)
409
423
  "noise": { "additionalIgnores": ["packages/*/build/**"] },
@@ -412,8 +426,8 @@ coordinator, and per-repo `noise.additionalIgnores`.
412
426
  "skipLabel": "ai-review:skip" }, // "label" (only labeled PRs)
413
427
  "breakGlass": { "marker": "/skip-review" }, // PR body marker that skips the review
414
428
  "commentTag": "expo-ai-code-reviewer", // hidden tag used to find/update the comment
415
- "auth": { "mode": "api-key", "provider": "openai",
416
- "tokenEnv": "OPENAI_API_KEY" }
429
+ "auth": { "providers": {
430
+ "anthropic": { "tokenEnv": "CLAUDE_CODE_REVIEW_SHARED_API_TOKEN" } } }
417
431
  }
418
432
  ```
419
433
 
@@ -427,15 +441,17 @@ Precedence: **`REVIEWER_MODEL` env** (global override) → per-file **frontmatte
427
441
  setup, and a developer can override everything locally.
428
442
 
429
443
  - **Specialist agents** (correctness/security/consistency) benefit from a
430
- reasoning-tier model — **`openai/gpt-5.5`** is the quality/speed sweet spot
431
- (the scaffolded default). The **pro tier** finds more but is slower and more
432
- expensive, so scope it to the highest-stakes agent: **security runs on
433
- `openai/gpt-5.5-pro`** (set in `security.md` frontmatter), the rest on the default.
444
+ reasoning-tier model — **`anthropic/claude-sonnet-5`** is the quality/speed
445
+ sweet spot (the scaffolded default). The **Opus tier** finds more but is slower
446
+ and more expensive, so scope it to the highest-stakes agent: **security runs on
447
+ `anthropic/claude-opus-5`** (set in `security.md` frontmatter), the rest on the
448
+ default.
434
449
  - **The coordinator** makes the final call (dedupe / re-judge / decide) — worth a
435
- strong model; set it in `coordinator.md` frontmatter.
450
+ strong model; the scaffold pins it to `anthropic/claude-opus-5` in
451
+ `coordinator.md` frontmatter.
436
452
  - If latency/timeouts dominate on big PRs, moving the specialists to a faster model
437
- (e.g. `openai/gpt-5.4-mini`) is the most direct lever (a real recall tradeoff —
438
- measure it).
453
+ (e.g. `anthropic/claude-haiku-4-5`) is the most direct lever (a real recall
454
+ tradeoff — measure it).
439
455
  - **Every run logs which model actually answered each pass** — in the job log
440
456
  (`Models used — …`), the Actions step summary table, and the run log's
441
457
  `agentModels` — and warns loudly if a pass ran on a different model than
@@ -640,9 +656,10 @@ variables: `ATLANTIS_BOT_LOGIN` (the Atlantis bot's comment login, e.g.
640
656
  Each run appends a JSON line to `.expo-code-review/.runs/reviews.jsonl` with the
641
657
  inputs, decision, finding count, duration, per-agent cost, and aggregate token
642
658
  usage (incl. prompt-cache read/write counts) — for auditing and measuring
643
- cost/latency/cache reuse over time. The same totals are printed as a one-line
644
- summary to the terminal / CI job log at the end of each run, so cache reuse is
645
- visible even in CI (where the run log is ephemeral).
659
+ cost/latency/cache reuse over time. It also records the same bounded `reviewTrace`
660
+ that the PR comment embeds for machine consumers. The same totals are printed as a
661
+ one-line summary to the terminal / CI job log at the end of each run, so cache reuse
662
+ is visible even in CI (where the run log is ephemeral).
646
663
 
647
664
  </details>
648
665
 
@@ -650,11 +667,35 @@ visible even in CI (where the run log is ephemeral).
650
667
  <details>
651
668
  <summary><b>Other providers & auth modes</b></summary>
652
669
 
653
- The recommended setup is an OpenAI API key — see Usage above. Alternatives, all
654
- set in `config.auth` (credentials come from OpenCode):
655
-
656
- - **ChatGPT/Codex subscription (OAuth) + usage-based API key — the recommended
657
- mix.** OpenAI permits subscription auth in third-party tools, and OpenCode
670
+ The scaffolded default is **Anthropic via the Claude Code CLI** — see First-time
671
+ setup above and the Anthropic bullet below for how it authenticates and is
672
+ sandboxed. Everything is set in `config.auth`; non-anthropic providers get their
673
+ credentials through OpenCode.
674
+
675
+ - **Anthropic / Claude (the default)** — use `anthropic/...` model ids and every
676
+ anthropic pass runs through the **Claude Code CLI** (`claude -p
677
+ --output-format stream-json --verbose`), inferred from the model. The
678
+ credential is (in order) a `tokenEnv` you name, an ambient
679
+ `CLAUDE_CODE_OAUTH_TOKEN`, or your local
680
+ `claude` login — an `auth` entry is entirely optional. Run `claude setup-token`
681
+ for a Max/Team subscription token or point `tokenEnv` at an Anthropic Console
682
+ API key (`sk-ant-api…`, forwarded as `ANTHROPIC_API_KEY`); the CLI reads
683
+ either. `ecr setup-auth` walks you through it. Each pass is trust-isolated and
684
+ read-only: it runs with `--safe-mode` (no `CLAUDE.md`/hooks/MCP/plugins),
685
+ `--strict-mcp-config`, `--permission-mode dontAsk`, and only the
686
+ `Read`/`Grep`/`Glob` tools — never `Bash`/`Edit`/`Write`/`WebFetch`/`WebSearch`.
687
+ The child env is an allowlist that omits ambient `ANTHROPIC_API_KEY`/`ANTHROPIC_AUTH_TOKEN`
688
+ (only the configured credential is re-injected).
689
+ ```jsonc
690
+ // The scaffolded default. No anthropic entry at all falls back to your
691
+ // `claude` login.
692
+ "auth": { "providers": {
693
+ "anthropic": { "tokenEnv": "CLAUDE_CODE_REVIEW_SHARED_API_TOKEN" }
694
+ } }
695
+ ```
696
+ - **OpenAI: ChatGPT/Codex subscription (OAuth) + usage-based API key — the
697
+ recommended mix if you review with OpenAI.** OpenAI permits subscription auth
698
+ in third-party tools, and OpenCode
658
699
  ships the plugin for it — so the reviewer runs its default models on the
659
700
  subscription (zero marginal cost) and reserves the metered key for pro-tier
660
701
  models the subscription doesn't offer (`gpt-5.5-pro` is subscription-excluded).
@@ -693,25 +734,6 @@ set in `config.auth` (credentials come from OpenCode):
693
734
  One caveat: OpenCode can't price alias models (they're config-declared), so
694
735
  pro passes report `$0` in the run log's cost column — token counts are
695
736
  correct, and the OpenAI project dashboard is the source of truth for spend.
696
- - **Anthropic / Claude** — use `anthropic/...` model ids and every anthropic pass
697
- runs through the **Claude Code CLI** (`claude -p --output-format json`), inferred
698
- from the model. The credential is (in order) a `tokenEnv` you name, an ambient
699
- `CLAUDE_CODE_OAUTH_TOKEN`, or your local `claude` login — an `auth` entry is
700
- entirely optional. Run `claude setup-token` for a Max/Team subscription token
701
- (forwarded as `CLAUDE_CODE_OAUTH_TOKEN`) or point `tokenEnv` at an Anthropic
702
- Console API key (`sk-ant-api…`, forwarded as `ANTHROPIC_API_KEY`); the CLI reads
703
- either. `ecr setup-auth` walks you through it. Each pass is trust-isolated and
704
- read-only: it runs with `--safe-mode` (no `CLAUDE.md`/hooks/MCP/plugins),
705
- `--strict-mcp-config`, `--permission-mode dontAsk`, and only the
706
- `Read`/`Grep`/`Glob` tools — never `Bash`/`Edit`/`Write`/`WebFetch`/`WebSearch`.
707
- The child env is an allowlist that omits ambient `ANTHROPIC_API_KEY`/`ANTHROPIC_AUTH_TOKEN`
708
- (only the configured credential is re-injected).
709
- ```jsonc
710
- // Optional — no anthropic entry at all falls back to your `claude` login.
711
- "auth": { "providers": {
712
- "anthropic": { "tokenEnv": "CLAUDE_CODE_OAUTH_TOKEN" }
713
- } }
714
- ```
715
737
  - **Another provider** — the current path is the `REVIEWER_MODEL`
716
738
  env override: `opencode auth login` once (pick the provider), then run with
717
739
  e.g. `REVIEWER_MODEL=google/gemini-3-pro`. It overrides every agent's model
@@ -724,8 +746,9 @@ any other) OpenCode provider, and each agent's `model` selects its engine.
724
746
  `REVIEWER_MODEL` still overrides every agent's model (and therefore every agent's
725
747
  engine), converging the whole run onto one engine.
726
748
 
727
- There is no shared fallback key; if a run fails for lack of credentials, authenticate
728
- a provider in OpenCode. `ecr doctor` diagnoses setup.
749
+ There is no shared fallback key; if a run fails for lack of credentials, log in
750
+ with `claude` (the default) or authenticate a provider in OpenCode. `ecr doctor`
751
+ diagnoses setup.
729
752
 
730
753
  **Setup errors fail fast, with the fix in the message.** A bad credential or model id
731
754
  would otherwise fail every pass identically — a run that spends its whole budget
@@ -10,6 +10,7 @@ import { buildDiffLineIndex } from "../core/render.js";
10
10
  import { applyPins, collectPins, scopedFingerprint } from "../core/schema.js";
11
11
  import { dropStaleVerdict, feedbackApplied, feedbackNeedsRunSeam } from "../core/adjudicate.js";
12
12
  import { runReview } from "../core/review.js";
13
+ import { reviewCanBeReused, reviewInputHash, reviewMatchesInput } from "../core/review-cache.js";
13
14
  import { GitHubPRSource } from "../sources/github-pr.js";
14
15
  import { memoizeSource, stackConfirmFromConfig, stackWalkFromConfig } from "../sources/source.js";
15
16
  import { GitHubReporter } from "../reporters/github.js";
@@ -428,9 +429,11 @@ async function runLegacyCi(source, repo, prNumber, cwd, configRoot, options) {
428
429
  // unchanged (see mergeFeedback). Memoized on the source — no extra API call. A
429
430
  // metadata failure leaves it unknown, which re-judges replies instead of trusting a
430
431
  // verdict against source we cannot pin.
432
+ let metadata;
431
433
  let headSha;
432
434
  try {
433
- headSha = (await source.getMetadata()).headOid;
435
+ metadata = await source.getMetadata();
436
+ headSha = metadata.headOid;
434
437
  }
435
438
  catch {
436
439
  // leave headSha unset → stored verdicts do not carry (fail safe, costs budget only)
@@ -454,24 +457,67 @@ async function runLegacyCi(source, repo, prNumber, cwd, configRoot, options) {
454
457
  catch (error) {
455
458
  process.stderr.write(`CI reviewer: break-glass check failed (continuing): ${errorMessage(error)}\n`);
456
459
  }
460
+ const stack = resolveStackWalk(config.stack, noStackAware);
461
+ const stackConfirm = resolveStackConfirm(config.stack, noStackAware);
462
+ const feedback = adjudicationSeam(config, reporter);
463
+ // Dynamic stack context and model-backed reply adjudication have inputs outside
464
+ // the scoped diff. Keep those paths fresh until their inputs join the cache key.
465
+ // A maintainer's explicit /review is also always a real rerun.
466
+ const cacheAllowed = !bypassTriggerGate && !stack && !feedback && metadata !== undefined;
467
+ let inputHash;
457
468
  try {
469
+ if (cacheAllowed) {
470
+ try {
471
+ const prepared = await source.prepareReadRootAsync?.();
472
+ try {
473
+ inputHash = await reviewInputHash({
474
+ files: await source.getChangedFiles(),
475
+ config,
476
+ metadata: metadata,
477
+ readRoot: prepared?.dir ?? cwd,
478
+ agents,
479
+ route,
480
+ contextText,
481
+ });
482
+ }
483
+ finally {
484
+ await prepared?.cleanup();
485
+ }
486
+ }
487
+ catch (error) {
488
+ process.stderr.write(`CI reviewer: could not hash the review input (continuing fresh): ${errorMessage(error)}\n`);
489
+ }
490
+ if (inputHash) {
491
+ try {
492
+ const prior = await reporter.readState();
493
+ if (prior && reviewMatchesInput(prior.review, prior.inputHash, inputHash)) {
494
+ await reporter.report(prior.review, undefined, inputHash);
495
+ process.stderr.write("CI reviewer: unchanged review input; reused the previous result.\n");
496
+ return;
497
+ }
498
+ }
499
+ catch (error) {
500
+ process.stderr.write(`CI reviewer: could not read the previous review cache (continuing fresh): ${errorMessage(error)}\n`);
501
+ }
502
+ }
503
+ }
458
504
  const review = await runReview(source, {
459
505
  config,
460
506
  mode: "ci",
461
507
  agents,
462
508
  route,
463
509
  contextText,
464
- stack: resolveStackWalk(config.stack, noStackAware),
465
- stackConfirm: resolveStackConfirm(config.stack, noStackAware),
510
+ stack,
511
+ stackConfirm,
466
512
  runsDir: workspaceRunsDir(cwd),
467
513
  // Adjudicate mode judges the matched replies against the source before the
468
514
  // comment is rendered; annotate mode lets the reporter match them at report
469
515
  // time. Either way the feedback path is fail-open (runReview swallows its own
470
516
  // errors), so it never fails the PR's checks.
471
- feedback: adjudicationSeam(config, reporter),
517
+ feedback,
472
518
  onProgress: (message) => process.stderr.write(`${message}\n`),
473
519
  });
474
- await reporter.report(review, review.feedback);
520
+ await reporter.report(review, review.feedback, inputHash && reviewCanBeReused(review) ? inputHash : undefined);
475
521
  process.stderr.write(`CI reviewer: posted review (${review.decision}).\n`);
476
522
  }
477
523
  catch (error) {
@@ -600,13 +646,14 @@ async function runRoutedCi(source, manifest, repo, prNumber, cwd, configRoot, op
600
646
  };
601
647
  // The reviewed head OID also binds each adjudication verdict to the source it judged
602
648
  // (see mergeFeedback); unresolved leaves it unknown, so verdicts are re-judged.
649
+ let metadata;
603
650
  let headSha;
604
651
  try {
605
- const { baseOid, headOid } = await source.getMetadata();
606
- if (baseOid) {
607
- link.baseSha = baseOid;
652
+ metadata = await source.getMetadata();
653
+ if (metadata.baseOid) {
654
+ link.baseSha = metadata.baseOid;
608
655
  }
609
- headSha = headOid;
656
+ headSha = metadata.headOid;
610
657
  }
611
658
  catch {
612
659
  // leave baseSha unset → out-of-diff findings degrade to plain text
@@ -651,11 +698,38 @@ async function runRoutedCi(source, manifest, repo, prNumber, cwd, configRoot, op
651
698
  // login. `withLink` is inert for the seam (matchAdjudicationItems renders nothing), so
652
699
  // one link-carrying reporter serves both uses.
653
700
  const scopeReporter = memoizeByScope((name) => reporterFor(scopedCommentTag(rootTag, name), true));
701
+ const cacheAllowed = !bypassTriggerGate &&
702
+ !stackWalk &&
703
+ !feedbackNeedsRunSeam(rootConfig.feedback) &&
704
+ metadata !== undefined;
705
+ let cacheReadRoot;
706
+ if (cacheAllowed) {
707
+ try {
708
+ const prepared = await source.prepareReadRootAsync?.();
709
+ cacheReadRoot = prepared?.dir ?? cwd;
710
+ await prepared?.cleanup();
711
+ }
712
+ catch (error) {
713
+ // The fresh run will retry the same materialization and apply CI's ordinary
714
+ // fail-closed behavior. Cache setup itself never changes that outcome.
715
+ process.stderr.write(`CI reviewer: could not prepare the review cache input (continuing fresh): ${errorMessage(error)}\n`);
716
+ }
717
+ }
718
+ let priorAggregateState = null;
719
+ if (cacheReadRoot && mode === "single") {
720
+ try {
721
+ priorAggregateState = await singleModeReporter.readState();
722
+ }
723
+ catch (error) {
724
+ process.stderr.write(`CI reviewer: could not read the previous aggregate cache (continuing fresh): ${errorMessage(error)}\n`);
725
+ }
726
+ }
654
727
  const results = [];
655
728
  for (const scope of active) {
656
729
  const scopeDef = manifest.scopes.find((entry) => entry.name === scope.name);
657
730
  const isDefault = scope.configDir === ".";
658
731
  let review;
732
+ let inputHash;
659
733
  try {
660
734
  // A scope whose config dir doesn't exist at the TRUSTED base commit is a
661
735
  // scope this PR introduces: review it with the root config rather than
@@ -679,26 +753,63 @@ async function runRoutedCi(source, manifest, repo, prNumber, cwd, configRoot, op
679
753
  ? (finding) => scopedFingerprint(isDefault ? null : scope.name, finding)
680
754
  : undefined)
681
755
  : undefined;
682
- review = await runReview(source, {
683
- config,
684
- mode: "ci",
685
- agents,
686
- route,
687
- includePaths: scope.files,
688
- contextText,
689
- stack: stackWalk,
690
- stackConfirm,
691
- passesBudgetMs: budget,
692
- runsDir: workspaceRunsDir(cwd),
693
- feedback: feedbackSeam,
694
- onProgress: (message) => process.stderr.write(`[${scope.name}] ${message}\n`),
695
- });
756
+ let cached;
757
+ if (cacheReadRoot) {
758
+ try {
759
+ const included = new Set(scope.files);
760
+ inputHash = await reviewInputHash({
761
+ files: changed.filter((file) => included.has(file.path)),
762
+ config,
763
+ metadata: metadata,
764
+ readRoot: cacheReadRoot,
765
+ agents,
766
+ route,
767
+ contextText,
768
+ });
769
+ if (mode === "single") {
770
+ cached = priorAggregateState?.scopes?.find((entry) => entry.scope === scope.name);
771
+ }
772
+ else {
773
+ cached = (await scopeReporter(scope.name).readState()) ?? undefined;
774
+ }
775
+ }
776
+ catch (error) {
777
+ inputHash = undefined;
778
+ process.stderr.write(`CI reviewer: [${scope.name}] could not use the previous cache (continuing fresh): ${errorMessage(error)}\n`);
779
+ }
780
+ }
781
+ if (cached && reviewMatchesInput(cached.review, cached.inputHash, inputHash)) {
782
+ review = cached.review;
783
+ process.stderr.write(`CI reviewer: [${scope.name}] unchanged review input; reused the previous result.\n`);
784
+ }
785
+ else {
786
+ review = await runReview(source, {
787
+ config,
788
+ mode: "ci",
789
+ agents,
790
+ route,
791
+ includePaths: scope.files,
792
+ contextText,
793
+ stack: stackWalk,
794
+ stackConfirm,
795
+ passesBudgetMs: budget,
796
+ runsDir: workspaceRunsDir(cwd),
797
+ feedback: feedbackSeam,
798
+ onProgress: (message) => process.stderr.write(`[${scope.name}] ${message}\n`),
799
+ });
800
+ }
696
801
  }
697
802
  catch (error) {
698
803
  process.stderr.write(`CI reviewer: [${scope.name}] failed (non-blocking): ${errorMessage(error)}\n`);
699
804
  review = failureReview(scope.name, publicFailureReason(error));
805
+ inputHash = undefined;
700
806
  }
701
- results.push({ scope: scope.name, isDefault, review });
807
+ results.push({
808
+ scope: scope.name,
809
+ isDefault,
810
+ review,
811
+ ...(inputHash && reviewCanBeReused(review) ? { inputHash } : {}),
812
+ });
702
813
  }
703
814
  if (mode === "single") {
704
815
  const aggregate = singleModeReporter;
@@ -707,7 +818,7 @@ async function runRoutedCi(source, manifest, repo, prNumber, cwd, configRoot, op
707
818
  // A partial run (--scopes) is authoritative ONLY for the named scopes: merge
708
819
  // the other scopes' previous results out of the existing aggregate comment's
709
820
  // state so re-running one scope doesn't silently discard the rest.
710
- const prior = (await aggregate.readState())?.scopes ?? [];
821
+ const prior = (priorAggregateState ?? (await aggregate.readState()))?.scopes ?? [];
711
822
  finalResults = mergePartialAggregate(results, prior, scopesFilter, manifest.scopes.map((scope) => scope.name));
712
823
  }
713
824
  if (finalResults.length === 0) {
@@ -754,7 +865,7 @@ async function runRoutedCi(source, manifest, repo, prNumber, cwd, configRoot, op
754
865
  const reporter = scopeReporter(scope.name);
755
866
  const result = results.find((entry) => entry.scope === scope.name);
756
867
  if (result) {
757
- await reporter.report(result.review, result.review.feedback);
868
+ await reporter.report(result.review, result.review.feedback, result.inputHash);
758
869
  }
759
870
  else {
760
871
  // A reconciled scope with zero matched files gets its stale comment deleted.
@@ -24,9 +24,10 @@ Options:
24
24
  --no-workflow Skip writing the CI workflows (review, command, and dismiss
25
25
  under .github/workflows/)
26
26
  --token-env <name[,name…]>
27
- Env var(s) holding the model credential (default OPENAI_API_KEY,
28
- e.g. CLAUDE_CODE_OAUTH_TOKEN). The scaffolded workflows forward
29
- the matching repo secret(s) and expect this tokenEnv
27
+ Env var(s) holding the model credential (default
28
+ CLAUDE_CODE_REVIEW_SHARED_API_TOKEN, e.g. OPENAI_API_KEY). The
29
+ scaffolded workflows forward the matching repo secret(s) and
30
+ expect this tokenEnv
30
31
  --force Overwrite existing files
31
32
  --force-workflows
32
33
  Overwrite only the CI workflow files, keeping your customized
@@ -34,7 +35,7 @@ Options:
34
35
  --token-env on an already-scaffolded repo.
35
36
  -h, --help Show this help
36
37
  `;
37
- const DEFAULT_TOKEN_ENV = "OPENAI_API_KEY";
38
+ const DEFAULT_TOKEN_ENV = "CLAUDE_CODE_REVIEW_SHARED_API_TOKEN";
38
39
  export async function initCommand(argv) {
39
40
  if (argv.includes("-h") || argv.includes("--help")) {
40
41
  process.stdout.write(USAGE);
@@ -96,7 +97,7 @@ async function scaffold(argv) {
96
97
  }
97
98
  // The reverse of the guard above: --force-workflows (or --force) always rewrites
98
99
  // the review workflows from the pristine template, which forwards the default
99
- // OPENAI_API_KEY unless --token-env names the credential again. An adopter who
100
+ // Anthropic token env unless --token-env names the credential again. An adopter who
100
101
  // scaffolded with a non-default tokenEnv and later refreshes the workflow YAML
101
102
  // without re-passing --token-env would silently lose the forwarded secret, and
102
103
  // CI would keep passing the auth lock but run with an empty credential. Refuse
@@ -109,7 +110,7 @@ async function scaffold(argv) {
109
110
  `${baked}, but this run has no --token-env, so rewriting them would restore the ` +
110
111
  `default ${DEFAULT_TOKEN_ENV} and CI would run with an empty credential. Re-run with ` +
111
112
  `--token-env ${baked} to keep the current credential, or --token-env ${DEFAULT_TOKEN_ENV} ` +
112
- `to reset to OpenAI on purpose.`);
113
+ `to reset to the default on purpose.`);
113
114
  }
114
115
  }
115
116
  // Create only the config dir; let copyInto create prompts/ so it reports
@@ -149,14 +150,15 @@ async function scaffold(argv) {
149
150
  const steps = [
150
151
  `Customize ${CONFIG_DIRNAME}/agents/*.md (and shared.md, coordinator.md) for this repo.`,
151
152
  // --token-env only rewires the workflows; the scaffolded config.jsonc still
152
- // declares OPENAI_API_KEY, and CI's `ecr verify-config` refuses to review
153
- // until the config's tokenEnv set matches the workflow's expected set.
153
+ // declares the default Anthropic tokenEnv, and CI's `ecr verify-config`
154
+ // refuses to review until the config's tokenEnv set matches the workflow's
155
+ // expected set.
154
156
  ...(tokenEnvs.join(",") !== DEFAULT_TOKEN_ENV
155
157
  ? [
156
158
  `Point ${CONFIG_DIRNAME}/config.jsonc at ${tokenEnvs.length > 1 ? "these credentials" : "this credential"}: set \`auth\` (and \`model\`) per the file's comments — CI's \`ecr verify-config\` refuses to review until the config names ${names}.`,
157
159
  ]
158
160
  : []),
159
- "Configure a model provider in OpenCode (or set REVIEWER_MODEL).",
161
+ "Log in with `claude` (or run `ecr setup-auth`) so local runs have a credential.",
160
162
  "Run `ecr doctor`, then `ecr review`.",
161
163
  withWorkflow
162
164
  ? `Add the ${names} repo secret${tokenEnvs.length > 1 ? "s" : ""} referenced by the workflow, then add an \`ai-review\` label to a PR.`
@@ -491,11 +493,14 @@ export function substituteTokenEnv(raw, tokenEnvs) {
491
493
  return raw;
492
494
  }
493
495
  const expectedFallback = `vars.ECR_EXPECTED_TOKEN_ENV || '${DEFAULT_TOKEN_ENV}'`;
496
+ // Byte-identical to the credential block in templates/workflow.yml AND
497
+ // templates/command.yml (both flow through this function).
494
498
  const credentialBlock = [
495
- " # OpenAI API key — the env var named by auth.tokenEnv in config.jsonc.",
496
- " # Store it as a repo secret; a project-scoped key restricted to model",
497
- " # inference (with a spend limit) is all the reviewer needs.",
498
- " OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}",
499
+ " # Anthropic review credential — the env var named by auth.tokenEnv in",
500
+ " # config.jsonc. Store it as a repo secret: an `sk-ant-oat…` token minted",
501
+ " # by `claude setup-token`, or an `sk-ant-api…` Console key (the Claude",
502
+ " # Code CLI reads either).",
503
+ " CLAUDE_CODE_REVIEW_SHARED_API_TOKEN: ${{ secrets.CLAUDE_CODE_REVIEW_SHARED_API_TOKEN }}",
499
504
  ].join("\n");
500
505
  if (!raw.includes(expectedFallback) || !raw.includes(credentialBlock)) {
501
506
  throw new Error("workflow template drifted: tokenEnv markers not found (report this bug)");
@@ -166,6 +166,7 @@ export async function adjudicateFeedback(handle, items, config, debug = () => {
166
166
  title: `adjudicate-${index}`,
167
167
  maxWaitMs: ADJUDICATE_TIMEOUT_MS,
168
168
  finalizeOnTimeout: true,
169
+ onActivity: (line) => debug(` [adjudicator] #${index + 1}: ${line}`),
169
170
  }, parseAdjudication);
170
171
  cost += callCost;
171
172
  addTokenUsage(tokens, callTokens);
@@ -13,7 +13,9 @@ const PROVIDER_KEY_ENV = {
13
13
  * Provider-owned credential env vars BEYOND the x-api-key ones above: Anthropic's
14
14
  * OAuth/subscription bearer envs. CLAUDE_CODE_OAUTH_TOKEN holds the long-lived (1-year)
15
15
  * Claude Max/Team subscription token that `ecr setup-auth`/`claude setup-token` export,
16
- * and ANTHROPIC_AUTH_TOKEN is Anthropic's documented bearer var. They belong to
16
+ * ANTHROPIC_AUTH_TOKEN is Anthropic's documented bearer var, and
17
+ * CLAUDE_CODE_REVIEW_SHARED_API_TOKEN is the SCAFFOLDED DEFAULT tokenEnv (see
18
+ * templates/config.jsonc) — it always holds an Anthropic credential. They belong to
17
19
  * anthropic, so the cross-provider guard below refuses a non-anthropic entry that names
18
20
  * one — without this, `{provider:"openai", tokenEnv:"CLAUDE_CODE_OAUTH_TOKEN"}` passes
19
21
  * (neither a FORBIDDEN secret nor a PROVIDER_KEY_ENV value) and prepareAuth forwards the
@@ -23,6 +25,7 @@ const PROVIDER_KEY_ENV = {
23
25
  const ANTHROPIC_TOKEN_ENVS = {
24
26
  CLAUDE_CODE_OAUTH_TOKEN: "anthropic",
25
27
  ANTHROPIC_AUTH_TOKEN: "anthropic",
28
+ CLAUDE_CODE_REVIEW_SHARED_API_TOKEN: "anthropic",
26
29
  };
27
30
  /**
28
31
  * Env vars that must NEVER be forwarded to a model provider. `auth.tokenEnv` names