@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.
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.
@@ -387,7 +390,7 @@ show writes (there is nothing to re-read within the run).
387
390
  ---
388
391
  description: One line the router uses to decide relevance.
389
392
  alwaysRun: true # run even when the router would skip this agent
390
- model: openai/gpt-5.5-pro # override the default model
393
+ model: anthropic/claude-opus-5 # override the default model
391
394
  temperature: 0.1
392
395
  ---
393
396
 
@@ -396,14 +399,14 @@ temperature: 0.1
396
399
 
397
400
  For a real-world example, see eas-cli's
398
401
  [`.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`.
402
+ — correctness/security/consistency agents, a stronger model for security, and
403
+ per-repo `noise.additionalIgnores`.
401
404
 
402
405
  `config.jsonc` (JSONC — comments + trailing commas supported):
403
406
 
404
407
  ```jsonc
405
408
  {
406
- "model": "openai/gpt-5.5", // default model for the specialists
409
+ "model": "anthropic/claude-sonnet-5", // default model for the specialists
407
410
  "policy": { "includeSuggestions": false }, // suppress suggestion-severity findings
408
411
  "chunk": { "maxChangedLines": 1000, "maxFiles": 20 }, // concurrency defaults: 6 (API key) / 3 (subscription)
409
412
  "noise": { "additionalIgnores": ["packages/*/build/**"] },
@@ -412,8 +415,8 @@ coordinator, and per-repo `noise.additionalIgnores`.
412
415
  "skipLabel": "ai-review:skip" }, // "label" (only labeled PRs)
413
416
  "breakGlass": { "marker": "/skip-review" }, // PR body marker that skips the review
414
417
  "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" }
418
+ "auth": { "providers": {
419
+ "anthropic": { "tokenEnv": "CLAUDE_CODE_REVIEW_SHARED_API_TOKEN" } } }
417
420
  }
418
421
  ```
419
422
 
@@ -427,15 +430,17 @@ Precedence: **`REVIEWER_MODEL` env** (global override) → per-file **frontmatte
427
430
  setup, and a developer can override everything locally.
428
431
 
429
432
  - **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.
433
+ reasoning-tier model — **`anthropic/claude-sonnet-5`** is the quality/speed
434
+ sweet spot (the scaffolded default). The **Opus tier** finds more but is slower
435
+ and more expensive, so scope it to the highest-stakes agent: **security runs on
436
+ `anthropic/claude-opus-5`** (set in `security.md` frontmatter), the rest on the
437
+ default.
434
438
  - **The coordinator** makes the final call (dedupe / re-judge / decide) — worth a
435
- strong model; set it in `coordinator.md` frontmatter.
439
+ strong model; the scaffold pins it to `anthropic/claude-opus-5` in
440
+ `coordinator.md` frontmatter.
436
441
  - 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).
442
+ (e.g. `anthropic/claude-haiku-4-5`) is the most direct lever (a real recall
443
+ tradeoff — measure it).
439
444
  - **Every run logs which model actually answered each pass** — in the job log
440
445
  (`Models used — …`), the Actions step summary table, and the run log's
441
446
  `agentModels` — and warns loudly if a pass ran on a different model than
@@ -650,11 +655,35 @@ visible even in CI (where the run log is ephemeral).
650
655
  <details>
651
656
  <summary><b>Other providers & auth modes</b></summary>
652
657
 
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
658
+ The scaffolded default is **Anthropic via the Claude Code CLI** — see First-time
659
+ setup above and the Anthropic bullet below for how it authenticates and is
660
+ sandboxed. Everything is set in `config.auth`; non-anthropic providers get their
661
+ credentials through OpenCode.
662
+
663
+ - **Anthropic / Claude (the default)** — use `anthropic/...` model ids and every
664
+ anthropic pass runs through the **Claude Code CLI** (`claude -p
665
+ --output-format stream-json --verbose`), inferred from the model. The
666
+ credential is (in order) a `tokenEnv` you name, an ambient
667
+ `CLAUDE_CODE_OAUTH_TOKEN`, or your local
668
+ `claude` login — an `auth` entry is entirely optional. Run `claude setup-token`
669
+ for a Max/Team subscription token or point `tokenEnv` at an Anthropic Console
670
+ API key (`sk-ant-api…`, forwarded as `ANTHROPIC_API_KEY`); the CLI reads
671
+ either. `ecr setup-auth` walks you through it. Each pass is trust-isolated and
672
+ read-only: it runs with `--safe-mode` (no `CLAUDE.md`/hooks/MCP/plugins),
673
+ `--strict-mcp-config`, `--permission-mode dontAsk`, and only the
674
+ `Read`/`Grep`/`Glob` tools — never `Bash`/`Edit`/`Write`/`WebFetch`/`WebSearch`.
675
+ The child env is an allowlist that omits ambient `ANTHROPIC_API_KEY`/`ANTHROPIC_AUTH_TOKEN`
676
+ (only the configured credential is re-injected).
677
+ ```jsonc
678
+ // The scaffolded default. No anthropic entry at all falls back to your
679
+ // `claude` login.
680
+ "auth": { "providers": {
681
+ "anthropic": { "tokenEnv": "CLAUDE_CODE_REVIEW_SHARED_API_TOKEN" }
682
+ } }
683
+ ```
684
+ - **OpenAI: ChatGPT/Codex subscription (OAuth) + usage-based API key — the
685
+ recommended mix if you review with OpenAI.** OpenAI permits subscription auth
686
+ in third-party tools, and OpenCode
658
687
  ships the plugin for it — so the reviewer runs its default models on the
659
688
  subscription (zero marginal cost) and reserves the metered key for pro-tier
660
689
  models the subscription doesn't offer (`gpt-5.5-pro` is subscription-excluded).
@@ -693,25 +722,6 @@ set in `config.auth` (credentials come from OpenCode):
693
722
  One caveat: OpenCode can't price alias models (they're config-declared), so
694
723
  pro passes report `$0` in the run log's cost column — token counts are
695
724
  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
725
  - **Another provider** — the current path is the `REVIEWER_MODEL`
716
726
  env override: `opencode auth login` once (pick the provider), then run with
717
727
  e.g. `REVIEWER_MODEL=google/gemini-3-pro`. It overrides every agent's model
@@ -724,8 +734,9 @@ any other) OpenCode provider, and each agent's `model` selects its engine.
724
734
  `REVIEWER_MODEL` still overrides every agent's model (and therefore every agent's
725
735
  engine), converging the whole run onto one engine.
726
736
 
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.
737
+ There is no shared fallback key; if a run fails for lack of credentials, log in
738
+ with `claude` (the default) or authenticate a provider in OpenCode. `ecr doctor`
739
+ diagnoses setup.
729
740
 
730
741
  **Setup errors fail fast, with the fix in the message.** A bad credential or model id
731
742
  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