深夜 2 時に走らせていた自動処理のセッションが、コンパクションを挟んだ直後に「さっき却下したはずの案」を再提案してきたことがあります。
会話の内容は要約されて残っていました。消えていたのは、なぜその案を却下したのか という一行だけでした。
コンパクションは文字数を削るのであって、重要度で選り分けてくれるわけではありません。要約に残るのは「何をしたか」で、落ちるのは「なぜそうしたか」です。個人開発で複数のサイトを夜間に回している立場からすると、この一行の欠落が翌朝の手戻りに直結します。
そこで私は、コンパクションが走る直前 に決定事項だけを外部ファイルへ逃がし、次のセッションの開始時 に読み戻す仕組みを入れました。使うのは Claude Code の PreCompact・SessionEnd・SessionStart という 3 つのフックです。
コンパクションで落ちるのは「判断の根拠」
自動コンパクションはコンテキストが上限に近づいたときに発火します。手動の /compact と同じ処理ですが、無人運用では発火のタイミングを選べません。
私の環境で 4 サイト分の記事生成セッションを 6 時間走らせたときのトランスクリプトは 14.2 MB(JSONL・約 3,900 行)でした。コンパクション後にセッションへ残っていた要約は 4,100 文字程度です。圧縮率としては妥当ですが、この 4,100 文字に「Cloudflare Workers の 62 MiB 制限があるので articles.json にはメタデータだけを載せる」という前提は残っていませんでした。
要約が悪いのではありません。要約に何を残すかを指定していないこと が問題でした。
ならば、要約に期待するのをやめて、自分で抜き出して外に置けばよい。それがこの記事の設計です。
PreCompact フックが受け取るもの
PreCompact は、自動・手動を問わずコンパクションが実行される直前に発火します。フックスクリプトは標準入力から JSON を1つ受け取ります。
{
"session_id" : "0b8f2c14-..." ,
"transcript_path" : "/Users/you/.claude/projects/.../0b8f2c14-....jsonl" ,
"cwd" : "/Users/you/work/claudelab.net" ,
"hook_event_name" : "PreCompact" ,
"trigger" : "auto" ,
"custom_instructions" : ""
}
押さえておきたいのは次の 3 点です。
trigger は "auto" か "manual" のどちらかです。/compact を手で叩いたときだけ "manual" になります。無人運用で拾いたいのはほぼ "auto" の方です。
transcript_path は、そのセッションの全履歴が入った JSONL ファイルの絶対パスです。1 行 1 メッセージで、まだコンパクション前 の状態が読めます。ここが肝心で、PreCompact を逃すとこの生の履歴には二度とアクセスできません。
custom_instructions は手動コンパクション時に渡した指示が入ります。自動時は空文字です。
フック 発火タイミング この設計での役割
PreCompactコンパクション直前 生の履歴から決定事項を抽出して退避
SessionEndセッション終了時 最終スナップショットと索引の確定
SessionStartセッション開始・再開時 退避した決定事項をコンテキストへ注入
決定事項だけを抜き出す
トランスクリプトを丸ごと保存しても、それは元の 14.2 MB を別の場所に置いただけです。復元時に読み戻せるサイズまで削る必要があります。
私が採っているのは、アシスタントの発話のうち決定・制約・却下を示す語を含む行だけを拾う という素朴な方法です。埋め込みも LLM 呼び出しも使いません。フックには 60 秒の実行時間制限があり、ここで外部 API を叩くのは筋が悪いためです。
#!/usr/bin/env python3
"""PreCompact: トランスクリプトから決定事項を抽出し handoff へ退避する。
入力: stdin の JSON(session_id, transcript_path, trigger, cwd)
出力: .claude/handoff/<session_id>.md(追記)
"""
import json
import re
import sys
from pathlib import Path
# 「判断の根拠」を含む行だけを拾うためのマーカー。
# 対象を広げすぎると要約が肥大するので、意図的に絞り込んでいます。
MARKERS = re.compile(
r " ( 決定 | 方針 | 採用しません | 却下 | 制約 | 理由は | 前提として"
r " | decided | constraint | because | instead of | do not)" ,
re. IGNORECASE ,
)
MAX_LINES = 60 # 復元時のコンテキスト圧を抑える上限
MAX_CHARS_PER_LINE = 240
def iter_assistant_text (transcript: Path):
"""JSONL を1行ずつ読む。壊れた行はスキップする(セッション中の書き込みと競合し得るため)。"""
with transcript.open( encoding = "utf-8" , errors = "replace" ) as f:
for raw in f:
raw = raw.strip()
if not raw:
continue
try :
rec = json.loads(raw)
except json.JSONDecodeError:
continue
if rec.get( "type" ) != "assistant" :
continue
content = rec.get( "message" , {}).get( "content" , [])
if isinstance (content, str ):
yield content
continue
for block in content:
if isinstance (block, dict ) and block.get( "type" ) == "text" :
yield block.get( "text" , "" )
def extract (transcript: Path) -> list[ str ]:
seen, picked = set (), []
for text in iter_assistant_text(transcript):
for line in text.splitlines():
line = line.strip( " -*# \t " )
if len (line) < 12 or not MARKERS .search(line):
continue
line = line[: MAX_CHARS_PER_LINE ]
if line in seen: # 同じ決定の再掲を落とす
continue
seen.add(line)
picked.append(line)
return picked[ - MAX_LINES :] # 新しい決定を優先して残す
def main () -> int :
payload = json.load(sys.stdin)
transcript = Path(payload[ "transcript_path" ])
if not transcript.exists():
return 0 # 履歴が無いなら黙って成功扱い
lines = extract(transcript)
if not lines:
return 0
out_dir = Path(payload.get( "cwd" , "." )) / ".claude" / "handoff"
out_dir.mkdir( parents = True , exist_ok = True )
out = out_dir / f " { payload[ 'session_id' ] } .md"
with out.open( "a" , encoding = "utf-8" ) as f:
f.write( f " \n ## compact ( { payload.get( 'trigger' , 'auto' ) } ) \n " )
for line in lines:
f.write( f "- { line }\n " )
# stderr はユーザーにのみ表示され、モデルのコンテキストには入りません。
print ( f "[handoff] { len (lines) } decisions -> { out } " , file = sys.stderr)
return 0
if __name__ == "__main__" :
sys.exit(main())
期待する出力は次のようになります(.claude/handoff/0b8f2c14-....md)。
## compact (auto)
- 決定: articles.json にはメタデータのみを載せ、本文HTMLは public/content/ 側へ分割する
- 制約: Cloudflare Workers のバンドル上限が 62 MiB のため、生成物を同梱しない
- 理由は、ビルド時に本文を含めると Worker のデプロイ自体が落ちるためです
- 採用しません: articles.json への本文インライン化(過去に一度戻して失敗しています)
14.2 MB のトランスクリプトから、この形式で 5.8 KB になりました。復元時に読み込ませても、体感できるほどのコンテキスト消費にはなりません。
SessionEnd で最後のひと押しを残す
PreCompact はコンパクションが起きなければ一度も発火しません。短いセッションの決定事項が一切残らないのは困るので、終了時にもう一度同じ抽出を回します。
SessionEnd の入力には reason が入ります。値は "clear"・"logout"・"prompt_input_exit"・"other" のいずれかです。
{
"hooks" : {
"PreCompact" : [
{
"matcher" : "auto" ,
"hooks" : [
{ "type" : "command" , "command" : "python3 .claude/hooks/handoff_capture.py" , "timeout" : 30 }
]
}
],
"SessionEnd" : [
{
"hooks" : [
{ "type" : "command" , "command" : "python3 .claude/hooks/handoff_capture.py" , "timeout" : 30 }
]
}
],
"SessionStart" : [
{
"matcher" : "resume" ,
"hooks" : [
{ "type" : "command" , "command" : "python3 .claude/hooks/handoff_restore.py" , "timeout" : 10 }
]
}
]
}
}
PreCompact の matcher に "auto" を指定すると自動コンパクションのときだけ走ります。手動 /compact のときは自分で意図して圧縮しているので、私は拾っていません。SessionEnd に matcher はありません。
なお matcher の一致判定は v2.1.195 でハイフンを含む文字列の扱いが厳密化されています。既存のフックが突然黙り込んだ経験がある方は、アップデート後に hook が発火しなくなったときの matcher 厳密一致化 を先に確認しておくと安全です。
SessionStart で読み戻す
復元側は hookSpecificOutput.additionalContext に文字列を返します。これが次のターンのコンテキストに追加されます。
#!/usr/bin/env python3
"""SessionStart: 直近の handoff を読み、additionalContext として注入する。"""
import json
import sys
from pathlib import Path
MAX_BYTES = 8_000 # 注入量の上限。これ以上は前提が薄まるより先に予算を食う
def main () -> int :
payload = json.load(sys.stdin)
hand = Path(payload.get( "cwd" , "." )) / ".claude" / "handoff"
files = sorted (hand.glob( "*.md" ), key =lambda p: p.stat().st_mtime)
if not files:
return 0
text = files[ - 1 ].read_text( encoding = "utf-8" )[ - MAX_BYTES :]
# stdout に出した JSON が Claude Code に解釈されます。
print (json.dumps({
"hookSpecificOutput" : {
"hookEventName" : "SessionStart" ,
"additionalContext" : (
"前セッションで確定した決定事項です。"
"矛盾する提案をする前に、必ずここを確認してください。 \n\n " + text
),
}
}))
return 0
if __name__ == "__main__" :
sys.exit(main())
ここで一点だけ注意があります。SessionStart と UserPromptSubmit は、stdout に出した素のテキストもそのままコンテキストへ入ります 。デバッグ用の print() を残したまま本番へ回すと、毎セッション冒頭にゴミが混ざります。診断ログは必ず stderr へ送ってください。私はこれで一度、[debug] loaded 12 files という一行を 3 日間ずっとモデルに読ませ続けていました。
実測: 何がどれだけ変わったか
6 時間・4 サイト分の生成セッションを、フック有無で 5 回ずつ回して比較しました。
指標 フックなし フックあり
コンパクション後に前提を取り違えた回数(5 回中) 4 回 0 回
復元に要する追加トークン(概算) — 約 1,600 トークン
PreCompact フックの実行時間(中央値) — 0.42 秒
handoff ファイルのサイズ — 5.8 KB
「前提を取り違えた」の判定は、翌朝のログレビューで、過去に却下した設計が再提案されていたかどうかで数えています。厳密なベンチマークではありませんが、手戻りが消えたという実感とは一致しています。
追加コストは 1 セッションあたり約 1,600 トークンです。Sonnet の入力単価で換算しても 1 円に届きません。やり直しの 30 分より安い というのが私の判断です。
踏んだ落とし穴を 3 つ
フックのタイムアウトは既定 60 秒
最初、抽出に外部の要約 API を挟もうとしました。ネットワークが遅い夜に 60 秒を超え、フックが打ち切られ、その回の決定事項が丸ごと失われました。本番運用に載せるフックの中では、正規表現で完結する処理だけに留める。この割り切りが、同じ失敗を回避する唯一の対処でした。
書き込み中のトランスクリプトを読むことがある
JSONL の最終行が途中で切れていて json.JSONDecodeError が飛ぶことが、20 回に 1 回程度あります。上のコードで壊れた行を黙って捨てているのはこのためです。例外で落とすとフックが非ゼロ終了し、余計な警告を招きます。
trigger の値を取り違える
私は "compact" だと思い込んでいました。実際の値は "auto" / "manual" です。matcher に "compact" と書いた設定は一致せず、何のエラーも出ないまま静かに発火しなくなります。フックが動かないときはまず入力 JSON を cat > /tmp/hook.json で丸ごと落として確認するのが早道です。フック全般の exit code とブロック挙動については Hooks で品質ゲートを壊さず回す実装メモ にまとめています。
どこまで残すかは、削る勇気で決まる
この仕組みを入れてから、handoff ファイルに残す行数を増やしたい誘惑が何度もありました。「これも大事だから」と MARKERS に語を足していくと、200 行を超えたあたりで元の要約と変わらない密度に戻ります。
残すべきは、次のセッションが同じ結論に至れなかったであろう情報 だけです。調べ直せば分かることは落としてよい。却下の理由は落としてはいけない。この線引きだけが、handoff を要約の劣化コピーにしないための基準だと考えています。
コンテキスト全体の予算配分をどう組むかは Context Window 管理の 7 つの実装パターン と合わせて読んでいただくと、上限に当たる前の打ち手が見えてくるはずです。
まずは PreCompact の 1 フックだけを cat > /tmp/precompact.json に置き換えて仕掛け、次に自動コンパクションが走ったときの入力 JSON を自分の目で見てみてください。そこから先の実装は、驚くほど短く済みます。
私自身、この仕組みに辿り着くまでに何度も同じ朝を繰り返しました。同じところで足踏みしている方の時間が少しでも減れば嬉しいです。お読みいただきありがとうございました。