0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Stop フックを 5 本並べたら最初の 1 本しか読まれなかったので、ルーター 1 本に集約した

0
Last updated at Posted at 2026-09-16

この記事はシリーズ「自律運用の土台を 1 本まるごと読む: claude-code-repository-base 全解剖」の第 4 回(全 10 回)です。

Claude Code に毎回同じ指示をしなくて済むように、ルール・フック・スキル・ツールを一式にまとめて公開している自作リポジトリ kai-kou/claude-code-repository-base(MIT)を、作った本人が解説する連載です。設計の意図だけでなく、実際に動かして確かめた結果(自分でも気づいていなかった穴を含む)をそのまま載せます。掲載する実行結果と数値はすべて各回の執筆時点で採取し直し、検証したコミット SHA を各回の冒頭に記します。

シリーズ全体の目次

検証時点: base kai-kou/claude-code-repository-base(MIT) HEAD 5fff7f0(2026-09-11 JST)

対象読者は、複数の Stop フックや PreToolUse フックを運用していて、出したはずの警告が一部しか届かない現象に当たった人です。

第 2 回では pre-git-push-check.sh という 1 本のフックを取り上げ、コマンド文字列の解析がパイプや変数展開でどう抜けるかを見ました。今回は 1 本のフックの中身ではなく、複数のフックを束ねる側 の設計です。

セッション終了時(Stop)に走らせたいチェックは、ふつう 1 つでは済みません。未コミットの変更が残っていないか、push したブランチの PR を作り忘れていないか、完了報告が「何ができるようになったか」になっているか、といった確認がそれぞれ独立しています。素直に考えれば、チェックごとにフックを 1 本書いて settings.jsonStop に並べるのが自然です。

ところが、この並べ方には落とし穴があります。複数のサブスクリプトがそれぞれ stderr へ出力すると、そのうち最初の 1 つしか読まれないことがある、という前提をコード中にコメントとして残してあります(CC-BUG-16 / L-050)。警告を 5 本出したつもりで、4 本が消えている状態です。消えていること自体が見えないので、運用していても気づきにくい種類の事故です。

そこで Stop に登録するフックを stop-router.sh の 1 本だけにして、サブフックのメッセージを 1 つの出力へ束ね直す形にしました。今回はその集約部分を読み、実際に Stop のペイロードを流し込んで、複数のメッセージが 1 つにまとまって出てくるところまで確かめます。

TL;DR

  • settings.jsonStop に登録するのは stop-router.sh 1 本だけで、この中から 5 本のサブフック を順に呼びます(stop-router.sh)。
  • 各サブフックの stderr は一時ファイル MSG_FILE に追記され、最後に 1 回だけ stderr へ出されます。区切りは --- です。
  • sandbox の git リポジトリに最小のペイロードを流したところ、終了コードは 2 で、3 本のフック由来のメッセージ+ルーターの付記--- 区切りで 1 つの stderr に並びました。
  • 集約される条件は 2 種類あります。exit 2(および 0 以外の異常終了)と、notify_on_success を渡したフックが exit 0 で終わったときの非空 stderr です。
  • stop-pr-check.sh は「PR がある / ない」だけでなく 「判定できない(unknown)」を 3 番目の状態として持ち、黙って素通りしません。クラウドではハーネス側で確定できないため、Claude 自身に MCP での確認を指示します。

5 本のフックを 1 本のルーターから呼ぶ

stop-router.sh の本体は、run_hook を 5 回呼ぶだけの短い並びです。

run_hook "stop-git-check.sh"
GIT_CHECK_EXIT=$LAST_HOOK_EXIT
run_hook "stop-pr-check.sh"
# (CLAUDE_STOP_GIT_CHECK_BLOCKED を export したうえで)
run_hook "stop-slack-notify.sh" notify_on_success
run_hook "stop-completion-report-check.sh"
run_hook "stop-publish-check.sh"

ファイル冒頭のコメントは「3 つの Stop フック → 1 つに統合(CC-BUG-16 対策)」のままですが、現在の呼び出しは 5 本です。増えたぶんを settings.json 側ではなくルーター側に足していった結果で、Stop の登録口は 1 本に保たれています。第 2 回の末尾で見た pre-tool-use-router.sh(Bash 系の PreToolUse を 1 本にまとめる役)と同じ考え方が、別のイベントにも適用されている形です。

もう 1 つ、設計意図をはっきり書き残してある箇所があります。

# 【L-050 修正】複数サブスクリプトが個別に stdout/stderr 出力すると
# Claude Code が最初の1つしか解析しないリスクがある。
# → 各サブスクリプトの stderr(hook_block 経由のブロック理由)を一時ファイルで収集し、
#   最後に単一の stderr メッセージとして出力する(Issue #142: stdout JSON と exit 2 は排他のため
#   stdout JSON ではなく stderr に統一する)。

出力先を stdout の JSON ではなく stderr に寄せているのは、exit 2 のときに stdout の JSON が無視される、という仕様との排他を避けるためです。つまり「どこへ出すか」と「いくつ出すか」の両方を 1 つに決め打ちしています。

ブロック側の書き込み口も共通化してあります。.claude/hooks/lib/hook_block.sh は 15 行ほどの小さなヘルパーで、stderr へ 1 行出して exit 2 するだけです。

hook_block() {
  printf '%s\n' "$1" >&2
  exit 2
}

run_hook が集約する 2 つの条件

集約の実体は run_hook の中にあります。まず、実行しつつ stderr だけを取り出します。

err=$(printf '%s\n' "$INPUT" | "$HOOK_DIR/$script" 2>&1 >/dev/null)
exit_code=$?
LAST_HOOK_EXIT=$exit_code

そのうえで、終了コードごとに扱いが分かれます。exit 2 ならブロック理由として追記し、ルーター全体の FINAL_EXIT を 2 に上げます。

  if [ "$exit_code" -eq 2 ]; then
    FINAL_EXIT=2
    # 既存メッセージがあれば区切り線を挿入
    if [[ -s "$MSG_FILE" ]]; then
      printf '\n\n---\n\n' >> "$MSG_FILE"
    fi

exit 1exit 127 のような異常終了も、同じように追記されて FINAL_EXIT=2 になります。クラッシュを黙って飲み込まないためで、コメントにも「サイレントスキップを防ぐ」と書いてあります。exit 2 なのに stderr が空だった場合にもフォールバックの文言が入るので、「ブロックされたが理由が分からない」状態が残りません。

もう 1 つの条件が notify_on_success です。

  elif [ -n "$notify_on_success" ] && [[ -n "$err" ]]; then
    # 正常終了(exit 0)だが伝えるべき情報がある場合。FINAL_EXIT は変えない(ブロックしない)。

これを渡すのは stop-slack-notify.sh の 1 本だけです。コメントによれば、このフックは常に exit 0 で終わる設計のため、既定のルール(exit 2 のときだけ拾う)では「WIP 自動コミットを 1 巡見送った」「push に失敗した」といった通知が Claude に一切届かなくなります。ブロックはしたくないが伝えたいことがある、という中間の出力を通すための引数です。

集約条件を整理すると次のようになります。

サブフックの終了コード notify_on_success MSG_FILE への追記 FINAL_EXIT
2 どちらでも する(空なら代替文言) 2
0 以外(1 / 127 等) どちらでも する(失敗した事実) 2
0 渡していない しない 変えない
0 渡している stderr が非空ならする 変えない(ブロックしない)

sandbox リポジトリに Stop のペイロードを流す

読むだけでは、5 本のうち何本が同時に鳴るのか分かりません。そこで、検証用の空リポジトリを 1 つ作って実際に流しました。自分の作業リポジトリで試すと WIP コミットが作られてしまうので、別ディレクトリに作ります。

mkdir -p <scratchpad>/stop-sandbox && cd <scratchpad>/stop-sandbox
git init -q && git config user.email test@example.com && git config user.name test
echo "init" > README.md && git add README.md && git commit -q -m init
git branch -m main
git checkout -q -b feature/test
echo "change" >> README.md   # 未コミット変更を作る

origin リモートは意図的に作っていません。「リモートが無い」状態で各フックがどう振る舞うかも同時に見たかったからです。

流し込んだのは stop_hook_active だけを持つ最小の JSON です。Stop イベントのペイロードに実際どのフィールドが来るかは公式の Hooks reference が正本なので、ここでは仕様を断定しません。今回渡したのはこの 1 フィールドだけであり、他のフィールドは欠落した状態です。

echo '{"stop_hook_active": false}' | bash <base>/.claude/hooks/stop-router.sh
echo "router_exit=$?"

結果は次のとおりです。stdout は空で、出力はすべて stderr に出ました。

router_exit=2
----STDOUT----
(空)
----STDERR----
There are uncommitted changes in the repository. Please commit and push these changes to the remote branch.

---

⚠️ PR確認できません: リポジトリ名(owner/repo)を自動検出できませんでした(GITHUB_REPOSITORY 未設定・origin 不正のいずれか)。`git remote -v` で origin を確認したうえで、mcp__github__list_pull_requests(クラウド一次経路)または `gh pr list --head feature/test --state all`(ローカル)で PR が作成されているか確認してください。

---

Warning: Stop-hook push failed after retries. Commit is local-only.

---

[continuation] これは Stop フックの差し戻しです。続行ターンでは直前の完了報告を再掲しない(SSOT: docs/rules/completion-report-rules.md §1.2)

MSG_FILE への集約が、そのまま出力の形として現れています。区切りの --- は 3 本あり、その前後に 4 ブロックが並んでいます。stop_hook_active 以外のフィールドを渡していないにもかかわらず、どのフックもクラッシュせずに最後まで走りました。

著者視点の発見ポイント: 3 本同時に鳴り、WIP コミットが残った

想定していたのは「未コミット変更の警告」と「PR が確認できない警告」の 2 本でした。実際には 3 本のフックが鳴り、さらにルーター自身の付記が 1 つ加わりました。

出力ブロック 由来
There are uncommitted changes ... stop-git-check.shexit 2
⚠️ PR確認できません: リポジトリ名(owner/repo)を... stop-pr-check.shexit 2
Warning: Stop-hook push failed after retries. stop-slack-notify.shexit 0notify_on_success
[continuation] これは Stop フックの差し戻しです。 stop-router.sh 自身の末尾の付記

3 本目が入っているところが、読むだけでは見落ちる部分です。stop-slack-notify.sh はブロックしていません(exit 0)。それでも notify_on_success が付いているので、「push に失敗してローカルだけにコミットが残った」という事実が同じ出力に混ざってきます。ブロック理由と通知が 1 つの stderr に同居する設計だと分かります。

4 本目の [continuation] は、サブフックではなくルーターが付けています。FINAL_EXIT が 2 かつ MSG_FILE が非空のときに 1 回だけ追記されるので、複数のフックがブロックしても 2 回書かれることはありません。

気になったのが 3 本目の「ローカルだけにコミットが残った」という警告です。git log を見ると、実際にコミットが 1 つ増えていました。

$ git -C <sandbox> log --oneline -5
f9ea09e [wip] 自動保全: 意味のあるコミット未作成のまま終了(2026-09-13 12:13)
a43eb58 init
$ git status --short
(空・WIP自動コミットで未コミット変更が解消されている)
$ git remote -v
(空)

git push を筆者が実行したわけではありません。stop-slack-notify.sh の内部で push が試行され、origin が無いため失敗し、その事実が警告として返ってきた、という並びです。ローカルの WIP コミットだけが残りました。

ここで 1 つ、今回の実行だけでは断定できないことがあります。ルーターは stop-git-check.shexit 2 を返したとき CLAUDE_STOP_GIT_CHECK_BLOCKED=1stop-slack-notify.sh にだけ渡します。「差し戻しと同一の Stop 呼び出しの中で WIP 自動コミットが先に確定してしまう」のを避けるためで、見送るかどうかの判断そのものは stop-slack-notify.sh 側(上限つきフェイルセーフ)に持たせてあります。今回の実行では差し戻しが起きた同じ呼び出しで WIP コミットが作られたので、見送りの条件には当たらなかったことになります。どの入力でその見送りの分岐に入るかは今回の 1 回の実行では踏めていないので、この実行から言えるのは「この条件ではコミットが作られた」という一点だけです。

いずれにしても、Stop フックを導入するときは検証用の空リポジトリで 1 回流しておくと安心できます。自分の作業ツリーに対していきなり流すと、自動保全のコミットが先に載ります。

判定できないときに黙って通さない(stop-pr-check.sh)

ルーターの集約と対になっているのが、サブフック側の「分からないときどうするか」です。stop-pr-check.sh は PR の有無を調べる前に、リポジトリ名(owner/repo)を 3 段で解決します。

# 優先順: GITHUB_REPOSITORY → gh repo view → origin URL パース。
REPO_SLUG="${GITHUB_REPOSITORY:-}"
# クラウドでは gh repo view が 403(GraphQL・L-114)のため試行せず origin URL パースへ進む
if [[ -z "$REPO_SLUG" ]] && [[ "${CLAUDE_CODE_REMOTE:-}" != "true" ]] && command -v gh >/dev/null 2>&1; then

3 段すべて外した場合に exit 0 で素通りしないのが要点です。owner/repo の形にならなければ hook_block でその場で止めます(repos//pulls のような不正な API パスを組み立てないため、というコメントも付いています)。先ほどの sandbox で出た「PR確認できません」は、まさにこの分岐です。origin が無かったので 3 段とも解決できず、断定ではなく判定不能として返ってきました。

案内文も実行環境で切り替わります。クラウド(CLAUDE_CODE_REMOTE=true)では gh の repo スコープ操作が 403 になるため、gh pr list を案内しても読者側で実行できません。そこで公式 MCP の mcp__github__list_pull_requests を案内する分岐を用意してあります。

if [[ "${CLAUDE_CODE_REMOTE:-}" == "true" ]]; then
  VERIFY_HINT="mcp__github__list_pull_requests(owner=\"${REPO_OWNER}\", ... ) で PR を確認してください(クラウドでは gh の repo 操作が 403 でブロックされます・L-114)"
else
  VERIFY_HINT="\`gh pr list --head ${current_branch} --state all -R ${REPO_SLUG}\` を手動実行して PR が作成されているか確認してください"
fi

ハーネス側で確定できない判定を、Claude 自身への指示に振り替えている、という構造です(クラウドで gh が使えない事情そのものは別の回で扱います)。

同じ方針はブランチの存在確認にも入っていて、状態が 2 値ではなく 3 値になっています。

# branch_check_status: "exists" | "not_found" | "unknown"
# "unknown" = timeout/認証/ネットワーク等で判定不能 → PR チェックに進む(サイレントスキップしない)
branch_check_status="unknown"

最終判定では、この 3 値で文面が変わります。PR が 0 件でブランチの存在が exists なら「push 済みなのに PR が無い」と断定しますが、unknown なら断定を避けて「確認できません」に落とします。PR の件数自体が取れなかった場合(total == "unknown")も、黙って終わらずに警告を出します。判定できなかったことを出力に残す のが一貫した方針で、ルーター側がメッセージを 1 本に束ねているからこそ、この手の「弱い警告」も埋もれずに届きます。

完了報告チェックは「マージ報告+PR 参照」だけを見る

4 本目の stop-completion-report-check.sh は、Stop の出力をうるさくしない方向の工夫が入っています。自己テストが同梱されているので、クローン直後にそのまま回せます。

$ bash <base>/.claude/hooks/stop-completion-report-check.sh --self-test
stop-completion-report-check: self-test PASS
EXIT=0

全件通ると PASS の 1 行だけが出る設計で、個別のケース数はログに現れません。分類の本体は classify_text() で、3 段の早期リターンになっています。

classify_text() {
  local text="$1"
  # マージ報告でなければ対象外
  if ! printf '%s' "$text" | grep -qE "$MERGE_RE"; then
    echo "ok"; return
  fi
  # PR 参照が無ければ(一般的な「マージ」言及)対象外
  if ! printf '%s' "$text" | grep -qE "$PR_REF_RE"; then
    echo "ok"; return
  fi
  # アウトカム/依頼再掲の構造があれば適正 → 素通り
  if printf '%s' "$text" | grep -qE "$OUTCOME_RE"; then
    echo "ok"; return
  fi
  echo "nudge"
}

「マージしたと書いてある」かつ「PR 番号を参照している」かつ「依頼の再掲やアウトカムの構造が無い」の 3 条件がそろったときだけ nudge を返します。自己テストのアサーション群は、この境界をケースで固定しています(実際の文字列はソースを参照してください)。

応答の形 期待される分類
マージ報告+PR 番号のみ(感想が続くだけ) nudge
マージ報告+PR 番号+依頼再掲・アウトカムの構造 ok
マージに言及しない普通の作業報告 ok
「squash merge 予定」のような未完了の言及 ok
「PR #N マージ済みを確認した」だけの短い受領応答 nudge

4 行目が効いているところが実用的です。「これからマージする」という言及で毎回差し戻されると、フックは数回で無視されるようになります。判定を広く取らないことが、ルーターに束ねた出力の信用を保つ側に働いています。

存在しないフックは正常スキップにする

5 本目の stop-publish-check.sh は、今回の公開クローンには存在しませんでした(ls で未検出・test -f でも NOT FOUND)。それでもルーターは失敗しません。run_hook の入口でファイル不在を吸収しているからです。

  # ファイル不在は正常スキップ(クラッシュ扱いにしない)。
  # 公開物では著者専用フック(例: stop-publish-check.sh)が DENYLIST で除外されるため、
  # 下流の配布先ではファイルが存在しない状態が正規の構成になる。
  # 個別の呼び出し箇所に条件を書くと将来別のフックが外れたときに同じ事故が再発するため、
  # run_hook 側で一律吸収する(存在するのに失敗した場合は従来どおりクラッシュ扱い)。
  if [ ! -f "$HOOK_DIR/$script" ]; then

第 3 回で見た配布の仕組みでは、公開リポジトリへ出すときに著者専用のファイルが除外されます。除外された側を呼ぶ行はルーターに残るので、そのままでは配布先で毎回 exit 127 相当になります。条件を呼び出し箇所ごとに書く(if [ -f ... ] を 5 箇所に散らす)のではなく、run_hook の入口 1 箇所で吸収しているので、次に別のフックが除外されても同じ対処が効きます。「存在しないのはスキップ、存在するのに失敗したらクラッシュ扱い」という線引きも保たれています。

集約点を 1 つに絞るという形

Stop に複数のチェックを持たせたいとき、このリポジトリで取っている形は次の 3 点です。

  • 登録口を 1 本(ルーター)に絞り、増やすのは run_hook の呼び出し行だけにする
  • メッセージの集約点を 1 つ(MSG_FILE)にして、最後に 1 回だけ stderr へ出す
  • 判定できなかったことも出力に残す(unknown を 3 番目の状態として持つ)

実際に流してみると、1 回の Stop で 3 本のフックが同時に鳴り、ブロック理由と通知とルーターの付記が 1 つの stderr に並びました。フックを並べて登録していたら、このうち何が届いて何が消えたのかを、出力からは判別できなかったはずです。チェックが 2 本を超えたら集約点を作る、というのは Stop に限らず PreToolUse でも同じ形で使えます。

自動保全の仕組みは Stop だけにあるのではありません。コンテキスト圧縮という別のタイミングでも、未コミットの変更を守るための仕掛けが動きます。次回はその二段構えを見ます。

参考リンク

シリーズの前後の記事

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?