お疲れ様です!IT業界で働くアライグマです!
個人開発している開発関連ニュースのキュレーションサービス「DevPick」は、Claude CodeやCodexといったAIコーディングエージェントに、実装からPR作成、マージ、本番デプロイの確認までを任せて開発しています。ところが、作業ブランチでコミットしてテストが通ったところで作業を終えてしまい、PRが作られないまま変更が手元に残る、ということが起きていました。今回は、その終わり方をClaude CodeのStopフックで機械的に止めた実装と、マージ前のAIレビューで2回見つかった「止まらない」経路をまとめます。
見届けのフックはあったのに、PRを作らなければ素通りだった
DevPickでは、1つの作業をAIエージェントが「実装 → PR作成 → AIレビューへの対応 → CI → マージ → 本番デプロイの確認」まで通して行います。この流れと、小さな作業でも毎回この重い流れが動くことへの対策を試した経緯は「AIエージェントへのIssue自動振り分けを1週間計測して廃止した話」に書きました。
この流れの最後まで走らせるために使っているのが、Claude CodeのStopフックです。AIエージェントが返答を終えて手を止めようとするたびに、決めておいたスクリプトが呼ばれます。スクリプトが「まだ終わってはいけない」という判定と理由を返すと、エージェントは止まらず、その理由を次の指示として受け取って作業を続けます。返す内容は、たとえば次のような形です。
{
"decision": "block",
"reason": "PRがまだマージされていません。CIの結果を確認してください。"
}
DevPickにはもともと、作ったPRがマージされ、本番へのデプロイが終わるまで終了を拒否するStopフックがありました。きっかけは以前、エージェントが「このままCIを確認しマージまで進めます」と宣言だけしてターンを終え、実際には一度も確認していなかったことです。ルールの文章だけでは繰り返し破られたため、機械的に止める仕組みにしていました。
このフックは、毎回のターンを遅くしないように、次のような2段構えになっていました。
- PRを作成したとき(
gh pr create)や既存のPRに切り替えたときに、別のフックが「このセッションはPRを持っている」という目印のファイル(マーカー)を置く - Stopフックは、そのマーカーがあるセッションだけ、PRのマージとデプロイの状況を確かめる。マーカーが無ければ何もせずに通す
ここに穴がありました。見届けが始まる合図が「PRを作ったこと」なので、PRを作らずに終わるエージェントには、そもそも見届けが始まりません。作業ブランチでコミットしてテストが通ったところで作業を終えるケースは、まさにPR作成を飛ばしているので、マーカーが無く、Stopフックは何もせず通していました。止めたい行動そのものが、止める仕組みのスイッチになっていたわけです。
IT女子 アラ美作業ブランチのコミットとPRの有無で止める
直し方として、マーカーが無いときでも「PRにすべき変更が手元に残っていないか」をStopフック自身が確かめるようにしました。見るのは次の3点です。
- 今いるブランチが作業ブランチか(
main・masterや、ブランチに乗っていない状態なら確認しない) - そのブランチに、
origin/mainには無いコミットがあるか(origin/mainが無い環境では手元のmainかmasterと比べる) - そのブランチのPRが既にあるか(
gh pr viewで問い合わせる)
コミットが無ければ、何もしていないか作業が済んでいるので通します。コミットがあり、そのブランチのPRが開いたまま(OPEN)なら、PR作成時と同じマーカーをここで補って、もともとの見届け処理(マージとデプロイの確認)にそのまま進ませます。PRが無ければ、「作業ブランチにコミットがありますが、PRが未作成です」という理由を返して終了を止めます。PRの状態を調べるためのghやjqが使えない環境でも、確かめられないことを理由に通すのではなく、止める側に倒しています。単純化すると、最初の実装は次のような流れです。
# 単純化した例(最初の実装)
BRANCH=$(git rev-parse --abbrev-ref HEAD)
case "$BRANCH" in HEAD|main|master) exit 0 ;; esac
# origin/main に無いコミットが無ければ通す
[ -n "$(git log origin/main..HEAD --oneline)" ] || exit 0
STATE=$(gh pr view "$BRANCH" --json state --jq .state 2>/dev/null)
if [ "$STATE" = "OPEN" ]; then
write_marker # PR作成時と同じ見届けへ進ませる
else
hook_block "作業ブランチにコミットがありますが、PRが未作成です。"
fi
あわせて、同じ変更で次のことも入れました。
- CLAUDE.mdに「ローカルでのコミットやテスト成功の時点を完了と扱わない。PR作成からデプロイの確認までを通して行う」という節を追加し、守れなかったときはこのフックが止めることを明記した
- DevPickではClaude CodeとCodexに加えて、Antigravity(以下AGY)というAIエージェント環境でも作業するため、AGY側の設定にも同じStopフックを登録した
- AGYはセッションを表す値を、Claude Codeとは違う名前の項目で渡してくるため、どちらの名前でも読めるようにした
なお、止められたエージェントが作業を続けたあとで、もう一度終わろうとしたときは、このフックは止めません。Stopフックが止めた結果として続いている状態では、Claude Codeが入力にstop_hook_activeという印を付けて渡してくるので、それを見て通しています。止め続けて終わらない状態になるのを避けるための、Stopフックの一般的な作法です。そのため、PRが無いことによる差し戻しは1回だけで、続けてもう一度終わろうとしたときには効きません。



AIレビューに2回突かれた「止まらない」経路
この変更自体もPRにして、DevPickのAIレビューにかけました。PRを更新するたびに走るAIレビューの仕組みは「AIコードレビューの増分レビューの話」に書いています。今回はマージ前のレビューで2回、どちらも「止めるはずの場面で止まらない」という指摘を受けました。本番で起きた不具合ではなく、同じPRの中で順に直したものです。
1回目:jqが無いと、AGYでは止まらない
止める返事の形は、エージェント環境によって違います。Claude Codeは、JSONで"decision": "block"を返すか、エラー出力に理由を書いて終了コード2で終わると止まります。そのため、JSONを組み立てるjqが無い環境では、後者に切り替えて止めていました。
一方AGYは、標準出力のJSONで"decision": "continue"を返したときにしか終了を止められません。最初の実装では、AGYでもjqが無いと同じ「エラー出力と終了コード2」に流れていたため、AGYには止める返事が届かず、終了が許されてしまうという指摘でした。
修正では、AGY向けのときはjqが無いか失敗した場合でも、シェルだけでJSONを組み立てて返すようにしました。理由の文章に含まれる"や\、改行などの制御文字を自前でエスケープしています。
# 単純化した例:AGY向けは jq が無くても JSON を返す
if [ "$FLAVOR" = "agy" ]; then
if ! jq -n --arg r "$reason" '{decision: "continue", reason: $r}' 2>/dev/null; then
printf '{"decision":"continue","reason":%s}\n' "$(json_string "$reason")"
fi
exit 0
fi
2回目:完了済みのPRがあると、その後のコミットが素通りする
1回目の修正と同じタイミングで、止めすぎの対策も1つ足していました。DevPickのPRはスカッシュマージ(PRの複数のコミットを1つにまとめてmainへ入れるマージ)をしています。この方法だと、作業ブランチにある元のコミットはmainの履歴に入らないため、マージが済んだあとでも「origin/mainに無いコミットがある」と判定されます。このままでは、作業を正しく終えたブランチでも永遠に止められてしまいます。そこで「そのブランチにマージ済みかクローズ済みのPRがあれば通す」という例外を入れていました。
2回目の指摘は、この例外が新しい抜け道になっているというものでした。gh pr viewにブランチ名を渡すと、過去に完了したPRが返ってくることがあります。つまり、一度PRをマージしたブランチに新しいコミットを積んで、PRを作らずに終わっても、「完了済みのPRがある」という理由で通ってしまいます。止めすぎを避けるために足した例外が、最初に塞ぎたかった「PRを作らずに終わる」をそのまま通していたわけです。
修正では、完了済みPRの最後のコミット(headRefOid)を一緒に取得し、それが今のHEADと同じときだけ通すようにしました。同じであれば、PRの後にコミットを積んでいないことが分かります。違えば、PRに入っていないコミットがあるので、PR未作成として止めます。
# 単純化した例(修正後)
PR=$(gh pr view "$BRANCH" --json state,headRefOid)
STATE=$(jq -r .state <<<"$PR")
PR_HEAD=$(jq -r .headRefOid <<<"$PR")
if [ "$STATE" = "OPEN" ]; then
write_marker
elif { [ "$STATE" = "MERGED" ] || [ "$STATE" = "CLOSED" ]; } \
&& [ "$PR_HEAD" = "$(git rev-parse HEAD)" ]; then
exit 0 # PRの後に積んだコミットが無いときだけ通す
else
hook_block "作業ブランチにコミットがありますが、PRが未作成です。"
fi
この修正のあとの3回目のレビューで指摘が無くなり、マージしています。



テストで固定したこと
Stopフックはシェルスクリプトですが、DevPickではバックエンドと同じpytestで、フックを実際に起動して結果を確かめています。テストごとに一時ディレクトリへ本物のGitリポジトリを作り、作業ブランチでコミットしたうえでフックを呼びます。PRの状態は、決まったJSONを返すだけの偽のghコマンドをPATHの先頭に置いて差し替え、jqが無い環境やjqが必ず失敗する環境も同じように作っています。今回足したテストは次のとおりです。
- 作業ブランチにコミットがあり、PRが無ければ止める。Claude Codeの形式では
"decision": "block"、AGYの形式では"decision": "continue"が返り、どちらにも「PRが未作成です」が入る - マージ済み・クローズ済みのPRがあり、その最後のコミットが今の
HEADと同じなら止めない(スカッシュマージ後に止めすぎない) - マージ済み・クローズ済みのPRの後に、同じブランチへもう1つコミットを積んだら止める(2回目の指摘の再発防止)
- AGYの形式で
jqが無い環境でも、止めるJSONが返る(1回目の指摘の再発防止) - AGYの形式で
jqが失敗する環境では、自前で組み立てたJSONを読み戻すと元の理由と完全に一致する。理由には"、\、改行、タブ、制御文字を混ぜている - Claude Codeの形式で
jqが失敗する環境では、これまでどおりエラー出力と終了コード2で止める
最後のテストは、AGY向けの変更でClaude Code側の止め方が変わっていないことを確かめるためのものです。どのテストも「止まること」だけでなく、エージェント環境ごとに止まる形で返事が出ているかまで見ています。止める返事が相手に届かなければ、フックの判定が正しくても止まらないからです。
フックが外部APIに頼る部分は、別の形でも壊れたことがあります。終了時のフックがGitHubのAPIを使いすぎて利用枠を使い切り、作業ごと止まった件は「GitHub GraphQLのレート制限が16回で枯渇した話」に書きました。今回の判定でgh pr viewを呼ぶのは、マーカーが無く、かつ作業ブランチにコミットがあるときの1回だけです。mainにいるときや、コミットが無いときは、GitHubに問い合わせずに通します。



まとめ
AIエージェントに作業を最後まで通させるためのStopフックは、PRを作ったときに置かれる目印をきっかけに動く作りでした。そのため、PRを作らずに終わるという、一番止めたい終わり方だけが見張りの外にありました。今は、目印が無いときでも作業ブランチに未PRのコミットが残っていないかを確かめ、残っていれば終了を止めています。
同じPRの中で、AIレビューから「止まらない」経路を2回指摘されました。1つは、止める返事の形がエージェント環境ごとに違い、jqが無いとAGYには返事が届かなかったこと。もう1つは、スカッシュマージ後に止めすぎないよう足した「完了済みPRがあれば通す」という例外が、PRの後に積んだコミットまで通していたことです。後者は、PRの最後のコミットと今のHEADが一致するときだけ通すことで塞ぎました。
今の私の考えは、見張りの仕組みを作るときは「何をきっかけに動き始めるか」と「どんな例外で通すか」の2つを、止めたい行動の側から点検する必要がある、というものです。きっかけが止めたい行動の後ろにあると見張りは始まらず、例外が広すぎると見張りは素通りします。今回はその両方を、1つの変更の中で踏んでいました。











