1
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 の自動引き継ぎ hook を作った

1
Last updated at Posted at 2026-08-07

長いセッションの終盤で、Claude Code の様子が変わったと感じたことが何度もあります。
序盤に伝えた制約を忘れる。
さっき読んだファイルをまた読み始める。
「モデルが悪くなったのでは」と疑いたくなりますが、私の場合、そう感じたときはたいていコンテキストの使用率が膨らんでいました。

そこで、コンテキスト使用率がしきい値を超えたら Claude 自身に「そろそろ区切ろう」と判断させ、引き継ぎノートまで書かせる仕組みを自作しました。
使ったのは hook です。
hook は「プロンプト送信時」「ターン終了時」といったタイミングに自作スクリプトを差し込める Claude Code の拡張機能で、設定ファイルに登録するだけで動きます。

動くには動きました。
ただ、素直には作れませんでした。
hook のスクリプトにはコンテキスト使用量が渡されてこないため、画面下部の表示を自作スクリプトに差し替える statusLine という別の機能を経由してデータを運ぶ迂回が必要になります。

その迂回も含めて、検証できた範囲を順にまとめます。

動作確認は Claude Code 2.1.224(macOS、Opus 5 の 1M コンテキスト)で行いました。
2026年8月時点の情報です。

セッション序盤は指示どおり軽快に動くが、終盤にコンテキストが肥大化するとトークン消費が加速し指示を取りこぼすようになる、という変化を示した図

コンテキストが膨らむと何が起きるか

確実に言えるのは料金の話です。
Claude Code は毎ターン、会話履歴・読んだファイル・ツールの出力をまとめてモデルに送ります。
コンテキストが 30 万トークンあれば、1 ターンごとに 30 万トークン分の入力が積み上がる計算で、セッションが長くなるほど消費は加速していきます。

一方、性能のほうは体感です。
セッションが長くなるにつれて、指示の一部を取りこぼしたり、既に確認した内容を再調査したりする頻度が増えると感じています。
振り返ると、そう感じたときはたいてい使用率が膨らんでいました。

対策の基本は、自分でセッションを区切ることと、こまめに /compact することです。
ところが、/compact も手動の区切りも、使うと決める側がいなければ動きません。

Claude は実装に夢中になっていると、ちょうどいい区切りを作らないまま走り続けます。
人間側も作業に集中していると使用率など見ていません。
つまり、区切りを作れる側が両方ともいなくなるのです。

気づいたときにはコンテキストが膨らみきっていて、どこで畳めばいいか分からなくなります。
私は何度もこれをやりました。

そのため、区切りの判断を人間の注意力に頼るのをやめて、hook で自動化しようというのが今回の試みです。

使用率で Claude 自身に区切りを判断させる hook

仕組みは 3 段構えです。

段階 動くタイミング やること
可視化 statusLine(毎レンダリング) 使用率を表示しつつ、状態ファイルへ書き出す
予告 20% UserPromptSubmit hook(プロンプト送信時) 「大きな調査を始めない」等の方針を Claude だけに伝える
発動 25% Stop hook(ターン終了時) 応答を終わらせず、完走か引き継ぎかを判断させる

しきい値の分母はコンテキストウィンドウ全体です。
割合で見ると早すぎるようですが、1M コンテキストなら発動時点で 1 ターンあたりの入力が 25 万トークンに達していて、ここから先は 1 往復ごとのコストが跳ね上がります。
数字自体は体感で決めたものです。

発動といっても、セッションを強制終了するわけではありません。
Stop hook が decision: "block" を返すと Claude は応答を終えられなくなり、reason に書いた文面を新しい指示として受け取って作業を続けます。
「1 ターン終わった直後」はちょうど区切りなので、そこに引き継ぎ処理を差し込みます。

発動するとこうなる

発動すると Claude は、A(このまま完走する)か B(ここで畳んで引き継ぐ)かの二択をまず判断します。
実際のログがこちらです。
注目してほしいのは、Stop hook error: という表示と、そのあと Claude が自分で B(引き継ぎ)を選んで根拠を 1 行示しているところです。

reason の中身は、TUI 上では Stop hook error: という見出しで表示されます。
エラーではなく正常な指示注入です。
仕様と割り切ってください。

このログは再現例です。
hook が出力している文言(Stop says: の行と Stop hook error: 以下)は私が運用しているフル版の実物そのままですが、周囲の作業内容(テスト修正)は説明用に組み直したものです。

⏺ Bash(npm test -- src/auth)
  ⎿  Tests:  3 failed, 41 passed, 44 total
     FAIL src/auth/session.test.ts
       ● refreshToken › 期限切れトークンで 401 を返す
  ⎿  Stop says: ⚠ context-guard: 使用率 26% — 完走するか引き継ぐかを判断させます

⏺ Ran 1 stop hook (ctrl+o to expand)
  ⎿  Stop hook error: [context-guard] コンテキスト使用率が 26.0%(261,402 / 1,000,000 トークン) となり、発動閾値 25%
  を超えました。

  応答を終える前に、**まず A / B のどちらかを判断**してください。
  (…中略:A) このまま完走する / B) ここで畳んで引き継ぐ の指示…)

⏺ B を選びます。失敗している 3 件のうち原因を特定できたのは 1 件だけで、
  残り 2 件はリフレッシュ処理の競合が疑わしいものの未調査です。見通しが立たないので引き継ぎます。

⏺ Bash(git status --short)
  ⎿  M  src/auth/session.ts
     M  src/auth/token.ts
     ?? src/auth/__snapshots__/

⏺ Write(.claude/handoffs/20260807-1432-auth-session-refresh.md)
  ⎿  Wrote 48 lines

  引き継ぎノートを書きました: `.claude/handoffs/20260807-1432-auth-session-refresh.md`

  未コミットの変更が 3 件ありますが、コミットはしていません(内訳はノートに記録済み)。
  新しいセッションを開いて、以下を貼ってください。

  ```
  `.claude/handoffs/20260807-1432-auth-session-refresh.md` を読んで、前のセッションの続きをやってください。

  いまの状況: src/auth のテスト 3 件が失敗中。うち 1 件(期限切れトークンの 401)は
  原因特定済みで token.ts に修正を入れたが、残り 2 件は未調査。
  (…中略:次にやること・再現コマンド・触ってはいけないファイル…)
  ```

  コンテキスト使用率が 26% なので、ここで区切ります。

引き継ぎノートと、新しいセッションに貼るだけのプロンプトまで出てくるので、人間がやることは「新しいターミナルで claude を起動して貼る」だけです。

ユーザーに見える文と Claude に届く文

hook は結果を JSON で標準出力に返します。
その JSON のどのキーに文面を入れるかで、届く先がユーザーと Claude に分かれます。

フィールド 届く先 ターミナル表示
systemMessage ユーザーのみ される
additionalContext / reason Claude のみ されない

additionalContext は Claude への入力にそのまま追記される文字列で、予告の文面はここに入れています。

この分離がいちばん効いているのが予告の段階です。
画面に出るのは ⚠ context-guard: 使用率 21% — 25% で引き継ぎ判断に入ります の 1 行だけです。

ところが同じ瞬間に、Claude 側へは 9 行の行動方針が渡っています。
「新しい大規模な調査・広範なファイル読み込み・投機的なリファクタを始めない」「長い出力をコンテキストに戻さない」といった内容です。

予告をこの位置に入れているのは、大きな作業は始まる前にしか止められないからです。
すでに 20% なのに、そこから数十万トークン級の調査や実装を組み始めると、途中で区切ること自体が難しくなります。
18 時退勤の日に、17 時から新規開発を始めないのと同じ理屈です。

ハマりどころ:hook にはコンテキスト使用量が渡ってこない

hook も statusLine も、実行されるたびに Claude Code から JSON データを標準入力(stdin)で受け取ります。
スクリプトはこの JSON を読んで動くわけですが、入っている中身が両者でまったく違います。

stdin に入るもの statusLine hook
context_window(使用率・ウィンドウサイズ) あり なし
model(id・表示名) あり なし

hook が受け取れるのは session_id(セッションの識別子)や cwd(作業ディレクトリ)といった管理情報だけです。
「使用率がしきい値を超えたら発動する hook」を作りたいのに、hook 自身は使用率を知る手段を持っていません

解決策は、見えている側に運ばせることです。
statusLine は毎レンダリング実行され、context_windowmodel の両方を受け取ります。

そこで statusLine スクリプトに数行足して、受け取った使用率を ~/.claude/.context-state/<session_id>.json に書き出させました。
hook はこのファイルを読んでしきい値を判定します。

statusLine が使用率を受け取り、状態ファイルに書き出し、hook がそれを読んでしきい値を判定し、Claude に指示を注入する、という 4 ステップの流れ図

なお、私が元から使っていた statusLine の表示自体は 1 文字も変えていません。
役割を「表示」から「表示 + データの運び屋」に拡張しただけです。
データを渡す以上、古い値を掴む心配は残ります。
このため、状態ファイルが古い場合(15 分以上前)は信用せず、発動を見送ります。

最初にこの仕様調査をサブエージェントに任せたところ「hook にも context_window が入る」と報告されました。
公式ドキュメントを直接読むと入っていません。

写して動く最小版

私が運用しているフル版(記事末尾に付録として畳んで載せています)は、発動させない条件や異常時の逃げ道といった運用都合のコードが大半で、抜粋しても動きません。
記事用に、機構だけを残した最小版を書き起こしました。

ファイルは 3 つで、statusLine だけホーム(~/.claude/)、hook と設定はプロジェクト(.claude/)に置きます。
まず statusLine から。
既に自作の statusLine を使っている場合は、状態ファイルへ書き出している部分だけ移植してください。

~/.claude/statusline-mini.py
#!/usr/bin/env python3
"""最小の statusLine。表示のついでに、コンテキスト使用率を状態ファイルへ書き出す。

statusLine の stdin には context_window(使用率)と model が入ってくる。
hook にはどちらも入ってこないので、ここから状態ファイル経由で渡す。
"""
import json
import os
import sys
import time

data = json.load(sys.stdin)

model = data.get("model", {}).get("display_name", "Claude")
ctx = data.get("context_window", {}).get("used_percentage")
session_id = data.get("session_id")

if session_id and ctx is not None:
    state_dir = os.path.expanduser("~/.claude/.context-state")
    os.makedirs(state_dir, exist_ok=True)
    with open(os.path.join(state_dir, f"{session_id}.json"), "w") as f:
        json.dump({"used_pct": ctx, "ts": time.time()}, f)

print(f"{model} | ctx {ctx:.0f}%" if ctx is not None else model, end="")

次に hook 本体です。
プロジェクト側の .claude/hooks/ に置きます。
1 つのスクリプトを引数(warn / stop)で使い分け、予告と発動の両方を担当させています。

やっていることは 3 つだけです。
statusLine が書いた状態ファイルから使用率を読み、発動済みかどうかをセンチネル(発動済みの目印として置くファイル)で確かめ、しきい値を超えていれば JSON を 1 行出す。
長く見えるのは、Claude へ渡す文面が定数として埋まっているからです。

.claude/hooks/context-guard-mini.py
#!/usr/bin/env python3
"""コンテキスト使用率を見て「そろそろ区切ろう」を Claude 自身に判断させる hook(最小版)。

settings.json から 2 イベントで呼ぶ:
  python3 context-guard-mini.py warn   # UserPromptSubmit
  python3 context-guard-mini.py stop   # Stop

hook の stdin にはコンテキスト使用量が入ってこないため、statusLine が
~/.claude/.context-state/<session_id>.json に書き出した値を読む。
"""
import json
import os
import sys
import time

WARN_PCT = 20  # 予告閾値(%)
FIRE_PCT = 25  # 発動閾値(%)
STATE_DIR = os.path.expanduser("~/.claude/.context-state")
GUARD_DIR = os.path.expanduser("~/.claude/.context-guard")
STATE_MAX_AGE = 900  # statusLine の状態ファイルをこの秒数まで信用する

WARN_TEXT = """[context-guard] コンテキスト使用率が {pct:.0f}% に達しました({fire}% で引き継ぎ判断に入ります)。
ここから先は、新しい大規模な調査や投機的なリファクタを始めず、
いま着手しているタスクを区切りのいいところまで進めることを最優先にしてください。
この警告をユーザーに復唱する必要はありません。"""

FIRE_TEXT = """[context-guard] コンテキスト使用率が {pct:.0f}% となり、発動閾値 {fire}% を超えました。
応答を終える前に、まず A / B のどちらかを判断してください。

A) このまま完走する(引き継ぎノートは作らない)
   残りが軽微で、あと 1〜2 ターンで確実に終わる見込みならこちら。
   新しい作業には着手しない。依頼の後続タスクが次セッションに残るなら、
   引き継ぎノートは作らず、再開用プロンプトを 1 個のコードブロックで添える。

B) ここで畳んで引き継ぐ(引き継ぎノートを作る)
   残タスクが複数ある・見通しが立たないならこちら。
   ここまでの状況・残タスク・注意点をまとめた引き継ぎノートを
   .claude/handoffs/ に書き出し、新しいセッションにそのまま貼れるプロンプトを
   1 個のコードブロックで出力する。git commit はしない。

どちらを選んだか、根拠を 1 行だけユーザーに示してから実行してください。
この発動は 1 セッションにつき 1 回だけです。"""


def read_pct(session_id):
    """statusLine が書いた状態ファイルから使用率(%)を読む。無い・古いなら None。"""
    path = os.path.join(STATE_DIR, f"{session_id}.json")
    try:
        if time.time() - os.path.getmtime(path) > STATE_MAX_AGE:
            return None
        with open(path) as f:
            pct = json.load(f).get("used_pct")
        return None if pct is None else float(pct)
    except Exception:
        return None


def load_guard(session_id):
    """発火済みフラグ。1 セッション 1 回だけ発火させるためのセンチネル。"""
    try:
        with open(os.path.join(GUARD_DIR, f"{session_id}.json")) as f:
            return json.load(f)
    except Exception:
        return {}


def save_guard(session_id, guard):
    os.makedirs(GUARD_DIR, exist_ok=True)
    with open(os.path.join(GUARD_DIR, f"{session_id}.json"), "w") as f:
        json.dump(guard, f)


def main():
    mode = sys.argv[1] if len(sys.argv) > 1 else "warn"
    try:
        data = json.load(sys.stdin)
    except Exception:
        return
    session_id = data.get("session_id")
    if not session_id:
        return
    pct = read_pct(session_id)
    if pct is None:
        return
    guard = load_guard(session_id)

    if mode == "stop":
        if data.get("stop_hook_active"):  # Claude Code 側のループ防止フラグ
            return
        if guard.get("fired") or pct < FIRE_PCT:
            return
        guard["fired"] = True
        save_guard(session_id, guard)
        print(json.dumps({
            "decision": "block",
            "reason": FIRE_TEXT.format(pct=pct, fire=FIRE_PCT),
            "systemMessage": f"⚠ context-guard: 使用率 {pct:.0f}% — 完走するか引き継ぐかを判断させます",
        }, ensure_ascii=False))
        return

    # mode == "warn" (UserPromptSubmit)
    if guard.get("warned") or pct < WARN_PCT:
        return
    guard["warned"] = True
    save_guard(session_id, guard)
    print(json.dumps({
        "hookSpecificOutput": {
            "hookEventName": "UserPromptSubmit",
            "additionalContext": WARN_TEXT.format(pct=pct, fire=FIRE_PCT),
        },
        "systemMessage": f"⚠ context-guard: 使用率 {pct:.0f}% — {FIRE_PCT}% で引き継ぎ判断に入ります",
    }, ensure_ascii=False))


if __name__ == "__main__":
    main()

読むところは read_pct(状態ファイルの読み取りと鮮度チェック)と main の分岐(stopwarn か)の 2 か所で、残りはファイルの入出力です。

最後に、プロジェクトの .claude/settings.json へ statusLine と hook を登録します。

.claude/settings.json
{
  "statusLine": {
    "type": "command",
    "command": "python3 ~/.claude/statusline-mini.py"
  },
  "hooks": {
    "UserPromptSubmit": [
      {
        "hooks": [
          {
            "type": "command",
            "command": "python3 \"${CLAUDE_PROJECT_DIR}/.claude/hooks/context-guard-mini.py\" warn"
          }
        ]
      }
    ],
    "Stop": [
      {
        "hooks": [
          {
            "type": "command",
            "command": "python3 \"${CLAUDE_PROJECT_DIR}/.claude/hooks/context-guard-mini.py\" stop"
          }
        ]
      }
    ]
  }
}

起動せずに動きを確かめる

Claude Code を起動しなくても、偽の入力データを流し込んで一連の流れを確かめられます。
statusLine に使用率 27% の偽データを流して状態ファイルを作り、その状態で Stop hook を叩きます。

echo '{"session_id":"TEST","model":{"display_name":"Opus 5"},"context_window":{"used_percentage":27}}' \
  | python3 ~/.claude/statusline-mini.py
echo '{"session_id":"TEST"}' \
  | python3 .claude/hooks/context-guard-mini.py stop

2 つ目のコマンドで、"decision": "block" を含む JSON が 1 行出力されれば成功です。
続けてもう一度同じコマンドを叩くと、今度は何も出力されません。
1 セッション 1 回のセンチネルが効いている証拠です。

確認が終わったらテスト用のファイルを消しておきます。

rm ~/.claude/.context-state/TEST.json ~/.claude/.context-guard/TEST.json

最小版の動作確認はこの偽データによるもので、実セッションの画面上での発動は同じ機構のフル版で確認しています。

写して使うなら、1 つ注意があります。
最小版の発動は 1 セッション 1 回きりで、/compact して作業を続けても二度と鳴りません。
鳴り直してほしければ、使用率がしきい値を下回ったときに fired フラグを落とす処理を足してください(フル版ではそうしています)。

設計で気を使った点

発動まわりの挙動で、あらかじめ決めておいたことが 4 つあります。

機械的に切らず、完走か引き継ぎかを判断させる

25% を超えた瞬間に一律で引き継ぎさせると、あと 1 ターンで終わる作業まで中断されて逆に無駄です。
そこで発動時の指示を、まず A / B を判断させてから動かす形にしました。
A なら新規作業に手を出さず完走、B なら引き継ぎノートの生成です。
判断の根拠は 1 行だけユーザーに示させます。

ただし A には抜け道を 1 つ用意してあります。
完走はできるけれど依頼の続き(レビューや公開など)が次のセッションに残る場合は、ノートを作らせず、再開用のプロンプトだけ添えさせます。
A でも B でもない中間ケースに実際にぶつかって、あとから足した条項です。

実際に A 側へ倒れたときのキャプチャです。

  ⎿  Stop says: ⚠ context-guard: 使用率 27% — 完走するか引き継ぐかを判断させます

⏺ Ran 4 stop hooks (ctrl+o to expand)
  ⎿  Stop hook error: [context-guard] コンテキスト使用率が 27.0%(268,974 / 1,000,000 トークン) となり、発動閾値 25%
  を超えました。
  (…中略…)

⏺ A を選びます。依頼された改善の反映・検証・記録・コミットはすべて完了しており、
  残タスクは次セッション向けの候補だけなので、新規着手せずここで区切ります。

25% を超えても中断せず、「もう終わるなら完走したほうが得」という判断が挟まっています。
中途半端な引き継ぎノートを量産しないための保険です。

発動の無限ループを防ぐ

判断させるということは、そのぶん Claude の応答終了を止めるということです。
止め続ければ、Claude は永遠に応答を終えられません。

そのため、Claude Code 側が渡してくるループ防止フラグ(stop_hook_active)の確認に加えて、先ほどのセンチネルで「1 セッション 1 回だけ」を保証しています。
フル版では、/compact などで使用率が 20% を下回ると目印を外し、次の超過でもう一度発動できるようにしました。

git commit は絶対にさせない

引き継ぎのついでにコミットまで自動化したくなりますが、やめました。
私は同じリポジトリで複数のセッションを並行させる使い方をしていて、working tree には自分と無関係な変更が常に混ざっています。
ここで自動コミットすると他セッションの作業を巻き込む事故になります。
このため、引き継ぎノートには「未コミット変更の一覧」を記録させるだけにとどめ、コミットの判断は人間に残しました。

発動させない条件

フル版では、次の条件に当てはまるときはそもそも発動させません。

  • Sonnet / Haiku のセッション
  • サブエージェント(メインの Claude から呼び出される別働隊の Claude)のターン
  • /goal の自走ループ中

上 2 つの理由は単純です。
私のサブスクリプションでは Sonnet に 1M コンテキストが無く、Opus 1M を前提にしたしきい値設計が合いません(しきい値は割合で判定しているので、200k コンテキストの Sonnet でも最小版自体はそのまま動きます)。
サブエージェントは親セッションとコンテキストが別物なので、親の使用率で判断しても意味がありません。

厄介なのが 3 つ目です。
/goal は完了条件を渡すと、それが満たされるまで Claude がターンをまたいで働き続けるコマンドです。

この hook が発動すると自走を中断させてしまうのに、goal が動いているかどうかを hook 側から知る公式の手段が見つかりませんでした。
hook の入力にも、statusLine の入力にも、環境変数にも goal の情報は出てきません。

代わりに、goal を回すときは起動時の環境変数で hook を丸ごと黙らせることにしました。

CC_CONTEXT_GUARD_OFF=1 claude

hook は Claude Code の子プロセスなので、起動時の環境変数をそのまま引き継ぎます。
プロセスが終われば設定も消えるので、設定ファイル方式と違って戻し忘れが起きません。

残る制約と、この仕組みの自己矛盾

いちばん大きいのは自己矛盾です。
フル版は予告で 9 行、発動時は 28 行の指示文を Claude のコンテキストに注入しています。
コンテキストを守るための仕組みが、コンテキスト自体を消費しているわけです。

発動を 1 セッション 1 回に絞ったのは重複防止のためでもありますが、この注入コストを抑える意味もあります。
ただ、改善の余地はまだあって、発動時の文面は手順の詳細を別ファイルに逃がし、「引き継ぎ手順ファイルを読め」の 1 行に寄せればさらに削れます。

しきい値の 20% / 25% は体感で決めた数字で、妥当性は検証していません。
1M コンテキストでの体験をもとにした値なので、200k のモデルで使うなら別の値が要るかもしれません。
定数を書き換えて試してみてください。

一方、効果のほうはまだ数字が少ししかありません。
入れた初日で、3 つのセッションが 25% を超えて発動しました。
うち 2 つは B に倒れて引き継ぎノートを書き、1 つは A で完走しています。
別の 2 つは予告だけ出て、発動まで届かずに終わりました。

週あたりのトークン消費がどう変わるかは、まだ言えません。
hook を入れたのがこの記事を書いた当日で、ウィークリーリミットへの効き方を語れるだけのデータがないからです。

確実に言えるのは、冒頭に書いた「気づいたらコンテキストが膨らみきっていて、どこで畳めばいいか分からない」という状態に、この 3 回とも陥らずに済んだことです。
区切りを決めたのは 3 回とも Claude で、私は使用率を見ていませんでした。

性能低下のほうも、体感レベルですが変化があります。
CLAUDE.md やスキルのルール、序盤に指示した内容のとおりに進むことが増え、あとからの手直しやレビューでの指摘は減ったように感じています。

とはいえ、コンテキスト管理の主役が /compact と手動の区切りであることは変わりません。
その上で、いまのところこの hook がやっているのは、人間が見張りをやめても区切りの提案だけは飛んでくるようにすることだけです。

付録:フル版のコード

私が実際に回しているほうのコードです。
439 行あります。
最小版で削った発動させない条件や異常時の逃げ道がそのまま入っているぶん読みにくいですが、こちらはそのまま運用に耐えます。
なお、日々いじっているので、皆さんが読んでいる時点では古くなっている可能性があります。

context-guard.py(439 行)
.claude/hooks/context-guard.py
#!/usr/bin/env python3
"""コンテキスト圧迫を検知して、予告警告と引き継ぎ発動を Claude に注入する hook。

この vault (iobsidian) 限定。<project>/.claude/settings.json から
UserPromptSubmit / Stop の 2 イベントで呼ばれる。

  python3 context-guard.py warn   # UserPromptSubmit 用
  python3 context-guard.py stop   # Stop 用

--------------------------------------------------------------------------
コンテキスト使用率の取得元(2 系統)
--------------------------------------------------------------------------
1. ~/.claude/.context-state/<session_id>.json
   statusline (~/.claude/statusline.py) が書き出す権威データ。
   statusLine の stdin JSON には context_window と model が含まれるが、
   hook の stdin JSON にはどちらも含まれないため statusline 経由で渡している。
2. 1 が無い / 古いときのフォールバック
   transcript_path の jsonl を末尾から読み、最後の assistant メッセージの
   usage を合計する。ただし transcript の message.model は 1M 版でも
   "claude-opus-5" としか記録されずウィンドウ幅を判別できないため、
   CC_CONTEXT_GUARD_WINDOW / DEFAULT_WINDOW を使う。

--------------------------------------------------------------------------
除外条件
--------------------------------------------------------------------------
- Sonnet / 指定モデル: model_id で判定してスキップ。
- /goal(自走ループ): goal は「セッションスコープのプロンプトベース Stop hook」
  として実装されており、hook からの検出手段は公式には存在しない
  (hook 入力・statusLine 入力・環境変数のいずれにも goal フィールドは無い)。
  そのため 2 段構えで防ぐ:
    (a) 明示トグル  … 確実。`/context-guard off` / OFF ファイル / 環境変数
    (b) ヒューリスティック … transcript から直近の `/goal` / `/goal clear` を探す。
        スラッシュコマンドが展開されて記録される場合は取りこぼすため補助扱い。
- サブエージェントのターン(agent_id あり)。
"""
import json
import os
import sys
import time

WARN_PCT = float(os.environ.get("CC_CONTEXT_GUARD_WARN", "20"))
FIRE_PCT = float(os.environ.get("CC_CONTEXT_GUARD_FIRE", "25"))

# フォールバック時のみ使う。statusline 由来の window が取れていればそちらが優先
DEFAULT_WINDOW = int(os.environ.get("CC_CONTEXT_GUARD_WINDOW", "1000000"))

# model_id にこれらの語が含まれるセッションでは一切発火しない
SKIP_MODELS = tuple(
    p.strip().lower()
    for p in os.environ.get("CC_CONTEXT_GUARD_SKIP_MODELS", "sonnet,haiku").split(",")
    if p.strip()
)

STATE_DIR = os.path.expanduser("~/.claude/.context-state")
GUARD_DIR = os.path.expanduser("~/.claude/.context-guard")
STATE_MAX_AGE = 900  # statusline の状態ファイルをこの秒数まで信用する

# このスクリプトは <project>/.claude/hooks/ に置かれる。cwd に依存せず OFF ファイルの
# 位置を決めたいので、自分の場所から逆算する
REPO_ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
OFF_FILE = os.path.join(REPO_ROOT, ".claude", ".context-guard-off")


def emit(obj):
    print(json.dumps(obj, ensure_ascii=False))
    sys.exit(0)


def bail():
    """判断材料が無い / 閾値未満 / 除外条件に該当。何もせず通す。"""
    sys.exit(0)


# ---------------------------------------------------------------- 使用率の取得


def read_statusline_state(session_id):
    path = os.path.join(STATE_DIR, f"{session_id}.json")
    try:
        if time.time() - os.path.getmtime(path) > STATE_MAX_AGE:
            return None
        with open(path) as f:
            state = json.load(f)
    except Exception:
        return None
    pct = state.get("used_pct")
    window = state.get("window")
    if pct is None or not window:
        return None
    return {
        "pct": float(pct),
        "used": int(state.get("used_tokens") or round(float(pct) * window / 100)),
        "window": int(window),
        "model": state.get("model_id") or "",
        "authoritative": True,
    }


def read_transcript_state(transcript_path):
    """jsonl の末尾から最後の usage を拾う。巨大セッションでも末尾だけ読む。"""
    if not transcript_path:
        return None
    try:
        with open(transcript_path, "rb") as f:
            f.seek(0, os.SEEK_END)
            start = max(0, f.tell() - 2_000_000)
            f.seek(start)
            chunk = f.read()
    except Exception:
        return None

    lines = chunk.split(b"\n")
    if start > 0:
        lines = lines[1:]  # 先頭は途中で切れた行なので捨てる

    for raw in reversed(lines):
        raw = raw.strip()
        if not raw:
            continue
        try:
            obj = json.loads(raw)
        except Exception:
            continue
        message = obj.get("message") or {}
        usage = message.get("usage") or {}
        if usage.get("input_tokens") is None:
            continue
        used = (
            usage.get("input_tokens", 0)
            + usage.get("cache_read_input_tokens", 0)
            + usage.get("cache_creation_input_tokens", 0)
        )
        return {
            "pct": used * 100.0 / DEFAULT_WINDOW,
            "used": used,
            "window": DEFAULT_WINDOW,
            "model": message.get("model") or "",
            "authoritative": False,
        }
    return None


# ------------------------------------------------------------------ 除外判定


def project_dir(data):
    return os.environ.get("CLAUDE_PROJECT_DIR") or data.get("cwd") or "."


def explicitly_disabled(guard):
    """確実に効く明示トグル。goal 実行時はこれを使うのが本筋。

    guard["disabled"] は ~/.claude/.context-guard/<session_id>.json を手で編集して
    そのセッションだけ黙らせるための逃げ道。
    """
    if os.environ.get("CC_CONTEXT_GUARD_OFF"):
        return True
    if guard.get("disabled"):
        return True
    return os.path.exists(OFF_FILE)


def _message_text(obj):
    content = (obj.get("message") or {}).get("content")
    if isinstance(content, str):
        return content
    if isinstance(content, list):
        parts = []
        for block in content:
            if isinstance(block, dict) and block.get("type") == "text":
                parts.append(block.get("text") or "")
        return "\n".join(parts)
    return ""


def goal_active(transcript_path):
    """transcript のユーザーメッセージから直近の `/goal` 系コマンドを探すヒューリスティック。

    公式な検出手段が無いための best-effort。スラッシュコマンドが展開後の形で
    記録される場合は検出できないので、確実性は明示トグルに委ねている。
    """
    if not transcript_path:
        return False
    try:
        with open(transcript_path, "rb") as f:
            f.seek(0, os.SEEK_END)
            start = max(0, f.tell() - 8_000_000)
            f.seek(start)
            chunk = f.read()
    except Exception:
        return False

    lines = chunk.split(b"\n")
    if start > 0:
        lines = lines[1:]

    for raw in reversed(lines):
        raw = raw.strip()
        if not raw or b"/goal" not in raw:
            continue
        try:
            obj = json.loads(raw)
        except Exception:
            continue
        if obj.get("type") != "user":
            continue
        text = _message_text(obj).strip()
        if not text.startswith("/goal"):
            continue
        rest = text[len("/goal"):].strip()
        # `/goal clear` で解除、引数なしの `/goal` はステータス表示なので状態を変えない
        if rest.startswith("clear"):
            return False
        if rest:
            return True
    return False


# ------------------------------------------------------------------ 状態管理


def load_guard(session_id):
    try:
        with open(os.path.join(GUARD_DIR, f"{session_id}.json")) as f:
            return json.load(f)
    except Exception:
        return {}


def save_guard(session_id, guard):
    try:
        os.makedirs(GUARD_DIR, exist_ok=True)
        path = os.path.join(GUARD_DIR, f"{session_id}.json")
        tmp = path + ".tmp"
        with open(tmp, "w") as f:
            json.dump(guard, f)
        os.replace(tmp, path)
        _prune(GUARD_DIR)
    except Exception:
        pass


def _prune(directory):
    """7 日以上前の状態ファイルを掃除する。"""
    try:
        cutoff = time.time() - 7 * 24 * 3600
        for name in os.listdir(directory):
            if not name.endswith(".json"):
                continue
            path = os.path.join(directory, name)
            if os.path.getmtime(path) < cutoff:
                os.remove(path)
    except Exception:
        pass


# -------------------------------------------------------------------- 文言


def fmt(state):
    note = "" if state["authoritative"] else "(概算: statusline の実測値が取れず transcript から推定)"
    return f"{state['pct']:.1f}%({state['used']:,} / {state['window']:,} トークン){note}"


WARN_TEXT = """[context-guard] コンテキスト使用率が {detail} に達しました。予告閾値 {warn:.0f}% 超過です({fire:.0f}% で引き継ぎ判断に入ります)。

ここから先は次の方針で動いてください:
- 新しい大規模な調査・広範なファイル読み込み・投機的なリファクタを始めない
- いま着手しているタスクを「区切りのいいところ」まで進めることを最優先にする
- 長い出力をコンテキストに戻さない(サブエージェントには結論の要約だけ返させる)
- 区切りがついたら、次の大きな作業には着手せずユーザーに判断を委ねる

この警告自体をユーザーに復唱する必要はありません。作業方針にだけ反映してください。"""

FIRE_TEXT = """[context-guard] コンテキスト使用率が {detail} となり、発動閾値 {fire:.0f}% を超えました。

応答を終える前に、**まず A / B のどちらかを判断**してください。

## A) このまま完走する(引き継ぎノートは作らない)

依頼された作業の残りが軽微で、**あと 1〜2 ターン程度で確実に終わる見込み**の場合はこちら。中途半端な引き継ぎを挟むほうが無駄です。

- 新しい調査・拡張・スコープ外の改善には一切着手しない
- 完走後、元の依頼の後続タスク(レビュー・公開・次の工程など、依頼のスコープに含まれる続き)が次セッションに残るなら、引き継ぎノートは作らず、新しいセッションにそのまま貼れる**再開用プロンプトを 1 個のコードブロック**で添える
- 最後に 1 行だけ「コンテキスト使用率が {pct:.0f}% なので、次の作業は新しいセッションを推奨します」と添える

## B) ここで畳んで引き継ぐ(引き継ぎノートを作る)

残タスクが複数ある / 見通しが立たない / 大きな調査や実装が残っている場合はこちら。引き継ぎ処理を実行してください。

1. `{project}/.claude/commands/handoff.md` を読み、そこに書かれた手順に従う
2. **git commit は行わない。** この vault は複数スキルが別セッションで並行稼働しており、
   working tree には自分と無関係な変更が混在している。未コミット変更は
   「一覧として引き継ぎノートに記録する」だけにとどめる
3. 引き継ぎノートを `{project}/.claude/handoffs/` に書き出す
4. 最後に、新しいセッションへそのまま貼り付けられるプロンプトを
   **1 個のコードブロック** として出力する

---

判断の根拠を 1 行だけユーザーに示してから実行してください(例: 「残りは検証のみなので完走します」)。
この発動は 1 セッションにつき 1 回だけです。"""

AFTER_FIRE_TEXT = """[context-guard] 引き継ぎ判断は既に 1 度実施済みです(現在 {detail})。このセッションでの新規作業は推奨されません。

引き継ぎノートを持って新しいセッションを開始するようユーザーに促してください。それでも続ける場合は、影響範囲の小さい単発の作業に限定してください。"""


# -------------------------------------------------------------------- main


def cli(mode):
    """hook ではなく人間 / Claude が直接叩く操作。stdin を読まない。"""
    off_file = OFF_FILE
    if mode == "off":
        os.makedirs(os.path.dirname(off_file), exist_ok=True)
        with open(off_file, "w") as f:
            f.write(f"disabled at {time.strftime('%Y-%m-%d %H:%M:%S')}\n")
        print(f"context-guard: 無効化しました -> {off_file}")
    elif mode == "on":
        try:
            os.remove(off_file)
            print("context-guard: 有効化しました")
        except FileNotFoundError:
            print("context-guard: すでに有効です")
    else:  # status
        disabled = os.path.exists(off_file) or bool(os.environ.get("CC_CONTEXT_GUARD_OFF"))
        print(f"context-guard: {'無効' if disabled else '有効'}")
        print(f"  予告 {WARN_PCT:.0f}% / 発動 {FIRE_PCT:.0f}%")
        print(f"  除外モデル: {', '.join(SKIP_MODELS) or 'なし'}")
        print(f"  OFF ファイル: {off_file} ({'あり' if os.path.exists(off_file) else 'なし'})")
    sys.exit(0)


def main():
    mode = sys.argv[1] if len(sys.argv) > 1 else "warn"

    if mode in ("off", "on", "status"):
        cli(mode)

    try:
        data = json.load(sys.stdin)
    except Exception:
        bail()

    # サブエージェントのターンでは発火させない(親セッションのコンテキストとは別物)
    if data.get("agent_id"):
        bail()

    session_id = data.get("session_id")
    if not session_id:
        bail()

    guard = load_guard(session_id)

    if explicitly_disabled(guard):
        bail()

    state = read_statusline_state(session_id) or read_transcript_state(
        data.get("transcript_path")
    )
    if not state:
        bail()

    model = (state.get("model") or "").lower()
    if model and any(p in model for p in SKIP_MODELS):
        bail()

    # compact / clear などで使用率が予告閾値を下回ったら再武装する
    if state["pct"] < WARN_PCT:
        if guard.get("warned") or guard.get("fired"):
            guard.update({"warned": False, "fired": False})
            save_guard(session_id, guard)
        bail()

    detail = fmt(state)

    # /goal の自走ループ中は止めない(検出はヒューリスティック、確実な無効化は明示トグル)。
    # transcript の全走査になるので、実際に何か出力する直前でだけ呼ぶ
    def bail_if_goal():
        if goal_active(data.get("transcript_path")):
            bail()

    if mode == "stop":
        # 無限ループ防止: Claude Code 側の block 済みフラグと、自前のセンチネル
        if data.get("stop_hook_active"):
            bail()
        if guard.get("fired") or state["pct"] < FIRE_PCT:
            bail()
        bail_if_goal()
        guard["fired"] = True
        save_guard(session_id, guard)
        emit(
            {
                "decision": "block",
                "reason": FIRE_TEXT.format(
                    detail=detail,
                    fire=FIRE_PCT,
                    pct=state["pct"],
                    project=project_dir(data),
                ),
                "systemMessage": f"⚠ context-guard: 使用率 {state['pct']:.0f}% — 完走するか引き継ぐかを判断させます",
            }
        )

    # mode == "warn" (UserPromptSubmit)
    if guard.get("fired"):
        bail_if_goal()
        emit(
            {
                "hookSpecificOutput": {
                    "hookEventName": "UserPromptSubmit",
                    "additionalContext": AFTER_FIRE_TEXT.format(detail=detail),
                },
                "systemMessage": f"⚠ context-guard: 使用率 {state['pct']:.0f}% — 判断済み。新セッション推奨",
            }
        )

    if guard.get("warned"):
        bail()

    bail_if_goal()
    guard["warned"] = True
    save_guard(session_id, guard)
    emit(
        {
            "hookSpecificOutput": {
                "hookEventName": "UserPromptSubmit",
                "additionalContext": WARN_TEXT.format(
                    detail=detail, warn=WARN_PCT, fire=FIRE_PCT
                ),
            },
            "systemMessage": f"⚠ context-guard: 使用率 {state['pct']:.0f}% — {FIRE_PCT:.0f}% で引き継ぎ判断に入ります",
        }
    )


if __name__ == "__main__":
    main()

Xをフォローいただけると嬉しいです!

AI駆動開発(特に Claude Code)のノウハウや Tips をよく発信しています!

1
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
1
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?