「Skill 便利すぎる、これ全部自動化できるじゃん」
Skill を 1 個書いた直後は、だいたいこの気持ちです。開発フロー用の Skill を叩けば PR まで仕上がる、ブログ下書き用の Skill を叩けば草稿が出てくる。実際どうなの? と疑っていた頃が嘘みたいに、手が止まっていた作業が勝手に進む(つまり、夜の自分の時間が戻ってくる)。
3ヶ月後。Skill が 30 個を超えたあたりから、雲行きが怪しくなります。意図しないタイミングで起動する Skill、関係ない Bash コマンドにまで発火する hook、長時間走る Skill が permissions の確認待ちで止まる、nix run .#update で symlink がぶっ壊れる。動くけど、触るのが怖い状態の出来上がり、というわけです。
この記事は、Skill 運用で実際に踏んだ 4 つのアンチパターンと、それぞれをどう直したかをまとめたものです。全部、私たちが踏みました。
この記事で学べること
- Skill の description / frontmatter で意図が伝わらず勝手に動く問題の直し方
- Hook の matcher 設計ミスで全 Bash に発火するパターンと内部ガード句の作り方
- Long-run session で permissions の deny が邪魔になるときの PreToolUse 移行設計
- Skill リポジトリの symlink を複数の管理機構で奪い合って壊す事故の防ぎ方
前提:入門編・上級編の続編
この記事は「Skill を 1 個も書いたことがない」人向けではありません。すでに 5〜10 個の Skill を書いて運用していて、「あれ、なんか最近壊れる頻度高くない?」と感じ始めた頃合いの読者を想定しています。
入門・設計の話は以下の記事を前提にしています。
アンチパターン 1:description が曖昧で Skill が「やりすぎる」
最初に踏みやすいのがこれです。Skill の description をそれっぽく書いたら、書いた本人が想定していない地点まで Skill が走り切ってしまう。
実例:開発フロー Skill が PR を勝手に merge する事件
社内で運用していた開発フロー Skill(issue を受け取って実装→PR→レビューまで通す)に、こんな不具合レポートが上がりました。
Skill 実行時、PR が LGTM を得た後に自動でマージまで実行されてしまう。 期待される動作: レビューで LGTM を取得したら終了 実際の動作: LGTM を得た後に PR をマージしてしまう
原因は SKILL.md の description、たった 1 行です。
description: |
End-to-end development flow automation - from issue to merged PR.
from issue to merged PR と書いてしまったがために、Claude が「Skill の責務は merged PR まで」と解釈し、CI が通った瞬間に gh pr merge 相当の挙動を取るシナリオが発生していた。実際のスクリプトには gh pr merge は どこにも書かれていない のに、description に書かれているという理由だけで動いてしまう。
なぜ起きるか
LLM ベースの Skill で description は「Skill 自体の取扱説明書」として機能します。description が完了条件・成功条件の定義として読まれるので、ここに書いた一文がそのまま Skill のスコープになる。コードに書いてないことでも、description に書いてあれば LLM はやろうとします。
直し方:description は責務の境界線として書く
修正対応はほぼ description の言い換えだけでした。
description: |
End-to-end development flow automation - from issue to LGTM.
Note: Merge is performed manually by the user after review approval.
merged PR を LGTM にして、明示的に「merge は人間がやる」を書く。この 2 行で Skill の挙動が変わります(LLM 相手なので、逆に言えば 2 行で挙動が変わってしまう)。
仕組みで防ぐ:frontmatter validation hook
description だけでなく、必須フィールドの欠落・文字数オーバーで詰まるケースもあります。私たちのリポジトリでは frontmatter validation スクリプトを PreToolUse hook として登録し、SKILL.md を書き込もうとした時点で機械的に弾く構成に変えました。検査内容はざっくりこんな感じです。
- 必須フィールド(
name,description)の存在 - description の文字数上限(500 文字)
model/effort/contextの値バリデーション
frontmatter の effort 値は途中で low|medium|high|xhigh|max まで拡張されています(出典は Claude Code Skills frontmatter reference)。validate スクリプトの許容値リストを公式仕様に合わせて更新する、という保守も発生する。ここを忘れると新しい frontmatter が書けなくなって全部の Skill 編集が詰まります。実体験です。
アンチパターン 2:hook の matcher が雑で全 Bash に発火する
Skill 単体ではなく、Skill を支える hook 設定でやらかすパターン。
実例:gh pr merge hook が全 Bash で発火していた問題
修正時に書いた振り返りメモにこうあります。
ifはマッチャーグループレベルではなく hook オブジェクト内に配置する必要がある。 誤配置により gh pr view 等の無関係なコマンドにもフックが発動していた。
それから数日後、gh pr merge hook の発火範囲をさらに絞り込む追加修正が入ります。修正メモにはこう書いてあります。
該当 hook は
if: Bash(gh pr merge *)のみに依存しており、内部ガード句が無かった。 これまで if のみに依存していたため、ifが効かないケースで全 Bash コマンドに対して hook が走り、"base= — nightly/* 以外はユーザー確認が必要" ダイアログが頻発していた。
要するに、gh pr merge のときだけ走らせたかった hook が、Skill が打つあらゆる Bash コマンドに発火していた。gh pr view でも git status でも、毎回確認ダイアログが出る(自動化のために書いたはずの hook で、ひたすら手動確認させられる時間が増える)。長時間走る Skill がこれで止まる。
なぜ起きるか
hook 側の matcher 設定(if フィールド)を信じすぎる構成になっていたのが本質です。settings.json の matcher は記述ミスや配置ミスで簡単に効かなくなるし、効かなくなったときに気づきにくい(普通の操作中はサイレントに過剰発火するだけなので)。
直し方:hook 本体に内部ガード句を入れる
この修正の対応はシンプルで、巨大インラインコマンドを専用スクリプトに切り出した上で、スクリプト先頭で gh pr merge 以外を早期 exit するガード句を追加する、という二段構えにしました。
#!/usr/bin/env bash
set -euo pipefail
# 内部ガード句: matcher を信じない
case "${COMMAND:-}" in
"gh pr merge "*) ;;
*) exit 0 ;;
esac
# 以降、本来の処理
settings.json の if フィールドだけに頼らず、hook script 自身が「自分の発火条件」を持つ。matcher が壊れても scripts が壊れにくい構成にする、というのがポイントです。
検証可能な hook を書く
私たちのリポジトリでは新規 hook には基本的に tests/ 以下にテストスクリプトが添えてあります。たとえば prod credential 検知 hook には 25 件のテストケース、Stop hook(feature ブランチで未 commit 差分が残ったまま session が終わるのを止める hook)には 9 件のテストケースが同梱されています。hook がサイレントに壊れるのを防ぐ唯一の方法はテストで、これは普通のコードと変わりません。
アンチパターン 3:permissions の deny が粗くて long-run が止まる
3 つ目は permissions 設計の罠。
実例:feature ブランチへの git push が毎回ブロックされていた
deny ルールを見直した時の修正メモはこうです。
包括的な Bash(git push) / Bash(git push origin) deny を削除。 保護ブランチ(main/master/dev/develop)への push deny は個別ルールで維持。 長時間走るスキルがパーミッション確認なしに feature push 可能に。
最初は「危険そうな操作は全部 deny」で組み始めるのが安全に見えるんですが、これをやると長時間走る Skill がブランチごとにユーザー確認待ちで止まり続ける。夜間に走らせたい巡回 Skill も同じ理由で詰まる(睡眠中に進めたい処理が、確認ダイアログ前で朝まで待っている)。
なぜ起きるか
Bash(git push) のような粒度の deny は、「main への push を禁止したい」と「feature ブランチへの push も全部止める」を分離できないからです。粒度が文字列マッチに支配されているので、コマンド自体の安全性ではなくコマンドの形で判定するしかない。
直し方:deny を消して PreToolUse hook に格上げする
実装としては、PreToolUse hook で feature ブランチ push を自動許可する構成に切り替えました。
Bash(git push)/Bash(git push origin)の包括 deny を削除- PreToolUse hook でブランチ判定
- 保護ブランチ (main/master/dev/develop) → deny
- feature/* 等 → allow(確認なし)
- 判定不能 → ask(ユーザーに確認)
- refspec 指定の明示的保護 deny ルールは settings.json 側で維持
文字列マッチで判定できないところは hook に逃がす、という分業です。settings.json の deny リストはあくまで「絶対に止めたい」最小集合に絞り、グレーゾーンは hook で動的に判定する。
prod credential 検知 hook も同じ思想で、完全 deny ではなく permissionDecision = "ask" を返してユーザー確認に落とす設計になっています。誤検知時の escape を残しておかないと長時間タスクが完全停止するので、ask がデフォルトです。
Codex / Gemini と統一しようとして詰まる
ちなみに、この permissions 構造を Codex (prefix_rule 形式) と統一しようとすると新しい罠が待っています。実際に統一を試みた時に書いた注記がこれです。
Read(.env)等のファイルアクセス制限は Codex の prefix_rule では表現不可mv /*,cp /*等の glob パターンも prefix_rule はリテラルマッチのみBash(git push)の bare 完全一致は prefix_rule だと全 push をブロックするためpromptに分類
Skill 自体は agentskills.io 標準で共有できても、permissions モデルは各ツールで方言がある。自動変換しようとせず、禁止ポリシーを言語化しておいて Skill 側で各フォーマットに変換させる、というのが現実解でした(このあたりは Skills 共有管理の記事 に詳しく書きました)。
アンチパターン 4:symlink の管理機構を増やしすぎて自分で踏む
4 つ目はもう「Skill 設計」というより「Skill リポジトリの運用ミス」ですが、3 ヶ月運用すると確実に踏むので入れておきます。
実例:nix run .#update で skills symlink が壊れる
不具合報告のタイトルがそのまま症状です。
nix run .#updateを実行すると、~/.claude/skillsの symlink が切れる。
原因が清々しいくらい単純です。~/.claude/skills を 2 つの異なるメカニズムで 管理していました。
- home-manager の activation script
→
~/.claude/skillsを dotfiles 配下のclaude-code/skillsにリンク - setup スクリプト
→
~/.claude/skillsを 別リポジトリの skills 専用ディレクトリ にリンク
最初に setup スクリプトを走らせると (2) が効いて別リポジトリを指すリンクができる。その後 nix run .#update を走らせると (1) が effect して dotfiles 配下を指すリンクで上書きされる。dotfiles 配下から skills は移動済みで存在しないので、symlink が切れる。
なぜ起きるか
「Skill リポジトリを別管理に切り出したい」「dotfiles 側にも残したい」を同時に追求すると、symlink の作成権限を持つ機構が複数並立してしまう。それぞれの機構は単体では正しく動くので、衝突に気づくのは壊れたあとです。
直し方:symlink の管理権限を 1 箇所に集約
修正時は、シンプルに 片方を諦める という選択を取りました。
- home-manager の activation script から skills symlink の管理コードを削除(13 行)
- skills symlink の作成・更新は setup スクリプトに一元化
nix run .#update実行後も setup スクリプトで設定したリンクが維持される
「あらゆるリソースを home-manager で宣言的に管理したい」気持ちはわかるんですが、外部リポジトリへのリンクなど lifecycle が異なるリソースは home-manager から外したほうがトータルで壊れません。Single Source of Truth は思想ではなく、複数の機構の事故を防ぐ運用ルールです。
発火源を絞る応用:setup スクリプト連鎖
派生形として、setup スクリプトに hooks symlink の自動設定も後から追加しています。claude-code/hooks/ 配下を ~/.claude/hooks/ に自動 symlink する関数を 1 個追加するだけ。hook 追加時の手動 symlink 忘れという別の事故を、setup の発火源を 1 箇所に絞ることで防いでいます。
まとめ
4 つのアンチパターンを並べて気づくのは、症状はバラバラだけど原因が同じ構造だということです。
| アンチパターン | 表面的な症状 | 根本原因 |
|---|---|---|
| description が曖昧 | Skill が想定外まで走る | LLM が description を完了条件として読む |
| hook matcher が雑 | 全 Bash に発火する | matcher を信じすぎ・script 側の内部ガード欠落 |
| permissions が粗い | long-run が止まる | 文字列マッチ deny で粒度を分離できない |
| symlink 管理の重複 | nix update で壊れる | 同じリソースを複数の機構で管理 |
共通しているのは「1 箇所だけで安全性を担保しようとしている」という構造です。description 1 行、settings.json の matcher 1 個、deny ルール 1 行、home-manager の activation 1 個。どれも書いた瞬間は綺麗ですが、運用が長くなるほど他の機構と衝突したり、抜け道が露呈したりする。
3 ヶ月分の修正履歴をまとめて読み返して出てきた回避策のパターンは、ほぼ同じ形をしています。
- 二段構えにする:matcher だけでなく hook 本体にもガード句、deny だけでなく PreToolUse hook での動的判定
- 責務の境界線を文章で書く:description に「やらないこと」を明示、SKILL.md に Note: で escape hatch を書く
- テストを添える:hook には tests/、frontmatter には validation スクリプト
- 管理機構を 1 つに絞る:symlink の作成権限はどこか 1 箇所、複数機構を並立させない
Skill を 1 個書くのは楽しい。30 個を超えてもメンテし続けられるかどうかは、書いたあとの設計判断にかかっているというのが、3 ヶ月運用してみての率直な感想でした。次に Skill を増やす前に、自分のリポジトリの description / hook matcher / deny リスト / symlink を見直してみると、たぶん何か見つかります(私たちはそれで毎週何か直しています)。



