0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Claude Code の PreCompact hook でコンテキスト圧縮(/compact・自動圧縮)の直前に作業状態を退避する実装手順 — 圧縮は止められない・stdout は注入されない・auto では custom_instructions が空、3つのハマりどころ【2026】

0
Posted at

はじめに / 対象と前提

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 の compact matcher で読み戻すと、圧縮後の 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 つ。

  1. transcript の JSONL を舐めて、type == "assistant" の text ブロックだけ取る
  2. キーワードを含むものを直近 15 件残す
  3. .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 の compact matcher で 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 を意識して行単位・末尾だけ読む
0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?