Claude Code のスキルと設定リポジトリを複数マシンで git 同期していたら、settings.json の hook が未追跡スクリプトを指していた
Claude Code の設定ディレクトリ ~/.claude を git リポジトリにして、自宅サーバーと Mac の間で commit / push / pull で同期している。棚卸ししたら、追跡済みの settings.json が未追跡のスクリプトを Stop hook として参照していた。このマシンでは動く。pull した側では、存在しないスクリプトを毎ターン呼ぶ設定になる。壊れ方を2種類に分けて、検証を pre-commit に置くまでの記録。
追跡済みの settings.json が、未追跡のスクリプトを hook にしていた
このリポジトリの .gitignore は、セッション履歴や認証情報を誤って commit しないためのホワイトリスト方式で、許可したディレクトリに置いた新規ファイルも git add するまでは untracked のままになる。
$ git -C ~/.claude status --short # 抜粋 M settings.json ?? scripts/sync-codex.sh ?? skills/find-skills ?? skills/nr-reminder/
settings.json は追跡済みなので、この変更を commit して push すれば他のマシンに届く。その中の Stop hook が指している scripts/sync-codex.sh は ??、つまりどこにも届かない。Stop hook は各ターンの終了時に走るので、pull した側では毎ターン「無いスクリプトを実行しようとして失敗する」設定が有効になる。
同じ性質の欠陥がもう1つあった。未追跡スキルのうち skills/find-skills は ../../.agents/skills/find-skills へのシンボリックリンクだった。リンク先は ~/.claude の外にあって git の管理外なので、こちらは commit しても直らない。他のマシンでは pull した瞬間にリンク切れになる。実体コピーに置き換えた。
どちらも、このマシンで見ている限り正常に動いている。壊れるのは「別のマシンで pull した瞬間」で、その瞬間は自分の目の前で起きない。
使った瞬間に壊れるものは別にあった
同期とは関係なく、呼んだ時点で壊れるものも2つあった。1つは本文が Call the Skill tool with "grilling". の1行だけのスキルで、呼び先の grilling はディスク上に存在しなかった。消した。
もう1つは、Codex にレビューを頼むスクリプトの default_branch だった。origin/HEAD が未設定のリポジトリで実行すると空文字を返す。~/.claude 自身がそうで、returned=[] を実測した。結果として codex review --base "" が走っていた。
# 直す前。|| は sed の終了コードに束縛され、sed は空入力でも 0 で終わる
default_branch() {
git symbolic-ref refs/remotes/origin/HEAD 2>/dev/null |
sed 's@^refs/remotes/origin/@@' || echo "main"
}
# 直したあと。パイプの終了コードではなく値で判定する
default_branch() {
local ref
ref=$(git symbolic-ref refs/remotes/origin/HEAD 2>/dev/null | sed 's@^refs/remotes/origin/@@')
printf '%s\n' "${ref:-main}"
}|| echo "main" はパイプライン全体、つまり最後の sed の終了コードに束縛される。sed は入力が空でも 0 で終わるので、フォールバックには一度も到達しない。「値が空ならデフォルト」を書いたつもりで、「sed が失敗したらデフォルト」を書いていた。
「呼ばれていない」は削除の証拠ではない。rules に置く基準も引き直した
次に、残りを減らしにかかった。Claude Code はセッション履歴を JSONL で残すので、Skill ツールの呼び出しを集計できる。
cd ~/.claude/projects
grep -rhoE '"name":"Skill","input":\{"skill":"[^"]+"' --include='*.jsonl' . \
| sed -E 's/.*"skill":"//;s/"$//' | sort | uniq -c | sort -rn追跡済み 34 スキルのうち 18 個が、履歴に1回も出てこなかった。ここで「呼ばれていないものを全部消す」案を検討して捨てた。履歴は一部しか残っていないので 0 回は「使われていない」の証明にならないし、稼働記録の打刻やセッションレポートのように、自動で発火させず自分が手で呼ぶ前提のスキルが混じっていた。集計は削除の証拠ではなく、候補を絞る材料として使った。この 18 個から消したのは、先に書いた本文1行のスキルと、内容の 9 割がモデルの既定知識だったコーディング規約スキルの2本だけだった。
減らす基準は、rules と skill の分担から決めた。rules は毎セッション無条件で文脈に載り、CLAUDE.md と合わせて 17,448 バイトあった。skill は呼ばれたときだけ読まれる。だから rules に置くのは「読まれなかったら事故になる規則」だけで、手順や参照情報は skill に落とす。rules の内容をそのまま再掲していた skill の再掲部分は参照に置き換えた。こうした整理の結果、スキル本文の合計は約 4,400 行から 3,079 行になった。
同じ基準で捨てた案がもう1つある。OpenAI Codex 向けのモデル切替ポリシーを rules に入れる案だ。rules は Claude の全セッションに載るので、Codex 用の指示が Claude に読まれ、Claude が Codex のモデル名への切替を薦めかねない。codex/ ディレクトリを新設して .gitignore のホワイトリストに !/codex/ を足し、Codex 向けの AGENTS.md は CLAUDE.md と rules と codex/ の連結で生成するようにした。
レビューでは見つからないので、検証を pre-commit に置いた
最初の欠陥に戻る。「他マシンで pull した瞬間に壊れる」設定は、diff を見ても分からない。追跡済みファイルの diff は正しく、問題は diff に出てこない未追跡ファイルの側にある。人が見つける経路が無いので、commit の直前に機械で止めることにした。scripts/validate-config.sh が検査するのは次の5点。加えて、スキルが参照する ~/... パスがこのマシンで解決しないときは warning だけ出す。マシン依存なので失敗にはしない。
- settings.json が JSON として読める
- settings.json の hook command が指すスクリプトが存在し、git 追跡済みである
- agents/ rules/ skills/ scripts/ codex/ に未追跡ファイルが無い
- skills/ にシンボリックリンクが無い
- 各 skills/<dir>/SKILL.md の frontmatter の name がディレクトリ名と一致し、description が 1〜1024 文字
存在しないスクリプトを hook に足して、止まることを確かめた。
$ scripts/validate-config.sh # 存在しない ~/.claude/scripts/nope.sh を hook に足して実行 ERROR: settings.json の hook が存在しないスクリプトを指している: ~/.claude/scripts/nope.sh ERROR: settings.json の hook スクリプトが git 未追跡: scripts/nope.sh validate-config: 2 error(s), 7 warning(s)
.git/hooks は追跡できないので、hook 本体は scripts/githooks/pre-commit に置き、clone ごとに1回 git config core.hooksPath scripts/githooks で有効化する。この1行は README に書いた。
Stop hook 側でもやり直しがあった。同期スクリプトは flock と -printf 対応の find と rsync 3 系を前提にしていて、素の macOS では毎ターン失敗する。前提を満たさないマシンでは何もせず終了する判定を足したのだが、2回間違えた。1回目は find を実装名で判定していて、このサーバーの find は bfs なので自分のマシンで skip された。2回目は rsync のバージョン判定を set -o pipefail 下でパイプに書いていた。grep -q が最初の一致で閉じると rsync が SIGPIPE で非 0 になり、パイプ全体が失敗扱いになって常に skip していた。skip はエラーではないので、同期が静かに止まるだけで気づく手がかりが無い。
# 直す前。set -o pipefail 下では grep -q が先に閉じ、rsync が SIGPIPE で非 0 になる ! rsync --version 2>/dev/null | grep -qE 'version 3\.' # 直したあと。パイプをやめて値で判定する rsync_version=$(rsync --version 2>/dev/null) || return 1 case "$rsync_version" in *"version 3."*) return 0 ;; *) return 1 ;; esac
まとめ
「動いている設定リポジトリ」は、動いているマシンで見ている限り壊れていることに気づけない。追跡済みファイルが未追跡ファイルを参照する、シンボリックリンクがそのマシンにしか無い場所を指す、といった欠陥は、diff にも動作確認にも出てこない。人が見つける経路が無いなら、検証は機械に置くしかない。
もう1つ。シェルの「失敗したらデフォルト」は、パイプラインの終了コードと SIGPIPE の両方で意図と逆に動く。同じ日に2回踏んだ。フォールバックは終了コードではなく値で判定する。