はじめに / 対象と前提
Claude Code で長いタスクを回していると、途中でコンテキストが圧縮(compaction)されて「さっきまで覚えていた決定事項」が要約に溶けて消える。自分は完全自律実装システムを 24 時間回しているので、この取りこぼしが一番痛かった。この記事では PreCompact hook を使って、圧縮の直前に「今の作業状態」をファイルへ退避し、圧縮後に読み戻す仕組みを組む。
- 想定読者:Claude Code を業務で使っていて、hook を 1 つ以上書いたことがある人
- 環境:Claude Code 2.x 系(2026 年 9 月時点)/ macOS 15 / Python 3.13
- hook の基本(
settings.jsonの書き方・stdin に JSON が来ること)は既知とする
TL;DR
- PreCompact hook は
/compact(manual)と自動圧縮(auto)の直前に呼ばれ、stdin でtrigger・transcript_path・custom_instructionsを受け取れる - できるのは「退避」だけ。圧縮を止めることも、stdout を Claude に読ませることもできない
- 退避したファイルは SessionStart hook の
compactmatcher で読み戻すと、圧縮後の Claude にそのまま届く
手順 / 動かし方
1. hook を登録する
.claude/settings.json(プロジェクト単位)に書く。
{
"hooks": {
"PreCompact": [
{
"matcher": "auto",
"hooks": [
{ "type": "command", "command": "python3 \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/precompact.py", "timeout": 30 }
]
},
{
"matcher": "manual",
"hooks": [
{ "type": "command", "command": "python3 \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/precompact.py", "timeout": 30 }
]
}
],
"SessionStart": [
{
"matcher": "compact",
"hooks": [
{ "type": "command", "command": "cat \"$CLAUDE_PROJECT_DIR\"/.claude/state/precompact-snapshot.md 2>/dev/null || true" }
]
}
]
}
}
PreCompact の matcher は auto / manual の 2 種類。両方に効かせるなら matcher を省略しても良いが、あとで挙動を分けたくなるので自分は分けて書いている。
2. stdin の中身を確認する
まず何が来るかを見る。確認用に stdin をそのままファイルへ落とすだけの hook を一時的に挟む。
cat > /tmp/dump.sh <<'EOF'
#!/bin/bash
cat > /tmp/precompact-input.json
EOF
chmod +x /tmp/dump.sh
これを command に指定して /compact 直近の決定事項を残して と打つと、次の JSON が落ちてくる。
{
"session_id": "8f1c...",
"transcript_path": "/Users/me/.claude/projects/-Users-me-app/8f1c....jsonl",
"cwd": "/Users/me/app",
"permission_mode": "default",
"hook_event_name": "PreCompact",
"trigger": "manual",
"custom_instructions": "直近の決定事項を残して"
}
transcript_path が本命。ここに会話全体が JSONL(1 行 1 メッセージ)で残っているので、圧縮前に自前でスキャンできる。
3. 退避スクリプト本体
#!/usr/bin/env python3
# .claude/hooks/precompact.py
import json, os, sys, datetime, pathlib
DEFAULT_INSTRUCTIONS = "決定事項とやり残しタスク を必ず残す"
KEYWORDS = ("決定", "やり残し", "次にやる", "方針", "採用", "却下")
data = json.load(sys.stdin)
transcript = pathlib.Path(data["transcript_path"])
out_dir = pathlib.Path(os.environ.get("CLAUDE_PROJECT_DIR", data["cwd"])) / ".claude" / "state"
out_dir.mkdir(parents=True, exist_ok=True)
picked = []
with transcript.open(encoding="utf-8") as f:
for line in f:
try:
rec = json.loads(line)
except json.JSONDecodeError:
continue
if rec.get("type") != "assistant":
continue
for block in rec.get("message", {}).get("content", []):
if isinstance(block, dict) and block.get("type") == "text":
text = block["text"]
if any(k in text for k in KEYWORDS):
picked.append(text.strip()[:400])
picked = picked[-15:] # 直近 15 件だけ
instructions = data.get("custom_instructions") or DEFAULT_INSTRUCTIONS
snapshot = out_dir / "precompact-snapshot.md"
with snapshot.open("w", encoding="utf-8") as f:
f.write(f"# 圧縮前スナップショット ({datetime.datetime.now():%Y-%m-%d %H:%M}) trigger={data['trigger']}\n\n")
f.write(f"- 圧縮時の指示: {instructions}\n")
for p in picked:
f.write(f"- {p}\n")
print(f"snapshot saved: {snapshot} ({len(picked)} items)", file=sys.stderr)
sys.exit(0)
やっていることは 3 つ。
- transcript の JSONL を舐めて、
type == "assistant"の text ブロックだけ取る - キーワードを含むものを直近 15 件残す
-
.claude/state/precompact-snapshot.mdに書く
4. 動作確認
$ claude --debug
> (適当に作業して決定事項をいくつか出す)
> /compact 直近の決定事項を残して
--debug を付けておくと、圧縮直前に PreCompact hook の実行ログが出るので、発火したかどうかはそこで確認できる。圧縮後に「さっきの決定事項を言って」と聞くと、SessionStart(compact)経由でファイルが注入されているので、要約に残っていない内容でも答えが返ってくる。
ファイル側はこうなる。
$ cat .claude/state/precompact-snapshot.md
# 圧縮前スナップショット (2026-09-27 22:41) trigger=manual
- 圧縮時の指示: 直近の決定事項を残して
- 決定: 認証は Cookie ではなく Authorization ヘッダーに統一する。理由は ...
- やり残し: users テーブルの migration をロールバック可能な形に直す
自動圧縮も同じ経路で走る。trigger=auto になる以外は同じ。
ハマりどころ
1. exit 2 を返しても圧縮は止まらない
PreToolUse や Stop の感覚で「exit 2 で圧縮をブロックできるだろう」と思って書いたが、止まらなかった。
hook の exit code 2 の意味はイベントごとに違い、PreCompact は「stderr をユーザーに表示するだけ」。JSON で {"decision": "block"} を返しても無視される。
- 原因:PreCompact には決定制御(decision control)が無い
-
回避策:止めるのではなく退避する設計に切り替える。どうしても圧縮を遅らせたいなら、重い調査をサブエージェントに逃がす、コマンド出力を
| tail -50で短くする、といった「コンテキストを使い切らない」側の対策のほうが筋がいい
2. stdout に書いても Claude には届かない
最初は退避内容を stdout に print して「これで圧縮後の Claude が読むだろう」と思っていた。読まない。
stdout がそのままコンテキストに注入されるのは UserPromptSubmit と SessionStart だけ。PreCompact の stdout は debug ログに残るだけで、要約にも新しいコンテキストにも入らない。
- 原因:イベントごとに stdout の扱いが違う
-
回避策:ファイルに書いて、SessionStart の
compactmatcher で cat する(手順 1 の設定)。圧縮直後は SessionStart がsource: "compact"で発火するので、この経路なら確実に届く
3. auto では custom_instructions が空文字
/compact 〇〇 で渡した指示が custom_instructions に入るのは manual のときだけ。自動圧縮では常に "" で来る。最初のバージョンでは data["custom_instructions"] をそのまま書いていたので、自動圧縮のたびに空行だけのスナップショットができていた。
- 原因:auto トリガーにはユーザー入力が存在しない
-
回避策:
data.get("custom_instructions") or DEFAULT_INSTRUCTIONSにしてデフォルトを hook 側で持つ。加えて CLAUDE.md にも「圧縮時は決定事項とやり残しタスク を必ず残す」と書いておくと、要約側の挙動も安定する
おまけ:transcript が大きいと timeout で落ちる
hook のデフォルト timeout は 60 秒。長時間セッションの transcript は数十 MB になることがある。上のスクリプトは行単位で読んでいるので手元の 40 MB では 2 秒程度だったが、正規表現を増やしたり LLM に要約させたりするなら timeout を明示するか、末尾 N 行だけ読む(collections.deque(f, maxlen=2000))に切り替える。timeout で hook が落ちてもエラー表示が出るだけで圧縮自体は進むので、気づきにくい。
背景・補足
なぜ「退避 + 再注入」なのか。圧縮の要約は Claude が書くので、何を残すかは制御しきれない。一方 hook は決定論的に動く。「要約は要約に任せて、絶対に落としたくない事実だけ自分でファイルに持つ」と割り切ると、自律稼働中に方針が勝手に巻き戻る事故が減った。
完全自律実装システムでは、司令塔モジュールが状態メモファイルを常に更新しているので、PreCompact での退避はその保険という位置づけ。それでも hook を入れてから「圧縮後に同じ調査をやり直す」ケースが体感で減った。
まとめ
- PreCompact hook は
trigger・transcript_path・custom_instructionsを stdin で受け取れる - 圧縮は止められない(exit 2 も decision も無効)。退避する設計にする
- stdout は Claude に届かない。ファイルに書いて SessionStart(compact)で cat する
- auto では
custom_instructionsが空。デフォルト指示を hook 側に持つ - transcript は大きくなる。timeout を意識して行単位・末尾だけ読む