Claude Code hooks for code review
A Stop hook that keeps Claude Code working while CodePanel's merge gate has not passed on the pull request's head commit, tested on each path it can take.
A review loop stalls when Claude ends its turn while the review is still running. Nothing brings the session back, so the findings wait for a person. A Claude Code Stop hook catches that moment. Before Claude stops, it reads the CodePanel commit status on the current commit, and if the gate has not passed it sends Claude back once with the next step.
The hook
#!/usr/bin/env bash
# Keep Claude working while CodePanel's gate has not passed on this branch's PR.
# Use it only where the gate is on: with no CodePanel status, every stop blocks once.
input=$(cat)
# Already continuing because of a Stop hook: let the turn end, so it never blocks twice in a row.
[ "$(jq -r '.stop_hook_active // false' <<<"$input")" = "true" ] && exit 0
[ "$(gh pr view --json state --jq .state 2>/dev/null)" = "OPEN" ] || exit 0
sha=$(git rev-parse HEAD)
# If GitHub cannot be read, let the turn end rather than send Claude after a guess.
status=$(gh api "repos/{owner}/{repo}/commits/$sha/status?per_page=100" \
--jq '([.statuses[] | select(.context == "CodePanel")][0] // empty) | "\(.state): \(.description)"') || exit 0
case "$status" in
success:*) exit 0 ;;
"") why="CodePanel has not judged $sha. Push it if needed, then call wait_for_review with headSha $sha and timeoutSeconds 45." ;;
pending:*) why="CodePanel on $sha is $status. Call wait_for_review with headSha $sha and timeoutSeconds 45, then act on payload.nextAction." ;;
*) why="CodePanel on $sha is $status. Call get_review and act on payload.nextAction." ;;
esac
jq -n --arg r "$why" '{decision: "block", reason: $r}'
Register it, then run chmod +x on the script. It needs gh and jq.
{
"hooks": {
"Stop": [{ "hooks": [{ "type": "command", "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/codepanel-gate.sh" }] }]
}
}
What it does in each case
Each row was run against CodePanel's own pull requests in October 2026.
Three rows used a stub gh. The failure row ran on a real failing commit with gh pr view stubbed to answer OPEN, since its branch had another name here. The API-failure row stubbed gh api to return a 502. The pending row's message was added after the run on a real pending commit, and checked with gh stubbed to return a pending status:
| Case | Result |
|---|---|
Claude is already continuing because of this hook (stop_hook_active is true) |
Exits 0: the turn ends |
| The branch has no open pull request | Exits 0 |
The local branch has a different name from the pull request's head branch, even one gh pr checkout -b set to track it |
Exits 0: gh pr view finds no pull request |
| GitHub cannot be read (the API call fails) | Exits 0 |
| The commit was not pushed, or CodePanel has posted no status on it. That is every commit on a repository whose gate is off or that CodePanel does not review, so install the hook only where the gate is on | Blocks: "CodePanel has not judged <sha>. Push it if needed, then call wait_for_review with headSha <sha> and timeoutSeconds 45." |
The status is pending |
Blocks with the status and its description, then "Call wait_for_review with headSha <sha> and timeoutSeconds 45, then act on payload.nextAction." get_review alone could still return the previous commit's review |
The status is failure |
Blocks with the status and its description, then "Call get_review and act on payload.nextAction." |
The status is success |
Exits 0 |
Why it blocks only once
The hook steps aside when stop_hook_active is true, so a second stop in a row always ends the turn. The block is a prompt to wait for the review, not a guarantee that it arrives. If CodePanel is down, or a spending limit skipped the commit, a hook that kept blocking would hold Claude in a loop it cannot finish. A failed API call ends the turn for the same reason: Claude should not act on a status nobody read.
The status is read from GitHub rather than from CodePanel's API because it is the same check the branch ruleset requires (Merge gate). When it lets Claude stop on success, the CodePanel check the ruleset requires has passed too. The combined status lists the latest status for each context, 30 contexts per page by default, so per_page=100 keeps CodePanel in view on repositories with many CI contexts.
Test it before you rely on it
Run the script by hand on a branch with an open pull request, with the input Claude Code sends:
echo '{"stop_hook_active": false}' | .claude/hooks/codepanel-gate.sh
It prints nothing when the gate has passed, and a {"decision": "block", ...} object otherwise. With {"stop_hook_active": true} it must print nothing. The rest of the loop is in Code review with Claude Code.