62 KiB
expo-code-review — roadmap / next steps
Working notes for the experimental reviewer, now published as
@expo/code-review-cli and extracted into its own repo (it was incubated inside
expo/eas-cli, which is why much of the history below references that repo and
PR #4022). Items are roughly ordered by priority.
Merge boundary — phase 1 vs follow-ups
PR #4022 lands a self-contained phase 1 (opt-in, comment-only, non-blocking) that is meant to merge and then iterate in follow-up PRs. What's in phase 1 is under "Recently shipped" below. The highest-value work deferred to follow-up PRs (do these first, in this order):
- Codex access-token rotator (cron) — MOST IMPORTANT NEXT. CI subscription
auth now uses ACCESS tokens (~10-day observed lifetime) because refresh tokens
are single-use: sharing one as a static secret spent it on first use and killed
the whole sign-in ("Your refresh token has already been used…", euxy#8,
2026-07-27). Until the rotator exists, the access-token secrets need a manual
re-mint (~weekly):
ecr setup-auth→gh secret set CODEX_OAUTH_ACCESS_TOKENper repo. Design (constraints are load-bearing):- a scheduled workflow (~twice weekly + workflow_dispatch) in ONE home repo is the refresh token's SOLE consumer — nothing else may ever hold or use it;
- serialized via a concurrency group (a race forks the rotation family);
- each run: one form-urlencoded refresh call (client_id
app_EMoamEEZ73f0CkXaXp7hrann, endpointauth.openai.com/oauth/token), then persist the NEW refresh token to its own secret FIRST, then fan the new access token out toCODEX_OAUTH_ACCESS_TOKENon every consumer repo (code-review-cli, eas-cli, euxy, …); - needs a fine-grained PAT with Actions-secrets write on the consumer repos (GITHUB_TOKEN cannot write secrets);
- failure mode: crashing between spending the old token and saving the new one
loses the family (recover:
opencode auth login+ reseed) — the rotation grace window makes an immediate retry likely to succeed, so retry once before alerting; doctor/preflight already warn when the access token is near expiry, which doubles as the rotator's health check.
- Incremental / delta review — review only what changed since the last review (persistent per-file-version state). Biggest speed + cost + reliability win on re-pushes; the last remaining lever for the monster-PR case. See §5.
- Cross-cutting pass parallelization — it's the serial long-pole on large PRs (one pass over every changed file), and it's now the pass that expands to fill the remaining budget (§3 #10). Splitting it by file halves is not an option — that hides the cross-half interactions it exists to find — but splitting by package/proximity, where a boundary is a real architectural seam, still is.
- ✅ Transient-error retry with backoff (shipped 2026-07-23, #3.) A non-timeout,
non-parse API error (one-off 429/5xx/network) used to drop that pass with no retry.
Now retried with bounded backoff via
isTransientApiError(excludesAgentTimeoutError), distinct from the timeout→subdivide and parse→retry paths. - Close the config/code injection vector (security) — the auto-workflow builds
- loads config from the PR merge ref, so a same-repo PR can rewrite the reviewer's
prompts/config/code and it runs with secrets. Fix: build + load config from the
trusted base ref (diff still via
gh), like the/reviewcommand workflow — or switch to the published package vianpx. Deferred only because the reviewer isn't onmainyet (nothing to build from base until this merges). See §3.9.
- loads config from the PR merge ref, so a same-repo PR can rewrite the reviewer's
prompts/config/code and it runs with secrets. Fix: build + load config from the
trusted base ref (diff still via
- Inline PR comments (§1).
Recently shipped
- Author feedback on findings (shipped 2026-08-01) — a PR author's reply is
now matched to the finding it answers deterministically (a quoted title or
an
id:token — no model decides which finding a reply is about; an ambiguous quote records nothing), recorded in the comment's embedded state, and shown as💬 @login repliedwith a visible above-the-fold count. Reply text is never stored or rendered — the record is fingerprint + login + comment link + two closed enums, nothing free-text. Root-onlyfeedbackconfig (mode: off|annotate|adjudicate,dismiss: never|maintainers|adjudicated,protectedCategories,maxAdjudications), asymmetric defaults (annotateon,dismissoff) for the same reasonstackshippedenabled: false— adopting repos never re-copy the template.mode: "adjudicate"has a model re-check a rebuttal against the actual source (distrust-by-default, mirroring the verifier) and record a verdict; acritical/secrets/securityfinding can never be cleared this way regardless of config — enforced as a hard floor in code. Newecr feedbackcommand mines this substrate retroactively across a repo's PR history (findings come from each PR's own past reviewer comment, so no re-review and no model call): totals, reply rate, breakdowns by category/severity/agent, and "repeat offenders" — findings whose title recurred across 2+ PRs and drew a reply every time. Closed a latent security hole found while building this:parseReviewState/parseEmbeddedFingerprintsmatched with a non-global regex and took the FIRST marker in the body while the genuine one is appended LAST, so a forged<!-- tag:state=… -->earlier in the body (e.g. inside a model-writtenrationale) would have won over the real state; every externally-sourced string rendered into the body is now sanitized (stripStateMarkers) to close it. Findings also gained engine-populatedagentattribution (which reviewer agent produced them), excluded from the fingerprint so attribution can never lapse an existing dismissal. See LLP 0011. - Bounded per-scope passes budget (shipped 2026-07-23) — active scopes review
SEQUENTIALLY in one
ecr ci, so the old per-scope budget (max(10m, floor(32m / N))) let 4+ scopes spend 40m+ of pass time alone, unbounded as scopes grow, blowing past the 32m the CI job timeout was sized around. The total is now bounded: a newbudgetkey inrouting.jsonc(totalPassesMinutesdefault 32,minScopeMinutesdefault 5; zod defaults, so absent = today's totals) is divided across active scopes by a purescopePassesBudgetMs(total, min, active)(even split, clamped up to the floor). When the floor would makeactive × perScopeovershoot the total, the floor is kept butecr ciwarns loudly (naming the overshoot + expected wall-clock) andecr doctorflags the worst case (scopes.length × floorvs total). Template + README document the knob. --config-dir/ECR_CONFIG_DIRfully composes with routing (shipped 2026-07-23) — the escape hatch previously half-composed:loadReviewConfig/hasConfighonored the override butloadRoutingManifestand scope loading did not, so the root config came from the override dir whilerouting.jsoncand scope configs came from the default tree. The override now designates an alternate ROOT config dir:loadRoutingManifest(root, { configDir })readsrouting.jsoncfrom the same resolved dir asconfig.jsonc(resolveConfigDir). Scopeconfigpaths stay repo-root-relative (an override must not invent a parallel scope universe), and each scope's.expo-code-reviewdir name is unchanged — only the ROOT artifacts follow the override.ecr cigained a--config-dirflag (mirroringecr review) threaded through the legacy and routed config loads;doctorprints an ℹ line naming the resolved root dir when the override is active;verify-confignotes the override (its repo-wide security sweep is unchanged). With no override, resolution is byte-identical (backcompat).- Full workflow template set + Opus rosters + SHA-pinned actions (shipped
2026-07-23) —
templates/shipped only the auto (pull_request)workflow.yml, so both production adopters (eas-cli, universe) hand-wrote the/reviewcommand workflow, the/dismissworkflow, and the always-run run-log upload. Those are now templates:command.yml(maintainer-gatedissue_comment/review, base-ref-only checkout,ecr verify-configguard, bash-array--agents/--route) anddismiss.yml(/dismiss///undismiss, sanitized id/reason parse, no model secret), both scaffolded byecr initasexpo-code-review-command.yml/expo-code-review-dismiss.yml.workflow.ymlgained thealways()"Upload review run log" artifact step so the otherwise-ephemeral.runs/reviews.jsonlsurvives a CI run. Everyuses:across the three templates is now pinned by full commit SHA with a version comment (checkout v7.0.1, setup-node v7.0.0, upload-artifact v7.0.1). And the templates match the production rosters' model provisioning:coordinator.mdandagents/security.mdnow pinanthropic/claude-opus-4-8(were haiku / unpinned). - Canonical
ecr verify-configCI guard (shipped 2026-07-23) — the pre-review tokenEnv guard, previously duplicated across adopter workflows as brittle bash/awk JSONC text-scraping at different robustness tiers, is now ONE command shipped by the CLI. It walks every.expo-code-review/config.jsonc|config.json+routing.jsoncin the checkout (plain recursive walk skipping node_modules/.git, so a staged unreferenced config can't hide from git's index), parses each with the engine's REAL comment-aware JSONC parser (never regex), and refuses (exit 1,::error::on stderr) whenauth.tokenEnv/defaults.auth.tokenEnvappears more than once, in a non-root file, or (with--expected/ECR_EXPECTED_TOKEN_ENV) differs from the expected name or is absent; when a non-root config declaresauth/breakGlass/commentTag(root-locked, checked WITHOUT trusting the manifest references the file); or when any file fails to parse (fail-closed).--jsonemits{ok, findings:[{file, problem}]}. The workflow template replaces its entire awk guard block withnpx ... ecr verify-config(setup-node moved before it; running the PUBLISHED package via npx is safe pre-review since no PR code is built), and this repo's own self-review workflow runsverify-configfrom source. Layer 2 to the runtimeECR_EXPECTED_TOKEN_ENVlock (layer 1), so guard/loader drift still fails safe. - Manual
/reviewbypasses the trigger gate (shipped 2026-07-23) — an explicit maintainer invocation (ecr ci --force, or a/reviewcomment command detected viaGITHUB_EVENT_NAME=issue_comment) now reviews even when the trigger policy would skip (label trigger unmet, or theai-review:skiplabel set), so the manual escape hatch is no longer silently defeated (eyes reaction then nothing). It still callsshouldReviewand logs a stderr notice naming what it overrode. The bypass affects ONLY the trigger gate (shouldBypassTriggerGate/passesTriggerGateinci.ts, applied identically in the legacy and routed paths) — break-glass and the auth lock still apply. - Monorepo routing manifest (shipped 2026-07-23) — an optional infra-owned
.expo-code-review/routing.jsoncmaps ordered path globs to scopes, each pointing at its own.expo-code-review/config dir. Oneecr ciprocess fans out INTERNALLY: it reads the PR's changed files once, assigns each to exactly one scope (last-match-wins), reviews each active scope over only its files, then renders ONE comment (aggregatedsingleby default, orper-scope). Single-writer/single- process, so there's no comment/check race and one sharedghdiff + link-context across scopes (rate-limit hygiene). Security:auth/tokenEnvis locked to the root (rejected at the Zod level in scope configs + the widened repo-wide CI guard that also assertscommentTaguniqueness);defaults.enforceAgentsinject a non-overridable roster (e.g.security) into every scope; scope-namespaced fingerprints keep cross-scope dismissals from colliding, and the default scope keeps the root marker so existing dismissal state carries over. Backcompat is free: norouting.jsonc⇒ today's exact single-config behavior. New surface:ecr init --monorepo/--scope <dir>,ecr review --scope/--config-dir(+ECR_CONFIG_DIR),ecr ci --scopes/--comment,ecr doctor --list-scopes. Deferred: per-scope GitHub check runs; auto-spilling an oversized aggregate comment into per-scope comments (we truncate with a "+N more" note instead); CLI-sideai-review:scope:<name>label parsing (the adopting repo's workflow maps labels →--scopes, as it already does for--agents). - Follow-up batch (2026-07-23, PRs #2–#7) — first round of post-extraction
low-hanging-fruit hardening:
- Transient-error retry with backoff (#3) — a one-off 429/5xx/network error on
a pass is now retried with bounded backoff (2s→8s) instead of dropping the whole
pass; classified by
isTransientApiError, which excludesAgentTimeoutError(timeouts still abandon). Closes reliability/merge-boundary follow-up #3 below. - Fail-fast provider-auth check (#4) —
checkProviderAuth(shared byprepareAuth+doctor) fails fast with one clear message before the run when the configured provider has no credential, instead of surfacing as N failed passes mid-run. Closes the "fail-fast provider-auth check" item under Model selection below. - Auth failures collapse to one note (#5) — a rejected credential (401/403)
hitting every pass now reports a single actionable coverage note (
isAuthError) instead of N generic failures. Closes the UX "auth-shaped errors → one message" item. --staged+--base/--headrejected (#2) — was silently ignored; now a clear error. Closes the UX-minor--staged/--basewarning item.- Usage totals surfaced to CI/terminal (#6) — token/cache/cost totals are
printed as a one-line summary via progress, so prompt-cache reuse is visible in
CI (where
.runs/reviews.jsonlis ephemeral). Closes the §4.1 follow-up. - Config-driven trigger policy + skip fix (#7) — new
review.trigger("all"default |"label") inconfig.jsonc;ecr ciself-gates viashouldReview(), honoring the write-gatedai-review:skiplabel. Fixed the workflowif:gate: the oldcontains(join(labels), 'ai-review')matchedai-review:skipas a substring (skip didn't skip) — now an exact array-form match, as an optional coarse opt-out on top of the config policy. Template + workflow kept in sync.
- Transient-error retry with backoff (#3) — a one-off 429/5xx/network error on
a pass is now retried with bounded backoff (2s→8s) instead of dropping the whole
pass; classified by
- Never-drop-work on timeout (2026-07-22) — a hard-timed-out chunk is now subdivided (halved recursively down to a single file) and re-reviewed instead of dropped; a single file that still won't converge gets a fast no-tools fallback over its inlined diff; only a genuinely un-reducible pass reports a coverage gap, never silently. Plus a tool-call cap (catches an agent that wanders instead of converging) and a global 32m passes budget. See §3 guarantees 1/2/5.
- Auto-discovered agents from
.expo-code-review/agents/*.md+ frontmatter. - Adaptive-hybrid chunking (per-chunk + cross-cutting pass), chunk retry.
- LLM router (
--route) and/reviewcomment command. - Noise filtering (lockfiles, generated markers, repo
additionalIgnores). - Comment/label split —
/review[ all|<agents>]comments are now purely one-shot (imperative "review now", no config side effect); continuous review is configured by labels (declarative, visible, UI-toggleable):ai-review(router),ai-review:all,ai-review:<agent>(scoped to auto-discovered agents),ai-review:skip(opt-out). The auto workflow resolves--agents/--routefrom the labels;/review-onceretired (all comments are one-shot). - Binary-diff blind-spot fix — the parser now flags
Binary files … differentries and noise-filters them instead of handing agents an empty patch; andnoise.tsno longer stashes a literal NUL byte as a glob sentinel (which had made git classify that source file as binary, hiding it from every reviewer). - Command-workflow hardening —
expo-code-review-command.ymlno longergh pr checkouts the PR head; it builds/runs only the trusted base ref. See "Reliability & security" below for the residual work. - Failure-path hardening (audit 2026-07-22) — a failed/timed-out run never renders as a clean "Ready for human review"; the coordinator has its own 5-min cap + soft-landing and a deterministic local-merge fallback (a coordinator hiccup no longer discards all findings); CI always posts a terminal state on failure; coverage notes now include filtered (binary/generated/ignored) files; the per-task retry explosion (up to ~9 model runs/task) was removed.
- Cross-cutting collapsed to ONE combined pass (was one per agent — 3 redundant full-diff passes), the biggest large-PR latency win.
- Cross-file pass no longer wanders the whole repo — the collapsed pass ran
under an undefined agent id, so OpenCode fell back to a default agent with
full tools and it crawled unrelated packages until its 15-min cap (contributing
nothing). Now defined as a real agent with a restricted tool set (
read+grep, noglob/listcrawling), so it converges instead of burning its budget. - Speed knobs — longest-processing-time-first task scheduling;
concurrency4→6; CI job timeout 20→30 min (so the worst-case internal cap chain fits with headroom). - Inlined chunk diffs — the reviewer task embeds the assigned files' diffs
(fenced as untrusted) instead of making the agent
readeach patch file, cutting per-pass tool round-trips. The cross-file task now does the same, up to a line budget (2026-07-26); only a huge diff's tail is still read on demand. - Extended caps — chunk 8→15m, coordinator 5→10m, CI job 30→50→90m (kept the invariant: worst-case serial chain < job timeout). The cross-cutting pass went 15→25m and then off a fixed cap entirely — it now expands to fill the remaining passes window (see §3 #10). Gives slow-but-progressing passes room to converge instead of finalizing partial; cost is longer max runs + more tokens. Still model-generation-bound on the largest PRs — see §3 size guard / faster-model levers.
- Generation-marker false-filter fix — the noise filter matched generation
markers (
@generated,do not edit, …) anywhere in the first 40 added lines / 4 KB, so hand-written files that merely mention those strings were wrongly skipped — includingnoise.tsitself (it lists them asDEFAULT_MARKERS) and a template comment. Now only the first few lines (a real header) count. Filtering on the #4022 diff dropped from 3 files → 1 (justyarn.lock). initerror handling — wrapped in try/catch with a clean stderr message + exit code, matching the other commands (was the one command that could crash raw).- Chunk-size retune —
maxChangedLines1500→1000. On the 51-file self-PR the reasoning-heavycorrectnessagent was the lone straggler at 15m with 1500-line chunks; smaller/more chunks (each finishing faster, run in parallel) reduce per-pass generation time. Reverses the earlier 1000→1500 bump.- Observation to watch: on that run the Haiku coordinator also ran ~10m (near its cap) — likely a large findings payload. If it recurs, trim the coordinator input (dedupe/cap findings before consolidation) or revisit its model.
1. Post a real PR review with inline comments (not one bottom comment)
Today the reporter posts a single consolidated issue comment. Move to a formal
review (POST /pulls/{n}/reviews) so findings land on the diff where the reader
is — matching what the Claude GitHub bot does.
Design:
event: COMMENT(neverREQUEST_CHANGES/APPROVE) to stay non-blocking in phase 1.- Review body carries
decision+summary+ any finding whosefile:lineis not part of the diff (GitHub rejects inline comments off the diff). This is also the home for cross-cutting findings (e.g. a CI/workflow supply-chain issue that reasons across files). - Findings whose
file:lineIS in the diff become inline comments, each with an embedded per-comment fingerprint (<!-- ecr-fp:… -->) so re-runs update in place instead of duplicating — same idea as today's single-comment fingerprint, applied per thread. - Graceful fallback: any finding that fails to anchor inline drops into the body list rather than erroring the whole post.
- Keep the existing single-comment reporter as a fallback path / for hosts without a PR context (local runs already print, don't post).
Open question: dedup/cleanup of stale inline threads across pushes (resolve vs. leave). The bot leaves them; simplest is to leave + update-by-fingerprint.
2. Unblocked integration (institutional context)
Prior research (see conversation + memory). The unblocked MCP/CLI exposes:
context_get_rules (structured repo rules w/ severity/task/paths),
context_research (why/who/when synthesis), context_get_urls.
- Mode A (recommended first step, low-risk): at review start, call
context_get_rulesfor the repo and inject the returned rules into theshared.md/ consistency-agent prompt as additional, authoritative conventions. Cheap, deterministic, directly improves theconsistencyagent. - Mode B (later): give agents a research tool (wire
context_researchas an OpenCode tool) so they can pull the why behind a changed area on demand. More powerful, but adds latency + nondeterminism and a network dependency in CI — gate behind config and a timeout. - Prereq: Unblocked auth in CI. Interactive-auth MCP servers may be absent in headless runs; needs a service token or a documented skip.
3. Reliability & security — "never hang / never silently fail"
Motivating incident: on the 49-file self-PR the review step ran ~28 min and was
killed. Root cause (confirmed from the run log, NOT rate-limiting): the 4 chunk
passes and two of the three cross-cutting passes completed fine; the single
correctness [cross-file] pass never converged. buildCrossCuttingTask lists
all changed files and lets the agent "read the surrounding source as needed" with
read/grep/glob/list over the whole repo, so on a 49-file diff it wandered
the entire monorepo (even unrelated packages) and could not emit its JSON within
the 8-min per-attempt cap. Retry-on-timeout then made it 3× worse: each retry
restarts the same unbounded wander (8 min × 3 = 24 min on that one task). There is
also no global wall-clock budget, so nothing bounded the total.
Guarantees (priority order; ✅ = shipped):
- ✅ Never silently drop work on timeout (subdivide-on-timeout). (Shipped
2026-07-22.) This supersedes the earlier "don't retry on timeout" rule, which
only avoided compounding a non-convergent run but left the chunk's work dropped
and merely reported as a gap — which is not acceptable for a review tool. Now a
hard-timed-out chunk is split in half and the halves re-reviewed (recursively,
down to a single file); a single file that still won't converge gets a fast
no-tools fallback over its inlined diff; only if that can't finish inside the
budget is a coverage gap reported (and it is always reported). See
runGrowableQueue+ the timeout branch inreview.ts. Motivation: the auto-review of our own PR still droppedcorrectness [4/7]even after the finalize soft-landing. (The per-task 3× retry wrapper stays removed; promptAndParse's internal parse retries are unchanged.) - ✅ Tool-call cap. (Shipped 2026-07-22.) A pass that makes too many
read/grepcalls without converging is wandering; the cap (chunk 50, cross-cutting 120) trips the same soft-landing as the time cap. This attacks the root cause (roaming) directly — the cheap, self-contained version of Greptile's pre-indexed retrieval, without an index or an external dependency. - ✅ Bound the cross-cutting pass — collapsed to one combined pass and tightened its prompt to stay within the changed files.
- ✅ Always post a result — a failed run reports "could not complete"; CI posts a terminal state on any failure; the coordinator has a deterministic fallback so its failure can't discard findings; and a failed/timed-out run never renders as a clean "Ready for human review".
- ✅ Global time budget. (Shipped 2026-07-22; retuned 2026-07-26.)
PASSES_BUDGET_MS(55m) is a hard wall-clock ceiling for all passes incl. subdivision/fallback waves: past it, a timed-out pass is reported as a gap rather than broken down further. Sits under per-task caps (chunk 15m, coordinator 10m) and the CI job cap (timeout-minutes: 90, the one hard-kill with no soft-landing). The cross-file pass has no fixed cap: it expands to fill whatever of the window is left (see #10). - ✅ Size guard / degraded mode. (Largely addressed by #1.) Subdivide-on-timeout is a reactive size guard: an oversized/dense chunk that can't converge is split until it does, rather than skipped. A proactive up-front "diff too large → review only the highest-signal subset" ceiling is still possible but no longer needed to prevent drops. The bigger remaining lever is incremental review (only review the delta since the last review, CodeRabbit-style) — see §5.
- ✅ Bound the coordinator — 10-min cap + soft-landing + deterministic fallback;
its
truncatedstatus now flows to a coverage note. - ✅ Concurrency default raised 4→6 (quality-neutral within rate limits).
- Publish + run via
npx(both workflows). Theinittemplate already runs the published package vianpx(only the diff is PR-controlled) — strictly safer than our in-repo workflows thatyarn buildfrom source. Once published, switch both in-repo workflows to the published binary and stop building from a checkout. Also revisit thepull_requestauto-workflow: it builds/runs the PR merge ref; fork PRs are protected (GitHub withholds secrets) but same-repo PRs run with secrets — acceptable for now (push access implies trust + label gate), but the npx switch removes the concern entirely. - ✅ Stall detection + an elastic cross-file pass. (Shipped 2026-07-26.)
Motivating incident: the auto-review of eas-cli#4084 (14 files, 1.5k added lines)
ran 37 minutes and posted a review with ZERO findings and two coverage gaps. The
run log is unambiguous about why: the cross-file pass ran 7
readcalls in its first 6 seconds and then went completely silent for 24m52s —agentTokensrecorded{input: 0, output: 0, reasoning: 0, cache: 0}for it — until its 25m cap fired. The finalize salvage then also went silent (and, ignoring its own instructions, opened 7 more files), so the pass's entire work product was lost. The coordinator stalled the same way and burned its full 10m cap on 618 input tokens. The wall-clock cap was NOT the binding constraint: the pass did ~6 seconds of work in 25 minutes, so a bigger budget would only have bought a longer silence. Fixes:- Stall watchdog — the poll loop fingerprints the in-progress reply (parts,
streamed text/reasoning length, tool status, token counters). Unchanged for
STALL_MS(4m) ⇒ the request is wedged, so abandon it and retry ONCE from a clean session, within the pass's existing deadline. Heartbeats now report how long a reply has been silent, which is what made this diagnosable at all. - Tool-free salvage — the finalize ("return what you have") and corrective ("re-emit the JSON") requests pass a per-request all-false tool map, so they physically cannot resume investigating. Finalize budget 90s → 3m.
- Elastic cross-file budget — a fixed 25m is replaced by "whatever is left of the passes window", so on a large diff it can genuinely finish.
- No more splitting the cross-file pass — halving its file set hides exactly the interactions it exists to find (a left-half/right-half interaction is invisible to both halves) while reporting success. It now goes straight to the no-tools fallback, which still sees the whole diff.
- Inlined cross-file diffs — it was spending one tool round-trip per changed file to read back patches we already had in memory, before any tracing could start. Now inlined up to a line budget, with the tail deferred on huge diffs.
- Scaled tool-call ceiling — cross-file 120 →
10 × files(120–400), or the fixed cap would become the binding constraint under the larger time budget.
- Stall watchdog — the poll loop fingerprints the in-progress reply (parts,
streamed text/reasoning length, tool status, token counters). Unchanged for
Prompt-injection posture (and the structural fix)
Untrusted input reaches the reviewer from three places; defenses by layer:
- Diff content, PR title/body, filenames, commit messages — the biggest and
most obvious surface. Defended in-prompt:
shared.mdlabels all reviewed content untrusted DATA (never instructions), neutralizes "ignore your instructions"-style injection, and rules that claims of intent ("this is safe/a fixture") carry no weight; the engine also sanitizes PR title/body + file paths (sanitizeUntrusted) and fences inlined diffs with BEGIN/END(untrusted) markers. This is mitigation, not a hard guarantee — an LLM can still be swayed — so it's defense-in-depth, not the last line. - Prior comments / dismissal state — NOT fed back to the review agents (runs are stateless), so there is no injection path there. The embedded comment state is only ever read from the bot's own comment (GitHub write perms are the boundary).
- The reviewer's OWN config, prompts, and code — structurally closed (see
PLAN-trusted-base-config.md).ecr ciloads all review configuration (config, routing, prompts, models, auth mapping) from the PR's immutable base commit, materialized via the GitHub API, failing closed when it can't; the PR head is materialized separately (OID-pinned) as source data only and is scrubbed of ambient runtime config (opencode.json{,c},.opencode/plugins,AGENTS.md,CLAUDE.md,.claude/,.mcp.json,.cursor*,.env*) before the OpenCode server starts, so a PR can't install a plugin/MCP/instruction file/.envinto the credentialed process. The scaffolded workflows check out only the base SHA withpersist-credentials: false, and the engine runs as the published npm package. Earlier defense-in-depth stays: theauth.tsdenylist, the scope-schema auth rejection,ECR_EXPECTED_TOKEN_ENV, andverify-config.
4. Caching (LLM cost / quota / latency)
Investigated 2026-07-21. Baseline finding: OpenCode already applies Anthropic
prompt caching automatically — the installed binary (1.18.1) adds
cacheControl: {type: "ephemeral"} (5-min TTL, hardcoded) to the first 2 system
messages and the last 2 non-system messages on every anthropic/bedrock/openrouter
request. So the expensive part — the per-session tool loop resending the whole
conversation each turn — is already incrementally cached; we should not add
cache_control markers ourselves. Under Claude Max OAuth, caching buys quota
headroom and latency, not dollars (cost is ~0 there anyway).
Quality impact: prompt caching replays byte-identical prefix tokens — the model's output is unchanged, so items 1–3 below are quality-neutral by construction (item 2 is arguably a small recall gain). Item 4 is the only one that trades quality for cost — treat it as a product decision, not just an engineering task.
What's left is at our layer, in suggested order:
- Log cache metrics (measure first). OpenCode's assistant message info
carries
tokens: {input, output, reasoning, cache: {read, write}}; we already poll that object inpromptAgentbut only extractcost. AddtokenstoPromptResultand the.runs/reviews.jsonlrecord. This also answers an open question we couldn't settle statically: whether our per-promptsystemparam lands inside OpenCode's "first 2 system messages" breakpoints (cross-session prefix reuse) or outside them.- Caveat: the
tokensobject likely reflects the most recent model request in the tool loop, not the cumulative session total — verify how OpenCode accumulates it before reading it as a per-task total. Fine either way for answering the breakpoint-placement question. - Confirmed working (2026-07-22) from local #4057 runs (OpenAI): cache
reads ~106–110k tokens/run vs ~10–18k fresh input — provider prompt caching
is clearly active.
cache.write: 0there is an OpenAI reporting quirk (it reports cached-reads only). Follow-up:.runs/reviews.jsonlis ephemeral in CI, so the Anthropic (default) cache numbers aren't visible after a run — surface the token/cache totals into the CI job log or the run summary so CI caching can be confirmed the same way (Anthropic reports write + read). ✅ Shipped (2026-07-23, #6):formatUsageSummaryprints a one-line totals summary (input/output/reasoning + cache read/write + cost) via progress, so it lands in the CI job log and the local terminal.
- Caveat: the
- ✅ Retry in the same session, not a fresh one. (Already implemented.)
promptAndParsesends theCORRECTIVEnudge as a follow-up in the first attempt's session (the model still holds the file context — a cache read and a cheap re-emit), and only falls back to a fresh session as a last resort. The documented implementation trap is handled: it snapshots the message count (baseline = (await fetchMessages(...)).length) and waits for a new assistant message viapollForCompletion(..., { fromIndex: baseline }), so the already-completed first message isn't returned as stale text. Timeouts still stay fresh/abandon. (Historical note: this section predates that implementation; kept for the rationale.) - Interleave tasks chunk-major, not agent-major (contingent on #1). A cache
entry is readable only after the first response starts streaming, so N
parallel same-prefix requests all pay full write price. Tasks are currently
built agent-major, so the first concurrency-4 wave is often 4 identical-prefix
requests for one agent. Chunk-major order (agent 1 chunk 0, agent 2 chunk 0,
…) writes each agent's prefix once and lets its later chunks read it.
- May be worth ~nothing: most PRs fit in one chunk (
maxChangedLines: 1000), so there's one task per agent and ordering changes nothing; and the benefit assumes the agent-specific system prompt is inside OpenCode's first-2-system-messages breakpoints — if it lands third, the only reusable prefix is the base prompt + tools, identical across all agents regardless of order. Do this only if #1's measurements show agent-prefix reuse is real.
- May be worth ~nothing: most PRs fit in one chunk (
- Result-level caching across runs (biggest saver; the one with a real
quality cost). The 5-min server-side TTL means Anthropic's cache is cold by
the next push to a PR, but most chunks are usually unchanged. Cache reviewer
output keyed by
(agent id, model, prompt-version hash, chunk content hash)— hash the patch contents + file list, not paths (patch paths embed the timestampedrunId). On re-review, unchanged chunks reuse prior findings. Persist under.runs/locally; in CI needsactions/cacheor an artifact keyed by PR number. Two design problems to settle before building:- Chunk boundaries are unstable. Chunking is greedy by changed lines over the whole file list, so one modified file in a new push can reshuffle every downstream chunk → different hashes → cache miss on everything. Needs either per-file caching (murky finding attribution — findings are per-chunk) or deterministic chunk assignment that's stable under small edits.
- Findings depend on more than the chunk — this is the recall trade. Reviewers read the surrounding repo, and sibling changes can invalidate an "unchanged" chunk's conclusions (push 2 changes a signature in chunk B; chunk A's byte-identical caller reuses its cached "no findings"; the pass that would have caught the broken call never runs). Mitigations: always rerun the cross-cutting pass (the backstop — but it only exists on multi-chunk diffs and is currently the least reliable pass; see reliability); consider reusing only when the rest of the PR is also unchanged (rebase / comment-only push — safer, reuses less). Including base SHA in the key kills nearly all reuse; excluding it accepts staleness. Since the tool's value proposition is recall, decide how much staleness a re-review may tolerate before implementing.
Not worth pursuing: 1-hour TTL (hardcoded in OpenCode's transform, and cross-run prompt caching is low-value since diff content dominates); padding prompts for cacheability (tool schemas + base prompt + system already clear Sonnet 4.5's 1024-token minimum); router-call caching (one small call per run).
5. Improving the review process to catch bugs like #1 / #2 in the future
The Claude bot caught two things we missed. Why, and what to change:
#1 — critical CI supply-chain (issue_comment + secrets RCE). We missed it
partly because our only completed run predated that workflow (later runs stalled),
and partly because our security agent is primed for code-level vulns, not
GitHub-Actions threat modeling. Improvements:
- Add an explicit CI/workflow-security checklist to
security.md(or a dedicated agent): trigger fork-restriction semantics (pull_requestvsissue_commentvsworkflow_run),gh pr checkout+ secrets,permissions:scope, unpinned actions, install-time script execution,pull_request_target. - Treat any change under
.github/workflows/**as always-review (bypass chunking/routing so a workflow file is never the file that got skipped) and raise its severity floor. - Never trust PR-description claims of mitigation (already policy) — verify which workflow a "mitigated for forks" note actually applies to.
#2 — binary/NUL blind spot. This was a structural miss: git marked the file
binary, our parser handed agents an empty patch, so the reviewer literally could
not see the file with the bug. The bot found it by doing its own working-tree
archaeology (git show/od), not by trusting the unified diff. Improvements:
- Fixed the immediate bug (parser flags binary; noise filters it).
- Surface skipped/unreviewable files in the review output ("N files not reviewed: binary/generated/too-large") so a coverage gap is visible, never silent. A finding we can't make is still information.
- Give agents (or a pre-pass) the ability to read the working tree, not only the unified diff, for files git can't diff cleanly — the bot's edge was exactly this.
- ✅ Unit test suite (shipped).
bun test(yarn test:unit) — 36 tests / 8 files over the deterministic core: diff parsing incl. binary detection, noise filtering incl. header-only markers + glob (?escaping, no-NUL), JSONC config parsing, JSON extraction, fingerprinting, prompt sanitization, render + fingerprint round-trip + coverage-note gating, quote-grounding, and chunking/policy/concurrency (incl. a guard for the index-vs-element FP). Each test guards a real regression from this session. Excluded from tsc (bun runs them). Follow-up: wire into CI — a small dedicated workflow onpackages/expo-code-review-cli/**(needs bun), or the package's own CI on extraction; the monorepo's jest-basedlerna run testdoesn't pick it up (script istest:unit, and files are*.test.tsnot the repo's-test.ts).
Cross-cutting: self-review coverage. The reviewer should be reliably run against its own PRs at a size it can handle (the 49-file mega-PR defeated it — see reliability items). A completed, non-degraded self-review would likely have surfaced at least #2 once the parser/working-tree gaps above are closed.
Audit follow-ups (2026-07-22) — remaining items
Three parallel audits (reliability / UX / speed) ran on 2026-07-22. The failure-path cluster (never-approve-on-failure, coordinator fallback, always-post, cross-cutting collapse, retry-explosion removal, LPT scheduling, timeout alignment, concurrency + maxChangedLines bumps) shipped — see "Recently shipped" and §3. What remains, by tier:
Correctness bugs — ✅ shipped (2026-07-22)
- ✅ GitHub comment lookup now paginates all comments (manual paging; the
issue-comments endpoint ignores
sort/directionand returns oldest-first) and keeps the newest marked comment. Fixes duplicate reviewer comments and missed/skip-reviewon PRs with >100 comments.reporters/github.ts - ✅ Temp-dir leak in
withBodyFilefixed — try/finallyrmof themkdtempdir, matchingauth.ts.reporters/github.ts - ✅ Cost/token metrics now capture the in-progress assistant's
cost/tokenson timeout (threaded throughDeadlineReached→ the finalize result orAgentTimeoutError), so abandoned/finalized tasks contribute their spend.opencode.ts,review.ts
Speed — ✅ shipped (2026-07-22)
- ✅ CI fixed cost:
fetch-depth: 1+cache: yarnin both workflows. - ✅
POLL_INTERVAL_MS2s→1s. - Faster coordinator model — (left to config, not hardcoded): set
model:incoordinator.mdfrontmatter (it uses no repo tools). Not forced so repos keep one model unless they opt in. (Open: OpenCode event-stream instead of polling — larger change, deferred.)
UX — ✅ mostly shipped (2026-07-22)
- ✅ Report → stdout, progress → stderr (
ecr review > out.txtnow works). - ✅ Terminal: per-severity headers + counts + a one-line tally.
- ✅ Progress heartbeat during silent model "thinking" (poll loop emits "still working… Ns elapsed" after 45s idle).
- ✅
doctorchecksgh+gh auth status;ci --helpadded. - ✅ Clickable
file:line— shipped. Every finding location renders as a markdown link: in-diff (file+line in a hunk) → the PR "Files changed" diff anchor (#diff-<sha256(path)>R<line>); out-of-diff (unchanged code the PR references) → the source blob on the PR base commit (/blob/<baseSha>/<path>#L<line>, a stable permalink). The reporter fetches the diff-line index + base SHA (both fail soft to plain text). Remaining (minor): the out-of-diff base-blob link can 404 in two edge cases — a finding citing a line beyond a PR-added file's length, or a wrong/hallucinated path. To guarantee zero 404s, link out-of-diff only after confirming the file exists on the base (one extra lookup per out-of-diff file), else plain text. Low priority — verified findings rarely cite bogus paths, and a 404 is more visible than the dead diff anchor this replaced. - ✅ Auth-shaped (401/403) agent errors → one actionable message (shipped
2026-07-23, #5) via
isAuthError— collapses to a single coverage note. - Minor: README uses
ecrthough unpublished (note once that real invocation isyarn workspace expo-code-review dev …);initnext-steps vs scaffoldedauth.mode;warn when✅ shipped (#2 — now rejected with a clear error).--stagedis combined with--base/--head
Extraction — done ✅ (remaining cleanup)
The package has been extracted from expo/eas-cli into this standalone repo and
published as @expo/code-review-cli. eas-cli now carries only its
.expo-code-review/ config and the workflows, which run the reviewer via npx
(the in-repo yarn build-from-source workflows are gone). The monorepo's oxlint/
oxfmt/tsconfig exclusions for the package were removed on the way out.
Still open:
- Give this repo its own lint + format setup (oxlint/oxfmt or ESLint +
Prettier) and wire it into CI (
test.ymlcurrently runs typecheck + build +bun test). The standalone ESM/NodeNext choice is now just this repo's standard, not a divergence — no CommonJS refactor needed.
Model selection & fallback
Current: config.jsonc model is the default; per-agent and coordinator.md
frontmatter model: override it; REVIEWER_MODEL env is a global override.
Precedence: env > frontmatter > config default. In use (since 2026-07-26):
GPT-5.5 for the specialists + cross-file pass, GPT-5.4-mini for the coordinator.
(Previously "Claude via subscription OAuth" — which, it turned out, never
actually ran: OpenCode has no Anthropic OAuth support (Anthropic prohibits
subscription tokens in third-party tools), so it silently substituted its free
gateway model in CI. That incident drove the model-preflight, agentModels
run-log tracking, and the per-pass "Models used" reporting.)
Decision (2026-07-22): do NOT auto-map to a cross-provider "equivalent" (e.g.
silently swapping anthropic/claude-sonnet-5 for an OpenAI model when only OpenAI
is authed). Reasons: "equivalent" is subjective and drifts with every lineup
change (an ongoing, frequently-wrong mapping table); and silently running a review
on a different model than configured hides why findings changed — for a review
tool, a clear failure beats an invisible substitution. Explicit overrides
(REVIEWER_MODEL, frontmatter model:) are the portability primitive.
Wanted instead:
- ✅ Fail-fast provider-auth check (shipped 2026-07-23, #4.)
checkProviderAuth(shared bydoctor+prepareAuth) says up front when the configured provider has no credential ("configuredanthropic/…but token env X isn't set — setREVIEWER_MODELor authenticate") instead of surfacing as N failed passes mid-run. Removes most of the perceived need for a fallback. - Optional, opt-in
fallbackModelfor availability only: fires ONLY on the primary being unavailable / rate-limited / errored, and is surfaced in the review output ("primary X unavailable; this pass ran on fallback Y") — never silent. Would need to be tier-aware given the mixed-model setup (a single global fallback would flatten the specialist-vs-coordinator model distinction). - ✅ First-class multi-provider support — mixed credentials (shipped 2026-07-26).
authnow accepts a per-provider map (auth.providers) alongside the legacy single object: each entry is{mode, tokenEnv, upstream?}, normalized internally to a list.upstreamsynthesizes an alias provider in the OpenCode config (npmper upstream,options.apiKey: {env:tokenEnv}, models = the ids the roster references) so ONE upstream can be reached with TWO credentials at once — the motivating setup:openaion a ChatGPT/Codex subscription (mode: "oauth", tokenEnv = the refresh token; OpenCode's codex plugin mints/refreshes access tokens) for the default models, plusopenai-api(upstreamopenai) holding a metered API key for the pro tier the subscription excludes (gpt-5.5-pro). Security guard updated to SET semantics:ECR_EXPECTED_TOKEN_ENV/verify-config --expectedtake a comma-separated set that must equal the declared tokenEnvs exactly (root-locked, no duplicates, one root file), and the runtime lock inecr cicompares the same set. OpenAI's policy allows subscription auth in third-party tools (unlike Anthropic's). Verified locally 2026-07-26: subscription provider registers with the filtered model list,gpt-5.5answers at $0, theopenai-api/gpt-5.5-proalias answers on the key, and refresh-token REUSE across runs works — a static CI secret is viable. Open: alias models are config-declared so OpenCode reports their cost as $0 (token counts correct; the OpenAI project dashboard is the spend source of truth) — could be fixed by embedding models.dev pricing in the synthesized block; per-agent mixing beyond the alias mechanism (Google, OpenRouter, local).
Claude Code (claude -p) as an alternative review engine
OpenCode cannot use a Claude Pro/Max subscription (Anthropic prohibits
subscription OAuth tokens in third-party tools, and OpenCode ships no Anthropic
OAuth support), so today Claude is reachable only via a metered API key. But
Claude Code itself is first-party: claude -p (headless print mode, with
--output-format json, --allowedTools, and per-invocation system prompts) can
run the same specialist/coordinator passes under a developer's existing
subscription login.
- Consider a pluggable engine layer:
opencode(current) vsclaude -pper run, selected by config or auto-detected. - Use
claude -plocally by default (i.e. when not on CI): developers already have Claude Code installed and logged in, soecr reviewwould need zero extra credential setup and zero per-token cost — the subscription covers it, sanctioned. - CI keeps the current OpenCode + API-key path (deterministic, secret-scoped);
claude setup-token+ the officialclaude-code-actionis the sanctioned subscription route in CI if we ever want it, but it means adopting the Agent SDK rather than shelling out. - Open questions: mapping the agent model (restricted tools read/grep/glob/list,
JSON-only output, per-agent system prompts) onto
claude -pflags; parity for token/cache/cost reporting (agentModels, run logs); stall/timeout handling outside OpenCode's poll loop; and whether the coordinator/verifier stay on one engine while specialists use another.
Full-repository review (audit mode)
Today the reviewer only reviews a diff (a PR or a working-tree range). Add a mode that reviews an entire repository, not just changed lines — for onboarding an existing codebase, a one-time security/quality audit, or establishing a baseline before turning on PR review. Design sketch:
- New entry point (e.g.
ecr review --all/ecr audit): enumerate the repo's source files (respecting the noise filter + an explicit scope of paths/globs and a size ceiling) instead of taking a diff. - Reuse the existing chunk → agents → cross-cutting → coordinate → verify pipeline, but chunk the whole tree by directory/package proximity rather than by changed lines. This is large, so it leans hard on the reliability machinery (subdivide, budgets) and almost certainly wants incremental/resumable state (§ merge-boundary #1) so an audit can run in bounded passes and resume.
- Output is a standalone report grouped by severity/area (written file + terminal
summary,
--jsonfor tooling) — not a PR comment. - Cost/time will be large on a real repo: make scope explicit, cap it, and report what was and wasn't covered (never silently partial).
Review trustworthiness — false positives + finding stability
Two related problems observed on #4022: a hallucinated critical (claimed
mapWithConcurrency did const item = next++ and was "uncompilable"; the code is
items[next++]! and builds fine), and a hamster wheel where each run surfaces
"new" issues that existed before. Both erode trust; both need addressing before this
is dependable.
A. Validate findings before surfacing (especially criticals)
Root cause: the reviewer runs one pass per agent with no verification, so an LLM's plausible-but-wrong claim ships as-is. Fixes, cheapest/highest-leverage first:
- ✅ Quote-grounding (deterministic) — shipped, then softened. Findings carry an
evidencefield (the flagged code, verbatim);verifyFindingsgrades it against the real file. Originally, evidence absent from the file was a hard drop. Measured on ~13 real PRs that proved too aggressive: it dropped 3 findings vs. 1 surfaced, and ≥1 dropped finding was a confirmed real bug (#4057's "--account silently ignored"). Root cause: exact-substring is a good POSITIVE signal but a poor NEGATIVE one — it misfires on structural/"missing" bugs (no single line is the bug), cross-line quotes, ellipsis, copied comment/diff markers, and slightly wrong locations. Fix (shipped 2026-07-23):- Fuzzy match (
matchEvidence/evidenceFragments): exact substring OR any substantive line/fragment present verbatim, after stripping comment/diff markers and splitting on newlines + ellipsis. Rescues the mechanical misquotes. - Escalate, don't drop: absent evidence (any severity) now routes to the LLM
verifier (which re-reads the real file + nearby files) instead of being dropped;
a finding is dropped ONLY on refutation.
present/unknownnon-criticals keep the cheap no-LLM fast path. - Verifier judges substance, not wording: the verifier prompt now treats the quote as an imperfect hint and rejects only if the underlying problem isn't real; the reviewer prompt asks for one contiguous verbatim line (no ellipsis/paraphrase).
- Fuzzy match (
- ✅ Adversarial verify pass — shipped (now all-severity on demand). A restricted
verifieragent (read+grep) re-reads the cited file and must confirm the finding is genuine (biased to reject). Runs for every critical, and for any finding whose evidence didn't ground (see #1). Parallel, 3-min cap, fails open (keeps the finding if verification itself errors). Decision is re-derived after drops (no criticals left → softenrequest_changes). - ✅
--prverification fidelity — read the PR-HEAD tree, not the checkout. (Shipped 2026-07-23; found re-testing #4057.) On a--prrun the diff is authoritative fromgh, but the agents' and the verifier's surrounding-source reads came from whatever was checked out. Re-reviewing #4057 (the PR that adds--account) from amaincheckout, the verifier readmain'sinit.ts— no--account— and false-refuted the real "--account ignored when already linked" finding — the mirror image of the quote-grounding over-drop (#1). Fix: a source can nowprepareReadRootAsync();GitHubPRSourcechecks the PR head (refs/pull/<n>/head, fetched from the repo URL so forks work) out into a throwaway git worktree, andrunReviewchdirs into it for the agents + verifier (config is already in memory; run-log/patch paths are absolute; fails soft to the current directory). This also fixes CI's/reviewcommand workflow, which checks out the base ref — it was reviewing base, not the PR. (The config-not-on-an -old-PR-branch problem is sidestepped: config loads from the checkout, source reads from the head worktree — two trees, resolved independently.) - Oracle checks. A finding that asserts "won't compile / type error" can be
validated against
tsc; "crashes"/"breaks tests" against the test suite. At minimum, never surface a compile-error claim when the package compiles. - Severity-gated effort: criticals get the most scrutiny — they carry the most weight and are the most damaging when wrong.
B. Finding stability (stop the hamster wheel)
Why "new" issues appear every run, roughly in order of impact:
- Partial coverage from timeouts — the dominant cause. When passes time out, each run reviews a different subset, so pre-existing issues surface only when a completing pass happens to reach that file. Every recent comment carries a "coverage note." Full coverage per run (the chunk-1000 + cap work, and the size guard for the extreme tail) is the #1 fix — with complete coverage the finding set converges.
- Findings not fixed when first raised re-surface every subsequent run. Fix (or
explicitly
expo-code-review-ignore) them when raised. - LLM nondeterminism — some run-to-run variance is inherent (temp already 0.1); the high-signal findings recur, noise doesn't.
- Moving-target self-PR — #4022 is the reviewer reviewing its own constantly growing PR, so some "new" issues are genuinely new code. A normal, stable PR with full coverage would not churn like this.
- Full-diff-every-run re-evaluates the whole backlog each push; fingerprint dedup + the single updated comment already mitigate, but a future option is reviewing the incremental delta and/or persisting resolved-finding state.
Bottom line: most of the churn is an artifact of (1) partial coverage + (4) a self-referential moving target, plus FPs from missing validation (§A). Closing coverage and adding quote-grounded verification should make runs boringly stable.
Telling the reviewer "I don't care about this" (suppression)
Design from a focused investigation. Principle for everything below: suppression is
a display filter, never a review skip; dismissed items collapse into an auditable,
reversible section rather than vanishing; and a critical/secrets finding is never
silently erased (it escalates to a "needs human sign-off" note).
Two latent bugs found while designing this:
- Fingerprint is unstable.
fingerprintFinding(schema.ts) keys on the LLM-writtentitle, which varies run-to-run at temp 0.1 — so any persistent dismissal keyed on it would silently lapse and the finding re-surfaces. Must fix before building dismissal. - The inline
expo-code-review-ignoredirective is prompt-only — grep finds zero code references; suppression today depends entirely on the model choosing to obey. Needs a deterministic backstop. - Also:
parseEmbeddedFingerprints(render.ts) has no callers — the embeddedfingerprints=[…]comment block is a built-but-unused substrate, ideal for storing dismissals.
Build order (✅ = shipped):
- ✅ Fingerprint v2. Re-keyed on the verbatim
evidencesnippet (sha1("v2"|file|category|normalizeCode(evidence))), falling back to title only when evidence is too short. Each finding now shows a short`id:…`in the comment. Dismissals lapse when the code changes — correct. - ✅ Per-PR
/dismiss <id>(+/undismiss). Maintainer-gatedissue_commentworkflow (expo-code-review-dismiss.yml, base-ref-only, no model secret) →ecr dismiss/undismiss. Dismissals stored as embedded state in the bot's own comment (read only from there); the reporter re-renders dismissed findings into a collapsed<details>section and carries them forward across re-reviews. The comment embeds the full review state (base64) so/dismissre-renders without re-running. - Repo config
policy.suppress— (skipped for now, deliberately.) Persistent cross-PR class-level opt-outs (byCategory / byPathGlob / byTitlePattern / severityFloor). Revisit if per-repo class suppression is wanted. - ✅ Inline-directive backstop.
expo-code-review-ignoreon/above a line now deterministically drops that finding (was prompt-only), with a critical/secrets carve-out (never suppressed this way).
Precedence (only /skip-review truly skips): /skip-review > inline directive >
(config policy.suppress, when built) > per-PR /dismiss. All but /skip-review are
display filters; agents always analyze everything.
Not fed to agents (by design): prior comments/findings are NOT threaded back into the review agents — each run is stateless (diff + repo + PR title/body only). Suppression/dedup is a post-agent display concern (fingerprints), so agents can't be anchored by, or biased into hiding via, a previous run or a dismissal. Cross-run context feedback would be a deliberate future feature with real anchoring/echo/ security tradeoffs.
Fits §1 (inline comments): dismissal is the first real consumer of the fingerprint
substrate; after §1, a 👎/reply on an inline thread maps to the same dismissed[]
store (thread↔fp gives the identity a single comment can't), so this design is forward
work, not throwaway.
Per-PR review guidelines (trusted-author-gated) — future
Let a PR give the reviewer extra per-PR direction — e.g. a ## Review guidelines
section in the description with bullets like "ignore the .md files" or "focus on
the API changes". Genuinely useful, but the PR description is the untrusted
channel the injection defenses (shared.md) exist to neutralize, so this needs to
be built as a trusted, bounded mechanism, never a free-text instruction pipe.
Design constraints:
- Trusted-author gate. Only honor the block when the PR author is
OWNER|MEMBER|COLLABORATOR— readgithub.event.pull_request.author_association(zero API calls; already the pattern inexpo-code-review-dismiss.yml) or the collaborator-permission REST API for an authoritative check.CONTRIBUTOR/NONEare not trusted (CONTRIBUTOR just means a prior merged PR, not push access). For untrusted authors, ignore the block and note that it was skipped. - Allowlisted, structured directives — not prose spliced into the agent prompt.
Parse bullets into known actions applied deterministically in code: "ignore
**/*.md" → anadditionalIgnoresentry in the noise filter (not "please ignore" sent to the LLM). A focus hint may be passed as clearly-fenced context, but the parser decides what is a directive, so arbitrary text can't become an instruction. - Same carve-outs as suppression. Guidelines may narrow scope or add focus; they
may never suppress
critical/secretsfindings or force a decision. - Repo-wide guidance belongs in trusted config, not the description: a
guidelinesfield inconfig.jsonc(or aguidelines.md) is maintainer-owned and side-steps the trust problem entirely. Do this first; the PR-description path is only for per-PR direction and layers the author gate on top. - Pairs with the base-ref checkout (§ merge-boundary #4): once config loads from the trusted base ref, repo-level guidelines are unambiguously trusted, and the author gate is the only extra check the per-PR path needs.
Noise filter: derive from the repo's lint/format ignores (opt-in) — future
Today noise filtering is content-based (generation markers) + a few path heuristics
(lockfiles, .min.js/.map, snapshots, binary) + explicit noise.additionalIgnores.
A repo's lint/format ignore files (.eslintignore, oxlint ignorePatterns,
.prettierignore) already encode "not hand-maintained / not worth linting", which
overlaps with review-noise — worth folding in. Constraints, because this is more
aggressive than suppression (a filtered file is dropped before ANY agent sees it,
with no critical/secrets carve-out):
- Opt-in, not default. Lint-scope ≠ review-scope: a repo may not lint vendored/ legacy code that should still be security-reviewed. Blanket import could silently drop security coverage.
- Lint/format ignores only — never
.gitignore(that's about what's tracked, not noise; diff files are tracked, and its negation/nesting semantics are fiddly).tsconfigexcludeis a weak signal at best. - Read from the trusted base ref, not the PR. In the auto-workflow, ignore files
come from the PR merge ref — a PR could add its own file to
.eslintignoreto dodge review. Same trust boundary as § merge-boundary #4. - Stay transparent — keep recording filtered files (already done), ideally noting which ignore file matched.