feat: two-pass Claude review (find, then filter) to cut false positives - behind feature flag - #110
Conversation
The reviewer's single pass has to both find issues and judge which ones are worth reporting at once, which produces noisier reviews under models that follow "report everything, be thorough" instructions more literally. Splits the review job into two claude-code-action steps: a read-only "find" pass with no GitHub-posting tools in its allowlist (so it cannot comment on the PR regardless of prompt behavior) that lists every candidate issue without filtering, then the existing "filter" pass verifies each claim against the diff/codebase and posts only what holds up. Findings pass between the two via a file in the runner's temp dir, so no cleanup step is needed and nothing shows up in git status. No change needed for repos that call claude-review.yml@main - this is entirely internal to the reusable workflow.
- Grant pass 1 --add-dir "${{ runner.temp }}" so it can actually reach the
findings file: Claude Code's file-tool sandbox is scoped to the working
directory independent of --allowedTools, and $RUNNER_TEMP sits outside it.
Without this, pass 1's Write would be refused and the flow would silently
fall back to single-pass with nothing in the logs saying so.
- Make pass 1 best-effort: continue-on-error + a 20-minute step cap, so a
slow or failed finder degrades to today's single-pass review instead of
taking pass 2 (the only step that can post) down with it.
- Log when no pass-1 findings file is found, instead of silently proceeding.
- Treat pass 1's literal "NONE" output as no findings, not as a
findings section to prepend.
- Fix a test cleanup bug: assigning `undefined` to process.env.RUNNER_TEMP
coerces to the string "undefined" instead of unsetting it.
- Reword "read-only" to the claim that's actually true and enforced: pass 1
cannot post to GitHub (no allowlisted tool reaches it), not that it can't
write anywhere - Write is granted, scoped by instruction to the findings
file only.
|
Detected new or modified files in |
So future Claude Code sessions run by Mounir automatically add him as Co-authored-by on commits in this repo, without misattributing other engineers' sessions. Co-authored-by: MounirDhahri <soussouheros@gmail.com>
This reverts commit f524293.
Flips the default back to today's single-pass review (legacy), and moves
the two-pass find+filter flow behind a new opt-in workflow input,
compare-two-pass-review (default false), set the same way callers already
set model/timeout-minutes.
When the flag is off (default): behavior is unchanged from production -
one review comment, one model call.
When the flag is on: the legacy review still runs and posts as normal,
and the find+filter pass also runs, posting a second, separately-labeled
comment ("Single-pass review (baseline)" vs "Two-pass review
(candidate)") on the same PR - so the two approaches can be compared
side by side before deciding whether to make two-pass the default.
Roughly 3x model calls while enabled.
Added scripts/build-review-prompt.ts --mode=legacy, which builds the
plain review prompt and never reads a pass-1 findings file - so the
baseline stays uncontaminated by the comparison run even when both
execute in the same job.
| await cleanupPreviousAIReviews({ github, context, core }); | ||
|
|
||
| - name: Run Claude Code Review | ||
| - name: Run Claude Code Review (legacy - single-pass) |
There was a problem hiding this comment.
The legacy step has no timeout-minutes, so it can eat most of the job's 45-minute cap before pass 1 and pass 2 even start.
All three passes run sequentially in this one job (timeout-minutes: ${{ inputs.timeout-minutes }}, line 164). Pass 1 is capped at 20 minutes. If legacy takes 20 and pass 1 takes its full 20, pass 2 has 5 minutes and gets killed by the job cap. Unlike pass 1, pass 2 has no continue-on-error, so the job goes red and the
docs/actions.md:214 says the flag "roughly triples ... wall-clock time", but the default only went from 30 to 45 (1.5x). Either cap the legacy step (timeout-minutes: 15) so the budget is bounded, or raise the default further when the comparison is on.
| uses: anthropics/claude-code-action@v1 | ||
| with: | ||
| anthropic_api_key: ${{ secrets.anthropic-api-key }} | ||
| claude_args: '--model ${{ inputs.model }} --add-dir "${{ runner.temp }}" --allowedTools "Read,Glob,Grep,Bash(git diff:*),Bash(git log:*),Bash(git show:*),Write"' |
There was a problem hiding this comment.
Write is unscoped, so the only thing keeping pass 1 out of the checkout is prompt text (FINDER_PROMPT: "Do not modify any files").
One concrete path if it ignores that: pass 1 writes .claude-review.yml, then the next step (Build review prompt (pass 2 - filter), line 288) calls loadRepoConfig(), which reads path.join(process.cwd(), ".claude-review.yml") — and buildPrompt returns repoConfig.prompt verbatim when a full prompt: key is set (build-review-prompt.ts:223). Pass 2's entire review prompt gets replaced.
Low likelihood, cheap to close: either scope the write to the temp dir in --allowedTools, or add a step between the two passes that fails on git status --porcelain output.
| const main = (): void => { | ||
| const isFinderMode = process.argv.includes("--mode=finder") | ||
| const isLegacyMode = process.argv.includes("--mode=legacy") | ||
|
|
There was a problem hiding this comment.
A typo'd flag silently degrades instead of failing. --mode=finderr matches neither branch, falls through to the pass-2 path, finds no findings file, logs a warning, and emits an ordinary single-pass prompt. The workflow step still succeeds, so the only signal is a console.warn buried in the step log — which is the same warning you get from a legitimately-failed pass 1.
main is also not exported, so none of the 150 new test lines cover the mode dispatch or the findings-file read; they all test the pure builders. Extracting resolveMode(argv) (throwing on an unrecognized --mode= value) would make both problems go away at once.
SummaryAdds an opt-in The default-off gating is done well. Issues Found🟡 The 45-minute job cap does not fit three sequential passes — All passes share one job. Pass 1 is capped at 20 minutes; the legacy step is uncapped. Legacy at 20 + pass 1 at 20 leaves pass 2 five minutes, and pass 2 has no 🟢 Pass 1's Only prompt text keeps pass 1 out of the checkout. If it strays, one path is real: a write to 🟢 A typo'd
🟢 Areas ReviewedArchitecture & Design. Reusing one script with a mode flag over a second script is the right call. Security. Pass 1's findings are model-generated text that flows into Testing. 149 new lines, and they cover the parts worth covering: the empty / whitespace / Bugs & Edge Cases. Questions for AuthorThe |
|
Merging this since it's gated behind a feature flag to try out for a week |
Trial: point run-claude-review.yml at the two-pass-review branch of duchamp and turn on compare-two-pass-review, so PRs here get both the standard single-pass review and a second, separately-labeled two-pass (find + filter) review for side-by-side comparison. Temporary - once artsy/duchamp#110 merges, revert the `uses:` ref to @main. Co-authored-by: Mounir Dhahri <noreply@anthropic.com>
Summary
Since moving to Opus 5 (#108), reviews have gotten chattier — more findings per PR, including more that don't hold up under scrutiny. This splits the single review pass into two:
This change is behind a feature flag active only for repos who opt-in, currently, that's Volt and Eigen
Output when I asked claude to review this compared to multiple repos
Test plan
yarn test scripts/build-review-prompt.test.ts— 24 tests, including new coverage forbuildFinderPrompt,pass1FindingsPath, andbuildPrompt's pass-1-findings prepending (with/without config, with/without a customprompt:override)yarn test(full suite) — 143 tests passclaude-code-actionlocally)Generated by Claude Code