CLAUDE.mdにNEVERと書いても3回目に破られた—hookだけが機能する理由を実測した設計ログ

「CLAUDE.mdにNEVER delete files in productionと書いたのに、また消された」——Claude Codeを長く使っていると、必ずこの局面が来ます。
一度は守られます。二度目も守られることが多い。でも三度目、四度目、コンテキストが長くなってきたタイミングで、ルールがすり抜けることがある。
これは「Claudeが言うことを聞かない」のではありません。**CLAUDE.mdのルールは構造的に「ソフトウォール」**なのです。そしてこの問題の唯一の解決策が、hookでした。
結論
| 仕組み | どう機能するか | 信頼性 |
|---|---|---|
| CLAUDE.md のルール記述 | LLMがコンテキストとして読む → 確率的に従う | ソフトウォール(長いコンテキストで抜けやすい) |
| hook(UserPromptSubmit / Stop等) | Claude Codeハーネスが実行する → 必ず動く | ハードウォール(LLMの判断と独立) |
CLAUDE.mdは「してほしいこと」の定義場所。hookは「必ずさせること」の実装場所。 この役割分担を設計に組み込むことで、「また破られた」が止まります。
この記事の前提(自分の状況・スタック・実験条件)
- 運用構成: masatoman.net(記事200本規模)をClaude Code + hooks + CLAUDE.md で自動化運用中
- hook実装済み: UserPromptSubmit hook(git contextを自動注入)、Stop hook(作業後のgit status可視化)、PreCompact hook(圧縮前ログ保存)の3種を運用
- 発見契機: settings.jsonにhookを追加した同セッション内で即座に発火した事実から、hookとCLAUDE.mdの「強制力の違い」を体感として理解した
- CLAUDE.md規模: 4,696文字・115行()→
トークン実測記事参照
読者のよくある詰まり(「それ俺だ」ポイント)
「CLAUDE.mdに禁止事項を書いたのに、セッションが長くなると守られなくなる」
具体的にこんな経験はないでしょうか。
NEVER push directly to mainと書いたのに、長い作業の後半で直接pushされた必ずlintを通してからcommitすることと書いたのに、lint未実行でcommitされた- 同じ禁止事項を3回書き直したが、毎回どこかで抜けが出る
これは「どう書けばより守ってもらえるか」を試行錯誤する問題ではありません。CLAUDE.mdの仕組み上、これは構造的な限界です。
実際に起きたこと:hookを実装した日の発見
2026年5月、CLAUDE.mdの指示だけで運用していた私が、はじめてhookを実装しました。
Stop hook、PreCompact hook、UserPromptSubmit hookの3種類を settings.json に追加した直後のことです。
「hookは次のセッションから反映される」と思い込んでいました。ところが、同じセッションの後半で、UserPromptSubmit hookが即座に発火しました。settings.json のhot reloadが効いたのです。
この瞬間に気づいたのは、hookとCLAUDE.mdの決定的な違いです。
- CLAUDE.mdを書き換えた直後: Claudeはそのセッション内のコンテキストに既に読み込み済みの古いバージョンを参照する
- settings.jsonのhookを書き換えた直後: ハーネスが即座に新しい設定を読み込み、次のフックポイントで実行する
「ルールを更新したのに効いていない」という場合の多くは、この仕組みの違いに起因しています。
原因分析:「ソフトウォール」と「ハードウォール」
CLAUDE.mdが「ソフトウォール」である理由
CLAUDE.mdに書いたルールは、Claudeへのコンテキスト注入として機能します。Claudeはそれを読み、プロンプトとして解釈し、「できる限り従おうとします」。
しかし、これは以下の条件で崩れます。
- コンテキストウィンドウが長くなる: 会話が長くなるにつれ、CLAUDE.mdの内容が相対的に薄まります。後半の会話の方が、LLMにとって「直近の文脈」として重く扱われます
- ルールの量が多い: 禁止事項が10個あると、12個目の判断が必要な場面で8番目の禁止事項が見落とされやすくなります()→
CLAUDE.mdの肥大化問題
- 矛盾する指示がある: 「素早く実行せよ」と「必ず確認を取れ」が共存すると、文脈によってどちらかが選ばれます
- 強調語の効力消失:
NEVER、MUST NOT、CRITICALを多用すると、すべての記述が同じ重みになります
hookが「ハードウォール」である理由
hookは、Claudeのコンテキストに依存しません。Claude Codeのハーネスが、定義されたライフサイクルポイントで実行します。
// settings.json の例
{
"hooks": {
"UserPromptSubmit": [
{
"hooks": [
{
"type": "command",
"command": "bash /path/to/git-context-inject.sh"
}
]
}
]
}
}
このスクリプトは、Claudeが「実行するかどうか判断する」のではなく、ハーネスが強制的に呼び出します。LLMの判断が介在しません。
git status を毎ターン表示するStop hookを実装した日、私のリポジトリに untracked ファイルが84件 存在することが一瞬で判明しました。CLAUDE.mdに「gitを常に意識せよ」と書いていても、この可視化は起きていませんでした。
判断基準:CLAUDE.mdに書くこと vs hookに移すこと
では、具体的にどう分けるか。私の現在の運用ルールを共有します。
CLAUDE.mdに書く(LLMに「理解してほしいこと」)
| 種別 | 例 |
|---|---|
| プロジェクトの文脈定義 | 「このサイトのターゲットは個人開発者・副業者」 |
| 高レベルの方針 | 「記事執筆は失敗ログ優先、成功談は控える」 |
| 役割とスコープ | 「コード変更はsrcディレクトリ以外触らない」 |
| 推奨パターン | 「コンポーネントは既存ライブラリを再利用する」 |
これらは「Claudeがどんな判断軸を持つべきか」を定義するもの。コンテキストとして理解させるのが目的で、厳密な強制力は求めません。
hookに移す(「必ず起こさなければならないこと」)
| タイミング | hook種別 | 用途 |
|---|---|---|
| 毎プロンプト前 | UserPromptSubmit | git contextの自動注入、lintチェック |
| 作業完了後 | Stop | git statusの可視化、未pushの警告 |
| コンテキスト圧縮前 | PreCompact | 状態スナップショットの保存 |
判断の基準はシンプルです: 「1回でも抜けたら困るルール」は全部hookへ移す。
# Stop hookの例(git-status-check.sh)
#!/bin/bash
STATUS=$(git status --short)
UNTRACKED=$(git ls-files --others --exclude-standard | wc -l)
if [ -n "$STATUS" ] || [ "$UNTRACKED" -gt 5 ]; then
echo "⚠️ 作業後の状態確認"
echo "変更: $(echo "$STATUS" | wc -l)件 / untracked: ${UNTRACKED}件"
git status --short | head -20
fi
このスクリプトは、Claudeが忘れてもハーネスが実行します。
今日やること(3つ)
- 自分のCLAUDE.mdを開いて「NEVER/MUST NOT/CRITICAL」でgrepする — 出てきたルールは「本当にhookで強制すべきか」を再評価する
- 最低1つのStop hookを実装する —
git status表示のみでも十分。未push/untrackedの可視化だけで運用が変わります()→hook実装の基礎はこちら
- CLAUDE.mdのルールを「コンテキスト定義」と「強制事項」に色分けする — 後者はhookへの移植候補リストとして管理する
masatoman のメルマガ — 毎週月曜の朝に手紙を 1 通
masatoman.net の今週の記事 1 本を、読者目線で深掘りした手紙が毎週月曜 9:00 に届きます。「これ自分のことだ」が見つかる予告編。登録特典に「個人開発の収益化チェックリスト 15 項目」。
masatoman のメルマガ — 毎週月曜の朝に 1 通
masatoman.net で今週公開した記事の中から 1 本を、読者目線で深掘りした手紙が届きます。「自分も同じことやってる」「ここで詰まってた」が見つかる予告編。