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 の Stop フックで「根拠のない『全部やりました』」を差し戻す(上限2回・ループ防止つき)

1
Posted at

何に困ったか

Claude Code に「X のブックマークを読んで、読めたものを外して」のような作業を任せたところ、実際は5件しか取れていないのに「全部読みました」と報告されたことがありました。
CLAUDE.md に「完了と言う前に中身を確かめる」と書いても、同じ日にもう一度、確かめずに「全部」と言われました。

文章のお願いは、守られないことがあります。そこで、言い切りがあるのに根拠の行がない返事を、Stop フックで差し戻すことにしました。
この記事では、実際に動かしているフックと、その確かめ方、すり抜ける例を書きます。

環境

  • Claude Code(CLI)、Python 3(手元の確認は 3.11.18)
  • フックはリポジトリの scripts/hooks/done-claim-stop.py に置き、リポジトリの .claude/settings.json から呼ぶ(~/.claude/ に置いても動く作りです)

仕組み

Stop フックは、Claude Code が返事を終えて止まろうとしたときに動きます。
標準入力に、transcript_path(会話の記録ファイル)と session_id が JSON で渡されます。
フックが {"decision": "block", "reason": "..."} を標準出力に出すと、止まらずに reason の文章が Claude に戻され、返事を続けます。

このフックの判定は3つだけです。

  • 最後の返事に、言い切りの語(「全部読」「完了しました」「直しました」など)があるか
  • 同じ返事に、行頭が「根拠:」の行があるか
  • 言い切りがあって根拠の行がなければ、差し戻す

設定: settings.json

{
  "hooks": {
    "Stop": [
      {
        "hooks": [
          {
            "type": "command",
            "command": "python3 \"$CLAUDE_PROJECT_DIR/scripts/hooks/done-claim-stop.py\"",
            "timeout": 10
          }
        ]
      }
    ]
  }
}

スクリプト: done-claim-stop.py

#!/usr/bin/env python3
import json, os, re, sys

sys.stdout.reconfigure(encoding='utf-8')
MAX_BLOCKS = 2
COUNT_DIR = os.path.expanduser('~/.claude/done-claim.d')
DONE = re.compile(
    r'(全部|全件|すべて|残らず|漏れなく|もれなく)(読|見|確か|終|済|外|直|入|やり|でき)|'
    r'(終わりました|完了しました|完了です|済みです|済ませました|片づきました|片付きました|直しました|通りました|動きました)')
MARK = re.compile(r'^\s*(\*\*)?根拠(\*\*)?\s*[::]', re.M)


def is_prompt(d):
    if d.get('type') != 'user':
        return False
    c = d.get('message', {}).get('content')
    if isinstance(c, str):
        return bool(c.strip())
    if isinstance(c, list):
        kinds = {p.get('type') for p in c if isinstance(p, dict)}
        return 'text' in kinds and 'tool_result' not in kinds
    return False


def main():
    data = json.load(sys.stdin)
    last, turn = '', ''
    try:
        with open(data.get('transcript_path', ''), encoding='utf-8') as f:
            for line in f:
                try:
                    d = json.loads(line)
                except ValueError:
                    continue
                if is_prompt(d):
                    turn = d.get('uuid', '') or turn + '1'
                    continue
                if d.get('type') != 'assistant':
                    continue
                for p in d.get('message', {}).get('content', []) or []:
                    if isinstance(p, dict) and p.get('type') == 'text' and p.get('text'):
                        last = p['text']
    except OSError:
        return
    if not last or MARK.search(last):
        return
    hits = sorted({m.group(0) for m in DONE.finditer(last)})
    if not hits:
        return
    os.makedirs(COUNT_DIR, exist_ok=True)
    cpath = os.path.join(COUNT_DIR, re.sub(r'[^\w-]', '_', f"{data.get('session_id', 'unknown')}_{turn}"))
    try:
        n = int(open(cpath).read())
    except (OSError, ValueError):
        n = 0
    if n >= MAX_BLOCKS:
        return
    open(cpath, 'w').write(str(n + 1))
    print(json.dumps({
        'decision': 'block',
        'reason': (
            f'返事に「{"」「".join(hits[:5])}」と言い切っていますが、「根拠:」の行がありません。'
            '言い切る前に、道具で中身を確かめてください(件数を数える・ファイルの中身を読む・画面やログの結果を見る)。'
            '確かめたら「根拠: 〇〇を△△で数えて□件/〜を開いて〜を見た」を1行書く。'
            '確かめきれなかったものは「全部」と言わず、確かめた範囲と残りを書く。'
            f'(差し戻し {n + 1}/{MAX_BLOCKS})'
        ),
    }, ensure_ascii=False))


if __name__ == '__main__':
    main()

ポイントは3つです。

  • 見るのは、記録の中の最後の返事の文だけ: 記録を上から読み、アシスタントのテキストを上書きしていくので、最後に残ったものが対象です。is_prompt は、ユーザーの本物の発言(ツールの結果ではないもの)を見つけて turn を更新するためのもので、下の差し戻し回数の数え方に使います
  • 差し戻しは1回の返事につき2回まで: カウントを ~/.claude/done-claim.d/ に、セッションと turn ごとのファイルで持ちます。上限を超えたら何も出さず、そのまま止まります。根拠が書けない返事で、無限に差し戻し合うのを避けるためです
  • 理由の文は、次にやることまで書く: 「ダメです」だけでなく、「件数を数える・ファイルの中身を読む」と具体的に書いておくと、Claude が次の返事で根拠の行を足しやすくなります

動いた結果

実際の会話ではなく、偽の会話記録(JSONL)を3つ作って、フックに直接流しました。

t1: 「13件、全部読んで外しました。」
t2: 「13件、全部読んで外しました。」+ 次の行に「根拠: 保存した13ファイルを開いて本文の長さを数えた。」
t3: 「今日は3件を確認しました。」

結果です。

  • t1: {"decision": "block", "reason": "返事に「全部読」と言い切っていますが、「根拠:」の行がありません。…(差し戻し 1/2 …" が出た
  • t2: 何も出力なし(通した)
  • t3: 何も出力なし(通した)
  • t1 を同じセッションで続けて3回流すと、2回目までは差し戻しが出て、3回目は何も出なかった(上限の2回)

つまずき・残っている穴

  • 言い換えはすり抜ける: t3 の「確認しました」は、言い切りの語に入れていないので止まりません。語を増やすと、普通の報告まで止めて空振りが増えます。語の一覧は、実際にすり抜けた返事を見つけたら足す運用にしています
  • 「根拠:」と書けば通る: 行頭が 根拠: かどうかしか見ていないので、中身の正しさまでは確かめません。フックが止めるのは「確かめた形跡が一行もない言い切り」までです。中身の確認は、件数をスクリプトに数えさせるなど、別の仕組みでやりました
  • 回数のカウントはファイルに残る: ~/.claude/done-claim.d/ に、発言ごとのファイルが溜まっていきます。掃除は入れていません
  • Stop フックは返事ごとに動く: timeout を短く(10秒)して、重い処理は入れないようにしています
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?