@expo/code-review-cli 0.12.6 → 0.13.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
@@ -11,6 +11,10 @@ is the **engine** — each repo supplies its own agents and settings under
11
11
 
12
12
  Inspired in part by Cloudflare's [_How we built our AI code review bot_](https://blog.cloudflare.com/ai-code-review/).
13
13
 
14
+ This README is the scannable overview. Every subsystem has a detailed design doc
15
+ under [`llp/`](./llp/) — start with
16
+ [LLP 0000](./llp/0000-expo-code-review-cli.explainer.md), the system map.
17
+
14
18
  ```mermaid
15
19
  flowchart TD
16
20
  SRC["Source<br/>local git · GitHub PR (gh)"] --> FILTER["Noise filter<br/>drop lockfiles · generated · binary"]
@@ -23,6 +27,70 @@ flowchart TD
23
27
  VERIFY --> REPORT["Reporter<br/>one PR comment (CI) · terminal (local)"]
24
28
  ```
25
29
 
30
+ ## Why not just `claude -p "review this diff"`?
31
+
32
+ A one-shot prompt in a workflow works — until the failure modes show up. The
33
+ engine exists for those:
34
+
35
+ - **Everything the reviewer reads is attacker-controlled.** The diff, the PR
36
+ description, and the repo files are all input an outside contributor wrote —
37
+ and a bare CLI run obeys instruction files from that same checkout
38
+ (`CLAUDE.md`, `.mcp.json`, plugins) while holding the model credential and
39
+ comment token. Here, configuration comes only from the trusted base commit,
40
+ those files are scrubbed before the engine starts, and every pass is read-only
41
+ ([details below](#the-security-gap-specifically)).
42
+ - **Confident-but-wrong findings post as-is.** Raw model output is the review.
43
+ Here, every finding is quote-grounded against the real file and criticals are
44
+ adversarially verified before anything is posted.
45
+ - **Failures read as silence or approval.** A one-shot call that stalls, gets
46
+ rate-limited, or returns garbage either fails the job or posts nothing. Here,
47
+ each failure class has its own bounded retry, a failed run never renders as a
48
+ clean result, and CI always posts a terminal comment.
49
+ - **Big diffs blow the context window.** Large PRs are chunked with a separate
50
+ cross-cutting pass for multi-file issues; specialist agents run in parallel; the
51
+ prompt layout is cache-stable, and every run reports tokens, cost, and cache
52
+ hit rate.
53
+ - **Noise wastes the model's attention.** Lockfiles, generated bundles, and
54
+ binaries are filtered before the model sees them — recorded, never silently
55
+ dropped.
56
+ - **Comment spam.** One fingerprinted comment updated in place, with `/dismiss`,
57
+ severity floors, inline ignores, and author-reply tracking — not a new wall of
58
+ text per push.
59
+ - **Review prompts rot.** `ecr ref-check` fails when a prompt cites code that
60
+ moved or vanished, and every run warns loudly if a provider substituted a
61
+ different model than configured.
62
+
63
+ ### The security gap, specifically
64
+
65
+ A review bot is a process holding a model credential and a GitHub write token
66
+ that reads attacker-controlled input for a living. A bare `claude -p` workflow
67
+ typically checks out the PR head and hands the model broad tools — so a PR that
68
+ says the right words can run code, read secrets, or post as you. The trust model
69
+ here ([LLP 0001](./llp/0001-trust-model.principles.md)) is built around that:
70
+
71
+ - **No PR-controlled code is ever built or executed.** The engine runs as the
72
+ published npm package via `npx`; the scaffolded workflows check out only the
73
+ base commit, with `persist-credentials: false`.
74
+ - **The model has no write tools and no ambient web.** `Read`/`Grep`/`Glob` only
75
+ — never `Bash`, `Edit`, or `WebFetch`. An injected instruction has nothing to
76
+ act with; the worst it can do is produce a wrong finding, which then has to
77
+ survive verification.
78
+ - **Review policy is not PR-editable.** Config, prompts, rosters, and the auth
79
+ mapping load from the PR's immutable base commit; `tokenEnv` is honored in
80
+ exactly one root-owned place, enforced at the schema level and again by an
81
+ independent CI guard step.
82
+ - **Credentials are compartmentalized.** Child environments are allowlists that
83
+ omit ambient keys; the research MCP never sees a model credential, the model
84
+ process never sees the search key, and outbound research queries fail closed
85
+ on credential-shaped input.
86
+ - **Untrusted text can't impersonate the reviewer.** PR prose travels inside
87
+ sanitized boundary markers, and every `<!--` in it is escaped so a forged
88
+ state marker can't hijack the dismissal list. `critical`/`secrets` findings
89
+ can't be dismissed or cleared by replies — enforced in code, not the prompt.
90
+ - **The blast radius is capped by design.** The review is advisory and
91
+ comment-only: even a fully fooled model can't approve, merge, or block
92
+ anything.
93
+
26
94
  ## Usage
27
95
 
28
96
  Run via `npx @expo/code-review-cli <command>` (or the `ecr` / `expo-code-review`
@@ -52,15 +120,16 @@ npx @expo/code-review-cli doctor
52
120
  The scaffolded default is **Anthropic via the Claude Code CLI**: locally your
53
121
  `claude` login is enough, and for CI it helps you mint a token with
54
122
  `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.
123
+ a **ChatGPT/Codex subscription** sign-in, or a Meta Model API key for **Muse
124
+ Spark**). `doctor` offers to run it whenever a credential is missing.
57
125
 
58
126
  In CI, store the credential as the repo secret the scaffolded workflow forwards
59
127
  (`CLAUDE_CODE_REVIEW_SHARED_API_TOKEN` by default — an `sk-ant-oat…` token from
60
128
  `claude setup-token`, or an `sk-ant-api…` Console key; the CLI reads either).
61
129
 
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.
130
+ Prefer **Muse Spark**, **OpenAI** (API key, or a ChatGPT/Codex subscription, or
131
+ both mixed), or another provider? See [Other providers & auth modes](#other-providers)
132
+ below.
64
133
 
65
134
  ### Reviewing (already configured)
66
135
 
@@ -133,219 +202,92 @@ untrusted external context; see below).
133
202
 
134
203
  ## Keeping prompts true (`ecr ref-check`)
135
204
 
136
- Good reviewer prompts cite real code: "the only session entry point is
137
- `server/src/session.ts`", "every webhook router must call `sanitizeSecrets`". Then the
138
- code moves and the prompt keeps citing a path that no longer exists — the reviewer
139
- reasons from a fiction, on every PR, with nothing to warn you.
140
-
141
- `ecr ref-check` makes those citations checkable. Pin each one with a ref, in a comment
142
- of its own (`<!-- … -->` in Markdown, `//` in JSONC):
205
+ Good reviewer prompts cite real code ("every webhook router must call
206
+ `sanitizeSecrets`"). Then the code moves and the prompt keeps citing a path that no
207
+ longer exists the reviewer reasons from a fiction on every PR. `ecr ref-check`
208
+ makes those citations checkable: pin each one with a `@ref` comment (`<!-- … -->` in
209
+ Markdown, `//` in JSONC):
143
210
 
144
211
  ```md
145
212
  <!-- @ref server/src/session.ts#createSession — the only place a session is minted -->
146
- <!-- @ref server/src/entities/oauth/ — every provider lives here -->
147
213
  <!-- @ref glob:**/*WebhookRouter.ts — the routers this rule is about -->
148
214
  ```
149
215
 
150
216
  A target is a file, a `dir/`, `glob:<pattern>`, `file#symbol`, or `doc.md#heading` —
151
- never a line number, since a line number rots without any signal. Symbol anchors are
152
- checked by whole-word match, so code moving inside its file is fine and a rename is not.
153
-
154
- The check is strict on purpose: a backticked token in `.expo-code-review/` that looks
155
- like a repo path **must** be a ref, because the stale citations are exactly the ones
156
- nobody thought to annotate. A token with no extension (`eas-build-worker/terraform`,
157
- `general-central/{module,production}`, `finops`) counts when it names something that
158
- exists — so those get pinned too, while `anthropic/claude-opus-5`, shaped the same way,
159
- stays prose. For a token that only looks like a path, say so once:
160
- `<!-- @ref-ignore knex.raw() -->`. It also checks what your config already declares —
161
- `enforceAgents` ids, scope `config` directories, scope path globs.
162
-
163
- Refs are repo-root-relative, including in a scope's own setup dir. A scope prompt that
164
- cites `general-central/module` for `infrastructure/general-central/module` gets told the
165
- root-relative form to use.
217
+ never a line number (line numbers rot without any signal). The check is strict on
218
+ purpose: any backticked token in `.expo-code-review/` that names something that
219
+ exists in the repo **must** be a ref, because stale citations are exactly the ones
220
+ nobody annotated. Mark the rare false positive once with
221
+ `<!-- @ref-ignore knex.raw() -->`. Refs are always repo-root-relative, including in
222
+ a scope's own setup dir, and config declarations (`enforceAgents` ids, scope
223
+ directories and globs) are checked too.
166
224
 
167
225
  Two run points:
168
226
 
169
227
  - `ecr ref-check` exits 1 on any problem. Run it in CI or a pre-commit hook.
170
- - `ecr review` / `ecr ci` run it too, and never fail a PR's checks with it. The comment
171
- carries a **Review setup** note instead: refs that no longer resolve, plus cited code
172
- *this PR* changes, where the ref still resolves but the guidance may not.
228
+ - `ecr review` / `ecr ci` run it too but never fail a PR's checks with it broken
229
+ refs (and cited code *this PR* changes) surface as a **Review setup** note in the
230
+ comment.
231
+
232
+ Full detail: [LLP 0012](./llp/0012-config-ref-integrity.explainer.md).
173
233
 
174
234
  ---
175
235
 
176
- ## Providing context and research capabilities
236
+ ## Platform research (bundled MCP)
177
237
 
178
- `@expo/code-review-cli` includes `review-research-mcp` and exposes that one bundled,
179
- local MCP directly to reviewer and cross-file passes. The agent decides whether an
180
- external API contract needs research and can either search for an exact symbol or
181
- fetch an exact supported documentation URL already present in the review context.
182
- Coordinator, verifier, stack-verifier, and time-critical no-tools passes never receive
183
- the MCP.
238
+ The package bundles `review-research-mcp`, a local MCP exposed only to the reviewer
239
+ and cross-file passes (never the coordinator, verifier, or no-tools passes). When a
240
+ judgment depends on an externally owned API contract, an agent can search a fixed
241
+ catalog of official documentation providers Apple/Android platform APIs, Expo,
242
+ React Native, OkHttp, Kotlin coroutines, Gradle/AGP, Swift evolution, and more — or
243
+ fetch one exact supported documentation URL. Queries are short exact symbols
244
+ (`CameraView barcodeScannerSettings` is useful; a source snippet or a question is
245
+ not), and an empty result stays empty rather than becoming a loose guess. There is
246
+ no offline index: every passage is fetched live during that review.
184
247
 
185
- Enable it only in the root config, which CI loads from the PR's trusted base:
248
+ Enable it only in the root config (CI loads it from the PR's trusted base) and add a
249
+ `BRAVE_SEARCH_API_KEY` Actions secret (Expo and OkHttp use their own official search
250
+ and consume no Brave quota):
186
251
 
187
252
  ```jsonc
188
253
  {
189
- "research": {
190
- "enabled": true,
191
- "maxQueries": 8,
192
- "resultsPerQuery": 2,
193
- "timeoutMs": 30000
194
- }
254
+ "research": { "enabled": true, "maxQueries": 8, "resultsPerQuery": 2, "timeoutMs": 30000 }
195
255
  }
196
256
  ```
197
257
 
198
- Add a repository Actions secret named `BRAVE_SEARCH_API_KEY`. The generated
199
- automatic and command-triggered workflows pass only that search credential to the
200
- MCP. Expo uses its public documentation search and OkHttp uses its official static
201
- search index; neither consumes Brave quota.
202
-
203
- ECR resolves the MCP entry point inside its own installed package and starts it with
204
- the current absolute Node executable, so a PR-owned `PATH` entry cannot replace
205
- either component. Each review gets an owner-only temporary MCP config and append-only
206
- audit. Claude receives that explicit config under `--strict-mcp-config`, with project
207
- settings and slash commands disabled; OpenCode receives the same fixed local command.
208
-
209
- That command is a wrapper, not the server. Both engines merge a configured MCP `env`
210
- onto their own environment instead of replacing it, so the declared block alone cannot
211
- bound the child the engine's model credential reaches it, and OpenCode additionally
212
- passes down the runner's whole ambient environment. The wrapper therefore rebuilds the
213
- environment from an explicit allowlist before starting the server, and loads no parser
214
- and opens no socket of its own. The server process sees the search key, the call
215
- bounds, and locale/proxy settings; it never sees a model credential. The Brave
216
- credential travels the same way and is never added to the model process env.
217
-
218
- The MCP bounds the shape, host, and volume of outbound requests. It is not a
219
- confidentiality boundary: the reviewing model chooses the query terms and URLs, and a
220
- low-entropy identifier can carry repository-derived data past every check below.
221
- Enable research only where repository-derived terms may be shared with Brave and the
222
- documentation providers. Search queries are normalized before
223
- logging or networking: quoted literals, URLs, email addresses, paths, prose stop
224
- words, overlong/high-entropy tokens, and unsupported punctuation are removed;
225
- credential-shaped or secret-labeled input fails closed. The remaining query must be at
226
- most eight short tokens and either contain an API-like symbol or be a short multi-word
227
- lowercase concept phrase. Direct URLs must use plain
228
- HTTPS with no credentials, port, query string, or fragment; suspicious/high-entropy
229
- path segments fail closed. The fixed provider host/path allowlist and redirect,
230
- response-size, content-type, and timeout checks still apply after that first gate.
231
- These deterministic checks greatly reduce accidental exfiltration; they are not a
232
- proof that every low-entropy string is harmless, so reviewer prompts also forbid
233
- sending repository text and the review-wide MCP budget defaults to eight calls.
234
-
235
- `maxQueries` bounds MCP calls, not network requests. One search selects up to four
236
- providers, and each issues its own discovery request plus a page fetch per candidate,
237
- so eight calls can mean roughly thirty discovery requests and over a hundred page
238
- downloads. Every call therefore reports its own ledger — discovery requests, page
239
- fetches, redirect hops, total HTTP requests, and elapsed time — and the review log and
240
- Actions summary report the totals. `timeoutMs` is the MCP's own end-to-end deadline for
241
- one call, enforced by the server across discovery, redirects, retrieval, and
242
- extraction; a call that hits it returns what it already has rather than failing. It has
243
- to live there because OpenCode's `timeout` bounds only tool discovery and Claude
244
- provides no per-call timeout at all.
245
-
246
- For non-Expo providers, discovery sends a fixed, provider-owned `site:` scope plus
247
- the bounded query to Brave's fixed Web Search endpoint. Search snippets and titles
248
- are never treated as evidence. ECR independently rejects off-allowlist result URLs,
249
- manually validates every redirect, fetches a few official pages, verifies content
250
- types and response sizes, extracts visible documentation text, and returns locally
251
- ranked bounded passages. Sparse search-engine coverage therefore produces an honest
252
- empty result rather than a loose guess.
253
-
254
- Research is root-only in routed monorepos because it starts a host process; scope
255
- configs cannot alter its network behavior or limits. Result-cache reuse remains
256
- disabled while research is enabled because web results and documentation can change
257
- without a config change.
258
-
259
- The fixed provider catalog covers Apple/Android APIs plus SDWebImage, Media3, Glide, OkHttp,
260
- Kotlin coroutines, Gradle/AGP, Swift concurrency/evolution, platform release/API
261
- availability, Expo, React Native, Reanimated, Gesture Handler, Screens, and Worklets.
262
- Queries are short exact symbols plus at most one useful member or behavior term. For
263
- example, `CameraView barcodeScannerSettings` is useful; a source snippet, import path,
264
- or natural-language question is not. The MCP publishes the same guidance in its tool
265
- metadata. An empty result stays empty; it is not replaced with a loose semantic guess.
266
- The tool metadata also includes an explicit provider map, so the reviewing model can
267
- distinguish core platform APIs from release notes, dependency-owned documentation,
268
- build-tool references, and issue-tracker context before choosing a corpus.
269
- Reviewer instructions require grounding whenever a judgment depends on an externally
270
- owned API contract, whether the evidence confirms a finding or dismisses a candidate
271
- as safe; model memory alone is not treated as sufficient for those decisions.
272
- Native source keeps its platform context: Apple or Android documents the OS contract,
273
- while an explicit dependency provider documents library-owned behavior. Providers are
274
- additive when both contracts matter. A path under `packages/expo-*` does not by itself
275
- route Swift or Kotlin code to Expo's JavaScript documentation.
276
-
277
- Direct clients can also call `fetch_platform_doc` with an exact documentation URL.
278
- The tool infers the narrowest matching provider (or accepts an explicit provider
279
- hint), then applies the same fixed HTTPS host/path allowlist, manual redirect checks,
280
- 10-second timeout, 5 MB response limit, content-type validation, extraction, and
281
- passage bounds as search-discovered pages. It returns normalized extracted text, never
282
- raw HTML or DocC JSON. An optional `query` selects context only within that one page;
283
- it never broadens discovery. Context expands progressively:
284
-
285
- - `focused` returns the best passage plus adjacent passages.
286
- - `section` (the default) returns a contiguous window of at most 12,000 characters
287
- around the best passage.
288
- - `document` returns at most 20,000 characters of extracted page text and should be
289
- used only when the contract is spread across the page.
290
-
291
- The response reports returned and original character counts, whether it was truncated,
292
- the anchor passage id, and bounded available passage ids. Search results also carry
293
- neighboring passage ids so an agent can recognize when more local context exists. For example,
294
- `https://developer.apple.com/documentation/swiftui/view/menustyle(_:)` is resolved to
295
- Apple's DocC JSON and returned with the canonical page URL and API availability.
296
-
297
- Every research-enabled review audits each sanitized outbound query and each returned result's
298
- title, provider, provenance class, and canonical URL. GitHub Actions receives the
299
- same audit trail in the step summary, while `.runs/reviews.jsonl` keeps the queries
300
- plus bounded returned passages for short-lived operational inspection. Reviewers
301
- are instructed to attach `sources` only when documentation materially supports a
302
- finding. ECR accepts only exact URLs returned during that review, restores canonical
303
- titles, carries citations through coordination, and renders them below the finding.
304
- A citation to a URL this review never retrieved is dropped outright. Relatedness is a
305
- separate, weaker guarantee: a cited finding is escalated to the verifier with the
306
- audited passage inline, which judges whether that passage actually supports the claim
307
- and strips the citation when it does not.
308
-
309
- Reviewers also emit a bounded `researchDecisions` record only when documentation
310
- materially confirms a finding candidate or proves one safe. ECR grounds those records
311
- against the exact MCP audit and discards ungrounded claims. After verification and
312
- suppression, the log and Actions summary report final findings with citations,
313
- supported and dismissed candidates, and unique audited results materially used versus
314
- unused. Counts use canonical URLs rather than passage count, so repeated hits do not
315
- inflate usefulness.
316
-
317
- For a query routed to the `expo` provider, `serve` POSTs the already-sanitized query
318
- directly to Expo's public Algolia search endpoint and returns canonical
319
- `docs.expo.dev` hits. The endpoint, application id, and browser-visible search-only
320
- key are fixed in the package; redirects are rejected; response size, timeout, hit
321
- count, and returned URL host are bounded.
322
-
323
- OkHttp's newly migrated documentation is still sparse in Brave, so its provider
324
- downloads the fixed official `lysine.dev` static search index, validates and indexes
325
- it in memory once per MCP process, and rejects any entry outside the existing OkHttp
326
- allowlist. Brave remains a fallback if that official index is unavailable.
327
-
328
- There is no offline index. Every passage a review sees is fetched live from the
329
- provider allowlist during that review, so evidence is never served from a local
330
- artifact whose contents ECR cannot vouch for. The crawler, its seed catalog, and
331
- `research.indexPath` were removed once live discovery replaced them; a config still
332
- naming `indexPath` fails to parse rather than silently ignoring it.
333
- Installation-specific provider configuration is intentionally
334
- deferred: when added, it should follow the trusted root-config model used for agents
335
- without permitting PR-controlled URLs, commands, or executable parsers. Expo skills
336
- are complementary, not another search corpus: their pinned procedural guidance can
337
- later be supplied to review agents as separately labeled trusted context, while
338
- documentation search continues to return citable API evidence. Dynamic skills or
339
- instructions retrieved from documentation must never become executable reviewer
340
- instructions.
258
+ The boundary, in brief:
259
+
260
+ - **Bounded egress, not a confidentiality boundary.** Queries are normalized and
261
+ sanitized (credential-shaped or secret-labeled input fails closed); direct URLs
262
+ must be plain HTTPS on a fixed provider host/path allowlist; redirects, response
263
+ sizes, content types, and per-call deadlines are enforced server-side. The model
264
+ still chooses the query terms, so enable research only where repository-derived
265
+ terms may be shared with Brave and the documentation providers.
266
+ - **The server never sees a model credential.** ECR starts the MCP from its own
267
+ installed package through a wrapper that rebuilds the child environment from an
268
+ explicit allowlist; the Brave key never enters the model process either.
269
+ - **Audited and citable.** Every outbound query and returned result is audited (job
270
+ log, Actions step summary, `.runs/reviews.jsonl`). A finding can cite only URLs
271
+ actually retrieved during that review, and the verifier strips citations whose
272
+ passage does not support the claim.
273
+ - **`maxQueries` bounds MCP calls, not HTTP requests** one search can fan out to
274
+ many discovery and page fetches, so each call reports its own request ledger and
275
+ the run reports totals.
276
+ - **Root-only in routed monorepos** (it starts a host process); scope configs
277
+ cannot alter it. Result-cache reuse is disabled while research is enabled,
278
+ because web content can change without a config change.
279
+
280
+ Full detail providers, query grammar, `fetch_platform_doc` modes, provenance and
281
+ citation grounding: [LLP 0013](./llp/0013-platform-research.explainer.md).
341
282
 
342
283
  ## Monorepos (routing manifest)
343
284
 
344
285
  A monorepo can route different subtrees to different reviewer rosters from a single
345
286
  infra-owned manifest. There is still **one workflow, one `ecr ci` process** per PR:
346
- it reads the changed files once, assigns each to exactly one scope, reviews each
347
- active scope over only its files, and renders one comment. Because it is a single
348
- writer in a single process there is no comment/check race and no locking.
287
+ it reads the changed files once, assigns each to exactly one scope (scopes are
288
+ ordered, the **last** match wins CODEOWNERS discipline), reviews each active scope
289
+ over only its files, and renders one comment — a single writer, so no comment race
290
+ and no locking.
349
291
 
350
292
  ```
351
293
  your-monorepo/
@@ -390,79 +332,56 @@ your-monorepo/
390
332
  }
391
333
  ```
392
334
 
393
- - **Path glob dialect** `**` crosses `/`, `*` matches within a segment. Keep a
394
- `**/*` catch-all scope so no changed file goes unreviewed (`ecr doctor` flags a
395
- coverage gap otherwise). Scopes are ordered and the **last** match wins, so put
396
- broad scopes first and specific ones after (CODEOWNERS/Renovate discipline).
397
- - **Comment modes** — `single` posts one aggregated comment (a scope summary table
398
- + a collapsed `<details>` per scope) under the existing marker; `per-scope` posts
399
- one cleanly-namespaced comment per scope. A scope with zero matched files gets its
400
- stale comment deleted.
401
- - **Scoped flags** — `ecr ci --scopes a,b` limits the fan-out; `ecr ci --comment
402
- single|per-scope` overrides the manifest; `ecr review --scope <name>` runs one
403
- scope locally; `ecr review`/`ecr ci --config-dir <dir>` (or `ECR_CONFIG_DIR`)
404
- load config from an alternate directory; `ecr doctor --list-scopes` prints the
405
- scope table. **`--config-dir` designates an alternate ROOT config dir: both
406
- `config.jsonc` and `routing.jsonc` are read from it, so the root config and its
407
- manifest always travel together. Scope `config` paths stay repo-root-relative —
408
- the override swaps the root config/manifest against the *real* scope tree, it
409
- does not relocate the scopes themselves.**
410
- - **Passes budget** — `defaults`-level `budget` bounds total review time:
411
- `totalPassesMinutes` (default 55) is split across active scopes (which run
412
- sequentially in one `ecr ci`), clamped up to `minScopeMinutes` (default 5) so a
413
- single scope still gets a workable window. When enough scopes are active that the
414
- floor would overshoot the total, `ecr ci` keeps the floor but warns, and `ecr
415
- doctor` flags the worst case (`scopes × floor` vs total) — raise the workflow
416
- `timeout-minutes` or trim scopes.
417
- - **Adoption is incremental** — with no `routing.jsonc`, behavior is exactly as
418
- before (single config). Add the manifest with just a default scope → still one
419
- comment, identical behavior. Land per-team scope dirs one at a time; everything
420
- else keeps hitting the default scope.
335
+ - **Keep a `**/*` catch-all scope** so no changed file goes unreviewed (`ecr
336
+ doctor` flags a coverage gap otherwise); broad scopes first, specific ones after.
337
+ - **Comment modes** `single` posts one aggregated comment; `per-scope` posts one
338
+ namespaced comment per scope. A scope with zero matched files gets its stale
339
+ comment deleted.
340
+ - **Passes budget** a `defaults`-level `budget` splits `totalPassesMinutes`
341
+ (default 55) across the active scopes, floored at `minScopeMinutes` (default 5)
342
+ per scope; `ecr ci` and `ecr doctor` warn when the floor can overshoot the total.
343
+ - **Scoped flags** — `ecr ci --scopes a,b`, `ecr ci --comment single|per-scope`,
344
+ `ecr review --scope <name>`, `--config-dir <dir>` / `ECR_CONFIG_DIR` (alternate
345
+ ROOT config+manifest dir scope `config` paths stay repo-root-relative), and
346
+ `ecr doctor --list-scopes`.
347
+ - **Adoption is incremental** no `routing.jsonc` means exactly the old
348
+ single-config behavior; a manifest with just a default scope is identical; land
349
+ per-team scope dirs one at a time.
421
350
 
422
351
  ### Security
423
352
 
424
- - **auth and research are locked to the root.** `tokenEnv` (which env var becomes the model
425
- credential) is honored in exactly one place: the root `config.jsonc` or
426
- `routing.jsonc` `defaults.auth`. A scope config declaring `auth`/`breakGlass`/`research`
427
- **fails to parse** (Zod-level rejection). A scope declaring `research` also fails,
428
- so PR-controlled routing cannot select a different host index. The CI guard step independently
429
- sweeps every `.expo-code-review/config.jsonc`/`routing.jsonc` repo-wide and refuses
430
- to run unless `tokenEnv` appears exactly once, in a root-owned file, equal to
431
- `ECR_EXPECTED_TOKEN_ENV`. A routing manifest can never widen exposure globs only
432
- choose *which roster* reviews a file, never *which secret* is sent.
433
- - **enforceAgents can't be weakened.** Agents listed in `defaults.enforceAgents`
434
- (e.g. `security`) are injected into every scope with `alwaysRun`, taken from the
435
- root roster a scope defining a same-id agent gets the root one, so a team can't
436
- shadow the enforced reviewer with a weaker version on its own subtree.
437
- - **Configuration comes from the PR's trusted base commit.** In `ecr ci`, review
438
- policy and reviewer configuration `config.jsonc`, `routing.jsonc`, prompts,
439
- models, and the auth mapping load from the PR's immutable **base** commit,
440
- materialized via the GitHub API. The PR head is untrusted data: it is
441
- materialized separately (pinned to its immutable OID) purely as source content
442
- to read and verify against. A PR editing rosters, prompts, or routing is
443
- reviewed under the **previous** config; its changes activate after merge. If
444
- the base commit can't be materialized, the run fails closed (one terminal
445
- comment) it never falls back to the checkout. A scope config that is new in
446
- a PR is reviewed with the root config until it merges.
447
- - **The model runtime never sees PR-owned ambient config.** The head worktree the
448
- agents read from is scrubbed of runtime configuration before the OpenCode
449
- server starts: `opencode.json{,c}`, `.opencode/` (plugins), `AGENTS.md`,
450
- `CLAUDE.md`, `.claude/`, `.mcp.json`, `.cursor*`, and `.env*` at every depth.
451
- A PR can't install a plugin, MCP server, instruction file, or `.env` into the
452
- process that holds the model credential and the comment token. (Changes to
453
- those files are still reviewed — their diffs are inlined in the prompt — but a
454
- finding citing one can't be re-read during verification; that's the tradeoff.)
353
+ Enforced in code and by an independent CI guard step, not by convention:
354
+
355
+ - **auth and research are locked to the root.** `tokenEnv` is honored only in the
356
+ root `config.jsonc` / `routing.jsonc` `defaults.auth`; a scope config declaring
357
+ `auth`/`breakGlass`/`research` fails to parse, and the CI guard sweeps every
358
+ config file repo-wide and refuses to run unless `tokenEnv` appears exactly once,
359
+ root-owned, equal to `ECR_EXPECTED_TOKEN_ENV`. Routing globs choose *which
360
+ roster* reviews a file, never *which secret* is sent.
361
+ - **enforceAgents can't be weakened.** Enforced agents (e.g. `security`) are
362
+ injected into every scope from the ROOT roster with `alwaysRun`; a scope defining
363
+ a same-id agent gets the root one.
364
+ - **Configuration comes from the PR's trusted base commit.** In `ecr ci`, all
365
+ review configuration loads from the PR's immutable base; the head is untrusted
366
+ content, materialized separately only to read and verify against. A PR editing
367
+ rosters, prompts, or routing is reviewed under the **previous** config, and a
368
+ missing base fails closednever a fallback to the checkout. (Temporary escape
369
+ hatch: `ecr ci --unsafe-config-from-head`, with a loud warning; to be removed.)
370
+ - **The model runtime never sees PR-owned ambient config.** The head worktree is
371
+ scrubbed of `opencode.json{,c}`, `.opencode/`, `AGENTS.md`, `CLAUDE.md`,
372
+ `.claude/`, `.mcp.json`, `.cursor*`, and `.env*` before the engine starts, so a
373
+ PR can't install a plugin, MCP server, instruction file, or `.env` into the
374
+ process holding the model credential and comment token.
455
375
  - **The scaffolded workflows check out only the base commit** with
456
- `persist-credentials: false`; the CLI's own git fetches authenticate through
457
- `gh` from `GH_TOKEN`, so the token never lands in `.git/config` or argv. The
458
- CLI enforces the trust model itself, so a custom workflow that checks out the
459
- PR head still gets base-commit configuration. The temporary escape hatch
460
- `ecr ci --unsafe-config-from-head` restores the old behavior with a loud
461
- security warning and will be removed on a minor boundary.
376
+ `persist-credentials: false`; git fetches authenticate through `gh`, so the
377
+ token never lands in `.git/config` or argv. The CLI enforces the trust model
378
+ itself, so even a custom workflow that checks out the head still gets
379
+ base-commit configuration.
462
380
 
463
- Ownership is enforced with CODEOWNERS: `/.expo-code-review/routing.jsonc @your-infra`
464
- (the single authoritative router) and `/apps/api/.expo-code-review/ @your-api-team`
465
- (each team owns only its own scope dir). Rerouting globs is gated behind infra review.
381
+ Enforce ownership with CODEOWNERS: `/.expo-code-review/routing.jsonc @your-infra`,
382
+ `/apps/api/.expo-code-review/ @your-api-team`. Full detail:
383
+ [LLP 0006](./llp/0006-config-schema-loading-routing.explainer.md) (routing) and
384
+ [LLP 0001](./llp/0001-trust-model.principles.md) (trust model).
466
385
 
467
386
  ---
468
387
 
@@ -493,62 +412,42 @@ Ownership is enforced with CODEOWNERS: `/.expo-code-review/routing.jsonc @your-i
493
412
  feedback always run fresh.
494
413
 
495
414
  Built on the [OpenCode](https://opencode.ai) SDK, which spawns the model provider
496
- and applies the provider's prompt caching automatically.
415
+ and applies the provider's prompt caching automatically. Full detail:
416
+ [LLP 0002](./llp/0002-review-engine-pipeline.explainer.md) (pipeline) and
417
+ [LLP 0005](./llp/0005-verification-fingerprints-rendering.explainer.md)
418
+ (verification and rendering).
497
419
 
498
420
  </details>
499
421
 
500
422
  <details>
501
423
  <summary><b>Tokens, cost &amp; prompt caching</b></summary>
502
424
 
503
- Every run reports what it spent and how much of it was served from the prompt
504
- cache, in three places:
505
-
506
- - **Job log (stderr)** one line at the end of the run:
507
- `Token usage input …, output …, cache read …, cache write … (cost $…)`.
508
- - **GitHub Actions step summary** — a per-pass table (one row per agent, plus the
509
- cross-cutting pass, coordinator, and verifier), the run's cache hit rate, and
510
- the exact comment that was posted. The PR comment is updated in place on every
511
- run, so the step summary is where past runs' comments remain readable.
512
- - **`.expo-code-review/.runs/reviews.jsonl`**one JSON line per run (uploaded as
513
- a CI artifact) with the same totals plus per-pass `agentTokens`, the raw
514
- per-agent findings, bounded reviewer traces, coverage notes, and what the verifier
515
- dropped.
516
-
517
- Each reviewer can also return a compact trace with up to three concrete checks and
518
- two unresolved questions. The reporter stores it only inside the existing base64
519
- `<!-- <commentTag>:state=… -->` comment marker as `review.reviewTrace`; it does not
520
- render in the visible review. Agents and other machine consumers can decode that
521
- state to see what a clean review covered. The payload declares
522
- `trust: "unverified-model-diagnostics"`: it contains bounded conclusions, never a
523
- raw transcript or chain-of-thought, and must not be treated as a verified finding.
524
- The engine sorts agent ids and caps the complete decoded trace at 6 KB so this hidden
525
- diagnostic cannot crowd visible findings out of GitHub's comment-size limit.
526
-
527
- **How the caching works.** Provider prompt caching is a *prefix match*: the
528
- provider caches the rendered prompt up to a point, and any byte change anywhere
529
- in that prefix invalidates everything after it. The reviewer is laid out so the
530
- prefix is stable — the system prompt (`shared.md` + the agent's own `.md`) is
531
- byte-identical for every chunk an agent reviews, while the volatile parts (the
532
- diff, file lists, PR metadata) travel in the user message *after* the prefix and
533
- never touch it. OpenAI caches automatically (no write premium; cached input is
534
- billed at a steep discount and shows up as `cache read`); Anthropic charges a
535
- small premium to **write** the cache (~1.25× input) and ~0.1× input to **read**
536
- it. Entries live minutes, refreshed on use — comfortably covering a run's
537
- concurrent calls.
538
-
539
- **Reading the numbers.** Hit rate = `cache read / (cache read + input)` — the
540
- share of prompt tokens served from cache instead of being reprocessed at full
541
- price. Multi-chunk reviews should show a high rate; single-chunk reviews mostly
542
- show writes (there is nothing to re-read within the run).
543
-
544
- **Keeping hits high:**
545
-
546
- - Keep `shared.md` and `agents/*.md` stable. Any edit writes a new prefix — one
547
- extra cache write per agent on the next run, then it is warm again. Never put
548
- varying text (dates, PR numbers) into prompt files.
549
- - Very short prompts may show `cache read 0`: prompts below the model's minimum
550
- cacheable size (~1–4K tokens depending on the model) are silently not cached.
551
- That is expected, not a bug.
425
+ Every run reports what it spent and how much was served from the prompt cache, in
426
+ three places: one `Token usage — …` line in the job log, a per-pass table + cache
427
+ hit rate in the GitHub Actions step summary (which also preserves each run's posted
428
+ comment, since the PR comment is updated in place), and one JSON line per run in
429
+ `.expo-code-review/.runs/reviews.jsonl` (uploaded as a CI artifact) with per-pass
430
+ tokens, raw per-agent findings, bounded reviewer traces, and coverage notes.
431
+
432
+ Each reviewer can also return a compact trace (up to three concrete checks and two
433
+ unresolved questions). It is stored only inside the hidden base64 comment marker as
434
+ `review.reviewTrace`never rendered declared
435
+ `trust: "unverified-model-diagnostics"`, and capped at 6 KB so it can't crowd
436
+ visible findings out of GitHub's comment-size limit.
437
+
438
+ **How the caching works.** Provider prompt caching is a *prefix match*: any byte
439
+ change in the prefix invalidates everything after it. The reviewer keeps the prefix
440
+ stable the system prompt (`shared.md` + the agent's own `.md`) is byte-identical
441
+ for every chunk an agent reviews, while the volatile parts (diff, file lists, PR
442
+ metadata) travel after it. OpenAI caches automatically at a steep read discount;
443
+ Anthropic charges ~1.25× input to **write** and ~0. to **read**. Hit rate =
444
+ `cache read / (cache read + input)`; multi-chunk reviews should show a high rate,
445
+ single-chunk reviews mostly show writes.
446
+
447
+ To keep hits high: keep `shared.md` and `agents/*.md` stable (an edit costs one
448
+ cache write per agent on the next run, then it's warm again), and never put varying
449
+ text (dates, PR numbers) in prompt files. Prompts below the model's minimum
450
+ cacheable size (~1–4K tokens) show `cache read 0` — expected, not a bug.
552
451
 
553
452
  </details>
554
453
 
@@ -637,60 +536,32 @@ change which model reviewed your code. Use an explicit override instead.
637
536
  <details>
638
537
  <summary><b>Reliability</b> — never hangs, never silently drops work</summary>
639
538
 
640
- - **Per-task time caps** — chunk passes 15 min; coordinator 10 min. A global passes
641
- budget (55 min) bounds all passes incl. the subdivision waves, fitting inside the
642
- CI job's `timeout-minutes` (90).
643
- - **The cross-file pass is elastic** it gets whatever is left of the passes budget
644
- rather than a fixed cap, because it's the one pass whose scope can't be traded for
645
- convergence: halving its file set deletes exactly the coverage it exists for. Chunk
646
- passes run alongside it under their own caps, so a long cross-file pass doesn't
647
- starve them.
648
- - **Tool-call cap** a pass that makes too many `read`/`grep` calls without
649
- finishing is *wandering*, not converging; hitting the cap trips the soft landing.
650
- The cross-file ceiling scales with the diff's file count (its diffs are inlined, so
651
- tool calls go to *tracing*, not fetching).
652
- - **Stall detection** — a pass whose reply stops changing entirely (no new tool call,
653
- no streamed text or reasoning, no token growth) has a wedged model request, not a
654
- hard problem. After 4 min of silence it's abandoned and retried once from a clean
655
- session, inside the same budget instead of spending the whole cap on a dead
656
- request. Progress lines say how long a reply has been silent, so this is legible in
657
- the CI log.
658
- - **Rate limits are detected and waited out, not fought.** The reviewer watches the
659
- OpenCode server's own log for provider 429s (hard evidence, per run). A stall
660
- *with* recent 429 evidence is throttling, not a wedge the pass waits in 90s
661
- beats (without consuming its one retry) instead of re-sending its whole context
662
- into a limited account; explicit 429 errors retry on a slow 15s/45s/90s schedule.
663
- Subscription (oauth) runs also default to `concurrency` 3 instead of 6, since one
664
- account may be serving several PRs' reviews at once. Rate-limit events are
665
- reported in the job log and the run log (`rateLimitEvents`), so throttling is a
666
- visible fact about a run, never a mystery slowdown.
667
- - **Soft landing on timeout** — at either cap, the run is interrupted and the agent
668
- is asked to return the findings it already has, rather than discarding its work.
669
- Tools are disabled for that request, so the salvage step can't resume investigating
670
- instead of answering.
671
- - **Subdivide-on-timeout** — a reviewer pass that times out with nothing to show has
672
- its chunk split in half and the halves re-reviewed (recursively, down to a single
673
- file), then a fast **no-tools fallback** over the inlined diff (the cross-file pass
674
- skips straight to the fallback, which still sees the whole diff). Only a genuinely
675
- un-reducible pass reports a coverage gap — and it is always reported, never silent.
676
- - **Parse failures are retried** (same session, then once in a bounded fresh
677
- session) — separate from the timeout path.
678
- - **Transient API errors are retried** (bounded backoff on 429/5xx/network) —
679
- distinct from both the timeout path (abandon) and the parse path; a one-off blip
680
- no longer drops an entire pass.
681
- - **Auth failures surface once, and fail fast** — `ecr` checks the configured
682
- provider's credential at startup and stops with one clear message if it's missing
683
- (rather than failing every pass); a credential rejected mid-run (401/403)
684
- collapses into a single actionable coverage note pointing at
685
- `auth.tokenEnv`/`REVIEWER_MODEL`.
686
- - **A failed run never reads as "Approve"** — all passes fail → "could not
687
- complete"; some fail → never a clean approve, and coverage-reduced.
688
- - **The coordinator can't sink the run** — if consolidation fails, findings are
689
- merged deterministically and still posted.
690
- - **Coverage notes** — passes that timed out/failed are listed (routine noise
691
- filtering is *not* flagged — it's expected and stays in the run log).
692
- - **CI always gets a terminal state** — on any failure the PR gets a "didn't run"
693
- comment, not a stuck reaction and silence.
539
+ - **Time caps everywhere** — chunk passes 15 min, coordinator 10 min, and a global
540
+ 55-min passes budget that fits inside the CI job's `timeout-minutes`. The
541
+ cross-file pass is elastic (it gets whatever budget is left) because halving its
542
+ file set would delete exactly the coverage it exists for.
543
+ - **Wandering and wedged passes are cut short** a tool-call cap catches a pass
544
+ that reads without converging; a 4-minute stall detector abandons a wedged model
545
+ request and retries once from a clean session, inside the same budget.
546
+ - **Rate limits are waited out, not fought** — provider 429s (observed in the
547
+ OpenCode server log) turn a stall into 90s waits instead of re-sends; explicit
548
+ 429s retry on a slow schedule; subscription (oauth) runs default to lower
549
+ concurrency. Rate-limit events are reported in the job and run logs, so
550
+ throttling is never a mystery slowdown.
551
+ - **Soft landing, then subdivision** — at a cap, the agent is asked to return the
552
+ findings it already has (tools disabled, so it can't keep investigating). A pass
553
+ with nothing to show has its chunk split and re-reviewed recursively, down to a
554
+ no-tools fallback over the inlined diff. Only a genuinely un-reducible pass
555
+ reports a coverage gap always reported, never silent.
556
+ - **Bounded retries per failure class** — parse failures, transient API errors
557
+ (429/5xx/network), and timeouts each have their own separate retry path; a
558
+ one-off blip never drops a whole pass.
559
+ - **Failure can't read as success** auth problems fail fast with one actionable
560
+ message; all-passes-failed reads "could not complete"; a partial run is never a
561
+ clean pass; a failed coordinator falls back to deterministic merging; and CI
562
+ always posts a terminal comment, never silence.
563
+
564
+ Full detail: [LLP 0002](./llp/0002-review-engine-pipeline.explainer.md).
694
565
 
695
566
  </details>
696
567
 
@@ -718,19 +589,19 @@ line: **comments = one-shot actions, labels = persistent configuration.**
718
589
  These workflows are comment-only (they never fail the PR's checks). The engine runs
719
590
  as the published package via `npx`, so no PR-controlled code is built.
720
591
 
592
+ Full detail: [LLP 0009](./llp/0009-adoption-templates-and-ci-workflows.guide.md)
593
+ (workflows) and [LLP 0007](./llp/0007-cli-commands-and-ci.explainer.md) (commands).
594
+
721
595
  </details>
722
596
 
723
597
  <details>
724
598
  <summary><b>Author feedback (replies to findings)</b></summary>
725
599
 
726
600
  A PR author's reply to a finding is matched to it deterministically — by quoting
727
- the finding's title back, or by citing its short `` `id:…` `` token (only the id can
728
- clear a finding, see below) and recorded
729
- in the comment's embedded state, no model involved in the matching itself. A
730
- matched finding shows `💬 @login replied` (linked to the comment) and a visible
731
- count above the fold; the reply's own text is never stored or rendered, only the
732
- login, the comment link, and (optionally) an enum-valued verdict. Controlled by
733
- the root-only `feedback` block in `config.jsonc`:
601
+ the finding's title back, or by citing its short `` `id:…` `` token with no model
602
+ involved in the matching. A matched finding shows `💬 @login replied` (linked to
603
+ the comment); the reply's own text is never stored or rendered. Controlled by the
604
+ root-only `feedback` block in `config.jsonc`:
734
605
 
735
606
  ```jsonc
736
607
  "feedback": {
@@ -743,36 +614,26 @@ the root-only `feedback` block in `config.jsonc`:
743
614
  ```
744
615
 
745
616
  - **`annotate`** (the default) matches and shows "author replied" with zero effect
746
- on the decision — safe and useful even if you never touch this block.
747
- - **Clearing a finding always needs the `` `id:…` `` token**, in the replier's own words
748
- (an id inside a `>` quote does not count). Quoting the title is enough to *annotate*,
749
- never to clear: GitHub's "Quote reply" copies the PR author's text verbatim, so a
750
- maintainer clicking it would otherwise dismiss a finding on words the author wrote
751
- by accident, or because they were led to.
752
- - **`adjudicate`** additionally has a model re-check the reply against the actual
753
- source (distrust-by-default, like the finding verifier) and record a verdict.
754
- Whether that verdict can actually clear a finding is a separate, still-off-by-
755
- default choice `mode` and `dismiss` are independent axes: `dismiss:
756
- "maintainers"` lets a maintainer's own reply dismiss with no model involved
757
- (it works under plain `annotate` too); `dismiss: "adjudicated"` additionally
758
- accepts an author reply the model confirmed (which does need `mode:
759
- "adjudicate"` for verdicts to exist). Either way the reply has to cite the finding's
760
- `` `id:…` ``. A `critical` finding, or one categorized `secrets`/
761
- `security`, can never be cleared this way, whatever the config — that floor is
762
- enforced in code, not the prompt.
763
- - **`/undismiss <id>` wins over a reply.** Running it on a finding a reply cleared
764
- puts the finding back in the active list and keeps it there: another reply from
765
- the PR author can't clear it again. The restore is recorded against the FINDING
766
- in the comment state, not against the reply, so editing or deleting the reply
767
- doesn't drop it either. Only a maintainer lifts that — either `/dismiss <id>` on
768
- the same finding, or a maintainer's own reply to it.
617
+ on the decision — safe even if you never touch this block.
618
+ - **Clearing a finding always needs the `` `id:…` `` token** in the replier's own
619
+ words (an id inside a `>` quote does not count). Quoting the title *annotates*,
620
+ never clears otherwise GitHub's "Quote reply" could dismiss a finding on words
621
+ the PR author wrote.
622
+ - **`mode` and `dismiss` are independent axes.** `adjudicate` has a model re-check
623
+ the reply against the actual source and record a verdict; `dismiss:
624
+ "maintainers"` lets a maintainer's own reply dismiss with no model involved;
625
+ `dismiss: "adjudicated"` additionally accepts an author reply the model
626
+ confirmed. A `critical`, `secrets`, or `security` finding can never be cleared
627
+ this way, whatever the config that floor is enforced in code, not the prompt.
628
+ - **`/undismiss <id>` wins over a reply** it restores the finding and pins the
629
+ restore to the FINDING, so another author reply (or editing/deleting the old
630
+ one) can't clear it again; only a maintainer lifts that.
769
631
 
770
632
  `ecr feedback` mines this substrate retroactively, with no model call and no
771
- re-review: it crawls a repo's PRs, reads each one's existing reviewer comment
772
- (which already embeds its findings), matches non-bot replies against it, and
773
- reports totals, a reply-rate, breakdowns by category/severity/agent, and — the
774
- highest-value part "repeat offenders": findings whose title recurred across 2+
775
- PRs and drew a reply every single time.
633
+ re-review: it crawls a repo's PRs, matches non-bot replies against each existing
634
+ reviewer comment, and reports totals, a reply-rate, breakdowns by
635
+ category/severity/agent, and "repeat offenders" findings whose title recurred
636
+ across 2+ PRs and drew a reply every time.
776
637
 
777
638
  ```bash
778
639
  ecr feedback --repo your-org/your-repo --limit 100 --since 2026-06-01
@@ -780,19 +641,13 @@ ecr feedback --as my-review-bot # if CI posts under a PAT/app identity
780
641
  ecr feedback --json # for scripting
781
642
  ```
782
643
 
783
- The crawl matches the reviewer's comments by author. CI posts them as
784
- `github-actions[bot]` (the default), so a locally-run crawl uses that identity
785
- pass `--as <login>` when your workflow posts under something else.
786
-
787
- `ecr feedback` always reads `.expo-code-review/config.jsonc` from the LOCAL
788
- checkout, even with `--repo`. If `--repo` points at a different repo, it warns
789
- that `commentTag` may not match, so a zero-findings result there is not read as
790
- zero pushback. It also warns when every scanned PR had no bot comment at all,
791
- instead of leaving that as an easy-to-miss "0 with a bot comment" in the totals.
644
+ The crawl matches the reviewer's comments by author (`github-actions[bot]` by
645
+ default pass `--as <login>` when your workflow posts under something else), and
646
+ always reads config from the LOCAL checkout, warning when `commentTag` may not
647
+ match a different `--repo`.
792
648
 
793
- See [LLP 0011](./llp/0011-author-feedback.explainer.md) for why matching is
794
- deterministic, why reply text is never echoed into the comment, and why the
795
- defaults are asymmetric.
649
+ Full detail why matching is deterministic, why reply text is never echoed, why
650
+ the defaults are asymmetric: [LLP 0011](./llp/0011-author-feedback.explainer.md).
796
651
 
797
652
  </details>
798
653
 
@@ -845,39 +700,54 @@ head commit, or local comment-policy fingerprint no longer matches.
845
700
  <details>
846
701
  <summary><b>Other providers & auth modes</b></summary>
847
702
 
848
- The scaffolded default is **Anthropic via the Claude Code CLI** see First-time
849
- setup above and the Anthropic bullet below for how it authenticates and is
850
- sandboxed. Everything is set in `config.auth`; non-anthropic providers get their
851
- credentials through OpenCode.
852
-
853
- - **Anthropic / Claude (the default)** — use `anthropic/...` model ids and every
854
- anthropic pass runs through the **Claude Code CLI** (`claude -p
855
- --output-format stream-json --verbose`), inferred from the model. The
856
- credential is (in order) a `tokenEnv` you name, an ambient
857
- `CLAUDE_CODE_OAUTH_TOKEN`, or your local
858
- `claude` login — an `auth` entry is entirely optional. Run `claude setup-token`
859
- for a Max/Team subscription token or point `tokenEnv` at an Anthropic Console
860
- API key (`sk-ant-api…`, forwarded as `ANTHROPIC_API_KEY`); the CLI reads
861
- either. `ecr setup-auth` walks you through it. Each pass is trust-isolated and
862
- read-only: it runs with `--safe-mode` (no `CLAUDE.md`/hooks/MCP/plugins),
863
- `--strict-mcp-config`, `--permission-mode dontAsk`, and only the
864
- `Read`/`Grep`/`Glob` tools — never `Bash`/`Edit`/`Write`/`WebFetch`/`WebSearch`.
865
- The child env is an allowlist that omits ambient `ANTHROPIC_API_KEY`/`ANTHROPIC_AUTH_TOKEN`
866
- (only the configured credential is re-injected).
703
+ Everything is set in `config.auth`. Engines are inferred **per agent** from that
704
+ agent's resolved model: an `anthropic/…` agent runs through the Claude Code CLI,
705
+ any other provider runs through OpenCode — in the SAME run. `REVIEWER_MODEL`
706
+ overrides every agent's model (and therefore engine) at once. There is no shared
707
+ fallback key; `ecr doctor` diagnoses setup.
708
+
709
+ - **Anthropic / Claude (the scaffolded default).** The credential is (in order) a
710
+ `tokenEnv` you name, an ambient `CLAUDE_CODE_OAUTH_TOKEN`, or your local
711
+ `claude` login an `auth` entry is entirely optional. `claude setup-token`
712
+ mints a Max/Team subscription token for CI, or point `tokenEnv` at a Console API
713
+ key (`sk-ant-api…`); the CLI reads either. Each pass is trust-isolated and
714
+ read-only: `--safe-mode` (no `CLAUDE.md`/hooks/MCP/plugins),
715
+ `--strict-mcp-config`, only `Read`/`Grep`/`Glob`, and an allowlisted child env
716
+ that omits ambient Anthropic credentials.
717
+
867
718
  ```jsonc
868
- // The scaffolded default. No anthropic entry at all falls back to your
869
- // `claude` login.
719
+ // The scaffolded default. No anthropic entry at all falls back to `claude` login.
870
720
  "auth": { "providers": {
871
721
  "anthropic": { "tokenEnv": "CLAUDE_CODE_REVIEW_SHARED_API_TOKEN" }
872
722
  } }
873
723
  ```
874
- - **OpenAI: ChatGPT/Codex subscription (OAuth) + usage-based API key — the
875
- recommended mix if you review with OpenAI.** OpenAI permits subscription auth
876
- in third-party tools, and OpenCode
877
- ships the plugin for it so the reviewer runs its default models on the
878
- subscription (zero marginal cost) and reserves the metered key for pro-tier
879
- models the subscription doesn't offer (`gpt-5.5-pro` is subscription-excluded).
880
- Use the per-provider map form:
724
+
725
+ - **Meta / Muse Spark 1.2 (public Model API).** Set the default model and any
726
+ model-pinned agent frontmatter to `meta/muse-spark-1.2`. ECR supplies the fixed
727
+ public Responses endpoint and the `@ai-sdk/openai` adapter; the generic
728
+ Chat-Completions compatibility adapter is intentionally not used because it
729
+ loses Muse's reasoning continuity across tool turns. Standard and Contributor
730
+ model ids are supported.
731
+
732
+ ```jsonc
733
+ "model": "meta/muse-spark-1.2",
734
+ "auth": { "providers": {
735
+ "meta": { "mode": "api-key", "tokenEnv": "META_API_KEY" }
736
+ } }
737
+ ```
738
+
739
+ Create the key in the [Meta AI developer portal](https://developer.meta.com/ai/).
740
+ Locally, export it as `META_API_KEY`. In each review-running workflow, replace
741
+ the Anthropic credential line with
742
+ `META_API_KEY: ${{ secrets.META_API_KEY }}`, set the repo variable
743
+ `ECR_EXPECTED_TOKEN_ENV=META_API_KEY`, and remove the Claude CLI install step
744
+ if no configured agent uses an `anthropic/…` model. Muse runs with high reasoning.
745
+
746
+ - **OpenAI: ChatGPT/Codex subscription (OAuth) + usage-based API key** — the
747
+ recommended mix if you review with OpenAI. Default models run on the
748
+ subscription (zero marginal cost); a metered key covers subscription-excluded
749
+ pro models via a synthesized `openai-api` alias (agents reference
750
+ `openai-api/gpt-5.5-pro` in frontmatter):
881
751
 
882
752
  ```jsonc
883
753
  "auth": { "providers": {
@@ -886,63 +756,26 @@ credentials through OpenCode.
886
756
  } }
887
757
  ```
888
758
 
889
- `openai-api` is an alias the reviewer synthesizes in the OpenCode config
890
- (`upstream` names the SDK it's backed by): agents reference `openai-api/gpt-5.5-pro`
891
- in frontmatter while everything else stays on `openai/gpt-5.5`. Notes:
892
-
893
- - **The oauth `tokenEnv` holds the ACCESS token** from an `opencode auth login`
894
- ChatGPT sign-in (`ecr setup-auth` extracts it) a plain bearer, valid for
895
- days, with no rotation involvement. Do **not** use the refresh token as a
896
- shared secret: refresh tokens are single-use (rotation), so a static copy is
897
- spent by its first use and the sign-in dies with it. Access tokens expire
898
- (~10 days observed), so CI secrets need periodic re-minting — see the
899
- token-rotator item in the [roadmap](./ROADMAP.md); `doctor` and the run
900
- preflight warn before expiry.
901
- - **The API key needs exactly two permissions** — a *Restricted* key with
902
- *Model capabilities*: **Responses → Request** and **Chat completions →
903
- Request**; everything else (including *List models*) stays None. Create it
904
- in a dedicated, budget-capped project. (`ecr setup-auth` prints these
905
- instructions too.)
906
- - **In CI**, set the `ECR_EXPECTED_TOKEN_ENV` repo variable to the
907
- comma-separated set of both env names
908
- (`CODEX_OAUTH_ACCESS_TOKEN,OPENAI_API_KEY`) and pass both secrets in the
909
- workflow.
910
- - **Auditability**: every pass logs which provider/model answered it (job log,
911
- step summary, run log), so the subscription/API split is visible per run.
912
- One caveat: OpenCode can't price alias models (they're config-declared), so
913
- pro passes report `$0` in the run log's cost column — token counts are
914
- correct, and the OpenAI project dashboard is the source of truth for spend.
915
- - **Another provider** — the current path is the `REVIEWER_MODEL`
916
- env override: `opencode auth login` once (pick the provider), then run with
917
- e.g. `REVIEWER_MODEL=google/gemini-3-pro`. It overrides every agent's model
918
- and uses your OpenCode login, so no `auth` block is needed.
919
-
920
- Engines are inferred **per agent** from that agent's resolved model alone: an
921
- `anthropic/…` agent runs through the Claude Code CLI while other agents run through
922
- OpenCode — in the SAME run. So an anthropic model may coexist with an `openai` (or
923
- any other) OpenCode provider, and each agent's `model` selects its engine.
924
- `REVIEWER_MODEL` still overrides every agent's model (and therefore every agent's
925
- engine), converging the whole run onto one engine.
926
-
927
- There is no shared fallback key; if a run fails for lack of credentials, log in
928
- with `claude` (the default) or authenticate a provider in OpenCode. `ecr doctor`
929
- diagnoses setup.
930
-
931
- **Setup errors fail fast, with the fix in the message.** A bad credential or model id
932
- would otherwise fail every pass identically — a run that spends its whole budget
933
- rediscovering one fixable thing, then reports N coverage gaps. So before any pass runs:
934
-
935
- - **The credential's shape is checked.** OpenCode refuses a malformed credential by
936
- dropping the provider entirely, which then surfaces as "model not found" for every
937
- model, with nothing pointing at the credential. A truncated value, surrounding
938
- whitespace, or a token that can't work for the configured `auth.mode` is rejected
939
- by name.
940
- - **Configured model ids for OpenCode-routed providers are checked against the running
941
- server**, so a typo or an id the provider doesn't have is reported once, up front,
942
- with the close matches. `anthropic/…` (Claude Code) model ids aren't checked up
943
- front — Claude validates them per-request, so a typo there surfaces as a per-pass
944
- error instead.
945
- - **`ecr doctor` reports the `opencode` version actually in use** and warns when a
946
- different one is first on your `PATH` — runs use the version this package pins.
759
+ The oauth `tokenEnv` holds the ACCESS token from an `opencode auth login`
760
+ ChatGPT sign-in (`ecr setup-auth` extracts it) never the single-use refresh
761
+ token. Access tokens expire (~10 days observed), so CI secrets need periodic
762
+ re-minting; `doctor` and the run preflight warn before expiry. The API key
763
+ needs only *Responses Request* and *Chat completions Request*, in a
764
+ dedicated budget-capped project. In CI, set `ECR_EXPECTED_TOKEN_ENV` to both
765
+ env names, comma-separated. Every pass logs which provider/model answered it,
766
+ so the subscription/API split is visible per run (alias-model passes report
767
+ `$0` cost OpenCode can't price config-declared aliases; the OpenAI dashboard
768
+ is the source of truth for spend).
769
+
770
+ - **Another provider** — `opencode auth login` once, then run with e.g.
771
+ `REVIEWER_MODEL=google/gemini-3-pro`. No `auth` block needed.
772
+
773
+ Setup errors fail fast, with the fix in the message, instead of failing every pass
774
+ identically: the credential's shape is checked by name before any pass runs,
775
+ configured OpenCode model ids are validated against the running server (with close
776
+ matches suggested; `anthropic/…` ids are validated per-request by Claude instead),
777
+ and `ecr doctor` reports the `opencode` version actually in use.
778
+
779
+ Full detail: [LLP 0003](./llp/0003-model-runtimes-and-credentials.explainer.md).
947
780
 
948
781
  </details>