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?

🔕 Stop Hookがlaunchd自動実行で誤発火する問題と無人セッション判定の実装

0
Posted at

自律環境が月商120万を支えているのは、Claude Codeが「勝手に動いて勝手に完結する」からです。その環境を壊しかけたのも、Claude Code自身でした。

なぜこの仕組みが効くのか

ここ半年で私が一番時間を溶かした問題は、コードの品質でも課金でもなく、**「人間がいない画面への通知が止まらない」**という構造的な問題でした。

Claude Codeには~/.claude/settings.jsonでhookを登録できます。Stopイベントは、Claudeが一つのターンを終えるたびに発火します。私はここに~/.claude/hooks/self_audit_stop.shという監査ナグを仕込んでいます。Claudeがファイルを変更したターンで、「ちゃんと敵対的セルフ監査をしたか?」をチェックして、忘れていたらexit 2でブロックするスクリプトです。

これが対話セッションでは完璧に機能します。Claudeが実装を出して監査を書き忘れたら、その場でブロックされて「⚠️ セルフ監査未実施。」と表示される。私はその画面を見て、「ああ、確認が甘かったな」と気づけます。

問題は、launchd経由でAgent SDK CLIを使った無人自動実行でも、まったく同じStopフックが発火する点です。

私の環境には、ai-portraitsの画像生成をlaunchdで毎日自動実行するパイプラインがあります。これはAgent SDK(claude -p)を使ったCLI実行で、ターミナルには誰もいません。ログは~/Library/Logs/に流れるだけです。ところがStopフックはこの実行を「普通のターン終了」として扱います。監査ナグが出ても読む人間はいない。exit 2でブロックしてもCLI実行はエラー終了するだけです。

2026-07-12の朝、ログを開いたら19件の監査ナグが一晩で積み上がっていました。

これがスクリプトのコメントに残っている実体験です。

# entrypoint=sdk-cli(launchd等の無人自動化がAgent SDK経由で起動)は監査ナグを読む人間がおらず
# 単発実行で次ターンも無いため無音スキップ(2026-07-12: 一晩でsdk-cli自動化19件がstopspamを埋めた)

19件という数字はai-portraitsパイプラインが2回走り(13:28と17:00の2ランチ)、各ランで複数ターンが生成されたことで積み上がりました。1件1件はログの1行ですが、CI的なパイプラインの中でstop hookがexit 2を返すと後続ステップの扱いが変わります。無人実行に人間向けのナグを混ぜることは、ログノイズだけでなく実行品質の劣化でした。

読者の方が同じ構成を持っているかどうかは関係ありません。「作業ツールの設定が、自動化環境では別の文脈で動く」という構造は、あらゆる自律エージェント環境に共通します。MakeでもZapierでも、「人間向けの通知ロジックが無人実行路に混入する」問題は必ず起きます。Claude CodeはStopフックというprimitive一つで人間と機械の両方をカバーしようとするため、この問題が特にシャープに現れます。

解決の鍵は一つです。「このセッションを開いたのは人間か、lauchdか」をフック自身が判断する。 その根拠がトランスクリプトの先頭15行に書かれているentrypointフィールドです。

hookが関係する「環境」の全体像

自動化環境でStopフックが誤発火する問題を理解するには、Claude Codeがどう動いているかを把握しておく必要があります。

Claude Codeの対話セッションは、ターミナルかIDEで人間が起動します。このとき内部で生成されるトランスクリプトファイル(.jsonl形式)の先頭には、起動方法を示すentrypointフィールドが含まれます。対話セッションは"entrypoint":"cli"、Agent SDK経由のCLI実行は"entrypoint":"sdk-cli"です。

Stopフックはsettings.jsonに登録されたシェルスクリプトで、発火時にセッション情報をJSON形式でstdinに受け取ります。受け取れるのはsession_idtranscript_pathです。transcript_pathがあれば、そのファイルを読んでセッションの素性を調べられます。

もう一つの問題は、一セッション内で何度も発火する点です。対話セッションで10ターンやり取りすれば、Stopフックも最大10回呼ばれます。監査ナグが毎ターン出続けると「ナグに慣れる」という認知的な問題が起きます。監査が儀式化して形骸化する。2026-07-11のパフォーマンス監査でこの問題が指摘され、「セッション毎に最大2回まで」という制限が入りました。

# セッション毎に最大2回まで。連発すると監査が儀式化して本題を壊す(2026-07-11パフォーマンス監査)
prompted="/tmp/claude-audit-prompted-${sid}"
count=$(cat "$prompted" 2>/dev/null || echo 0)
if [ "$count" -ge 2 ]; then rm -f "$flag"; exit 0; fi

この2つの制御——「無人セッションの検出」と「発火回数の上限」——を組み合わせることで、監査ナグは「必要なときだけ、読める人間に、多くて2回」という動作になります。

なぜStop hookなのか

Claude Codeにはhookの種類が複数あります。PreToolUseはツール呼び出し前、PostToolUseは呼び出し後、そしてStopはモデルが応答を完了してターンを閉じるときです。

監査ナグをStopに置く理由は明確です。実装の全体を見てから評価できるのはStopだけだからです。PostToolUseでファイル変更を検知するアプローチも検討しましたが、1ターン中に複数のツール呼び出しが走る場合、途中の状態で監査を促しても意味がありません。モデルが「このターンで何をやったか」を全部出し終えてから、まとめて「ちゃんと報告したか?」を問うのが正しい粒度です。

また、Stopフックはexit 2を返すとモデルへのフィードバックになります。exit 0は合格・無音、exit 1は警告(ターンは進む)、exit 2はモデルへのメッセージ(stderrの内容がモデルに見える)という仕様です。これにより、スクリプト一つで「人間への表示」と「モデルへのフィードバック」を同時に実現できます。


全体の流れ

スクリプトの制御フローを先に見ておきます。実装の詳細に入る前に全体を把握しておくと、各部品の役割が明確になります。

Stopイベント発火
      │
      ▼
session_id・transcript_path を stdin から取得
      │
      ├─ flagファイル (/tmp/claude-audit-pending-{sid}) が無い
      │        → exit 0(そのターンはファイル変更なし・監査不要)
      │
      ├─ flagファイルあり → transcript_path を head -15 で読む
      │        │
      │        ├─ "entrypoint":"sdk-cli" が見つかる
      │        │        → flag削除・exit 0(無人セッション・無音スキップ)
      │        │
      │        └─ 見つからない(人間セッション)
      │                 │
      │                 ├─ prompted カウンタ ≥ 2
      │                 │        → flag削除・exit 0(発火上限・無音)
      │                 │
      │                 └─ カウンタ < 2
      │                          │
      │                          ├─ 直近assistantテキストに監査マーカーあり
      │                          │        → exit 0(合格・無音)
      │                          │
      │                          └─ マーカーなし
      │                                   → カウンタ+1・exit 2(ブロック+ナグ)
      │
      ▼
 (次ターンへ)

全体で5つのチェックポイントがあります。①変更の有無、②無人セッション判定、③発火回数上限、④監査マーカー検出、⑤ブロック&通知。このうち①②が今回の核心です。

①変更フラグの仕組み

/tmp/claude-audit-pending-${sid}というファイルがフラグです。これはStopフック自身が作るのではなく、PostToolUseフックが「ファイルを変更するツールが呼ばれた」タイミングで作ります。

Stopフックはこのファイルの存在を確認するだけです。

flag="/tmp/claude-audit-pending-${sid}"
[ -n "$sid" ] && [ -f "$flag" ] || exit 0   # 変更が無かったターン=何もしない

フラグがなければ即exit 0。「何もしていないターン」にナグを出す意味はありません。session_idが空の場合も同様に素通りします(フェイルオープン)。

②無人セッション判定の実装

フラグがあったら次のチェックに進みます。これがsdk-cli検出のコアです。

if [ -n "$tpath" ] && [ -f "$tpath" ] && head -15 "$tpath" 2>/dev/null | grep -q '"entrypoint":"sdk-cli"'; then
  rm -f "$flag"
  exit 0
fi

head -15でトランスクリプトの先頭15行だけを読みます。トランスクリプトは.jsonl形式で、ファイルが成長するにつれ数MBになることもあります。ファイル全体を読む必要はなく、entrypointは必ず先頭に書かれているため15行で確実に捕捉できます。

grep -q '"entrypoint":"sdk-cli"'で一致したらflagを消してexit 0。無人セッションでは監査ナグは一切出ません。

この行が入る前は、lauchdからAgent SDKで起動した全セッションがStopフックを素通りし、「人間向けのナグ」を誰も読まないログに垂れ流していました。19件という数字は、ai-portraitsパイプラインがその日の夕方に2回走り、各ランで複数ターンが完了したことで積み上がりました。

③②の直後に来る発火上限

sdk-cli判定をパスした(=人間セッションと判定された)場合、次に発火回数を確認します。

prompted="/tmp/claude-audit-prompted-${sid}"
count=$(cat "$prompted" 2>/dev/null || echo 0)
if [ "$count" -ge 2 ]; then rm -f "$flag"; exit 0; fi

/tmp/claude-audit-prompted-${sid}というファイルに発火回数を整数で書き込みます。ファイルが存在しなければecho 0でデフォルト値。catが失敗しても0扱いなのでフェイルオープンです。

カウンタが2以上であれば無音スキップ。「1セッション最大2回」という制限により、長いセッションでも監査ナグが洪水にならない。

④監査マーカーの検出

発火条件が揃ったら、直近のassistantメッセージを取り出して監査マーカーを探します。

if printf '%s' "$last" | grep -qE '監査|潰した|既に堅牢|あえて見送り|セルフ監査|三層|予測できる不具合'; then
  exit 0
fi

Pythonスクリプトでトランスクリプトの.jsonlをパースし、role=assistantの最後のテキストブロックを抽出します(スクリプト内27〜53行)。このテキストに上記いずれかのパターンが含まれていれば合格・無音。

マーカーのリストは実用的に選ばれています。「監査」「潰した」「既に堅牢」「あえて見送り」——これらはセルフ監査の3区分(潰した/既に堅牢/あえて見送り)の語彙です。「三層」「予測できる不具合」は監査フォーマットの別表現。どれか一つがあれば監査済みと判断します。

⑤ブロックとナグの出力

マーカーが見つからなければ、カウンタをインクリメントしてexit 2を返します。

echo $((count + 1)) > "$prompted"
echo "⚠️ セルフ監査未実施。実装/配線したなら敵対的監査(並行/失敗時/冪等/境界/秘密値/実検証)を済ませ、報告は**3行以内**で(要点のみ・表や長文禁止=2026-07-11フィードバック)。軽微なら『監査不要:理由』の一言で良い。" >&2
exit 2

stderrに出力することでモデルへのフィードバックになります。Claude Codeはstopフックのexit 2+stderrを受け取り、次のターンでその内容を「観察」として扱います。実質的に「Claudeが自分自身の監査漏れを指摘される」という構造です。

メッセージが具体的に観点を列挙しているのは意図的です。「何を監査するか」が曖昧だと形骸化するため、「並行/失敗時/冪等/境界/秘密値/実検証」という6点を毎回明示します。また「報告は3行以内」という制約は2026-07-11のフィードバックに由来します——それ以前は長文の監査表が返ってきて、本題のアウトプットが埋もれていました。

flagの削除タイミング

一点、実装上の注意があります。flagファイルは「チェックのどの段階でも必ず消える」設計になっています。

rm -f "$flag"                       # 単発: このターンのflagは必ず消す(ループ防止)

この行(スクリプト55行目)は監査マーカー検出の直前、Pythonパース後に配置されています。sdk-cli検出・カウンタ上限・合格・ブロック——どのパスを通っても、このターンのflagは必ず消えます。

なぜかというと、flagが残るとStopフックが次のターンで同じflagに反応してしまうからです。flagは「今ターン変更があった」という信号であり、次ターンにはPostToolUseフックが新しいflagを作ります。古いflagを持ち越すと、何もしていないターンでナグが出る誤発火になります。

実装の詳細

flagを立てる側は5行で十分

audit_flag_set.shを見ると驚くほどシンプルです。

#!/bin/bash
# PostToolUse(Write|Edit): このターンでファイル変更があった印をセッション別に立てる。
# Stopフック(self_audit_stop.sh)が拾って、セルフ監査の出し忘れを促す。
sid=$(/usr/bin/python3 -c 'import sys,json;print(json.load(sys.stdin).get("session_id",""))' 2>/dev/null)
[ -n "$sid" ] && touch "/tmp/claude-audit-pending-${sid}" 2>/dev/null
exit 0

67行のself_audit_stop.shに対して、こちらは実質3行です。「変更があったという事実をファイルの存在で表す」という設計のおかげでここまで短くなります。

settings.jsonでこのフックはWrite|Editのマッチャーを使ったPostToolUse登録です。

{
  "matcher": "Write|Edit",
  "hooks": [
    {
      "type": "command",
      "command": "~/.claude/hooks/audit_flag_set.sh"
    }
  ]
}

Bashツールでファイルを変えても、Readだけなら、このフックは発火しません。「ファイルを書いたターン」だけがフラグを立てる。これにより読み取り専用の調査ターンでナグが出るという誤発火が原理的に起きません。

sidだけを取り出してファイル名に埋め込む設計にも理由があります。セッションIDは/tmp/上のファイル名として安全な文字列です。逆にtranscript_pathをここで取り出してどこかに保存する必要はない。パスはself_audit_stop.sh側でstdinから直接取れるので、二つのフック間でデータを受け渡す仕組みは不要です。フック間の通信はファイルの存在だけ、という割り切りが保守性を上げています。

head -15が成立する理由

sdk-cli検出の核心はここです。

if [ -n "$tpath" ] && [ -f "$tpath" ] && head -15 "$tpath" 2>/dev/null | grep -q '"entrypoint":"sdk-cli"'; then

なぜhead -15なのか。Claude Codeのトランスクリプトは.jsonl形式——1行1JSONオブジェクトが積み上がるフォーマットです。長いセッションでは数MB、ターン数が多ければ数十MBになることもあります。ファイル全体をcatしてgrepするのは無駄どころか、パイプラインが詰まるリスクがあります。

重要なのは、entrypointフィールドは必ずファイル先頭のメタデータ行に書かれるという点です。Claude Codeがセッションを開始するとき、最初に記録するのがそのセッションの属性情報です。{"type":"system","session_id":"...","entrypoint":"sdk-cli",...}のような行が1〜数行目に来ます。15行あれば余裕を持って捕捉できます。

grepの文字列が'"entrypoint":"sdk-cli"'(ダブルクォートを含む)になっているのも意図的です。entrypointという文字列がコメントや他の値として現れる可能性を排除するため、JSON的な文脈——キーと値がコロンで繋がっている形——で一致させます。

[ -n "$tpath" ] && [ -f "$tpath" ] &&の二重ガードも重要です。tpathが空文字列の場合(後述する初期バグで実際に遭遇しました)は-fがからのパスを評価してエラーになります。変数が空の場合と、変数が示すファイルが存在しない場合を両方ガードすることで、フェイルオープン(誤ブロックしない)を担保しています。

Pythonヒアドキュメントをbashに埋め込む判断

直近のassistantテキストを取り出す部分は、bashの中にPythonをヒアドキュメントで埋め込む形になっています。

last=$(/usr/bin/python3 - "$tpath" <<'PY'
import sys, json
msgs = []
try:
    for line in open(sys.argv[1], encoding="utf-8"):
        line = line.strip()
        if not line:
            continue
        try:
            o = json.loads(line)
        except Exception:
            continue
        if o.get("type") == "assistant" or o.get("role") == "assistant":
            m = o.get("message", o)
            c = m.get("content")
            if isinstance(c, list):
                for b in c:
                    if isinstance(b, dict) and b.get("type") == "text":
                        msgs.append(b.get("text", ""))
            elif isinstance(c, str):
                msgs.append(c)
except Exception:
    pass
print(msgs[-1] if msgs else "")
PY
)

別の.pyファイルにしなかった理由は二つあります。第一に、このロジックはself_audit_stop.sh以外から呼ばれることがない。独立ファイルにすると「どこから使われるかわからないロジック」という誤解を生みます。第二に、hookディレクトリのファイル数を増やさない。hook群は既に17ファイルあります。責任が一つのスクリプト内で完結していれば、削除・更新・移動が一操作で済みます。

コードの中で注目すべきはroleの判定条件です。

if o.get("type") == "assistant" or o.get("role") == "assistant":

typeroleの両方を見ているのは、Claude Codeのトランスクリプト形式がバージョンによって揺れるからです。古い形式は{"type":"assistant",...}、新しい形式は{"role":"assistant",...}のような違いがあります。どちらか一方だけ見ると、バージョンアップ後に突然「直近メッセージが取れない」障害になります。

contentがlistの場合とstrの場合を両方ハンドリングしているのも同様の理由です。ツール呼び出しが混在するターンではcontentが配列になります。テキストだけのターンでは文字列のままのケースもあります。isinstance(c, list)でまず配列判定し、type=="text"のブロックだけを取り出す。文字列なら直接追加。どちらのフォーマットでも最後のassistantテキストが正しく取れる設計です。

外側のtry-exceptで全体を包んでいることも大事です。パーサーが何らかの理由で失敗した場合、msgsは空リストのままprint("")を返します。その後ろで[ -z "$last" ] && exit 0がフェイルオープンします。パーサーの失敗で監査をブロックすることはないという方針です。

監査マーカーの語彙はなぜ6パターンなのか

if printf '%s' "$last" | grep -qE '監査|潰した|既に堅牢|あえて見送り|セルフ監査|三層|予測できる不具合'; then

最初は「監査」の一語だけで検出しようとしていました。しかしこれは誤検出が多すぎました。「監査ログを確認しました」「監査不要だと判断します」のような文でも引っかかります。一方で本当に必要な監査報告を書いていない場合もこの語を使わず素通りすることがあった。

現在の6パターンはセルフ監査の出力フォーマット由来の語彙です。私がClaude Codeに要求している監査報告の構造は「潰した / 既に堅牢 / あえて見送り」という3区分の表です。このどれかの語が最後のassistantメッセージに含まれているということは、監査報告が実際に書かれているという証拠になります。「三層」「予測できる不具合」は別の監査フォーマット表現で、バリエーションをカバーするために追加しました。

printf '%s'を使っているのはechoのエスケープ展開を避けるためです。assistantのテキストに\n\tが含まれていると、echoはそれを解釈してしまいます。printf '%s'は文字列をそのまま出力します。

exit 2がモデルに届く仕組み

echo "⚠️ セルフ監査未実施。..." >&2
exit 2

Claude Codeのhookはexitコードで動作を制御します。exit 0は合格・無音。exit 1は警告(ターンは進む)。exit 2は「モデルへのメッセージ」です——stderrに書いた内容がそのままモデルへのフィードバックとして次のターンに注入されます。

これにより、スクリプト一つで「人間への表示」と「モデルへの通知」を同時に達成できます。画面を見ている人間は⚠️のメッセージを見て気づく。同時にモデルは「自分が監査を忘れた」という事実をフィードバックとして受け取り、次のターンで自発的に修正します。hookがモデルの自律的な振る舞いを外から制御できる、これがStopフックを選んだ最大の理由です。


私が詰まった話

詰まり①:flagを消すタイミングを間違えて無限ナグになった

症状:何もファイルを変えていないターンでも監査ナグが出続ける。「ちょっと考えて」と言っただけのターンに⚠️が来る。

原因:初期実装ではrm -f "$flag"をスクリプトの末尾——exit 2の直前にだけ書いていました。Pythonパーサーが失敗してフェイルオープンした場合や、監査マーカーが検出されてexit 0で抜ける場合、flagが残ったままになっていました。次のターンのStopフック発火時に、前のターンの残留flagに反応してナグが出る。しかも何もしていないターンなのでassistantテキストにも監査マーカーはない。結果として連続ナグになりました。

直し方rm -f "$flag"をPythonパーサー呼び出し直後、マーカー判定の前に移動しました(現在の55行目)。

rm -f "$flag"                       # 単発: このターンのflagは必ず消す(ループ防止)
[ -z "$last" ] && exit 0            # 読めなければフェイルオープン(誤ブロックしない)

「チェックの結果がどうであれ、このターンのflagは消す」という原則を徹底したことで、残留flagの問題が根絶されました。次ターン以降のflagはaudit_flag_set.shが新たに立てる責任を持つ。フラグの所有権を明確にする設計です。

詰まり②:tpathが空でエラーになった

症状:稀にhookがエラー終了して、ログにhead: : No such file or directoryが残る。

原因:初期のget()関数呼び出しで、transcript_pathがJSONに含まれていない場合に空文字列が返ります。そのままhead -15 ""を実行するとシェルエラーになります。

最初の実装はこうでした。

# 初期の壊れたバージョン
head -15 "$tpath" 2>/dev/null | grep -q '"entrypoint":"sdk-cli"'

tpathが空の場合、head -15 ""はエラーになりますが2>/dev/nullでエラーを捨てているため、grepは何も受け取らずにexit 1(マッチなし)を返します。その結果sdk-cli判定が失敗して、無人セッションに監査ナグが出ていました。問題がわかりにくかったのは「エラーは出ないが誤動作する」という挙動だったためです。

直し方[ -n "$tpath" ] && [ -f "$tpath" ] &&の二重ガードを追加しました。変数が空の場合と、示すファイルが存在しない場合を明示的にガードします。どちらもfalseなら条件全体がfalseになり、sdk-cli判定をスキップして人間セッションとして続行します(最悪のケースでも誤ブロックはしないフェイルオープン方向)。

詰まり③:監査が儀式化してナグに慣れた

症状:⚠️が来たら内容を読まずにエンターを押す習慣ができていた。「監査しました」という形式だけ満たせばナグが消えることを覚えてしまい、内容が形骸化していく。

原因:発火回数の上限がなかった時代、10ターンのセッションで最大10回ナグが出ていました。最初の2〜3回は真剣に対応しますが、5回目以降は「また来た」という感覚になります。認知的な摩耗です。監査ナグが「本当に必要なとき」なのか「いつものやつ」なのか区別できなくなる。

2026-07-11のパフォーマンス監査でこの問題が表面化しました。監査報告の質が下がっていることが会話ログから検出されたのです。

直し方/tmp/claude-audit-prompted-${sid}というカウンタファイルを導入して、1セッションの発火を最大2回に制限しました。

prompted="/tmp/claude-audit-prompted-${sid}"
count=$(cat "$prompted" 2>/dev/null || echo 0)
if [ "$count" -ge 2 ]; then rm -f "$flag"; exit 0; fi

2回という数字は実験から出ています。1回だと「見逃し」が起きる(1ターン目で合格したが後続ターンで監査を怠るケース)。3回以上だと「多すぎる」という感覚が戻る。2回が、「念押し」と「過剰」の境界線でした。

詰まり④:監査報告が長文になって本題が埋もれた

症状:監査を促した結果、Claudeが20行以上の監査表を返すようになった。「並行/失敗時/冪等/境界/秘密値/実検証」の6観点を1行ずつ丁寧に書いてくるため、実際の実装結果の要約が画面外に押し出される。

原因:ナグのメッセージに観点を列挙したことで、Claudeが「全観点について均等に報告すべき」と解釈したのです。観点リストは「何を確認するか」のガイドのつもりでしたが、「何を書くか」のテンプレートとして機能してしまいました。

2026-07-11のフィードバックがこれです。「監査報告に時間を割きすぎて、本質的なアウトプットが埋もれている。監査は本題の注釈であって、本題より長くなってはいけない」。

直し方:ナグのメッセージ末尾に分量の制約を明示しました。

echo "⚠️ セルフ監査未実施。実装/配線したなら敵対的監査(並行/失敗時/冪等/境界/秘密値/実検証)を済ませ、報告は**3行以内**で(要点のみ・表や長文禁止=2026-07-11フィードバック)。軽微なら『監査不要:理由』の一言で良い。" >&2

「3行以内」「軽微なら一言で良い」という逃げ道を明示することで、監査の粒度がコンテキストに合わせて調整されるようになりました。些細な変更で「監査不要:出力の変更のみ」と一言返すのが正しい場合もある。それをナグが許容することで、形骸化ではなく実質的な判断を引き出せるようになりました。

詰まり⑤:Pythonパーサーがcontentのバリエーションに対応できなかった

症状:特定のターン——ToolUseとToolResultが混在するターン——でlastが空になり、フェイルオープンして監査漏れを見逃す。

原因:初期のパーサーはassistantメッセージのcontentが文字列のケースしか想定していませんでした。

# 初期の壊れたバージョン
if o.get("role") == "assistant":
    msgs.append(o.get("content", ""))

ToolUseが含まれるターンではcontentは配列です。[{"type":"tool_use","id":"..."},{"type":"text","text":"...監査..."}]のような形になります。これを配列ごとappendするとテキスト部分が取り出せず、msgs[-1]が配列オブジェクトになります。grepに渡しても一致しません。

直し方isinstance(c, list)で配列判定し、type=="text"のブロックだけを拾い出すように変更しました。現在のコードの27〜52行がそれです。配列の各要素を走査してテキストブロックだけを結合する。文字列のケースはそのまま追加。どちらの形式でも最後のassistantテキストが正しく抽出されることを確認するために、実際のセッションのトランスクリプトを7種類集めてテストしました。

パーサーのデバッグで一番役立ったのは次のワンライナーです。自分のセッションの.jsonlを読んで、assistantのcontentがどういう型で来るかを確認できます。

python3 -c "
import json, sys
for line in open(sys.argv[1]):
    o = json.loads(line.strip()) if line.strip() else {}
    if o.get('role') == 'assistant' or o.get('type') == 'assistant':
        c = o.get('message', o).get('content')
        print(type(c).__name__, repr(c)[:80])
" ~/.claude/projects/*/transcripts/*.jsonl | head -20

トランスクリプトのパスは~/.claude/projects/以下にセッションIDごとのディレクトリで格納されています。実際のデータを見ることで「どのバリエーションを想定すべきか」が一発でわかります。


上記5つの失敗はすべて「動いているように見えたが正しく動いていなかった」ケースです。監査ナグの誤発火や見逃しはエラーを吐かずに静かに起きます。「なんとなくナグが多い気がする」「なんとなく監査報告の質が下がった気がする」という曖昧な感覚から原因を特定するには、ログを定量的に見る習慣が必要でした。今は~/Library/Logs/に流れているlaunchdのログと、/tmp/claude-audit-*の残留ファイルを週次で確認することで、異常を早期に検出しています。

つまずきポイント

前編・中編でケースごとの原因と直し方を説明しました。ここでは「実装中に実際に踏んだ穴」を網羅的に整理します。同じ実装をする方が同じ箇所でつまずかないよう、体験の順ではなく「検出しにくい順」で並べています。

① flagを消すタイミングが遅い → 残留flagで無限ナグ

最初期の実装ではrm -f "$flag"exit 2の直前にだけ書いていました。監査マーカーが見つかってexit 0で抜けたとき、フェイルオープンしてpython3が空文字列を返したとき——どちらのパスでもflagが残ります。次のターンのStopフックが古いflagを拾い、「今ターン変更があった」と誤認する。ファイルを何も変えていないターンにも⚠️が出続ける典型的な残留バグです。直し方は「Pythonパーサー呼び出し直後、判定処理より前に無条件で消す」一点に尽きます。

rm -f "$flag"   # どのパスを通っても必ずここで消す

tpathが空でも2>/dev/nullがエラーを隠す

head -15 "" 2>/dev/nullはシェルエラーを吐きますが2>/dev/nullで捨てます。grepは空の入力を受け取りexit 1(マッチなし)を返す。結果としてsdk-cli判定が失敗し、無人セッションに監査ナグが出ます。エラーも出ないし動きも微妙にズレる——最も気づきにくいパターンです。[ -n "$tpath" ] && [ -f "$tpath" ]の二重ガードが必要な理由はここにあります。

③ hookファイルの実行権限を付け忘れると無音スキップ

chmod +xを忘れると、hookは発火したように見えてシェルがパーミッションエラーで終了するだけです。settings.jsonの登録は通り、ログにも何も残らない。「hookが効いていない」という症状だけが出て原因がわからない。ls -la ~/.claude/hooks/で実行ビットを確認するのが最初の手順です。

④ grepパターンが広すぎて誤検出

最初のパターンは'監査'の1語でした。「監査ログを確認しました」「監査不要と判断します」のような文でも一致してしまい、監査報告を書いていないターンを見逃す。逆に「監査」を含まない別の表現で書いたターンは引っかからない。現在の6パターン——'監査|潰した|既に堅牢|あえて見送り|セルフ監査|三層|予測できる不具合'——はすべて監査フォーマットの語彙に絞っています。「確認しました」という曖昧な動詞は含まれていません。

exit 1exit 2を混同するとモデルにフィードバックが届かない

Claude Codeのhookはexit 0(合格・無音)、exit 1(警告、ターンは進む)、exit 2(stderrの内容をモデルへ送る)の3値で動作が変わります。exit 1にしていた時期があり、⚠️は人間の画面に出るがモデルには届かない、という状態が続きました。Claudeは自分のナグを認識できないので次ターンでも監査を書かない。人間が毎回手動でフォローしないとナグが機能しません。ブロック&フィードバックのセットで使う場合は必ずexit 2です。

⑥ launchd環境はPATHが貧弱

通常のターミナルと違い、launchdで起動したジョブには/usr/local/bin~/.nvm/がPATHに入っていません。スクリプト内でpython3と書くと「コマンドが見つからない」でサイレント失敗します。self_audit_stop.sh/usr/bin/python3とフルパスを使っているのはこのためです。hookスクリプトに外部コマンドを書くときは、常にフルパスか明示的なPATH設定を使う習慣が必要です。

/tmp/のカウンタファイルはOS再起動でリセットされる

macOSは起動時に/tmp/を空にします。カウンタが消えるため、「1セッション最大2回」という制限も再起動のたびにリセットされます。長期的には「セッションが続く間だけ有効な制約」として機能します。これは仕様として受け入れています——そもそも再起動後の翌日セッションで「昨日の続き」として2回ルールを引き継ぐ必要はありません。ただし、意図せず再起動が起きたときに「なぜ上限がリセットされているのか」がわかるよう、コメントに残しておく価値はあります。

echoのエスケープ展開でgrep結果がズレる

assistantテキストに\n\tが含まれているとき、echo "$last"はエスケープシーケンスを解釈します。テキストが変形した状態でgrepにかかるため、監査マーカーが含まれているのに一致しないケースが起きます。printf '%s' "$last"はエスケープを解釈せずそのまま出力するため、この問題が起きません。bash内での文字列パイプは原則としてprintf '%s'を使う癖をつけると、このクラスのバグが根絶できます。

⑨ Pythonパーサーがcontentの型バリエーションに非対応

ToolUseとToolResultが混在するターンではcontentがリストになります。テキストだけのターンでは文字列です。最初は文字列だけを想定していたため、配列が来たときにmsgsが空になりフェイルオープン——監査漏れを見逃す、という状況が起きました。実際のトランスクリプト7種類でテストして初めて全パターンを把握できました。「コードレビューより実データでのテスト」という教訓です。

session_idが空のセッションが稀に存在する

Claude Codeの起動タイミングによっては、Stopフック発火時点でstdinにsession_idが含まれていないケースがあります。[ -n "$sid" ] && [ -f "$flag" ]の最初の条件で弾けますが、これに気づかないと「なぜかフラグがあるのに動かない」という現象になります。session_idが空のときはflag名が/tmp/claude-audit-pending-(sid部分が空)になり、存在チェックが通らないためフェイルオープンします。意図した動作ですが、デバッグ時はset -xで変数展開を確認するのが近道です。

⑪ sdk-cli判定をsession開始直後のターンで行うとファイルが存在しないことがある

トランスクリプトファイルはセッション開始と同時に生成されますが、ごく稀に最初のStopフック発火時点でまだ1行も書き込まれていないケースがあります。[ -f "$tpath" ]は通るがhead -15が空を返す。grepが一致しないためsdk-cli判定が失敗し、実際は無人セッションなのに人間セッションと判定されます。この場合はカウンタが0なのでナグが1回出ます。完全には防げませんが、影響は「初回1件だけ誤発火」に限定されます。


ベストプラクティス

実装を安定運用に乗せる過程で固まったルールです。コード片とともに記します。

1. hookは常にフェイルオープンで設計する

hookが途中でエラーしたとき、「誤ブロック(止めてはいけないものを止める)」よりも「誤スキップ(通してはいけないものを通す)」のほうがシステムへの影響が小さい。監査ナグを1回見逃しても環境は壊れません。パーサーのバグで正常な対話セッションをブロックし続ける方が破壊的です。[ -z "$last" ] && exit 0のような逃げ路を各チェックポイントに置く設計が、長期安定の鍵です。

2. 起動属性は先頭数行に集中している——head -Nで読む

Claude Codeのトランスクリプトは.jsonlです。セッション属性(entrypointsession_id)は必ず先頭の1〜3行に書かれます。数MBになるファイルを全部読む必要はありません。head -15は「15行あれば確実に捕捉できる」という余裕を持ったバッファです。大きなファイルをパイプに流すコストを避けつつ、判定に必要な情報だけを取ります。

3. grepする文字列はJSON文脈のまま一致させる

grep -q '"entrypoint":"sdk-cli"'

entrypointという語は他のフィールド名やコメントにも現れ得ます。キーと値がJSONのコロンで繋がった形で一致させると、文字列の誤検出を排除できます。ダブルクォートをシングルクォートで囲む書き方は、shellのエスケープを最小にするための慣用です。

4. フラグの「存在」で状態を表し、「内容」は使わない

/tmp/claude-audit-pending-${sid}はファイルの中身が空でも構いません。touchで作りrm -fで消す。存在=「このターンに変更あり」、不在=「変更なし」。この設計によって、フラグを立てるaudit_flag_set.sh側は3行で済みます。2つのスクリプト間の通信がファイルの存在のみに限定され、データ形式の齟齬が原理的に起きません。

5. 二重ガードで防御する

[ -n "$tpath" ] && [ -f "$tpath" ] && head -15 "$tpath" ...

変数が空のケースとファイルが存在しないケースは別物です。どちらが起きてもショートサーキットで抜けるよう、-n-fを両方書く。片方だけでは「変数が空文字列でファイルパスとして解釈される」バグが静かに紛れ込みます。

6. カウンタはcat || echo 0パターンで初期値を持つ

count=$(cat "$prompted" 2>/dev/null || echo 0)

ファイルが存在しない初回は0が返ります。catの失敗(ファイルなし・読み取りエラー)を||で拾い、デフォルト値を返す。この1行でファイルの有無を気にせずカウンタを扱えます。

7. 発火上限は「1回ではなく2回」に設定する

1セッション1回だと「1ターン目で合格したが後続ターンで監査を怠る」見逃しが起きます。3回以上だと「またか」という認知的摩耗が戻ります。2回という数字は、念押しと過剰の境界線として実験から得ました。あなたの環境によって最適値は変わるかもしれませんが、2が出発点として機能します。

8. 監査マーカーはフォーマット語彙に縛る

広い語(「確認」「完了」「報告」)はfalse positiveが多い。監査フォーマットの固有語彙——「潰した」「既に堅牢」「あえて見送り」——は、監査報告が実際に書かれたことの証拠になります。スクリプトのマーカーパターンと、Claudeへの指示で定義している監査フォーマットを同期させておくのがポイントです。フォーマットを変えたらマーカーパターンも更新する。

9. 外部コマンドはフルパスで書く

hookはlaunchd経由の無人実行でも呼ばれます。その環境は通常のシェルPATHとは異なります。python3ではなく/usr/bin/python3/bin/bashなどフルパスを使う。スクリプトを書いたあとenv -i bash <script>(環境変数を空にしてテスト)で確認する習慣をつけると、PATH依存のバグが事前に潰せます。

10. Pythonロジックはヒアドキュメントで埋め込む

このスクリプトから外から呼ばれることのないPythonは、別ファイルにする必要がありません。hookディレクトリのファイル数は現在17本。これ以上増やさず、責任を一つのスクリプトに閉じ込める。削除・移動・更新が一操作で完結します。

last=$(/usr/bin/python3 - "$tpath" <<'PY'
# ここにpythonコードを書く
PY
)

<<'PY'のシングルクォートで、ヒアドキュメント内のシェル展開を無効にできます。Pythonコードに$`が含まれていてもエスケープ不要です。

11. スクリプトに実体験のコメントを残す

# 2026-07-12: 一晩でsdk-cli自動化19件がstopspamを埋めた

「なぜこのコードがあるか」を後の自分に伝えるコメントは、抽象的な説明より事件と数字で書くと忘れにくい。「19件」「一晩で」という具体性が、「このチェックを消してはいけない」という判断を支えます。

12. デバッグは実トランスクリプトで行う

hookのPythonパーサーを机上でテストしても、実際のトランスクリプトの型バリエーションには追いつけません。以下のワンライナーで自分のセッションデータを直接調べる方が速い。

python3 -c "
import json, sys
for line in open(sys.argv[1]):
    o = json.loads(line.strip()) if line.strip() else {}
    if o.get('role') == 'assistant' or o.get('type') == 'assistant':
        c = o.get('message', o).get('content')
        print(type(c).__name__, repr(c)[:80])
" ~/.claude/projects/*/transcripts/*.jsonl | head -20

このコマンド一本で「contentが配列のケース」「文字列のケース」「nullのケース」を実データで確認できます。テストデータを作るより速く、本番のバリエーションを全網羅できます。

13. printf '%s'echoの代わりに使う

変数をパイプに渡すとき、echo "$var"-eオプション相当のエスケープ解釈が起きる実装もあります。printf '%s' "$var"はそれを避け、変数の内容をそのまま出力します。bash内でテキストを別コマンドに渡す場面は常にこちらを使う。


まとめ

この実装が解いた問題は一言でいうと、**「人間向けの監査ロジックが、機械向けの自動化路に混入すること」**です。

Claude CodeのStopフックは、人間の対話セッションと、launchd経由のAgent SDK自動実行を区別しません。どちらのターン終了でも同じスクリプトが呼ばれます。設計として正しい——hookは汎用プリミティブであるべきで、起動元を意識する必要はない。問題はその上に乗せるロジックが「人間だけを想定して書かれている」ことです。

2026-07-12の朝、ログに積み上がった19件の監査ナグは、その構造的な問題を数字で見せてくれました。ai-portraitsパイプラインが13:28と17:00の2ランチ走り、各ランで複数ターンが完了するたびにStopフックが発火した。誰も読まないログにナグが積み上がり、CLIパイプラインはエラーで終了する。

解決策はシンプルでした。フック自身がトランスクリプト先頭15行のentrypointフィールドを読み、sdk-cliを検出したら無音スキップする。加えて人間セッションでも発火上限を2回に制限し、監査ナグの「慣れによる形骸化」を防ぐ。67行のシェルスクリプトが、5つの判断ポイントを経て「必要なときだけ、読める人間に、多くて2回」という動作を実現しています。

この構造は、Claude Code固有の問題ではありません。「人間への通知ロジックが無人実行路に混入する」問題は、MakeでもGitHub Actionsでも起きます。違うのはClaude Codeがhookというシンプルなプリミティブを提供していること、そしてそのhookがexit 2一本でモデルへのフィードバックループになれることです。

月商120万の自律環境を支えているのは、こういう「小さな47〜67行のスクリプトが正しい文脈でだけ動く」積み重ねです。一つ一つは地味ですが、積み上げた環境の総体として「Claudeが勝手に動いて勝手に完結する」仕組みが機能します。


仕組みの全体像・月120万の内訳・30日手順は有料noteにまとめています。

📕 Claude Code自律環境で、実際どう稼ぐか ― 仕組み・実例・始め方・サポート


Lily@bokuwalily)― 個人開発者。Claude Code で自動化基盤を組みながら、iOSアプリやWebサービスを量産しています

皆さんの ❤️ やシェアが励みになります!

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?