何に困ったか
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秒)して、重い処理は入れないようにしています