PR レビューを Claude Code と Codex に同時にやらせる — Hermes Agent スキルで多モデルレビューを自動化する

重岡 正 · Wed, July 22, 2026

PR レビューを 1 つのモデルだけに任せると、そのモデルが構造的に見落とすものは永遠に見落とされます。同じモデルに 2 回読ませても、2 回とも同じところを素通りします。

対策として素直なのは、系統の異なるモデルに同じ差分を読ませることです。Claude CodeCodex は学習データもポストトレーニングも別物なので、盲点の位置がずれます。両方が独立に指摘した箇所は、かなりの確度で本物です。片方しか言っていない指摘は、有用な可能性もあれば単なるノイズの可能性もあり、人間が判断する対象になります。

これ自体はこれまでもやっていました。ターミナルのタブを複数枚開いて、Claude Code と Codex を別セッションで走らせ、出てきた結果を並べて読む。ただ、PR ごとにこの手順を踏むのは面倒ですし、どちらか一方を回し忘れることもあります。同じことを 1 コマンドにまとめたい、というのが出発点でした。

そこで oh-my-hermesomh-pr-multi-review というスキルを追加しました(PR #5)。本記事では、その設計と実装上の判断を整理します。

oh-my-hermes というリポジトリ

前提として、oh-my-hermes は Hermes Agent 向けの個人的なカスタマイズを集めたリポジトリです。エージェント本体には一切手を入れず、skills・plugins・hooks・config テンプレートだけを外付けする方針で運用しています。識別子はすべて omh プレフィックスを付けて、他の拡張と衝突しないようにしてあります。

スキルは tap として配布できます。

hermes skills tap add codenote-net/oh-my-hermes

今回追加した omh-pr-multi-review も、この skills ディレクトリ配下に置かれた 1 スキルです。構成は 3 ファイルだけです。

skills/omh-pr-multi-review/
├── SKILL.md              # スキル定義(エージェントへの指示)
├── agents/openai.yaml    # 表示名と既定プロンプト
└── scripts/review_pr.py  # 実処理(208 行)

エージェントに手順を語らせない

このスキルで最初に決めたのは、レビューの実行手順をエージェントの判断に委ねないことです。

スキルというと、SKILL.md に手順を自然言語で書き並べて、エージェントがそれを読んで順番にコマンドを打つ、という形を想像しがちです。実際その方が書くのは楽です。ただ今回のように「チェックアウトを切り替えて、4 つの CLI を順に叩いて、必ず元のブランチに戻す」という副作用の強い処理では、この形は危険です。エージェントは途中でコマンドを省略しますし、失敗したときに気を利かせて git checkout -f のようなことをやりかねません。

なので SKILL.md には「同梱のスクリプトを走らせろ」としか書いていません。

python3 "$SKILL_DIR/scripts/review_pr.py" "https://github.com/OWNER/REPO/pull/123"

実際の制御フローはすべて review_pr.py の中にあります。エージェントに残された裁量は、PR の URL を渡すことと、出力パスやタイムアウトを上書きするかどうかだけです。

python3 "$SKILL_DIR/scripts/review_pr.py" \
  "https://github.com/OWNER/REPO/pull/123" \
  --output "./reviews/pr-123.md" \
  --timeout 2400

決定的なスクリプトと非決定的なエージェントの境界を、なるべく手前に引く。これは今回に限らず、副作用を伴うスキルを書くときの一般則だと思っています。

全体の流れ

flowchart TD
    START[PR URL を受け取る] --> VAL[URL 形式・CLI 存在・認証・<br/>リポジトリ一致・作業ツリーの清潔さを検証]
    VAL -->|NG| ABORT[exit 2 で中断]
    VAL -->|OK| CO[gh pr checkout --detach]
    CO --> R1[claude /review]
    CO --> R2[claude /security-review]
    CO --> R3[claude /code-review]
    CO --> R4[codex review]
    R1 --> AGG[4 セクションを集約]
    R2 --> AGG
    R3 --> AGG
    R4 --> AGG
    AGG --> SUM[codex exec で<br/>横断サマリを生成]
    SUM --> OUT[Markdown レポートを書き出す]
    AGG --> RESTORE[元のチェックアウトに復元]

実行前の検証を厚くする

スクリプトは何をするより先に、前提条件を洗いざらい確認します。ここで落とせる失敗は、レビューを 30 分走らせた後で落とすより圧倒的に安いからです。

まず PR URL を正規表現で厳密に照合します。

PR_RE = re.compile(
    r"^https://github\.com/(?P<owner>[A-Za-z0-9_.-]+)/(?P<repo>[A-Za-z0-9_.-]+)/pull/(?P<number>[1-9][0-9]*)/?(?:[?#].*)?$"
)

fullmatch で照合しているので、部分一致で通ってしまうことはありません。クエリ文字列やフラグメントが付いた URL(ブラウザからコピーするとよく付きます)は許容します。

次に、必要なコマンドがすべて PATH にあることを確認します。

def require_tools() -> None:
    missing = [name for name in ("gh", "claude", "codex", "git", "python3") if not shutil.which(name)]
    if missing:
        raise RuntimeError(f"missing required command(s): {', '.join(missing)}")

そして認証状態です。3 つの CLI それぞれについて、ログイン確認コマンドを叩き、失敗したら具体的な復旧手段を添えて中断します。

checks = (
    ("GitHub CLI", ["gh", "auth", "status"], "Run `gh auth login`."),
    ("Claude Code CLI", ["claude", "auth", "status", "--text"], "Run `claude auth login`."),
    ("Codex CLI", ["codex", "login", "status"], "Run `codex login`."),
)

エラーメッセージに「次に何をすればいいか」を書いておくのは、エージェントに使わせるツールでは特に効きます。エージェントは復旧手段が明示されていれば自分で直しますが、exit 1 だけ返されると憶測で妙なことを始めます。

さらに 2 つ、地味ですが重要な検証があります。

ひとつは、カレントリポジトリと PR の所属リポジトリが一致しているかの確認です。

repo_code, repo_name = run(["gh", "repo", "view", "--json", "nameWithOwner", "--jq", ".nameWithOwner"], 60, top)
expected = f"{match['owner']}/{match['repo']}"
if repo_code or repo_name.lower() != expected.lower():
    raise RuntimeError(f"current repository is `{repo_name}`; run this skill from `{expected}`")

別リポジトリの PR URL を渡されたまま gh pr checkout を走らせると、意味不明な状態になります。GitHub の owner / repo は大文字小文字を区別しないので、比較は lower() を挟んでいます。

もうひとつは作業ツリーの清潔さです。

status = git_output(["status", "--porcelain", "--untracked-files=all"], top)
if status != "(command produced no output)":
    raise RuntimeError("working tree is not clean; commit, stash, or remove changes before PR checkout")

--untracked-files=all を付けているので、未追跡ファイルがあるだけでも止まります。厳しすぎるようにも見えますが、このスクリプトはブランチを切り替えるので、ユーザーの手元の変更を巻き込む可能性を一切残したくありませんでした。強制チェックアウトも変更の破棄も、このスクリプトは一切しません。

レビュアーの構成

チェックアウトが済んだら、4 つのレビュアーを順に走らせます。Claude Code 側は同じモデル設定で 3 つのスラッシュコマンドを叩きます。

code, text = run(
    ["claude", "-p", "--model", CLAUDE_MODEL, "--effort", "high",
     "--no-session-persistence", f"/{slash_command} {args.pr_url}"],
    args.timeout, top,
)

-p で非対話実行、--effort high で推論を厚くし、--no-session-persistence でセッション履歴を残しません。3 コマンドを分けているのは、/review が一般的なレビュー、/security-review がセキュリティ観点、/code-review が差分に対する詳細レビューと、それぞれ異なるプロンプトと観点を持っているからです。同じモデルでも、視点を変えると拾うものが変わります。

Codex 側は codex review に設定を注入します。

codex_command = [
    "codex", "review", "-c", f'model="{CODEX_MODEL}"',
    "-c", 'model_reasoning_effort="high"', "-c", 'service_tier="fast"',
    "--base", base_ref,
]

-c によるインライン設定にしているのは、ユーザーの config.toml を書き換えたくないからです。グローバル設定を触るツールは、一度使っただけで環境の状態が変わってしまい、次に別のことをするときに事故ります。service_tier="fast" は現行 CLI での Fast ティアの指定で、レビュー待ちが体感で短くなります。

--base に渡す比較対象は、gh pr view で取った baseRefName を使って origin/<base> に解決します。fetch に失敗した場合はローカルの <base> にフォールバックし、警告を stderr に出します。

fetch_code, fetch_text = run(["git", "fetch", "--quiet", "origin", base], 300, top)
base_ref = f"origin/{base}"
if fetch_code:
    base_ref = base
    print(f"warning: could not refresh origin/{base}; using local `{base}`: {fetch_text}", file=sys.stderr)

古いローカルブランチを基準にすると差分が膨らんでレビューがぼやけるので、本来は fetch を通したい。ただしオフラインでも動かないよりはマシなので、劣化して継続する形にしました。

失敗を隔離する

このスクリプトで一番気を使ったのが、失敗の扱いです。

30 分かけて 3 つのレビューが成功した後で 4 つ目がタイムアウトしたとき、全体を失敗にして何も残さないのは最悪の設計です。成功した 3 つは価値があるし、4 つ目が落ちたという事実自体も情報です。

なので、レビュアーごとの結果は例外を投げずにタプルで積み上げます。

def fenced_result(code: int, output: str) -> str:
    if code == 0:
        return output
    return f"> Reviewer failed (exit code {code}). Other reviews continued.\n\n{output}"

タイムアウトも同様で、subprocess.TimeoutExpired から部分出力を救出してレポートに残します。

except subprocess.TimeoutExpired as exc:
    partial = exc.stdout or ""
    if isinstance(partial, bytes):
        partial = partial.decode(errors="replace")
    message = f"ERROR: timed out after {timeout} seconds."
    if partial.strip():
        message += f"\n\nPartial output:\n{partial.strip()}"
    return 124, message

途中まで出ていた指摘は、それだけでも読む価値があります。終了コードは timeout(1) の慣例に合わせて 124 にしています。

チェックアウト自体が失敗した場合は、4 セクションすべてに同じエラーを埋めてレポートを書き出します。空のレポートより、なぜ空なのかが書いてあるレポートのほうがいい。

そして復元は finally で必ず走らせます。

finally:
    restore_code, restore_text = run(["git", "checkout", "--quiet", original_target], 120, top)
    if restore_code:
        restore_error = f"WARNING: failed to restore original checkout `{original_target}`: {restore_text}"

復元先は、実行前に記録しておいた元のブランチ名です。detached HEAD で実行された場合は symbolic-ref が失敗するので、コミット SHA にフォールバックします。

original_branch_code, original_branch = run(["git", "symbolic-ref", "--quiet", "--short", "HEAD"], 30, top)
original_target = original_branch if original_branch_code == 0 else git_output(["rev-parse", "HEAD"], top)

復元に失敗しても、レポートは書き出したうえで警告を出します。ここで例外を投げてレポートを捨てるのは、ユーザーにとって二重の損失にしかなりません。

横断サマリの生成

4 つの結果が揃ったら、最後にもう 1 回だけモデルを呼びます。これがこのスキルの本題です。

prompt = (
    f"Read {context_path}. Summarize only important concrete issues independently flagged by multiple "
    "reviewers. Merge duplicates and name the agreeing reviewer sections. If there is no credible overlap, "
    "say so. Return concise Markdown bullets only; do not modify files."
)

指示のポイントは 3 つです。

  • 複数のレビュアーが独立に指摘した項目だけを拾う。単独指摘は要約に含めない。
  • 重複をマージしたうえで、どのレビュアーが同意したかを明記する。
  • 信頼できる重複がなければ「無い」と言う。

3 番目が特に重要です。要約モデルに「まとめてください」とだけ言うと、材料がなくても何かしらそれっぽいことを書きます。重複が無いという結論を明示的に許可しておかないと、サマリが常に嘘をつく装置になります。

呼び出しは読み取り専用で、一時的な環境にします。

summary_code, summary_text = run(
    ["codex", "exec", "-c", f'model="{CODEX_MODEL}"',
     "-c", 'model_reasoning_effort="high"', "-c", 'service_tier="fast"',
     "--ephemeral", "--sandbox", "read-only", "--skip-git-repo-check", "-C", str(top), prompt],
    args.timeout, top,
)

--sandbox read-only でファイル書き込みを封じ、--ephemeral でセッションを残しません。要約フェーズがコードを触る理由はどこにもないので、能力ごと落としておきます。レビュー結果は一時ディレクトリの Markdown ファイルに書き出してパスだけ渡しており、コマンドライン引数に巨大なテキストを乗せることは避けています。

サマリ生成が失敗しても、レポート全体は成功として扱います。

summary = "- Cross-review synthesis was unavailable. Read the individual sections above."

サマリはあくまで導線であって、証拠そのものは各セクションの生出力です。この優先順位は SKILL.md の出力契約にも明記してあります。生成されたサマリを個別の証拠の代替として提示するな、と。

レポートの形

最終的な出力はこうなります。

# PR Review: Add multi-model PR review skill (#5)
 
- URL: https://github.com/codenote-net/oh-my-hermes/pull/5
- Executed: 2026-07-22T10:59:33+00:00
- Claude Code: `claude-opus-4-8`, effort `high`, non-interactive `-p`
- Codex: `gpt-5.6-sol`, reasoning effort `high`, service tier `fast`, base `main`
 
## Claude Code `/review` results
...
## Claude Code `/security-review` results
...
## Claude Code `/code-review` results
...
## Codex `/review` results
...
## Summary
...

実行時刻を UTC で、モデル設定を正確に記録しているのは、後からレポートを読み返したときに「どの構成で得られた結果か」が分からないと再現も比較もできないからです。モデルは数か月で入れ替わります。半年前のレポートを見て指摘の質を評価するには、そのとき何を使ったかが書いてある必要があります。

使ってみての実感とコスト

このスキルの明白なコストは、1 PR あたり 4 回の高 effort 推論に、サマリ用の 1 回が乗ることです。安くはありません。--effort highmodel_reasoning_effort="high" を両方指定しているので、なおさらです。

なので全 PR に走らせるものではないと考えています。適しているのは次のような場面です。

  • 認証・認可・課金など、間違えたときの損害が大きい領域に触る PR
  • 外部からの入力を扱う箇所や、サブプロセスを起動する箇所
  • 自分が書いていない、あるいはエージェントが大量に書いたコードで、全体像を把握しきれていない PR

逆に、依存関係の更新や設定値の変更、typo 修正のような PR に 4 モデル分の推論を投げるのは、単に無駄です。そういう PR には従来どおり 1 つのレビューで足ります。

もうひとつ、実行時間の問題があります。4 つのレビューを直列で回すので、既定の 1,800 秒タイムアウトを 4 回分待つ可能性があります。並列化は検討しましたが、同じ作業ツリーを 4 プロセスが同時に読む形になるうえ、出力の混線とレート制限の扱いが面倒になるので、初版では直列にしました。ここは git worktree で作業ツリーを分ければ解ける話なので、改善の余地として残しています。

まとめ

単一モデルによるコードレビューは、そのモデルの盲点をそのまま引き継ぎます。系統の異なる複数のモデルに同じ差分を独立に読ませ、重なった指摘を優先的に見る。この単純な運用を、1 コマンドで再現可能にしたのが omh-pr-multi-review です。

実装で重視したのは 3 点です。手順の判断をエージェントではなく決定的なスクリプトに閉じ込めること。レビュアーの失敗を隔離して、成功した結果を必ず残すこと。そして横断サマリを生の出力の代替ではなく導線として位置付け、重複が無いときには無いと言わせることです。

グローバル設定を書き換えない、強制チェックアウトをしない、作業ツリーが汚れていたら動かないという 3 つの禁則も、エージェントから呼ばれるツールとしては同じくらい重要でした。人間が使うツールなら「気をつけて使ってください」で済むものが、エージェントから呼ばれる前提では、そもそも危険な経路を実装しないという形でしか担保できません。

以上、Claude Code と Codex に同じ PR を独立にレビューさせて指摘を突き合わせる Hermes Agent スキルを実装した、現場からお送りしました。

参考情報