長めのリファクタリングをClaude Codeに任せて眺めていると、画面に「Compacting conversation」の表示が出ることがある。数秒後、会話は何事もなかったかのように再開する。ただ、その後のAIは様子が少しおかしい。さっき却下したはずの設計案を、良いことを思いついた顔でもう一度提案してくる。修正済みのファイルを開き直して「まずこの問題から直しましょう」と言い出す(さっきまでの阿吽の呼吸はどこへ)。
これがコンテキストウィンドウ圧縮、いわゆるauto-compactの副作用だ。AIが一度に扱える会話の記憶領域には上限があり、長いセッションで上限に近づくと、Claude Codeは会話履歴をAI自身の要約へ自動で置き換える。セッションを続けるために必要な仕組みではあるものの、要約はあくまで要約で、何を残して何を捨てるかの判断はモデル任せになる。そこで漏れた決定事項は、「忘れた」という自覚ごと消える。
この記事では、この「圧縮のたびに文脈が欠ける」問題への汎用的な対策として、PreCompactとSessionStartという2つのhookを組み合わせる設計パターンを解説する。実際にdotfilesとして公開・運用している実装をもとに、自分の~/.claude/hooks/に置いてそのまま使える粒度まで噛み砕いていく。
この記事で学べること
- auto-compact(コンテキストウィンドウ圧縮)がいつ発火し、何を失わせるのか
- 圧縮の直前に作業状態をファイルへ退避するPreCompact hookの書き方
- 退避した状態を次のcontextへ自動で再注入するSessionStart hookの書き方
- mtimeに依存しない最新ファイル選定や二重rotationなど、運用してわかる設計の勘所
前提条件
- Claude Code(Pro $20/月、Max $100〜$200/月)で長時間セッション、または日をまたぐ開発をしている
settings.jsonのhooks設定を編集できる(jqがあると理想だが、無くても動く形で書く)
auto-compactで何が起きているのか — コンテキストウィンドウ圧縮の挙動
まず挙動を正確に押さえたい。コンテキストウィンドウ圧縮には2つの入り口がある。ひとつはトークン使用量が上限に近づいたときに自動で走るauto-compact。もうひとつはユーザーが任意のタイミングで打つ/compactコマンドで、こちらは「テスト方針の議論は要約に残して」のような指示を添えることもできる。どちらの場合も、圧縮後の会話は「モデル自身が書いた要約 + それ以降の履歴」で構成される。
問題は、この要約が非決定的であることだ。直前に合意した設計判断、「このテストは触らない」という制約、既に試して失敗したアプローチ——どれが要約に残るかは、そのときのモデルの重要度判断に依存する。しかも漏れた情報は漏れたことすら記録されないので、AIは欠けた前提のまま自信を持って作業を続ける(記憶は要約版なのに、自信は完全版のままだ)。コーディングエージェントDevinの開発元も、モデル単独の自己要約に頼ると「知らないことを知らない」ギャップが生まれるとレポートで指摘している。冒頭の「却下済みの案をもう一度提案してくる」現象は、まさにこのギャップの現れだ。
ではコンテキストウィンドウが大きくなれば、この問題は消えるのだろうか。そうでもない。実装時の設計メモにも「1Mトークン級のコンテキストウィンドウが一般化して緊急度は下がったが、圧縮時に重要情報が抜けるリスクはゼロではない」と残している。ウィンドウが広がるほど1セッションが長くなり、いざ圧縮が走ったときに失うものはむしろ増える(つまり、広い部屋ほど引っ越しの荷物は増える)。auto-compact自体を設定で無効化する手もあるが、それは圧縮のタイミングを手動に移すだけで、有限のコンテキストウィンドウという制約そのものは消えない。
つまり、圧縮は「起きるかもしれない事故」ではなく「いつか必ず来る定期イベント」として設計に織り込むほうが筋がいい。Anthropicのコンテキストエンジニアリング解説も、長時間タスクの戦略としてcompaction・structured note-taking(構造化メモ)・マルチエージェントの3つを挙げている。今回のhookパターンは、このstructured note-takingをワークフロー側ではなくハーネス側で自動化する、という位置づけになる。
対策の全体像 — 圧縮前に退避して、開始時に再注入する
発想はシンプルで、「モデルの自己要約に委ねず、決定的(deterministic)に取れる状態をファイルへ書き出しておく」だけだ。Claude Codeのhooksには、/compactまたはauto-compactの実行直前に発火するPreCompactというイベントが公式に用意されている。ここで作業状態をファイルへ退避し、セッションの開始時に発火するSessionStart hookでそのファイルを読み戻す。2つのhookの分業をフローにすると次のようになる。
ポイントは役割を一方向に分けていることだ。PreCompact側は書くだけ、SessionStart側は読むだけ。そもそもhooksの仕様上、stdoutがそのままcontextへ追加されるのはSessionStartを含む一部のイベントだけで、PreCompactのstdoutはデバッグログに残るのみ、contextには載らない。退避役は静かにファイルへ書いて終わり、注入はすべてSessionStart側の仕事に寄せることで、圧縮直後と翌日の新セッションを同じ経路で扱えて挙動を追いやすい。SessionStartはstartup(新規起動)・resume(再開)・compact(圧縮直後)のどのソースでも発火するので(ソースにはほかにclearやforkもある)、この1本で「圧縮をまたいだ復元」と「日をまたいだ復元」の両方をカバーできる。
PreCompact hook — 圧縮直前に作業状態をファイルへ退避する
PreCompact hookには、stdinからJSONが渡ってくる。使うのは主に3つで、cwd(作業ディレクトリ)、trigger(manualかautoか)、そして/compactに添えた指示が入るcustom_instructionsだ。session_idやtranscript_pathも来るので、ログとの突き合わせに使える。
退避すべきは「会話履歴からは要約経由でしか復元できないが、環境からは決定的に取れる情報」だ。実運用している実装では次を書き出している。
- git の現在地: ブランチ、worktree、
git status --short、直近10コミット、git diff --stat - プロジェクト固有の決定事項: 決定事項を書き溜めたメモファイル(例:
docs/decisions.md)があれば、その先頭200行 - 直近のツール実行履歴: 権限リクエストの監査ログ(JSONL)からセッションIDが一致する末尾20件。一致が無ければ直近10件で代用(各上限は運用しながら調整してきた参考値なので、自分の環境に合わせて増減してよい)
このうちgit部分だけでも効果があるので、まず最小構成を示す。~/.claude/hooks/pre-compact-dump.shとして保存し、chmod +xしておく。
#!/usr/bin/env bash
set -euo pipefail
INPUT=""
[ ! -t 0 ] && INPUT=$(cat || true)
CWD=$(printf '%s' "$INPUT" | jq -r '.cwd // empty' 2>/dev/null || true)
TRIGGER=$(printf '%s' "$INPUT" | jq -r '.trigger // "manual"' 2>/dev/null || echo manual)
cd "${CWD:-$PWD}" 2>/dev/null || true
# cwd から project root を解決し、退避先を決める
PROJECT_ROOT=$(git rev-parse --show-toplevel 2>/dev/null || echo "$PWD")
DUMP_DIR="$PROJECT_ROOT/docs/session-dumps"
mkdir -p "$DUMP_DIR"
DUMP_FILE="$DUMP_DIR/session-$(date +%Y%m%d-%H%M%S).md"
{
echo "# Session Dump (pre-compact)"
echo "- trigger: $TRIGGER"
echo "- branch: $(git rev-parse --abbrev-ref HEAD 2>/dev/null || echo none)"
echo "## Git Status"
git status --short 2>/dev/null || true
echo "## Recent Commits"
git log --oneline -n 10 2>/dev/null || true
echo "## Diff Stat"
git diff --stat HEAD 2>/dev/null || true
} > "$DUMP_FILE"
# 14日より古い退避ファイルは削除して肥大化を防ぐ
find "$DUMP_DIR" -maxdepth 1 -name 'session-*.md' -type f -mtime +14 -delete 2>/dev/null || true
echo "[pre-compact-dump] wrote $DUMP_FILE" >&2
exit 0
実装上の要点が3つある。第一に、stdoutには何も出さない。進捗ログはstderrへ逃がし、ファイルへ静かに書くだけにする(注入はSessionStart側の担当なので)。第二に、必ずexit 0で終える。hookの失敗で圧縮を止めても誰も得をしない。だからgit系コマンドはすべて|| trueでフェイルセーフにしてある。第三に、rotationを退避側に持たせる。読まれなかった退避ファイルが無限に溜まると、それはそれで別の掃除問題が生まれる(保険が本体の邪魔をしたら世話がない)。
退避先ディレクトリ名(ここではdocs/session-dumps/)は好みで変えて構わない。決定事項メモを先頭200行に制限しているのも意図的な設計で、退避ファイルは次のセッションのcontextへ丸ごと載るため、退避自体がコンテキストウィンドウを圧迫しては本末転倒になる。この「メモを絞って構造化する」方向性は、先に挙げたAnthropicの解説でいうstructured note-takingの実践形でもある。
SessionStart hook — 前セッションのdumpをcontextへ再注入する
読み込み側はさらに短い。SessionStart hookのstdinにはcwdとsource(startup / resume / compact など)が入ったJSONが渡され、stdoutへ書いた内容はそのままcontextへ注入される。つまり最新の退避ファイルをcatするだけで、AIは新しいセッションの冒頭から前回の作業状態を「知っている」状態になる。
#!/usr/bin/env bash
set -euo pipefail
INPUT=""
[ ! -t 0 ] && INPUT=$(cat || true)
CWD=$(printf '%s' "$INPUT" | jq -r '.cwd // empty' 2>/dev/null || true)
cd "${CWD:-$PWD}" 2>/dev/null || true
PROJECT_ROOT=$(git rev-parse --show-toplevel 2>/dev/null || echo "$PWD")
DUMP_DIR="$PROJECT_ROOT/docs/session-dumps"
[ -d "$DUMP_DIR" ] || exit 0
# mtimeではなく、ファイル名の辞書順で最新を選ぶ
LATEST=$(printf '%s\n' "$DUMP_DIR"/session-*.md | sort -r | head -n 1)
[ -f "$LATEST" ] || exit 0
# 14日以上古い退避ファイルは注入しない(フェイルセーフ)
find "$LATEST" -mtime +14 -print 2>/dev/null | grep -q . && exit 0
echo "## 前セッションのpre-compact dump(自動読み込み)"
echo ""
cat "$LATEST"
exit 0
地味だが効いているのが「最新ファイルの選び方」だ。ls -tでmtime順に取りたくなるところを、あえてファイル名の辞書順ソートにしている。ファイル名にsession-YYYYMMDD-HHMMSS.mdと時刻を埋め込んであるので、辞書順の降順がそのまま時刻の降順になる。mtimeを信用しないのは、フォーマッタの一括適用などでファイルが軒並みtouchされると、古い退避ファイルが「最新」に化けて誤注入されるからだ(フォーマッタは仕事をしただけなのに)。この種のmtimeドリフトを設計段階で潰した結果として、ファイル名基準に落ち着いている。
14日チェックが読み込み側にもあるのは二重の保険で、削除rotationは退避側の担当だが、何らかの理由でそちらが動かなくても、賞味期限切れの文脈を注入しない。2週間前の作業状態を冒頭に読まされても、もはや別プロジェクトの話である。
settings.jsonへの登録
2本のスクリプトをsettings.jsonのhooksセクションで配線する。SessionStartはmatcherでソース別に登録できる。
{
"hooks": {
"PreCompact": [
{
"matcher": "",
"hooks": [
{
"type": "command",
"command": "bash \"$HOME/.claude/hooks/pre-compact-dump.sh\"",
"statusMessage": "session 状態を docs/session-dumps/ に退避中...",
"timeout": 10
}
]
}
],
"SessionStart": [
{
"matcher": "compact",
"hooks": [
{
"type": "command",
"command": "echo 'Context compacted. Reminder: Read CLAUDE.md for project context. Run git status before making changes.'"
},
{
"type": "command",
"command": "bash \"$HOME/.claude/hooks/session-start-replay.sh\"",
"timeout": 5
}
]
},
{
"matcher": "startup",
"hooks": [
{
"type": "command",
"command": "bash \"$HOME/.claude/hooks/session-start-replay.sh\"",
"timeout": 5
}
]
}
]
}
}
PreCompactのmatcherは空にしてあり、manual(/compact)とauto(auto-compact)の両方で発火する。区別が要る場合はstdinのtriggerをスクリプト側で見ればいい。SessionStart側はcompactとstartupに加えて、実運用ではresumeにも同じreplayスクリプトを登録している(--resumeで古いセッションを開いたときも状態を思い出させるため)。
もうひとつの小技が、compactのmatcherにだけ入れているechoの1行だ。圧縮直後のセッションに「CLAUDE.mdを読み直せ、変更前にgit statusを確認しろ」という静的なリマインダを注入している。退避ファイルが動的な作業状態を運び、echoが普遍的な行動規範を運ぶ。この2段構えは退避ファイルが存在しない初回でも効く。timeoutを短めに絞っているのは、hookの実行時間がそのままセッション開始の待ち時間になるからだ(起動のたびに待たせる保険は、正直、解約したくなる)。
動作確認
hookはstdinにJSONを渡せば手で叩けるので、Claude Codeを起動する前にスクリプト単体で検証できる。
# 退避側: dumpファイルが生成され、stderrにパスが出ることを確認
echo '{"trigger":"manual","cwd":"'"$PWD"'"}' | bash ~/.claude/hooks/pre-compact-dump.sh
ls docs/session-dumps/session-*.md
# 注入側: 直前に作ったdumpの中身がstdoutへ流れることを確認
echo '{"source":"startup","cwd":"'"$PWD"'"}' | bash ~/.claude/hooks/session-start-replay.sh
スクリプト単体で動いたら、実セッションで通しの確認をする。Claude Codeでいくつか作業した後に/compactを実行し、docs/session-dumps/に新しいsession-*.mdが生成されること、圧縮後の会話にリマインダと退避内容が注入されていることを見る。hooksの登録状況はClaude Code内の/hooksコマンドでも確認できる。auto-compact側の発火は意図的に起こしにくいが、トリガーが違うだけで通る経路は同じなので、/compactでの確認が通れば十分実用になる(避難訓練のために本物の火事を起こす必要はない)。
設計の勘所
実装のディテールから独立して、このパターンを自分の環境に合わせて崩すときに残すべき性質をまとめる。
- hookは常に成功させる: 退避も注入も「あれば嬉しい保険」であり、本体の進行より優先しない。exit 0固定、内部コマンドは全部フェイルセーフに
- 書く係と読む係を分ける: PreCompactは書くだけ、SessionStartは読むだけ。復元経路が1本になり、圧縮直後・新規起動・再開のすべてが同じコードで動く
- mtimeを信用しない: 時刻はファイル名に埋めて辞書順で選ぶ。mtimeは他のツールに簡単に書き換えられる
- rotationは両側に置く: 退避側で古いファイルを削除し、注入側でも古いファイルを無視する。片方が壊れても古い文脈の誤注入だけは防げる
- 退避は絞る: 退避ファイルは次のcontextに丸ごと載る。決定事項メモの行数制限のように、「復元に効く最小限」へ絞る意識を持つ
もうひとつ付け加えたいのは、hookは書いて終わりではないということだ。同じdotfilesで運用している別のガード系hookでは、git -Cのようなグローバルオプション付きコマンドが前方一致の判定を素通りする迂回が実運用で見つかり、回帰テストを積み増して塞いだ経緯がある。今回の2本は読み取り専用に近い性質のぶん事故は起きにくいが、「hookに任せたから安心」ではなく、動作確認をスクリプト単体で再実行できる形に保っておくことが長持ちの秘訣になる。
なお、このhookパターンは万能薬ではない。以前書いたコンテキスト圧縮に耐える状態管理パターンは、ワークフロー自身が進行状態をJSONへ書き出すアプリケーション層の設計で、特定ワークフローの正確な再開に強い。今回のhookはその下のハーネス層で、どのセッションにも無差別に効く代わりに、運べるのは環境から機械的に取れる情報に限られる。性質が違うので、両方を併用するのが実際のところ一番堅い。
| アプローチ | 層 | 得意なこと | 限界 |
|---|---|---|---|
| モデルの自己要約(既定の動作) | モデル層 | 設定不要で会話の流れを保つ | 何が残るかは非決定的。漏れに気づけない |
| ワークフロー側の状態ファイル | アプリ層 | 特定ワークフローの正確な再開 | 状態設計したワークフローにしか効かない |
| PreCompact/SessionStart hook | ハーネス層 | 全セッションに自動で効く | 環境から取れる汎用情報のみ運べる |
まとめ
コンテキストウィンドウ圧縮は、長時間セッションを続ける限り避けられない定期イベントだ。モデルの自己要約だけに委ねると、直前の決定事項が「忘れた自覚もなく」欠け落ちる。対策の骨格は、PreCompact hookで圧縮の直前に決定的な状態をファイルへ退避し、SessionStart hookで次のcontextへ自動再注入する、という書き読み分離の2本立て。スクリプト自体は今日書ける分量なので、まず最小構成のgit状態退避から始めて、決定事項メモやツール実行履歴へ退避対象を育てていくのがおすすめだ。以前hooksで危険操作を止める安全設計を書いたときはhooksを「事故を防ぐブレーキ」として使ったが、今回は「記憶を守るセーフティネット」という別の顔で、同じ仕組みがここまで違う仕事をしてくれる。あなたの環境でも、次にauto-compactの表示を見かけたら、その直前に何が退避されていてほしかったかを考えてみてほしい。それがそのまま、あなたのプロジェクト用dumpスクリプトの仕様書になる。こうしたAIエージェントの長時間運用まわりの設計も含めて開発の型づくりから相談したい場合は、AI実装支援も選択肢の一つになる。



