@expo/code-review-cli 0.3.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/README.md +307 -47
  2. package/build/cli.js +24 -17
  3. package/build/commands/ci.js +410 -43
  4. package/build/commands/dismiss.js +16 -16
  5. package/build/commands/doctor.js +219 -26
  6. package/build/commands/init.js +244 -34
  7. package/build/commands/review.js +118 -30
  8. package/build/commands/verify-config.js +252 -0
  9. package/build/config/load.js +200 -55
  10. package/build/config/routing.js +122 -0
  11. package/build/config/schema.js +153 -19
  12. package/build/core/auth.js +237 -75
  13. package/build/core/coordinator.js +7 -7
  14. package/build/core/diff.js +19 -19
  15. package/build/core/exec.js +10 -10
  16. package/build/core/log.js +3 -3
  17. package/build/core/noise.js +52 -52
  18. package/build/core/opencode.js +495 -95
  19. package/build/core/prompts.js +220 -150
  20. package/build/core/render.js +202 -48
  21. package/build/core/review.js +277 -102
  22. package/build/core/router.js +10 -10
  23. package/build/core/schema.js +26 -12
  24. package/build/core/step-summary.js +18 -0
  25. package/build/core/suppress.js +7 -7
  26. package/build/core/tools.js +9 -9
  27. package/build/core/util.js +2 -2
  28. package/build/core/verify.js +28 -26
  29. package/build/reporters/github.js +103 -51
  30. package/build/reporters/terminal.js +19 -19
  31. package/build/sources/github-pr.js +21 -21
  32. package/build/sources/local-git.js +20 -20
  33. package/build/sources/source.js +35 -1
  34. package/package.json +8 -3
  35. package/templates/agents/security.md +5 -0
  36. package/templates/command.yml +167 -0
  37. package/templates/config.jsonc +26 -13
  38. package/templates/coordinator.md +5 -3
  39. package/templates/dismiss.yml +110 -0
  40. package/templates/routing.jsonc +27 -0
  41. package/templates/scope-config.jsonc +25 -0
  42. package/templates/shared.md +12 -0
  43. package/templates/workflow.yml +61 -26
package/README.md CHANGED
@@ -41,21 +41,33 @@ Scaffold, add credentials, verify.
41
41
  npx @expo/code-review-cli init
42
42
  ```
43
43
 
44
- Then give it model credentials. **Recommended: a Claude Pro/Max subscription** — the
45
- scaffolded config uses OAuth by default, so just mint a token and export it under the
46
- env var your `config.jsonc`'s `auth.tokenEnv` names:
44
+ Then give it model credentials. **Default: an OpenAI API key** — the scaffolded
45
+ config reviews with GPT via `auth.mode "api-key"`.
46
+
47
+ Create the key in the OpenAI dashboard, scoped to the minimum the reviewer needs:
48
+
49
+ - Put it in a **dedicated project** (not "Default project") so you can set a
50
+ monthly budget + alert on it and see the reviewer's spend in isolation.
51
+ - Make it a **Restricted** key with exactly two permissions, both under *Model
52
+ capabilities*: **Responses (/v1/responses) → Request** and **Chat completions
53
+ (/v1/chat/completions) → Request**. Everything else — including *List models* —
54
+ stays **None** (the reviewer resolves model ids from its own catalog and only
55
+ ever makes inference requests).
47
56
 
48
57
  ```bash
49
- # Mint a Claude Pro/Max token (prints an sk-ant-oat… token)
50
- claude setup-token
51
- # Export it under the env var your config.jsonc's auth.tokenEnv names
52
- export ANTHROPIC_OAUTH_API_KEY=sk-ant-oat...
58
+ export OPENAI_API_KEY=sk-proj-...
53
59
  # Check env, config, and credentials
54
60
  npx @expo/code-review-cli doctor
55
61
  ```
56
62
 
57
- Prefer an Anthropic **API key**, or **OpenAI/GPT** or another provider? See
58
- [Other providers & auth modes](#other-providers) at the bottom.
63
+ In CI, store the same key as the `OPENAI_API_KEY` repo secret (the scaffolded
64
+ workflow forwards it).
65
+
66
+ **Have a ChatGPT Plus/Pro (Codex) subscription? Use both.** The recommended
67
+ production setup pairs the subscription (runs the default models at no marginal
68
+ cost) with the usage-based key (covers only the pro-tier models the subscription
69
+ excludes) — see [the mixed setup](#other-providers) below. Prefer
70
+ **Anthropic/Claude** or another provider? Same section.
59
71
 
60
72
  ### Reviewing (already configured)
61
73
 
@@ -74,7 +86,7 @@ Options (most to least common):
74
86
  | --- | --- |
75
87
  | `--pr <n>` | Review GitHub PR #n by number (diff fetched via `gh`, no checkout); not combinable with `--base`/`--head`/`--staged`. |
76
88
  | `--post` | With `--pr`, also post the result as the PR comment (needs `gh` auth). Omit to preview only; re-run with `--post` to publish. |
77
- | `--staged` | Review only staged changes. |
89
+ | `--staged` | Review only staged changes (index vs HEAD; not combinable with `--base`/`--head`). |
78
90
  | `--base <ref>` | Base ref to diff against (default: merge-base with the default branch). |
79
91
  | `--head <ref>` | Head ref to diff (default: working tree, incl. uncommitted changes). |
80
92
  | `--agents <a,b>` | Run only these agents (comma-separated ids); default: all. |
@@ -101,14 +113,132 @@ is a ready example to adapt.
101
113
  | Command | What it does |
102
114
  | --- | --- |
103
115
  | `ecr init [--no-workflow] [--force]` | Scaffold `.expo-code-review/` (config, agents, prompts) + a CI workflow. |
116
+ | `ecr init --monorepo` | …and add a `routing.jsonc` routing manifest (one default scope). |
117
+ | `ecr init --scope <dir>` | Scaffold a per-team scope under `<dir>` and register it in the manifest. |
104
118
  | `ecr review [options]` | Review local changes and print an advisory review (default command). |
119
+ | `ecr review --scope <name>` | Review only one routing scope over just that scope's changed files. |
105
120
  | `ecr ci` | Review the current GitHub PR and post/update a comment. For GitHub Actions. |
106
- | `ecr doctor` | Check environment, config, and model credentials. |
121
+ | `ecr doctor [--list-scopes]` | Check environment, config, credentials, and (with a manifest) scopes. |
122
+
123
+ Extra flags for monorepos: `review`/`ci` `--config-dir <dir>` (load config from an
124
+ alternate dir; also `ECR_CONFIG_DIR`), `ci --scopes a,b` (limit the fan-out to
125
+ named scopes), `ci --comment single|per-scope` (override the manifest).
107
126
 
108
127
  (When developing this repo itself, use `bun run src/cli.ts <command>`.)
109
128
 
110
129
  ---
111
130
 
131
+ ## Monorepos (routing manifest)
132
+
133
+ A monorepo can route different subtrees to different reviewer rosters from a single
134
+ infra-owned manifest. There is still **one workflow, one `ecr ci` process** per PR:
135
+ it reads the changed files once, assigns each to exactly one scope, reviews each
136
+ active scope over only its files, and renders one comment. Because it is a single
137
+ writer in a single process there is no comment/check race and no locking.
138
+
139
+ ```
140
+ your-monorepo/
141
+ .expo-code-review/
142
+ routing.jsonc # the manifest — infra-owned, ordered scope list + locked defaults
143
+ config.jsonc # the default/root scope; the ONLY place auth/tokenEnv lives
144
+ shared.md coordinator.md agents/
145
+ server/
146
+ www/.expo-code-review/{config.jsonc(NO auth),coordinator.md,agents/} # www team
147
+ website/.expo-code-review/{config.jsonc(NO auth),coordinator.md,agents/} # website team
148
+ .github/workflows/expo-code-review.yml # unchanged shape: one workflow, one `ecr ci`
149
+ ```
150
+
151
+ ```jsonc
152
+ // .expo-code-review/routing.jsonc
153
+ {
154
+ // Central guardrails every scope inherits and CANNOT override.
155
+ "defaults": {
156
+ // The ONLY place auth/tokenEnv is honored (besides the root config.jsonc).
157
+ "auth": { "mode": "api-key", "provider": "openai", "tokenEnv": "OPENAI_API_KEY" },
158
+ "enforceAgents": ["security"], // always runs on every scope, roster or not
159
+ "commentTag": "expo-ai-code-reviewer" // per-scope markers derive from this
160
+ },
161
+ "comment": "single", // "single" = one aggregated comment (default) | "per-scope"
162
+ // Ordered; the LAST matching scope wins per changed file (CODEOWNERS discipline).
163
+ "scopes": [
164
+ { "name": "default", "paths": ["**/*"], "config": "." },
165
+ { "name": "server-www", "paths": ["server/www/**"], "config": "server/www" },
166
+ { "name": "server-website", "paths": ["server/website/**"], "config": "server/website" }
167
+ ]
168
+ }
169
+ ```
170
+
171
+ ```jsonc
172
+ // server/www/.expo-code-review/config.jsonc (the www team owns this)
173
+ {
174
+ // NO "auth" block — locked centrally; a tokenEnv here is rejected by loader + CI guard.
175
+ "model": "openai/gpt-5.5",
176
+ "policy": { "includeSuggestions": false },
177
+ "noise": { "additionalIgnores": ["server/www/**/__generated__/**"] }
178
+ // shared.md, coordinator.md, agents/*.md live beside this file — the www team's roster.
179
+ }
180
+ ```
181
+
182
+ - **Path glob dialect** — `**` crosses `/`, `*` matches within a segment. Keep a
183
+ `**/*` catch-all scope so no changed file goes unreviewed (`ecr doctor` flags a
184
+ coverage gap otherwise). Scopes are ordered and the **last** match wins, so put
185
+ broad scopes first and specific ones after (CODEOWNERS/Renovate discipline).
186
+ - **Comment modes** — `single` posts one aggregated comment (a scope summary table
187
+ + a collapsed `<details>` per scope) under the existing marker; `per-scope` posts
188
+ one cleanly-namespaced comment per scope. A scope with zero matched files gets its
189
+ stale comment deleted.
190
+ - **Scoped flags** — `ecr ci --scopes a,b` limits the fan-out; `ecr ci --comment
191
+ single|per-scope` overrides the manifest; `ecr review --scope <name>` runs one
192
+ scope locally; `ecr review`/`ecr ci --config-dir <dir>` (or `ECR_CONFIG_DIR`)
193
+ load config from an alternate directory; `ecr doctor --list-scopes` prints the
194
+ scope table. **`--config-dir` designates an alternate ROOT config dir: both
195
+ `config.jsonc` and `routing.jsonc` are read from it, so the root config and its
196
+ manifest always travel together. Scope `config` paths stay repo-root-relative —
197
+ the override swaps the root config/manifest against the *real* scope tree, it
198
+ does not relocate the scopes themselves.**
199
+ - **Passes budget** — `defaults`-level `budget` bounds total review time:
200
+ `totalPassesMinutes` (default 55) is split across active scopes (which run
201
+ sequentially in one `ecr ci`), clamped up to `minScopeMinutes` (default 5) so a
202
+ single scope still gets a workable window. When enough scopes are active that the
203
+ floor would overshoot the total, `ecr ci` keeps the floor but warns, and `ecr
204
+ doctor` flags the worst case (`scopes × floor` vs total) — raise the workflow
205
+ `timeout-minutes` or trim scopes.
206
+ - **Adoption is incremental** — with no `routing.jsonc`, behavior is exactly as
207
+ before (single config). Add the manifest with just a default scope → still one
208
+ comment, identical behavior. Land per-team scope dirs one at a time; everything
209
+ else keeps hitting the default scope.
210
+
211
+ ### Security
212
+
213
+ - **auth is locked to the root.** `tokenEnv` (which env var becomes the model
214
+ credential) is honored in exactly one place: the root `config.jsonc` or
215
+ `routing.jsonc` `defaults.auth`. A scope config declaring `auth`/`breakGlass`
216
+ **fails to parse** (Zod-level rejection), and the CI guard step independently
217
+ sweeps every `.expo-code-review/config.jsonc`/`routing.jsonc` repo-wide and refuses
218
+ to run unless `tokenEnv` appears exactly once, in a root-owned file, equal to
219
+ `ECR_EXPECTED_TOKEN_ENV`. A routing manifest can never widen exposure — globs only
220
+ choose *which roster* reviews a file, never *which secret* is sent.
221
+ - **enforceAgents can't be weakened.** Agents listed in `defaults.enforceAgents`
222
+ (e.g. `security`) are injected into every scope with `alwaysRun`, taken from the
223
+ root roster — a scope defining a same-id agent gets the root one, so a team can't
224
+ shadow the enforced reviewer with a weaker version on its own subtree.
225
+ - **Config comes from the checked-out ref (documented tradeoff).** The scaffolded
226
+ auto workflow checks out the PR **merge ref**, so a PR *can* edit rosters, prompts,
227
+ and routing globs for its own advisory review — that only steers what the
228
+ comment-only reviewer says about that PR. What a PR can **never** do is touch
229
+ credentials: `auth` is locked by the scope-schema rejection, the CLI's runtime
230
+ `ECR_EXPECTED_TOKEN_ENV` check, and the guard step above, all three of which run
231
+ against whatever ref is checked out. A `/review`-command workflow that checks out
232
+ only the trusted base ref (see eas-cli's) closes the prompt-tampering vector too;
233
+ resolving config from the base ref on the auto path as well is on the
234
+ [roadmap](./ROADMAP.md).
235
+
236
+ Ownership is enforced with CODEOWNERS: `/.expo-code-review/routing.jsonc @your-infra`
237
+ (the single authoritative router) and `/server/www/.expo-code-review/ @your-www-team`
238
+ (each team owns only its own scope dir). Rerouting globs is gated behind infra review.
239
+
240
+ ---
241
+
112
242
  <details>
113
243
  <summary><b>How it works</b></summary>
114
244
 
@@ -131,7 +261,51 @@ is a ready example to adapt.
131
261
  suppressed.
132
262
 
133
263
  Built on the [OpenCode](https://opencode.ai) SDK, which spawns the model provider
134
- and applies Anthropic prompt caching automatically.
264
+ and applies the provider's prompt caching automatically.
265
+
266
+ </details>
267
+
268
+ <details>
269
+ <summary><b>Tokens, cost &amp; prompt caching</b></summary>
270
+
271
+ Every run reports what it spent and how much of it was served from the prompt
272
+ cache, in three places:
273
+
274
+ - **Job log (stderr)** — one line at the end of the run:
275
+ `Token usage — input …, output …, cache read …, cache write … (cost $…)`.
276
+ - **GitHub Actions step summary** — a per-pass table (one row per agent, plus the
277
+ cross-cutting pass, coordinator, and verifier), the run's cache hit rate, and
278
+ the exact comment that was posted. The PR comment is updated in place on every
279
+ run, so the step summary is where past runs' comments remain readable.
280
+ - **`.expo-code-review/.runs/reviews.jsonl`** — one JSON line per run (uploaded as
281
+ a CI artifact) with the same totals plus per-pass `agentTokens`, the raw
282
+ per-agent findings, coverage notes, and what the verifier dropped.
283
+
284
+ **How the caching works.** Provider prompt caching is a *prefix match*: the
285
+ provider caches the rendered prompt up to a point, and any byte change anywhere
286
+ in that prefix invalidates everything after it. The reviewer is laid out so the
287
+ prefix is stable — the system prompt (`shared.md` + the agent's own `.md`) is
288
+ byte-identical for every chunk an agent reviews, while the volatile parts (the
289
+ diff, file lists, PR metadata) travel in the user message *after* the prefix and
290
+ never touch it. OpenAI caches automatically (no write premium; cached input is
291
+ billed at a steep discount and shows up as `cache read`); Anthropic charges a
292
+ small premium to **write** the cache (~1.25× input) and ~0.1× input to **read**
293
+ it. Entries live minutes, refreshed on use — comfortably covering a run's
294
+ concurrent calls.
295
+
296
+ **Reading the numbers.** Hit rate = `cache read / (cache read + input)` — the
297
+ share of prompt tokens served from cache instead of being reprocessed at full
298
+ price. Multi-chunk reviews should show a high rate; single-chunk reviews mostly
299
+ show writes (there is nothing to re-read within the run).
300
+
301
+ **Keeping hits high:**
302
+
303
+ - Keep `shared.md` and `agents/*.md` stable. Any edit writes a new prefix — one
304
+ extra cache write per agent on the next run, then it is warm again. Never put
305
+ varying text (dates, PR numbers) into prompt files.
306
+ - Very short prompts may show `cache read 0`: prompts below the model's minimum
307
+ cacheable size (~1–4K tokens depending on the model) are silently not cached.
308
+ That is expected, not a bug.
135
309
 
136
310
  </details>
137
311
 
@@ -156,7 +330,7 @@ and applies Anthropic prompt caching automatically.
156
330
  ---
157
331
  description: One line the router uses to decide relevance.
158
332
  alwaysRun: true # run even when the router would skip this agent
159
- model: anthropic/claude-sonnet-5 # override the default model
333
+ model: openai/gpt-5.5-pro # override the default model
160
334
  temperature: 0.1
161
335
  ---
162
336
 
@@ -165,21 +339,24 @@ temperature: 0.1
165
339
 
166
340
  For a real-world example, see eas-cli's
167
341
  [`.expo-code-review/`](https://github.com/expo/eas-cli/tree/main/.expo-code-review)
168
- — correctness/security/consistency agents, Opus for security + the coordinator, and
169
- per-repo `noise.additionalIgnores`.
342
+ — correctness/security/consistency agents, a stronger model for security + the
343
+ coordinator, and per-repo `noise.additionalIgnores`.
170
344
 
171
345
  `config.jsonc` (JSONC — comments + trailing commas supported):
172
346
 
173
347
  ```jsonc
174
348
  {
175
- "model": "anthropic/claude-sonnet-5", // default model for the specialists
349
+ "model": "openai/gpt-5.5", // default model for the specialists
176
350
  "policy": { "includeSuggestions": false }, // suppress suggestion-severity findings
177
351
  "chunk": { "maxChangedLines": 1000, "maxFiles": 20, "concurrency": 6 },
178
352
  "noise": { "additionalIgnores": ["packages/*/build/**"] },
353
+ "review": { "trigger": "all", // which PRs `ecr ci` reviews: "all"
354
+ "label": "ai-review", // (default, except ai-review:skip) or
355
+ "skipLabel": "ai-review:skip" }, // "label" (only labeled PRs)
179
356
  "breakGlass": { "marker": "/skip-review" }, // PR body marker that skips the review
180
357
  "commentTag": "expo-ai-code-reviewer", // hidden tag used to find/update the comment
181
- "auth": { "mode": "oauth", "provider": "anthropic",
182
- "tokenEnv": "ANTHROPIC_OAUTH_API_KEY" }
358
+ "auth": { "mode": "api-key", "provider": "openai",
359
+ "tokenEnv": "OPENAI_API_KEY" }
183
360
  }
184
361
  ```
185
362
 
@@ -193,14 +370,19 @@ Precedence: **`REVIEWER_MODEL` env** (global override) → per-file **frontmatte
193
370
  setup, and a developer can override everything locally.
194
371
 
195
372
  - **Specialist agents** (correctness/security/consistency) benefit from a
196
- reasoning-tier model — **Sonnet** is the quality/speed sweet spot (default for
197
- correctness/consistency). **Opus** finds more but is slower and more
198
- rate-limited, so scope it to the highest-stakes agent: **security runs on Opus**
199
- (set in `security.md` frontmatter), the rest on Sonnet.
373
+ reasoning-tier model — **`openai/gpt-5.5`** is the quality/speed sweet spot
374
+ (the scaffolded default). The **pro tier** finds more but is slower and more
375
+ expensive, so scope it to the highest-stakes agent: **security runs on
376
+ `openai/gpt-5.5-pro`** (set in `security.md` frontmatter), the rest on the default.
200
377
  - **The coordinator** makes the final call (dedupe / re-judge / decide) — worth a
201
378
  strong model; set it in `coordinator.md` frontmatter.
202
379
  - If latency/timeouts dominate on big PRs, moving the specialists to a faster model
203
- is the most direct lever (a real recall tradeoff — measure it).
380
+ (e.g. `openai/gpt-5.4-mini`) is the most direct lever (a real recall tradeoff —
381
+ measure it).
382
+ - **Every run logs which model actually answered each pass** — in the job log
383
+ (`Models used — …`), the Actions step summary table, and the run log's
384
+ `agentModels` — and warns loudly if a pass ran on a different model than
385
+ configured, so a provider-side substitution can never pass unnoticed.
204
386
 
205
387
  There is no automatic cross-provider "equivalent" fallback — that would silently
206
388
  change which model reviewed your code. Use an explicit override instead.
@@ -210,19 +392,43 @@ change which model reviewed your code. Use an explicit override instead.
210
392
  <details>
211
393
  <summary><b>Reliability</b> — never hangs, never silently drops work</summary>
212
394
 
213
- - **Per-task time caps** — chunk passes 15 min; cross-cutting 25 min; coordinator
214
- 10 min. A global passes budget (32 min) bounds all passes incl. the subdivision
215
- waves, fitting inside the CI job's `timeout-minutes` (60).
395
+ - **Per-task time caps** — chunk passes 15 min; coordinator 10 min. A global passes
396
+ budget (55 min) bounds all passes incl. the subdivision waves, fitting inside the
397
+ CI job's `timeout-minutes` (90).
398
+ - **The cross-file pass is elastic** — it gets whatever is left of the passes budget
399
+ rather than a fixed cap, because it's the one pass whose scope can't be traded for
400
+ convergence: halving its file set deletes exactly the coverage it exists for. Chunk
401
+ passes run alongside it under their own caps, so a long cross-file pass doesn't
402
+ starve them.
216
403
  - **Tool-call cap** — a pass that makes too many `read`/`grep` calls without
217
404
  finishing is *wandering*, not converging; hitting the cap trips the soft landing.
405
+ The cross-file ceiling scales with the diff's file count (its diffs are inlined, so
406
+ tool calls go to *tracing*, not fetching).
407
+ - **Stall detection** — a pass whose reply stops changing entirely (no new tool call,
408
+ no streamed text or reasoning, no token growth) has a wedged model request, not a
409
+ hard problem. After 4 min of silence it's abandoned and retried once from a clean
410
+ session, inside the same budget — instead of spending the whole cap on a dead
411
+ request. Progress lines say how long a reply has been silent, so this is legible in
412
+ the CI log.
218
413
  - **Soft landing on timeout** — at either cap, the run is interrupted and the agent
219
414
  is asked to return the findings it already has, rather than discarding its work.
220
- - **Subdivide-on-timeout** a pass that times out with nothing to show has its
221
- chunk split in half and the halves re-reviewed (recursively, down to a single
222
- file), then a fast **no-tools fallback** over the inlined diff. Only a genuinely
415
+ Tools are disabled for that request, so the salvage step can't resume investigating
416
+ instead of answering.
417
+ - **Subdivide-on-timeout** a reviewer pass that times out with nothing to show has
418
+ its chunk split in half and the halves re-reviewed (recursively, down to a single
419
+ file), then a fast **no-tools fallback** over the inlined diff (the cross-file pass
420
+ skips straight to the fallback, which still sees the whole diff). Only a genuinely
223
421
  un-reducible pass reports a coverage gap — and it is always reported, never silent.
224
422
  - **Parse failures are retried** (same session, then once in a bounded fresh
225
423
  session) — separate from the timeout path.
424
+ - **Transient API errors are retried** (bounded backoff on 429/5xx/network) —
425
+ distinct from both the timeout path (abandon) and the parse path; a one-off blip
426
+ no longer drops an entire pass.
427
+ - **Auth failures surface once, and fail fast** — `ecr` checks the configured
428
+ provider's credential at startup and stops with one clear message if it's missing
429
+ (rather than failing every pass); a credential rejected mid-run (401/403)
430
+ collapses into a single actionable coverage note pointing at
431
+ `auth.tokenEnv`/`REVIEWER_MODEL`.
226
432
  - **A failed run never reads as "Approve"** — all passes fail → "could not
227
433
  complete"; some fail → never a clean approve, and coverage-reduced.
228
434
  - **The coordinator can't sink the run** — if consolidation fails, findings are
@@ -243,9 +449,12 @@ line: **comments = one-shot actions, labels = persistent configuration.**
243
449
  - **command workflow** — one-shot `/review` comments (maintainers): `/review`
244
450
  (router picks agents), `/review all`, `/review correctness security`. Never
245
451
  changes configuration.
246
- - **auto workflow** — continuous review, configured by **labels**: `ai-review`
247
- (router), `ai-review:all`, `ai-review:<agent>` (e.g. `ai-review:security`;
248
- combine to widen), `ai-review:skip` (opt-out).
452
+ - **auto workflow** — continuous review. **Which PRs get reviewed is set in
453
+ `config.jsonc` `review.trigger`**: `"all"` (default) reviews every PR except
454
+ those labeled `ai-review:skip`; `"label"` reviews only PRs labeled `ai-review`
455
+ (or `ai-review:<agent>` to scope agents). `ecr ci` self-gates on this policy;
456
+ the workflow's `if:` is an optional coarse gate layered on top. `ai-review:skip`
457
+ always wins and is write-gated, so a contributor can't opt their own PR out.
249
458
  - **dismiss workflow** — `/dismiss <id> [… -- reason]` / `/undismiss <id>`
250
459
  (maintainers). Each finding shows a short `` `id:…` ``. Dismissal is a **display
251
460
  filter only** — the reviewer still analyzes everything, and a `critical`/`secrets`
@@ -263,7 +472,9 @@ as the published package via `npx`, so no PR-controlled code is built.
263
472
  Each run appends a JSON line to `.expo-code-review/.runs/reviews.jsonl` with the
264
473
  inputs, decision, finding count, duration, per-agent cost, and aggregate token
265
474
  usage (incl. prompt-cache read/write counts) — for auditing and measuring
266
- cost/latency/cache reuse over time.
475
+ cost/latency/cache reuse over time. The same totals are printed as a one-line
476
+ summary to the terminal / CI job log at the end of each run, so cache reuse is
477
+ visible even in CI (where the run log is ephemeral).
267
478
 
268
479
  </details>
269
480
 
@@ -271,23 +482,72 @@ cost/latency/cache reuse over time.
271
482
  <details>
272
483
  <summary><b>Other providers & auth modes</b></summary>
273
484
 
274
- The recommended setup is a Claude Pro/Max subscription (OAuth) — see Usage above.
275
- Alternatives, all set in `config.auth` (credentials come from OpenCode):
276
-
277
- - **Anthropic API key** set `auth.mode` to `"api-key"` and point `tokenEnv` at the
278
- env var holding the key (e.g. `ANTHROPIC_API_KEY`); it's sent as `x-api-key`. Omit
279
- the `auth` block entirely to fall back to OpenCode's own login / `ANTHROPIC_API_KEY`.
280
- - **OAuth (Pro/Max)** `tokenEnv` holds an `sk-ant-oat…` token from
281
- `claude setup-token` (*not* an x-api-key); it's written to an isolated OpenCode
282
- `auth.json` as a bearer credential, using the native subscription path.
283
- - **OpenAI / GPT, or another provider** — the current path is the `REVIEWER_MODEL`
485
+ The recommended setup is an OpenAI API key — see Usage above. Alternatives, all
486
+ set in `config.auth` (credentials come from OpenCode):
487
+
488
+ - **ChatGPT/Codex subscription (OAuth) + usage-based API key the recommended
489
+ mix.** OpenAI permits subscription auth in third-party tools, and OpenCode
490
+ ships the plugin for it so the reviewer runs its default models on the
491
+ subscription (zero marginal cost) and reserves the metered key for pro-tier
492
+ models the subscription doesn't offer (`gpt-5.5-pro` is subscription-excluded).
493
+ Use the per-provider map form:
494
+
495
+ ```jsonc
496
+ "auth": { "providers": {
497
+ "openai": { "mode": "oauth", "tokenEnv": "CODEX_OAUTH_REFRESH_TOKEN" },
498
+ "openai-api": { "mode": "api-key", "tokenEnv": "OPENAI_API_KEY", "upstream": "openai" }
499
+ } }
500
+ ```
501
+
502
+ `openai-api` is an alias the reviewer synthesizes in the OpenCode config
503
+ (`upstream` names the SDK it's backed by): agents reference `openai-api/gpt-5.5-pro`
504
+ in frontmatter while everything else stays on `openai/gpt-5.5`. Notes:
505
+
506
+ - **The oauth `tokenEnv` holds the refresh token** from an `opencode auth login`
507
+ ChatGPT sign-in (copy `.openai.refresh` out of OpenCode's `auth.json`) —
508
+ access tokens are short-lived, so the refresh token is the durable secret and
509
+ OpenCode mints access tokens on demand. Refresh-token reuse across runs is
510
+ verified, so a static CI secret works.
511
+ - **The API key needs the same two permissions** as the default setup above
512
+ (Responses + Chat completions → Request; all else None), in a budget-capped
513
+ project.
514
+ - **In CI**, set the `ECR_EXPECTED_TOKEN_ENV` repo variable to the
515
+ comma-separated set of both env names
516
+ (`CODEX_OAUTH_REFRESH_TOKEN,OPENAI_API_KEY`) and pass both secrets in the
517
+ workflow.
518
+ - **Auditability**: every pass logs which provider/model answered it (job log,
519
+ step summary, run log), so the subscription/API split is visible per run.
520
+ One caveat: OpenCode can't price alias models (they're config-declared), so
521
+ pro passes report `$0` in the run log's cost column — token counts are
522
+ correct, and the OpenAI project dashboard is the source of truth for spend.
523
+ - **Anthropic / Claude (API key)** — set `auth.provider` to `"anthropic"`, point
524
+ `tokenEnv` at the env var holding a Console API key (e.g. `ANTHROPIC_API_KEY`),
525
+ and use `anthropic/...` model ids; the key is sent as `x-api-key`. Note that
526
+ Claude Pro/Max **subscription** tokens cannot be used here: Anthropic prohibits
527
+ them in third-party tools, and OpenCode has no Anthropic OAuth support — only an
528
+ API key works.
529
+ - **Another provider** — the current path is the `REVIEWER_MODEL`
284
530
  env override: `opencode auth login` once (pick the provider), then run with
285
- e.g. `REVIEWER_MODEL=openai/gpt-5.4-mini-fast`. It overrides every agent's model
286
- and uses your OpenCode login, so no `auth` block is needed. *(First-class
287
- per-provider config Anthropic/OpenAI/others in `config.jsonc`, and mixing them
288
- per agent — is on the [roadmap](./ROADMAP.md).)*
531
+ e.g. `REVIEWER_MODEL=google/gemini-3-pro`. It overrides every agent's model
532
+ and uses your OpenCode login, so no `auth` block is needed. *(Per-agent
533
+ provider mixing beyond the alias mechanism above is on the
534
+ [roadmap](./ROADMAP.md).)*
289
535
 
290
536
  There is no shared fallback key; if a run fails for lack of credentials, authenticate
291
537
  a provider in OpenCode. `ecr doctor` diagnoses setup.
292
538
 
539
+ **Setup errors fail fast, with the fix in the message.** A bad credential or model id
540
+ would otherwise fail every pass identically — a run that spends its whole budget
541
+ rediscovering one fixable thing, then reports N coverage gaps. So before any pass runs:
542
+
543
+ - **The credential's shape is checked.** OpenCode refuses a malformed credential by
544
+ dropping the provider entirely, which then surfaces as "model not found" for every
545
+ model, with nothing pointing at the credential. A truncated value, surrounding
546
+ whitespace, or a token that can't work for the configured `auth.mode` is rejected
547
+ by name.
548
+ - **Configured model ids are checked against the running server**, so a typo or an id
549
+ the provider doesn't have is reported once, up front, with the close matches.
550
+ - **`ecr doctor` reports the `opencode` version actually in use** and warns when a
551
+ different one is first on your `PATH` — runs use the version this package pins.
552
+
293
553
  </details>
package/build/cli.js CHANGED
@@ -1,9 +1,10 @@
1
1
  #!/usr/bin/env node
2
- import { ciCommand } from './commands/ci.js';
3
- import { dismissCommand } from './commands/dismiss.js';
4
- import { doctorCommand } from './commands/doctor.js';
5
- import { initCommand } from './commands/init.js';
6
- import { reviewCommand } from './commands/review.js';
2
+ import { ciCommand } from "./commands/ci.js";
3
+ import { dismissCommand } from "./commands/dismiss.js";
4
+ import { doctorCommand } from "./commands/doctor.js";
5
+ import { initCommand } from "./commands/init.js";
6
+ import { reviewCommand } from "./commands/review.js";
7
+ import { verifyConfigCommand } from "./commands/verify-config.js";
7
8
  const USAGE = `expo-code-review (ecr) — config-driven AI code reviewer
8
9
 
9
10
  Usage:
@@ -11,41 +12,47 @@ Usage:
11
12
  ecr ci Review the current PR and post a comment (GitHub Actions).
12
13
  ecr dismiss --pr <n> <id...> Hide a finding on a PR (see \`ecr dismiss --help\`).
13
14
  ecr undismiss --pr <n> <id...> Restore a dismissed finding.
14
- ecr init [--with-workflow] [--force] Scaffold .expo-code-review/ in this repo.
15
- ecr doctor Check environment, config, and credentials.
15
+ ecr init [--monorepo] [--scope <dir>] Scaffold .expo-code-review/ in this repo.
16
+ ecr doctor [--list-scopes] Check environment, config, credentials, and scopes.
17
+ ecr verify-config [--expected <env>] [--json] Refuse to run if a config could redirect the credential (CI guard).
16
18
 
17
19
  Agents live in each repo under .expo-code-review/. This CLI is the engine.
20
+
21
+ Monorepos: .expo-code-review/routing.jsonc routes paths to per-team scopes (see README).
18
22
  `;
19
23
  async function main() {
20
24
  const [, , sub, ...rest] = process.argv;
21
- if (sub === '-h' || sub === '--help' || sub === 'help') {
25
+ if (sub === "-h" || sub === "--help" || sub === "help") {
22
26
  process.stdout.write(USAGE);
23
27
  return;
24
28
  }
25
29
  // No subcommand (or a leading flag) defaults to `review`.
26
- if (!sub || sub.startsWith('-')) {
30
+ if (!sub || sub.startsWith("-")) {
27
31
  await reviewCommand(process.argv.slice(2));
28
32
  return;
29
33
  }
30
34
  switch (sub) {
31
- case 'review':
35
+ case "review":
32
36
  await reviewCommand(rest);
33
37
  break;
34
- case 'ci':
38
+ case "ci":
35
39
  await ciCommand(rest);
36
40
  break;
37
- case 'dismiss':
38
- await dismissCommand(rest, 'add');
41
+ case "dismiss":
42
+ await dismissCommand(rest, "add");
39
43
  break;
40
- case 'undismiss':
41
- await dismissCommand(rest, 'remove');
44
+ case "undismiss":
45
+ await dismissCommand(rest, "remove");
42
46
  break;
43
- case 'init':
47
+ case "init":
44
48
  await initCommand(rest);
45
49
  break;
46
- case 'doctor':
50
+ case "doctor":
47
51
  await doctorCommand(rest);
48
52
  break;
53
+ case "verify-config":
54
+ await verifyConfigCommand(rest);
55
+ break;
49
56
  default:
50
57
  process.stderr.write(`Unknown command: ${sub}\n\n${USAGE}`);
51
58
  process.exitCode = 2;