merge 前の codex review を必須化する

gh pr merge を実行する前に codex review (OpenAI クロスモデルレビュー) の完了を確認し、未レビューな PR が merge されるのを防ぐプラグインです。個人環境 (ChatGPT Plus の codex CLI) 向けに、git push の都度ではなく merge 前に 1 回だけ codex review を行う運用を成立させます。GitHub には何も書きません。単独 install で自立動作します。
レビュー済みの証拠は merge 実行 repo の git-dir 直下にあるローカル記録 です。codex-reviewer subagent が起動する wrapper は、レビュー対象の PR 番号と head SHA を pending attestation として git-dir 直下に書き、subagent lifecycle hook (auto-mark.sh) が parent-safe report を検証して final attestation へ昇格します。gh pr merge を実行すると、merge gate が final attestation を PR 番号と現在の head SHA の両方と照合し、一致した場合だけ merge を通します。PR に commit を追加すると head SHA が変わるため、記録は自動的に「古いレビュー」となり、再レビューなしには merge が通りません。
v4.0.2
claude plugin marketplace add natsuume/natsuume-cc-marketplace
claude plugin install pre-merge-cross-review@natsuume-plugins
公式 codex プラグインへの依存があるため、codex review wrapper を動作させるには次も install してください:
claude plugin install codex@openai-codex
review cadence (Codex review 一定回数ごとの checkpoint 強制) の計数は pre-push-codex-review (v4.0.0 以上) の lifecycle hook が担います。計数対象は codex-reviewer です。本 plugin は pre-push-codex-review との同時 install を前提としないため、本 plugin 単独構成 (個人環境) では review cadence は適用されません (明示的な仕様です)。
jq と gh は merge gate の必須依存です。いずれかが見つからない環境では、未レビューの merge を通さないため block-pre-merge.sh が gh pr merge を fail-closed に deny し、インストール後の再実行を案内します。merge と無関係な Bash 呼び出しは影響を受けません。
ただし jq 不在時はコマンド文字列を取り出せず判定が hook payload 全体に対する粗い文字列フィルタに落ちるため、merge と無関係でも gh / pr / merge に類する文字列を含むコマンドが稀に deny されることがあります (jq を install すると解消します)。
Bash)ファイル: hooks/scripts/block-pre-merge.sh
次の手順で merge を確認します:
gh pr merge の連続列を含む場合のみ関与する。含まないコマンドには関与しない (無出力)。連続列の検出は粗い文字列判定であり、quoted な言及等で誤爆した場合はコマンドの言い換えで回避できる--auto / --admin (遅延 merge 予約・保護 bypass) または --repo / -R (別 repo の PR を merge しうる repo selector) の文字列を含む場合は deny する (粗い文字列検出でよく、レビュー記録の有無に依らない)gh pr merge [<number>] [flags...] に完全一致するかを確認する (照合の前に、コマンド前後の空行・空白・区切りだけは落とす)。番号を置けるのは gh pr merge の直後の 1 語だけで、それ以降は長フラグ (--name / --name=value) と単文字の短フラグ (-d 等) のみを許す。短フラグの束ね形 (-dR 等) は受理しない (束の中に repo selector を隠すと --repo / -R の文字列検出をすり抜けるため)。一致しない関与形 — リダイレクト (> file / 2>&1 / 10> file 等)、シェル演算子による連結 (&& / || / ; / | / &)、quote、$ 展開、フラグより後ろの数字、複数の merge、前置コマンド — は 一律 deny する (gate の解釈と shell の実挙動が乖離しうるため。リダイレクトや連結を外した単独コマンドへの言い換えで対応する)cwd (merge が実行されるディレクトリ) を正本とする。cwd の欠落・空・非絶対パス・不在ディレクトリ・移動不能はいずれも deny する (hook プロセスの cwd への fallback は持たない)gh pr view --json number,headRefOid) に委ねる (正規形で番号があればその番号、無ければ current branch の PR)。gh / jq が見つからない・PR を解決できない・取得に失敗した・PR 番号や head SHA が得られない場合はすべて deny する (fail-closed)。gh は取得だけに使い、PR のレビューやコメントは読まず、GitHub には何も書かない.claude-pre-merge-codex-reviewed) が symlink でない通常ファイルで、1 行目 pr=<全数字> / 2 行目 head=<40 hex 小文字> の形式を満たし、PR 番号と現在の head SHA の両方に一致する場合のみ無出力で終了する (既定の許可フローに委ねる)。記録は消さない (merge が失敗して再実行する場合も同じ記録で通り、head SHA が変われば自動的に失効する)pre-merge-cross-review:codex-reviewer subagent の実行を案内する。subagent がレビューを実行してローカルに記録を保存し、その後の gh pr merge で gate が記録を検証してから merge に進む流れを示す。レビューは current branch の PR に対して実行され記録もその PR に紐づくため、別 PR を番号指定して merge する場合は、先にその PR のブランチへ git switch してから subagent を起動する必要がある旨も併せて案内するBash)ファイル: hooks/scripts/block-bg-codex-wrapper.sh
codex review wrapper (run-pre-merge-codex-review.sh) の起動を検証する PreToolUse hook です。hook payload トップレベルの agent_type が pre-merge-cross-review:codex-reviewer (namespace 付き完全一致) でなければ fail-closed に deny します。background 起動・pipeline 経由の起動も同様に deny します。wrapper の basename を pre-push-codex-review の wrapper (run-pre-push-codex-review.sh) と別名にしているのは、両 plugin が併存する環境で互いの wrapper 検出 gate (basename ベース) が相手の wrapper 起動を deny し合う干渉を塞ぐためです。
ファイル: hooks/scripts/auto-mark.sh
pre-merge-cross-review:codex-reviewer subagent の lifecycle を追跡し、wrapper が書いた pending attestation を final attestation へ昇格する hook です。matcher は SubagentStart / SubagentStop が ^pre-merge-cross-review:codex-reviewer$、PostToolUse が ^SubagentHandback$ (tool 名)、PostToolUseFailure が Agent|Task で、script 側でも agent_type / subagent_type を完全一致で再検証します。束縛キーはローカル HEAD の full SHA (git rev-parse HEAD) です。
.claude-pre-merge-launch-<agent_id>) へ、同一ディレクトリ内 temp file + 排他 ln で atomic に書きます。tombstone (.claude-pre-merge-done-<agent_id>) が既に在る場合と launch attestation が既に在る場合は書きません (resume による attestation の再鋳造を構造的に拒否します)。agent_id が ^[A-Za-z0-9._-]{1,128}$ に一致しない場合はファイル操作を一切行いません。1 日より古い launch attestation は best-effort で掃除し、tombstone は無期限に保持しますSubagentHandback): Claude Code v2.1.271 以降の auto mode では subagent の最終 report が SubagentHandback tool の message として親に届き、SubagentStop の last_assistant_message には締めの文しか入りません。そのため hand-back された report の Status をここで判定し、launch attestation が存在し tombstone が無い場合のみ handback record (.claude-pre-merge-handback-<agent_id>) に pass / findings / invalid を書きます。同一 agent_id の 2 回目以降の hand-back は重複 report として invalid に上書きします。tool_response.success が false (未配信) の hand-back は記録せず、last_assistant_message 経路の判定に委ねますstop_hook_active が boolean false である最初の stop でのみ消費します。launch attestation を tombstone へ不可逆に遷移させたうえで、(1) report (handback record があればその判定結果を one-shot で消費して採用し last_assistant_message は見ない。無ければ last_assistant_message) に Status: で始まる行がちょうど 1 つあり ^Status: (pass|findings)$ に一致する、(2) launch attestation の HEAD が現在の HEAD と一致する、(3) pending attestation が symlink でない通常ファイルで 2 行の形式を満たし head が現在の HEAD と一致する、をすべて満たす場合のみ pending を mv で final へ昇格します。Status: execution-failed・Status 行の欠落 / 重複 / 未知値・HEAD 不一致・形式不正はいずれも昇格しません (fail-closed)ファイル: hooks/scripts/inject-merge-order-rules.sh
hooks/prompts/merge-order-rules.md の全文を毎セッションの SessionStart で additionalContext として注入します。注入文は、PR のマージ前提条件を確認した後・gh pr merge を実行する前に codex-reviewer subagent を起動する手順、起動 prompt の定型文、--delete-branch を付けない merge の形、起動が classifier に拒否された場合に AskUserQuestion でユーザの許可を得る手順を定めます (背景は「auto mode での利用」節)。permission mode やモデルによる分岐はなく常に同一内容を注入します。jq 不在・prompt ファイルの欠落・空・読み取り不能のいずれでも無出力で exit 0 とし (fail-open)、セッションを壊しません。
ファイル: hooks/module/register.ts (エントリ)、hooks/module/tool-check-policy.mjs (判定ロジック)
hooks/hooks.json の "modules" で宣言する hooks module です。auto mode で、merge gate を通過した単独の gh pr merge と、単独の gh pr view / gh pr checks を tool.check イベントで allow に引き上げ、auto mode classifier の判定を経ずに実行させます。背景は「auto mode での利用」節を参照してください。
tool.check では下位の判定 (permissions ルール・classic PreToolUse hook を含む) を先に得て、次の すべて を満たすときだけ allow を返します。それ以外は下位の判定をそのまま返します。
ask で、settings のルールによるものではない。deny (merge gate の deny・permissions.deny を含む)、allow、permissions.ask ルールによる ask は変更しませんauto である。mode が分からないときは引き上げませんgh pr merge [<番号>] <--squash|--merge|--rebase> (戦略フラグはちょうど 1 つ。他の引数を含まない)gh pr view [<番号>|<branch>] に --json <fields> / --jq <式> / -q <式> / --comments / -c を付けたものgh pr checks [<番号>|<branch>] に --json <fields> / --jq <式> / -q <式> / --watch / --interval <秒> / -i <秒> / --required / --fail-fast を付けたもの<fields> は英数字・_・,、<秒> は整数、<式> はシングルクォートで囲んだ 1 語か英数字・_・. だけの語、<branch> は英数字・.・_・/・- だけの語 (先頭は - 以外) に限ります。<式> に $ や識別子としての env を含むものは、jq の env / $ENV で環境変数を読み出せるため対象外です。値付きフラグの = 形は長フラグだけで受け付け、タブと印字可能な ASCII 以外の文字を含む command も対象外です。-R / --repo・URL・: を含む指定は、別ホストへの通信になりうるため対象外です。quote の内側を含めて ; & | < > バッククォート $( ${ 改行を含む command、シングルクォートの外に $ " \ を含む command、gh の前に env 代入・ラッパー (env / bash -c / eval / xargs 等) がある command も対象外です。対象外の command は従来どおり classifier の審査を受けます。
merge gate (block-pre-merge.sh) は classic PreToolUse として tool.check より先に評価され、その deny は tool.check の下位判定として渡されます。module は deny を上書きしないため、codex review の記録が無い merge は従来どおり gate の deny で止まります。module 自身は review 記録を検証しません。
tool.check の入力は permission mode も呼び出し元の agent も持たないため、次の 2 つを記録して引き当てます。
tool.call イベントの入力から、呼び出しの id と呼び出し元の agent の対応を記録します呼び出し元の agent に mode の記録が無い間 (起動直後の subagent 等) は、mode 不明として引き上げません。
tool.call と tool.check のハンドラは Bash の呼び出しに限って登録します。Claude Code は、tool.call のハンドラが登録されたツールの呼び出しを background へ切り離さないため、module が読み込まれている間は Bash の呼び出しが切り離されなくなります。Bash 以外のツールには関与しません。
読み込まれる条件: Claude Code プロセスの env に CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1 があり、workspace trust を承諾済みであることが必要です。managed settings の disableAllHooks / allowManagedHooksOnly、--bare、Safe mode では読み込まれません。読み込まれない環境でも、本 plugin の他の機能は従来どおり動作します。env は次の setup skill で設定できます。
ファイル: skills/setup/SKILL.md、bin/pre-merge-cross-review-enable-function-hooks
/pre-merge-cross-review:setup は、user settings (${CLAUDE_CONFIG_DIR:-$HOME/.claude}/settings.json) の env に CLAUDE_CODE_ENABLE_FUNCTION_HOOKS: "1" を書き込みます。既存の他のキーは保持し、既に設定済みなら書き込みません。settings.json が JSON として解析できない・top-level が object でない・env が object でない場合は書き込まずに中止します。設定は次に起動する Claude Code から有効になります。
ローカルの記録は merge 実行 repo の git-dir 直下に置かれ、次の 5 種類です (名前の単一ソースは hooks/scripts/lib/markers.sh)。GitHub には何も書きません:
| ファイル | 書き手 | 役割 |
|---|---|---|
.claude-pre-merge-codex-reviewed | auto-mark (SubagentStop) | final attestation。gate はこれが PR 番号と現在の head SHA に一致するかを検証する |
.claude-pre-merge-codex-reviewed.pending | wrapper | pending attestation。report 検証を通ると final へ昇格する |
.claude-pre-merge-launch-<agent_id> | auto-mark (SubagentStart) | レビュー開始時のローカル HEAD |
.claude-pre-merge-done-<agent_id> | auto-mark (SubagentStop) | attestation を消費した記録 (one-shot 保証。無期限に保持する) |
.claude-pre-merge-handback-<agent_id> | auto-mark (PostToolUse) | SubagentHandback で hand-back された report の Status 判定結果 (pass / findings / invalid)。SubagentStop が消費する |
pending / final attestation の内容は次の 2 行で、昇格は rename のみで内容を書き換えません:
pr=<PR 番号>
head=<レビュー対象の full head SHA (40 hex 小文字)>
ファイル名の prefix はすべて .claude-pre-merge- で、pre-push-codex-review が使う .claude-pre-push-* とは衝突しません。
pre-merge-cross-review:codex-reviewer (subagent)ファイル: agents/codex-reviewer.md
codex review wrapper (hooks/scripts/run-pre-merge-codex-review.sh) を foreground で 1 回起動し、wrapper の stdout / stderr を subagent context 内で評価して parent-safe markdown report に抽象化する最小 subagent です。wrapper は current branch の PR を gh で解決し、その PR の実 base との merge-base..head 全差分に対して codex review を実行して、完了時に pending attestation を git-dir 直下に書きます (GitHub には何も書きません。ローカル HEAD が PR の head SHA と一致しない場合は、記録する head SHA と実際にレビューした内容が食い違うため実行せず中断します)。別の PR をレビューさせたい場合は、その PR のブランチへ git switch してから起動してください。レビュー範囲と対象内容は wrapper 側で束縛します:
baseRefOid) がローカルの origin/<base> から到達可能 (ancestor) であることを確認します。到達不能なら 1 度だけ明示 refspec (git fetch origin +refs/heads/<base>:refs/remotes/origin/<base>) で fetch して再判定し、それでも到達不能ならレビューも記録の書き込みも行いません。base branch は PR 作成後も進むため完全一致は要求せず、レビュー範囲の anchor は git merge-base HEAD origin/<base> (GitHub の PR diff と同じ範囲) を使いますtools は Bash, Read に制限され (Read は Bash timeout による background 移行後の回収専用で、wrapper が書く terminal sentinel とその run の output file だけを読みます)、model は sonnet に固定されます。
wrapper が exit 0 で完了した場合、parent-safe report の Status は Codex の report 本文 から決めます (wrapper が非 0 で終了した場合は本文の内容に関わらず Status: execution-failed です)。本文に finding の記述 (## Finding 節・Severity: 行・番号付き / 箇条書きの個別指摘) が 1 つも無く「指摘なし」の趣旨で結ばれている場合は Status: pass / Findings: 0 を返し、個別の指摘が 1 つでもあれば Status: findings を返します。finding として返せるのは Codex の report 本文に存在する指摘のみで、wrapper の挙動・記録の書き込みの成否・subagent 自身の観測範囲の限界は finding にしません (Status: execution-failed の Failure class で表現します)。本文が finding も「指摘なし」の結論も含まず判定できない場合 (途中で切れている・空・記述のみ等) は pass に倒さず、Status: execution-failed (Exit status 0・Failure class other) で返し、wrapper 自体は完了しレビュー記録を書き終えている可能性がある旨を recovery direction に書きます。
auto mode (permission_mode = auto) では、Claude Code の classifier が各 tool call を審査します。classifier が読むのは「ユーザ発言・tool call・CLAUDE.md」で、tool result (merge gate の deny 文を含む) は除去されます。そのため gh pr merge の呼び出し直後に pre-merge-cross-review:codex-reviewer を起動すると、classifier には「ユーザが依頼していない merge 系操作の一部」に見え、起動が Blocked by classifier で拒否されることがあります。作者環境での集計では、merge gate の deny 直後 (間に人間の発言なし) の起動 11 件中 8 件が拒否され、merge 試行を挟まない起動 13 件と、ユーザの許可発言を挟んだ起動 3 件はすべて通過しました。
本 plugin はこれを次の 3 段で扱います:
hooks/prompts/merge-order-rules.md を毎セッション注入し、マージ前提条件の確認後・gh pr merge の前に codex-reviewer を起動する手順、起動 prompt の定型文、--delete-branch を付けない merge の形、拒否時は AskUserQuestion でユーザの許可を得る手順を定めます~/.claude/settings.json の autoMode.allow に次のルールを追加します。"$defaults" を残さないと組み込みルールが失われるので必ず併記してください{
"autoMode": {
"allow": [
"$defaults",
"Launching the pre-merge-cross-review:codex-reviewer subagent while preparing to merge the current branch's pull request is allowed, including immediately after a gh pr merge attempt: it only runs a read-only review of the pull request diff and returns a report; the review result is kept as a local record under the repository's .git directory. It does not write to GitHub, merge, push, or delete anything."
]
}
}
この allow ルールが緩めるのは subagent の起動の審査だけです。subagent が実行中に行う各 tool call は引き続き classifier が親 session と同じ規則で審査し、完了時にも行動履歴が審査されます。
classifier は project settings (.claude/settings.json / .claude/settings.local.json) の autoMode を読まないため、ユーザ設定 (~/.claude/settings.json) に書く必要があります。classifier は CLAUDE.md も読むため、プロジェクトの CLAUDE.md に同趣旨の 1 文を書く方法でも代替できます。設定なしで拒否された場合は、ユーザが「マージ前レビューとマージを実行してよい」と発言すれば次の起動は通ります (classifier は明示的なユーザ意図で soft block を解除します)。
gh pr merge 自体や、merge 直後の gh pr view も classifier に [Merge Without Review] で拒否されることがあります。codex review の結果は subagent の report として届くため、classifier からは review 済みであることが見えません。hooks module (「Hooks module (Claude Mods)」節) を有効にすると、merge gate を通過した単独の gh pr merge と、単独の gh pr view / gh pr checks は classifier を経ずに実行されます。hooks module を有効にしていない環境では、ユーザ自身がマージを指示する発言をすると、次の実行は通ります。
--delete-branch は remote branch の削除として組み込み soft_deny の対象になるため、merge は gh pr merge <番号> --squash 等の単独正規形で実行し、branch の掃除は merge 後に別コマンドで行ってください。hooks module も戦略フラグ以外の引数を含む merge は allow に引き上げません。
gh pr merge (連続列を含む形) のみ: gh api による直接 merge 呼び出し、gh alias、意図的な難読化、非 Bash の tool 経路、Web UI や他 client からの merge は観測できません--auto / --admin は常に deny: 遅延 merge 予約 (gate 確認と実 merge の分離) と保護 bypass はサポート外です。必要な場合は plugin を無効化して実行してくださいPATH と引数列に依存する: guard はコマンド名だけの codex を guard 自身の PATH で解決します。codex を複数の場所にインストールしていて、broker を起動した環境と guard を実行する環境で PATH の順序が違うと、broker が使っているものとは別の実行ファイルの時刻を見ます。また、実行ファイルのパスに空白が含まれると codex の app-server を特定できず、警告を出して続行しますtool.check の allow が classifier の判定を省略する挙動は Claude Code 2.1.282 の実装で確認したもので、公式ドキュメントには記述がありませんpermissions.ask ルールによる ask は引き上げません-R / URL の指定は対象外にしていますが、対象リポジトリは Bash の作業ディレクトリの git remote で決まります。merge の安全性は merge gate のレビュー記録の照合に委ねますgh pr merge の連続列を quoted な文字列として含むだけのコマンド (コミットメッセージへの言及等) も関与対象になります。誤 deny された場合はコマンドを言い換えて回避してくださいgh -R owner/repo pr merge 123 等) は gh pr merge の連続列を含まないため gate が関与せず、この形の merge は観測できません。別 repo の PR を merge する場合はその repo のディレクトリへ移動し、gh pr merge を先頭に置いた単独コマンドとして番号指定 (gh pr merge 123) か current branch 指定 (gh pr merge --squash) で実行してください (repo selector 付きの形は gate が deny します)git switch してから subagent を起動してください (別ブランチのまま起動すると、記録が current branch の PR のものになり、codex の利用枠だけを消費して目的の merge は deny のままになります)gh pr merge [<number>] [flags...] の単独正規形だけです。正規形外の形 (リダイレクト・連結・quote・変数展開等) を許可する parser 拡張は行いません。「除去して近似する」処理は shell の実挙動との乖離を生み、未レビュー merge を通す穴になるためで、必要な操作は単独コマンドへの言い換えで対応してくださいcwd に従う: gate は payload の cwd が指す repo で PR とレビュー記録を照合します。payload の cwd が実際にコマンドを実行する shell の cwd と乖離する環境では、gate は payload 側の repo を照合し、その乖離自体は検出できませんorigin = PR の repository が前提 (fork 構成は非対応): wrapper はレビュー範囲の base をローカルの origin/<base> で解決するため、remote origin が PR の属する repository を指す個人環境を前提とします。fork からの PR (origin と PR の repository が異なる構成) には対応しません--auto を付けなくても merge が queue 経由の遅延実行になりえますが、gate はこれを検出しません (遅延 merge はサポート外です)gh pr merge は観測しない: PreToolUse hook の matcher が Bash であるため、PowerShell tool (CLAUDE_CODE_USE_POWERSHELL_TOOL=1 で Linux / macOS でも有効化できる) および Monitor tool 経由で発行された gh pr merge を gate は観測しません。これらの tool を有効にした環境はサポート外です本 plugin は単独 install で自立動作し、pre-push-review core (git push 前の code review / security review gate) と併用しても push gate に一切影響しません。push gate (git push) と merge gate (gh pr merge) は独立した PreToolUse hook であり、互いの判定に関知しません。
本 plugin は個人環境 (ChatGPT Plus の codex CLI) 向けに「merge 前に 1 回だけ codex review」を運用する設計です。push の都度 codex review を要求する会社環境向け pre-push-codex-review との併用は前提としていません。会社環境では codex 系 2 plugin のうち pre-push-codex-review の側を install し、本 plugin は install しないでください (pre-push-review core は会社環境でもそのまま併用します)。
openai-codex plugin の companion は workspace ごとに常駐 broker を起動して再利用します。broker は起動時の codex app-server を抱え続けるため、codex CLI を更新しても、更新前に起動した broker は古いバイナリのまま review を実行します。
wrapper (run-pre-merge-codex-review.sh) は companion の review を起動する直前に hooks/scripts/lib/stale-broker-guard.mjs を実行します。guard は broker 配下で動く codex app-server の実行ファイルが broker の起動より後に更新されているか、実行ファイルが無くなっていれば、その broker を止め、止めた broker の記録が残っていれば消して、止めた旨を stderr に 1 行出します。その後に起動する companion は、現行のバイナリで新しい broker を起動します。同じ broker で実行中の別の job は中断されます。
検出や停止に失敗した場合 (companion の内部 module を読み込めない、ps が失敗する、記録の pid が companion の broker でない、broker が停止しない等) は stderr に警告を 1 行出し、review をそのまま続行します。guard は stdout に何も書かないため、review report の出力には影響しません。
本 plugin は次の lib を、canonical の byte-identical なコピーとして保持します。この同一性は tests/test_pre_merge_lib_copies.py の契約テストが検査します。
hooks/scripts/lib/codex-companion-resolver.sh: pre-push-codex-review (plugins/pre-push-codex-review/hooks/scripts/lib/) が canonical (codex review の実行機構は両 plugin で同一のため)hooks/scripts/lib/stale-broker-guard.mjs: 同じく pre-push-codex-review が canonicalそれ以外の lib コピーは持ちません。reviewer 一式 (wrapper / subagent 定義 / hook script 群) は pre-merge 専用の実装であり、pre-push 系との文字列同一性契約は設けません。lib/markers.sh (レビュー記録のファイル名と内容契約) も pre-merge 専用の lib であり、この同一性契約の対象外です (lib/markers.sh は pre-push 系の同名 lib とは別の名前空間・別の束縛キーを扱います)。
| パス | 役割 |
|---|---|
hooks/hooks.json | フック配送経路の定義 |
hooks/scripts/block-pre-merge.sh | 軽量 merge gate 本体 (PreToolUse)。ローカルのレビュー記録を PR 番号と head SHA に照合する |
hooks/scripts/block-bg-codex-wrapper.sh | codex review wrapper の起動検証 (PreToolUse) |
hooks/scripts/auto-mark.sh | subagent lifecycle hook (SubagentStart / PostToolUse / SubagentStop / PostToolUseFailure)。codex review の pending attestation を final へ昇格する |
hooks/scripts/inject-merge-order-rules.sh | SessionStart hook。merge 前 cross review の起動順規律を additionalContext として注入する |
hooks/prompts/merge-order-rules.md | 注入する起動順規律の本文 |
hooks/scripts/run-pre-merge-codex-review.sh | codex review wrapper 本体 (レビュー実行 + ローカル記録の書き込み。basename は pre-push-codex-review の wrapper と別名) |
hooks/scripts/lib/codex-companion-resolver.sh | codex companion 解決ロジック (pre-push-codex-review からの byte-identical コピー) |
hooks/scripts/lib/stale-broker-guard.mjs | codex CLI 更新前に起動した companion の broker を検出して止める guard (pre-push-codex-review からの byte-identical コピー) |
hooks/scripts/lib/markers.sh | レビュー記録のファイル名と内容契約の単一ソース |
agents/codex-reviewer.md | pre-merge-cross-review:codex-reviewer subagent 定義 |
hooks/module/register.ts | hooks module のエントリ。permission mode と呼び出し元の agent を記録し、tool.check で判定ロジックを呼ぶ |
hooks/module/permission-mode-tracker.mjs | agent ごとの permission mode と、呼び出しごとの agent の記録 |
hooks/module/tool-check-policy.mjs | tool.check の判定ロジック (純関数) |
skills/setup/SKILL.md | hooks module を有効化する setup skill |
bin/pre-merge-cross-review-enable-function-hooks | user settings の env に CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1 を書き込むコマンド |
git push 前の push gate (code review / security review の 2 マーカー)。本 plugin の merge gate とは独立に動作し、併用しても互いの gate に影響hooks/module/register.ts 76 lines1import type { On } from 'claude-code'
2
3import { createPermissionModeTracker } from './permission-mode-tracker.mjs'
4import { decideToolCheck } from './tool-check-policy.mjs'
5
6/**
7 * pre-merge-cross-review の hooks module (Claude Mods)。
8 *
9 * auto mode で、merge gate を通過した単独の `gh pr merge` と、単独の `gh pr view` /
10 * `gh pr checks` を tool.check で allow に引き上げ、auto mode classifier の判定を経ずに実行させる。
11 * 判定本体は tool-check-policy.mjs の純関数で、ここでは engine との接続だけを行う。
12 *
13 * tool.check の入力は permission mode も呼び出し元の agent も持たないため、次を記録して
14 * 引き当てる (permission-mode-tracker.mjs)。
15 * - agent ごとの permission mode: permission_mode を持つ classic イベント (SessionStart /
16 * UserPromptSubmit / PostToolUse / PostToolUseFailure) の入力の agent_id (subagent のみ) ごと
17 * - 呼び出しごとの agent: tool.call の入力の tool_use_id と agentId。tool.check は同じ呼び出しの
18 * tool.call の後に評価される
19 * 呼び出し元の agent に mode の記録が無い間は mode 不明として引き上げない。記録は直近の classic
20 * イベント時点の mode なので、ターンの途中で mode を切り替えた直後の 1 回のツール呼び出しには、
21 * 切り替え前の mode が使われる。classic イベントと tool.call は next(e) でそのまま下位へ渡し、
22 * 内容を変えない。
23 *
24 * tool.call と tool.check は matcher で Bash に限って登録する。engine は、一致する tool.call の
25 * ハンドラが存在するツールの呼び出しを background へ切り離さないため、matcher 無しで登録すると
26 * Bash 以外のツールの切り離しまで止めてしまう。
27 *
28 * merge gate (block-pre-merge.sh) は classic PreToolUse として tool.check より先に評価され、
29 * その deny は tool.check の下位判定 (next(e) の結果) として渡される。判定ロジックは deny を
30 * 上書きしないため、gate が deny した merge は allow にならない。
31 *
32 * @param on the engine's registrar
33 */
34export const register = (on: On) => {
35 const tracker = createPermissionModeTracker()
36
37 const recordPermissionMode = (e: { permission_mode?: unknown; agent_id?: unknown }) => {
38 tracker.recordMode({ agentId: e.agent_id, mode: e.permission_mode })
39 }
40
41 on('classic.SessionStart', ($, e, next) => {
42 recordPermissionMode(e)
43 return next(e)
44 })
45 on('classic.UserPromptSubmit', ($, e, next) => {
46 recordPermissionMode(e)
47 return next(e)
48 })
49 on('classic.PostToolUse', ($, e, next) => {
50 recordPermissionMode(e)
51 return next(e)
52 })
53 on('classic.PostToolUseFailure', ($, e, next) => {
54 recordPermissionMode(e)
55 return next(e)
56 })
57
58 on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
59 tracker.recordCall({ toolUseId: e.tool_use_id, agentId: e.agentId })
60 try {
61 return await next(e)
62 } finally {
63 tracker.forgetCall(e.tool_use_id)
64 }
65 })
66
67 on('tool.check', { tool: 'Bash' }, async ($, e, next) =>
68 decideToolCheck({
69 tool: e.tool,
70 input: e.input,
71 beneath: await next(e),
72 permissionMode: tracker.modeForCall(e.tool_use_id),
73 }),
74 )
75}
76hooks/module/permission-mode-tracker.mjs 54 lines1// pre-merge-cross-review hooks module の permission mode 記録 (I/O を持たない状態管理)。
2//
3// tool.check の入力は permission mode も agent も持たないため、次の 2 つを記録して引き当てる。
4// - agent ごとの最新の permission mode: permission_mode を持つ classic イベントの入力から、
5// agent_id (subagent のみ。メインは無し) ごとに記録する
6// - 呼び出しごとの agent: tool.call の入力の tool_use_id と agentId (subagent のみ) の対応。
7// tool.check は同じ呼び出しの tool.call の後に評価される
8//
9// 引き当ての規則:
10// - 呼び出しの記録が無い tool_use_id、または呼び出し元の agent に mode の記録が無い場合は
11// undefined (mode 不明) を返す。別の agent の mode を代わりに使わない
12// - mode は文字列のときだけ記録する
13
14/**
15 * permission mode の記録を作る。
16 *
17 * @returns {{
18 * recordMode: (args: { agentId: unknown, mode: unknown }) => void,
19 * recordCall: (args: { toolUseId: unknown, agentId: unknown }) => void,
20 * forgetCall: (toolUseId: unknown) => void,
21 * modeForCall: (toolUseId: unknown) => string | undefined,
22 * }}
23 */
24export const createPermissionModeTracker = () => {
25 // メインの loop を表すキー。agent の id (空でない文字列) と衝突しない値にする。
26 const MAIN = Symbol("main");
27 const modesByAgent = new Map();
28 const agentsByCall = new Map();
29
30 const agentKey = (agentId) =>
31 typeof agentId === "string" && agentId !== "" ? agentId : MAIN;
32 const isToolUseId = (toolUseId) => typeof toolUseId === "string" && toolUseId !== "";
33
34 return {
35 recordMode: ({ agentId, mode }) => {
36 if (typeof mode === "string") {
37 modesByAgent.set(agentKey(agentId), mode);
38 }
39 },
40 recordCall: ({ toolUseId, agentId }) => {
41 if (isToolUseId(toolUseId)) {
42 agentsByCall.set(toolUseId, agentKey(agentId));
43 }
44 },
45 forgetCall: (toolUseId) => {
46 agentsByCall.delete(toolUseId);
47 },
48 modeForCall: (toolUseId) =>
49 isToolUseId(toolUseId) && agentsByCall.has(toolUseId)
50 ? modesByAgent.get(agentsByCall.get(toolUseId))
51 : undefined,
52 };
53};
54hooks/module/tool-check-policy.mjs 227 lines1// pre-merge-cross-review hooks module の tool.check 判定ロジック (純関数)。
2//
3// register.ts が tool.check chain の下位判定 (next(e) の結果) と記録済みの permission mode を
4// 渡し、本 module が最終判定を返す。I/O を持たないため、node から直接読み込んでテストできる。
5//
6// 不変条件:
7// - 下位判定が `ask` の場合に限り `allow` へ引き上げる。`deny` / `allow` は変更しない
8// (merge gate の deny・permissions.deny を上書きしない)
9// - `rule` を持つ `ask` (ユーザが permissions.ask ルールで明示した確認) は引き上げない
10// - 引き上げは permission mode が `auto` のときだけ行う。mode 不明 (undefined 等) は引き上げない
11// - `deny` を自ら返さない
12
13// shell の連結・リダイレクト・置換・改行。quote の内側にあっても対象外とする (保守側に倒す)。
14const SHELL_METACHARACTERS = /[;&|<>`\n\r]|\$\(|\$\{/;
15
16// タブと印字可能な ASCII 以外の文字 (制御文字・非 ASCII の空白・全角文字等)。
17const NON_PRINTABLE_ASCII = /[^\t\x20-\x7e]/;
18
19// jq の式で識別子として現れる env (`.env` / `$env` / `env_x` 等のフィールド・名前は除く)。
20const JQ_ENV_IDENTIFIER = /(^|[^A-Za-z0-9_.$])env(?![A-Za-z0-9_])/;
21
22const REASONS = {
23 merge:
24 "pre-merge-cross-review: auto mode で merge gate を通過した単独の gh pr merge を許可しました",
25 view: "pre-merge-cross-review: auto mode で単独の gh pr view を許可しました",
26 checks: "pre-merge-cross-review: auto mode で単独の gh pr checks を許可しました",
27};
28
29const MERGE_STRATEGY_FLAGS = ["--squash", "--merge", "--rebase"];
30
31const PR_NUMBER = /^[1-9][0-9]*$/;
32const BRANCH = /^[A-Za-z0-9._/][A-Za-z0-9._/-]*$/;
33const FIELDS = /^[A-Za-z0-9_,]+$/;
34const SECONDS = /^[0-9]+$/;
35const JQ_EXPRESSION_FORM = /^'[^']*'$|^[A-Za-z0-9_.]+$/;
36
37// jq の env / $ENV は環境変数を読み出せるため、$ と識別子 env を含む式は許可しない。
38const JQ_EXPRESSION = {
39 test: (value) =>
40 JQ_EXPRESSION_FORM.test(value) && !value.includes("$") && !JQ_ENV_IDENTIFIER.test(value),
41};
42
43// サブコマンドごとの許可フラグ。値を取るフラグは値の形 (正規表現) を持つ。
44const READ_ONLY_FLAGS = {
45 view: {
46 values: { "--json": FIELDS, "--jq": JQ_EXPRESSION, "-q": JQ_EXPRESSION },
47 switches: ["--comments", "-c"],
48 },
49 checks: {
50 values: {
51 "--json": FIELDS,
52 "--jq": JQ_EXPRESSION,
53 "-q": JQ_EXPRESSION,
54 "--interval": SECONDS,
55 "-i": SECONDS,
56 },
57 switches: ["--watch", "--required", "--fail-fast"],
58 },
59};
60
61/**
62 * スペースとタブで語に分ける。シングルクォートの内側の空白は語を区切らず、quote は語に残す。
63 * 閉じていないシングルクォートがある場合は null を返す。
64 */
65const splitWords = (command) => {
66 const words = [];
67 let current = "";
68 let inWord = false;
69 let inQuote = false;
70 for (const character of command) {
71 if (inQuote) {
72 current += character;
73 inQuote = character !== "'";
74 continue;
75 }
76 if (character === " " || character === "\t") {
77 if (inWord) {
78 words.push(current);
79 current = "";
80 inWord = false;
81 }
82 continue;
83 }
84 current += character;
85 inWord = true;
86 inQuote = character === "'";
87 }
88 if (inQuote) {
89 return null;
90 }
91 if (inWord) {
92 words.push(current);
93 }
94 return words;
95};
96
97const isCanonicalMerge = (args) => {
98 const strategies = args.filter((arg) => MERGE_STRATEGY_FLAGS.includes(arg));
99 const positionals = args.filter((arg) => !MERGE_STRATEGY_FLAGS.includes(arg));
100 return (
101 strategies.length === 1 &&
102 positionals.length <= 1 &&
103 positionals.every((arg) => PR_NUMBER.test(arg))
104 );
105};
106
107const isCanonicalReadOnly = (subcommand, args) => {
108 const { values, switches } = READ_ONLY_FLAGS[subcommand];
109 let positionals = 0;
110 for (let index = 0; index < args.length; index += 1) {
111 const arg = args[index];
112 if (!arg.startsWith("-")) {
113 positionals += 1;
114 if (positionals > 1 || !(PR_NUMBER.test(arg) || BRANCH.test(arg))) {
115 return false;
116 }
117 continue;
118 }
119 // `=` で値を渡す形は長フラグ (`--`) に限る
120 const separator = arg.startsWith("--") ? arg.indexOf("=") : -1;
121 const name = separator === -1 ? arg : arg.slice(0, separator);
122 const valuePattern = Object.hasOwn(values, name) ? values[name] : undefined;
123 if (valuePattern !== undefined) {
124 const value = separator === -1 ? args[(index += 1)] : arg.slice(separator + 1);
125 if (value === undefined || !valuePattern.test(value)) {
126 return false;
127 }
128 continue;
129 }
130 if (separator !== -1 || !switches.includes(arg)) {
131 return false;
132 }
133 }
134 return true;
135};
136
137/**
138 * command が次の正規形の単独呼び出しかを判定する。
139 *
140 * - `gh pr merge [<番号>] <--squash|--merge|--rebase>`: 番号は 1 以上の整数で任意、戦略フラグは
141 * ちょうど 1 つ。順序は問わない。それ以外の引数 (`--admin`・`--delete-branch`・`-R` 等) を含む
142 * 形は対象外
143 * - `gh pr view [<番号>|<branch>] [flags]`: flags は `--json <fields>` / `--jq <式>` / `-q <式>` /
144 * `--comments` / `-c`
145 * - `gh pr checks [<番号>|<branch>] [flags]`: flags は `--json <fields>` / `--jq <式>` / `-q <式>` /
146 * `--watch` / `--interval <秒>` / `-i <秒>` / `--required` / `--fail-fast`
147 *
148 * 引数の規則:
149 * - 値を取るフラグは `--flag value` と `--flag=value` の両方を受け付ける
150 * - `<fields>` は英数字・`_`・`,` のみ、`<秒>` は整数のみ
151 * - `<式>` はシングルクォートで囲んだ 1 語 (内側に `'` を含まない)、または英数字・`_`・`.` のみ。
152 * いずれの形でも `$` と、識別子としての `env` (`.env` のようなフィールド参照は除く) を含まない
153 * (jq の `env` / `$ENV` は環境変数を読み出せるため)
154 * - 値付きフラグの `=` 形は長フラグ (`--`) のみ。短フラグは空白区切りのみ (`-q=.x` / `-i5` は対象外)。
155 * 真偽値フラグ・merge の戦略フラグに `=` は付けない。同じ戦略フラグの重複は対象外
156 * - `<branch>` は英数字・`.`・`_`・`/`・`-` のみで、`-` で始まらない (`:` を含む指定や URL は対象外)
157 * - 位置引数は 1 つまで
158 *
159 * command 全体の規則:
160 * - タブと印字可能な ASCII (0x20〜0x7E) 以外の文字を含まないこと。語の区切りはスペースとタブのみ
161 * - 前後の空白を除いた command が `gh` で始まること (env 代入・ラッパーを前置しない)
162 * - `;` `&` `|` `<` `>` バッククォート `$(` `${` 改行を、quote の内側を含めて含まないこと
163 * - シングルクォートの外に `$` `"` `\` を含まないこと (上記の引数規則で弾かれる)
164 *
165 * @param {unknown} command Bash tool の input.command
166 * @returns {boolean}
167 */
168export const isTargetCommand = (command) => {
169 if (
170 typeof command !== "string" ||
171 NON_PRINTABLE_ASCII.test(command) ||
172 SHELL_METACHARACTERS.test(command)
173 ) {
174 return false;
175 }
176 const words = splitWords(command.trim());
177 if (words === null) {
178 return false;
179 }
180 const [program, group, subcommand, ...args] = words;
181 if (program !== "gh" || group !== "pr") {
182 return false;
183 }
184 if (subcommand === "merge") {
185 return isCanonicalMerge(args);
186 }
187 if (subcommand === "view" || subcommand === "checks") {
188 return isCanonicalReadOnly(subcommand, args);
189 }
190 return false;
191};
192
193const isObject = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
194
195/**
196 * tool.check の最終判定を返す。
197 *
198 * beneath が `rule` を持たない (値が undefined または空文字列の) `ask`、permissionMode が `auto`、
199 * tool が `Bash`、input.command が isTargetCommand を満たす場合に限り `{ decision: "allow", reason }` を
200 * 返す。rule がそれ以外の値 (null・空白のみの文字列・object 等) の ask は引き上げない。reason は
201 * 許可したコマンド名 (gh pr merge / gh pr view / gh pr checks) を含む日本語の文である。
202 *
203 * 制約:
204 * - rule を持たない ask の由来 (core の既定・PreToolUse hook の ask・core の安全検査) は区別できず、
205 * いずれも引き上げの対象になる
206 * - 検査するのは engine が tool.check に渡した input であり、PreToolUse hook の updatedInput との
207 * 前後関係はこの関数では保証しない
208 * - 対象リポジトリは Bash の作業ディレクトリの git remote で決まる。merge の安全性は merge gate に委ねる
209 *
210 * @param {{ tool: unknown, input: unknown, beneath: { decision: string, reason?: string, rule?: string }, permissionMode: unknown }} args
211 * @returns {{ decision: string, reason?: string, rule?: string }} 引き上げない場合は beneath をそのまま (同一オブジェクトで) 返す
212 */
213export const decideToolCheck = ({ tool, input, beneath, permissionMode }) => {
214 if (!isObject(beneath) || beneath.decision !== "ask" || permissionMode !== "auto") {
215 return beneath;
216 }
217 // permissions.ask ルールでユーザが明示した確認は残す。rule が想定外の値の場合も引き上げない
218 if (beneath.rule !== undefined && beneath.rule !== "") {
219 return beneath;
220 }
221 if (tool !== "Bash" || !isObject(input) || !isTargetCommand(input.command)) {
222 return beneath;
223 }
224 const subcommand = splitWords(input.command.trim())[2];
225 return { decision: "allow", reason: REASONS[subcommand] };
226};
227