Independent LLM PR review¶
The standing assignment for this repo: Claude plans → DeepSeek codes → Claude reviews. The review runs by itself when the test suite passes. Everything else is a comment on an issue or a pull request — no tools to install, same from a laptop or the GitHub mobile app.
The commands¶
Two you'll use constantly, two you'll rarely need.
| Type this | Where | What happens |
|---|---|---|
/oc <instruction> |
On an issue | DeepSeek writes the code and opens a PR |
/oc <instruction> |
On a PR | DeepSeek makes the change and commits to that branch |
/review |
On a PR | Re-runs Claude's review (it already ran automatically) |
/review-deepseek |
On a PR | DeepSeek reviews — required for PRs Claude wrote |
You don't normally type either review command. Claude reviews every PR
automatically once pytest succeeds. /review forces a re-run;
/review-deepseek covers the one case that can't be automatic.
Rules that apply to all four:
- It must be the first thing in the comment. "Can you
/ocfix this?" does nothing. That's deliberate: it means quoting an earlier comment doesn't set an agent off again. - Everything after the phrase is the instruction, in plain English.
- Only someone with write access to the repo can trigger them.
Examples worth copying¶
On an issue, once the plan is agreed:
On a PR, after a review:
/oc the review flagged an unscoped query at apps/documents/views.py:142 —
fix it and add the cross-household test
You can also comment on a specific line in the PR's Files tab, and opencode picks up the file and line automatically:
Forcing a review (rarely needed — Claude's runs automatically):
Neither takes an instruction; the brief is already written
(.github/prompts/review.md).
From a terminal¶
Identical, just posted a different way:
gh issue comment 239 --repo ollyoxford/maud --body "/oc implement the plan in this issue"
gh pr comment 240 --repo ollyoxford/maud --body "/review"
From the GitHub mobile app¶
- Open the repo → Issues (or Pull requests)
- Tap the issue or PR
- Scroll to the bottom, past any existing comments
- Type into the comment box and send
It's the ordinary comment box — the same one you'd use to write a note to
yourself. There is no special agent UI, no button, no menu. If a
suggestion menu pops up when you type /, that's GitHub's own slash
commands: ignore it and keep typing.
A worked example, end to end¶
- Plan. Open a Claude session, agree what you're building, and have it write the issue: the plan plus acceptance criteria. Say it's #251.
- Code. Comment on #251:
/oc implement the plan in this issue - Wait a few minutes. A PR appears — say #252 — with the code.
- Wait for CI.
pytestanddocs-driftrun on it. - The review runs itself the moment
pytestgoes green — nothing to type. - Claude posts a findings table and adds
review:passorreview:changes-requested. - Fix, if needed. Comment on #252:
/oc fix the Blocker in the review — the missing require_owner() on the delete view - The new commit clears the verdict label, tests re-run, and the review
re-runs behind them.
/reviewforces one by hand if you need it. - Merge, once CI is green and the label says
review:pass. That step is yours and stays yours.
Why it exists¶
Claude plans most PRs here. A model reviewing its own diff shares the blind spot that produced the bug, so the reviewer is deliberately a different model family with no shared context.
pytest and docs-drift already catch regressions and drift. What they
can't catch is judgement: a misread plan, a query that skipped the scoped
manager, a memory rule quietly broken. That's the gap this fills — and
every merge to main auto-deploys within ~2 minutes, so there's no
staging between a merged PR and real users' documents.
How it actually works¶
Three ideas, if you haven't worked with GitHub Actions before.
A workflow is a recipe file in .github/workflows/. GitHub reads it,
and when something happens in the repo it rents a fresh computer for a few
minutes and runs the recipe there. Nothing runs on your machine.
An event is the "something happens" — a comment posted, a commit pushed, a label added. Each recipe declares which events it cares about. Ours listen for comments — and, for the review, for another workflow finishing.
A token is a password for robots. A recipe usually needs to act back
on GitHub — post a comment, add a label, push code — so GitHub mints it a
temporary one, GITHUB_TOKEN, created fresh per run and destroyed after.
So /oc isn't a command in any special sense. It's an ordinary comment
that a recipe happens to be watching for. That's why it works from a phone
with nothing installed: you aren't running anything, you're leaving a
note, and something on GitHub's servers is watching for notes shaped like
that. It's also why the comment stays in the thread forever — a permanent
record of exactly what you asked for.
When the review runs¶
Automatically, when the Tests workflow succeeds on a PR. Nothing to type.
Reviewing is the one stage in the loop with no decision in it — you always want the review — so a manual trigger there was ceremony. Worse than ceremony: a step you always say yes to is one you eventually forget, and the failure mode is merging unreviewed while believing you have a review process. A gate that depends on remembering isn't a gate.
Hanging it off Tests-succeeded also gets the ordering right for free. The
reviewer only ever sees code the suite has already passed, so its
attention goes on judgement rather than on what pytest already proves.
The stages that do hold a decision stay yours: whether the plan is right before code gets written, which findings to accept, and whether to merge. Three touch points instead of four, and the one that went is the one you'd never have said no at.
- Draft PRs are skipped — that's how you say "not yet, still pushing", without needing a command to withhold.
- Every green test run re-reviews. The verdict label is cleared on each push anyway, so anything else would leave a stale pass standing. The concurrency group cancels a review a newer push has superseded.
The two modes¶
The rule underneath everything: the model that wrote it never reviews it.
| Plan | Code | Review | |
|---|---|---|---|
| Standing assignment | Claude | /oc (DeepSeek) |
automatic (Claude) |
| When Claude wrote the code | Claude | Claude | /review-deepseek |
Claude both plans and reviews by default. That's fine — reviewing someone else's implementation of your own spec is not self-review, and the spec's author is well placed to spot a misreading. The gap it leaves is an ambiguous plan: Claude won't flag its own under-specification, so if the code is odd in a way that traces back to the brief, that's a plan bug.
The reviewer is chosen from the branch name rather than from your memory
of who wrote what. A claude/* branch never auto-reviews, and
/review refuses on it by hand too, pointing you at /review-deepseek.
Those PRs sit at llm-review pending with "comment /review-deepseek"
written into the status, so the one case that still needs you can't go
unnoticed. The guard is only
as honest as the branch name, so any branch Claude commits to is named
claude/<slug> — Claude Code on the web does that automatically; from
the CLI you name it yourself, which is where it's easy to get wrong.
Both reviewers read the same brief, deliberately. If they read different briefs you couldn't tell a model difference from a prompt difference.
Repeat reviews¶
A PR gets reviewed again on every green test run, so a long-lived PR accumulates reviews. The brief tells each reviewer to read the ones before it and to report only what is new or genuinely still open — a finding an independent reviewer has explicitly discharged is closed, and re-raising it is noise rather than a second opinion.
This was learned the hard way on #247, which collected four reviews carrying about one and a half reviews' worth of distinct content. Each pass re-raised findings the previous one had resolved, which is the same "manufactured findings train the reader to skim" failure the brief warns about, reached from the other direction.
The brief also tells a reviewer that finds its own model family in the commit trailers to say so and stop rather than post a findings table anyway. A thorough self-review is still a self-review, and a page of authoritative-looking analysis buries the one line that matters: this needs the other reviewer.
Trigger phrases must not overlap¶
/review-deepseek also starts with /review, so the Claude workflow
carries an explicit negation. Without it, one comment fires both
reviewers: double cost, and they race each other for the verdict label.
Remember that before adding a third phrase.
How it's wired¶
| File | Role |
|---|---|
.github/workflows/review-claude.yml |
/review — Claude reviews. Default. Read-only. |
.github/workflows/review-deepseek.yml |
/review-deepseek — for PRs Claude wrote. Read-only. |
.github/workflows/agent-deepseek.yml |
/oc — DeepSeek writes code. The only one with write access. |
.github/workflows/review-gate.yml |
Publishes the llm-review commit status |
.github/prompts/review.md |
The review brief, shared by both reviewers |
CLAUDE.md (via the AGENTS.md symlink) |
Invariants, memory rules, review checklist |
The reviewer workflows concatenate AGENTS.md and the brief into one
prompt. The rulebook is inlined rather than auto-discovered —
discovery is convention, not contract, and a reviewer that silently
doesn't know the house rules still produces confident-looking output.
That's also why the brief doesn't restate the invariants: CLAUDE.md
stays the single place they live.
Design decisions worth not undoing¶
- Reviewers are read-only.
contents: read, so they comment and label but physically cannot push. If a reviewer disagrees with you, it has to argue. - Every workflow is gated on
author_association. opencode has no built-in permission check. - Reviewer checkouts land on
main, not the PR head. Anissue_commentevent carries no PR ref, and this is what we want: a PR can't edit the criteria it's judged by. startsWith, notcontains. Quoting a previous review comment would otherwise re-trigger the job.
The coding agent¶
/oc <instruction> on an issue opens a PR; on a PR it commits to that
branch.
It authenticates through the opencode GitHub App rather than
GITHUB_TOKEN, and that is not incidental — see the first gotcha below.
The merge gate¶
review-gate.yml publishes an llm-review commit status: success on
review:pass, failure on review:changes-requested, pending otherwise.
On a new push it clears both verdict labels and resets to pending — that
half is the point, because otherwise a stale pass keeps the status green
over commits the reviewer never saw.
It is advisory until llm-review is added to branch protection as a
required check. Do that only once the reviewer has earned it. A gate you
don't trust gets bypassed, and a bypassed gate is worse than none because
it looks like coverage.
Rate each review afterwards¶
On merge, add one of review:caught-real-bug or review:noise. It takes
a second and it's the only way to find out whether the review is earning
its place — a review nobody trusts is worse than no review, because it
launders the diff.
Gotchas that will cost you an afternoon¶
Two of these (1, 2) are the same underlying fact; three more (3, 4, 6) are all "don't let strangers spend your money or read your keys"; the rest is housekeeping.
1. Anything done with GITHUB_TOKEN doesn't count as an event.
GitHub's rule, to stop infinite loops — otherwise a workflow that pushes a commit would start a workflow that pushes a commit, forever.
The trap: if the coding agent pushed using that token, GitHub would treat
the push as a non-event, so pytest and docs-drift would never run on
it. And the PR wouldn't look broken — it would look clean, because no
checks are pending at all. Absence of a red X reads as "passed". Hence the
opencode GitHub App: a separate identity, so its pushes are real events
and CI fires. Don't "simplify" that back to use_github_token: true.
2. That same rule is why triggers are comments, not labels.
Tapping a label is a nicer phone interaction, so the original design was
label → a workflow posts /review → the review workflow sees it. But the
middle step would post using GITHUB_TOKEN, so the reviewer would never
wake up. Comments trigger things; labels record state.
3. author_association is the thing keeping strangers out.
With every comment GitHub reports the commenter's relationship to the repo
— OWNER, MEMBER, COLLABORATOR, CONTRIBUTOR or NONE. Our
workflows require one of the first three. Without that check, anyone who
can comment could run an agent with write access, on our money.
4. Never use pull_request_target.
There are two ways to trigger on a PR. The safe one withholds secrets when
the PR comes from a stranger's fork. pull_request_target hands over full
secrets — and people reach for it precisely because they want the
secrets, then check out the stranger's code and run it with access to
their keys. It's the classic way repos get their secrets stolen. The
comment trigger gives us secrets safely instead.
5. share: false matters.
opencode can publish a transcript of the agent's session to a public URL, and it defaults to on for public repos. That transcript contains chunks of the codebase.
6. Fork PRs get no secrets.
If someone forks the repo and opens a PR, GitHub deliberately hides our secrets from workflows running on it — so none of this fires on outside contributions. Fine while this is a solo repo; a real design question the day it isn't.
7. Model IDs move.
Providers retire names. The deepseek-chat and deepseek-reasoner
aliases retired 2026-07-24. A workflow failing with "unknown model" is
almost always this; check models.dev for the current ID.
8. claude-code-action needs id-token: write even though it never pushes.
It authenticates as the Claude GitHub App by exchanging a GitHub OIDC
token, and minting that token needs the scope. Read-only-on-code and OIDC
are separate axes, so "this job only comments, it needs no write
permissions" is the wrong inference — and it fails at the very last step,
after checkout and prompt-building have all succeeded, with Could not
fetch an OIDC token repeated three times.
9. The spend risk is loops, not tokens.
A review is roughly 4p. What actually costs money is two agents triggering
each other, or one hanging for hours. timeout-minutes kills a job after
20–30 minutes, and the per-PR concurrency group means a second run
cancels the first rather than stacking.
Configuration¶
DEEPSEEK_API_KEYandCLAUDE_CODE_OAUTH_TOKEN— repository secrets (Settings → Secrets and variables → Actions). Not the local.env: a workflow runs in a fresh container that never sees it.- The opencode GitHub App must be installed on the repository.
- Both reviewer models are pinned. Leave them pinned — if the model can drift, a change in findings can't be attributed to the model or to the brief.
If nothing happens¶
In rough order of likelihood:
- The phrase wasn't the first thing in the comment.
- The workflow isn't merged to
mainyet. Comment-triggered workflows always run the version on the default branch — so a new one added in a PR does nothing until that PR merges, and testing it on its own PR is impossible. (pull_request-triggered workflows are the opposite: they run the PR branch's version, soreview-gate.ymldoes take effect immediately. Easy to trip over, because two workflows in the same folder behave differently.) - The opencode GitHub App isn't installed.
- A secret is missing or the DeepSeek balance is zero (a key with no credit looks valid and fails every call).
- The model ID retired — see gotcha 7.
The Actions tab shows every run and its logs, and is the first place to look.
Background¶
The full design and its reasoning: docs/multi-llm-pr-review-plan.md
(maud-specific) and docs/two-model-review-playbook.md (repo-agnostic).