DEV Community

Lily
Lily

Posted on Originally published at dev.to

19 Audit Nags in One Night: Making a Claude Code Stop Hook Detect Unattended Sessions

My autonomous setup earns its keep precisely because Claude Code starts on its own and finishes on its own. The thing that nearly broke that setup was Claude Code itself.

Why This Mechanism Matters

The biggest time sink for me over the past six months wasn't code quality or billing costs. It was a structural problem: notifications that wouldn't stop firing at a screen with no human in front of it.

Claude Code lets you register hooks in ~/.claude/settings.json. The Stop event fires every time Claude finishes a turn. I've planted an audit nag there called ~/.claude/hooks/self_audit_stop.sh. On any turn where Claude modified a file, the script checks "did you actually do an adversarial self-audit?" and blocks with exit 2 if it was skipped.

This works perfectly in interactive sessions. If Claude ships an implementation and forgets to write the audit, it gets blocked on the spot and "⚠️ セルフ監査未実施。" appears on screen. I see that and realize my verification was sloppy.

The problem is that the exact same Stop hook fires during unattended automated runs launched through launchd using the Agent SDK CLI.

My environment has a pipeline that runs ai-portraits image generation automatically every day via launchd. That's a CLI run using the Agent SDK (claude -p), and nobody's at the terminal. The logs just flow into ~/Library/Logs/. But the Stop hook treats this run like any ordinary turn ending. The audit nag appears with no human to read it. Blocking with exit 2 just makes the CLI run exit with an error.

On the morning of 2026-07-12, I opened the logs and found 19 audit nags stacked up overnight.

That's the real-world incident preserved in the script's comment:

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

The 19 came from the ai-portraits pipeline running twice (two launches at 13:28 and 17:00), with each run producing multiple turns. Each item is just one line in a log, but when a stop hook returns exit 2 inside a CI-like pipeline, the handling of downstream steps changes. Mixing human-facing nags into unattended runs wasn't just log noise — it degraded execution quality.

Whether you happen to have the same setup is beside the point. The structure — "a tool setting behaves in a different context inside an automation environment" — is common to every autonomous agent environment. With Make or Zapier alike, the "human-facing notification logic leaks into the unattended execution path" problem is guaranteed to happen. Claude Code tries to cover both humans and machines with a single Stop hook primitive, which makes the problem show up especially sharply.

There's one key to solving it. The hook itself decides whether a human or launchd opened this session. The evidence for that decision is the entrypoint field written in the first 15 lines of the transcript.

The Big Picture of the "Environment" the Hook Touches

To understand why Stop hooks misfire in automated environments, you need a grasp of how Claude Code operates.

A Claude Code interactive session is started by a human in a terminal or IDE. The transcript file generated internally at that point (.jsonl format) contains an entrypoint field at the top indicating how it was launched. Interactive sessions are "entrypoint":"cli"; CLI runs via the Agent SDK are "entrypoint":"sdk-cli".

A Stop hook is a shell script registered in settings.json that receives session information as JSON on stdin when it fires. What you get is session_id and transcript_path. Given transcript_path, you can read that file and investigate the session's provenance.

The other problem is that it fires many times within a single session. Exchange 10 turns in an interactive session and the Stop hook gets called up to 10 times. When the audit nag shows up every turn, a cognitive problem sets in: you get used to the nag. The audit becomes ritual and hollows out. This problem was flagged in a performance audit on 2026-07-11, and a "maximum of 2 per session" limit was added.

# セッション毎に最大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
Enter fullscreen mode Exit fullscreen mode

Combining these two controls — "detecting unattended sessions" and "capping fire count" — gives the audit nag this behavior: only when needed, only to a human who can read it, at most twice.

Why the Stop Hook?

Claude Code has several kinds of hooks. PreToolUse runs before a tool call, PostToolUse after, and Stop when the model completes its response and closes the turn.

The reason for putting the audit nag on Stop is clear: Stop is the only place you can evaluate the implementation as a whole. I considered an approach that detects file changes in PostToolUse, but when multiple tool calls run within one turn, prompting for an audit mid-state is meaningless. The correct granularity is asking "did you report properly?" once the model has finished putting out everything it did on this turn.

Also, a Stop hook returning exit 2 becomes feedback to the model. The spec is: exit 0 means pass and stay silent, exit 1 is a warning (the turn proceeds), and exit 2 is a message to the model (the stderr content is visible to the model). This lets a single script achieve both "display to the human" and "feedback to the model" at once.


The Overall Flow

Let's look at the script's control flow first. Grasping the whole before diving into implementation details makes each component's role clear.

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(ブロック+ナグ)
      │
      ▼
 (次ターンへ)
Enter fullscreen mode Exit fullscreen mode

There are 5 checkpoints in total. ① whether there was a change, ② unattended session detection, ③ fire count cap, ④ audit marker detection, ⑤ block and notify. Of these, ① and ② are the core of this article.

① How the Change Flag Works

The file /tmp/claude-audit-pending-${sid} is the flag. The Stop hook doesn't create it — the PostToolUse hook creates it at the moment "a tool that modifies files was called."

The Stop hook only checks whether this file exists.

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

No flag means immediate exit 0. There's no point nagging on a turn where nothing happened. If session_id is empty, it passes through the same way (fail-open).

② Implementing Unattended Session Detection

If the flag exists, we move to the next check. This is the core of sdk-cli detection.

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

head -15 reads only the first 15 lines of the transcript. Transcripts are .jsonl, and the file can grow to several MB. There's no need to read the whole file — entrypoint is always written at the top, so 15 lines reliably captures it.

If grep -q '"entrypoint":"sdk-cli"' matches, delete the flag and exit 0. In unattended sessions, no audit nag appears at all.

Before this line existed, every session launched from launchd via the Agent SDK sailed right past the Stop hook, spilling "human-facing nags" into logs nobody reads. The 19 accumulated because the ai-portraits pipeline ran twice that evening, with multiple turns completing in each run.

③ The Fire Cap That Comes Right After ②

If the sdk-cli check passes (i.e., it's judged a human session), the next step checks the fire count.

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
Enter fullscreen mode Exit fullscreen mode

The fire count is written as an integer into the file /tmp/claude-audit-prompted-${sid}. If the file doesn't exist, echo 0 supplies the default. Even if cat fails it's treated as 0, so this is fail-open.

If the counter is 2 or more, skip silently. The "max 2 per session" limit keeps audit nags from flooding even long sessions.

④ Detecting the Audit Marker

Once the firing conditions are met, we extract the most recent assistant message and look for an audit marker.

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

A Python script parses the transcript .jsonl and extracts the last text block with role=assistant (lines 27–53 of the script). If that text contains any of the above patterns, it passes silently.

The marker list was chosen for practicality. 「監査」「潰した」「既に堅牢」「あえて見送り」 — these are the vocabulary of the self-audit's 3 categories (fixed / already robust / deliberately deferred). 「三層」 and 「予測できる不具合」 are alternate phrasings of the audit format. Any one of them means the audit is considered done.

⑤ Emitting the Block and the Nag

If no marker is found, increment the counter and return exit 2.

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

Writing to stderr makes it feedback to the model. Claude Code receives the stop hook's exit 2 plus stderr and treats the content as an "observation" on the next turn. In effect, the structure is "Claude gets called out for its own missing audit."

The message enumerating specific dimensions is deliberate. If "what to audit" is vague, it becomes an empty ritual, so the six points 「並行/失敗時/冪等/境界/秘密値/実検証」 are spelled out every time. The "report in 3 lines or fewer" constraint comes from feedback on 2026-07-11 — before that, long audit tables came back and buried the actual output.

When the Flag Gets Deleted

One implementation note. The flag file is designed to always be deleted at every stage of checking.

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

This line (line 55 of the script) sits immediately before audit marker detection, right after the Python parse. sdk-cli detection, counter cap, pass, block — whichever path is taken, this turn's flag is always removed.

The reason is that a lingering flag causes the Stop hook to react to that same flag on the next turn. The flag is a signal meaning "there was a change this turn," and on the next turn the PostToolUse hook creates a new one. Carrying an old flag forward causes a misfire: a nag on a turn where nothing was done.

Implementation Details

5 Lines Is Enough on the Flag-Setting Side

Looking at audit_flag_set.sh is surprisingly simple.

#!/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
Enter fullscreen mode Exit fullscreen mode

Against the 67-line self_audit_stop.sh, this one is effectively 3 lines. It gets this short thanks to the design of "expressing the fact that a change occurred through the existence of a file."

In settings.json, this hook is a PostToolUse registration using a Write|Edit matcher.

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

If you change files with the Bash tool, or only Read, this hook doesn't fire. Only "turns that wrote a file" set the flag. This makes the misfire of nagging on a read-only investigation turn structurally impossible.

There's a reason for the design that extracts only sid and embeds it in the filename too. Session IDs are strings that are safe as filenames in /tmp/. Conversely, there's no need to extract transcript_path here and store it somewhere. The path can be taken directly from stdin on the self_audit_stop.sh side, so no mechanism to hand data between the two hooks is required. Inter-hook communication is nothing but file existence — that simplification raises maintainability.

Why head -15 Works

Here's the core of sdk-cli detection.

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

Why head -15? Claude Code transcripts are .jsonl — a format where one JSON object per line accumulates. Long sessions reach several MB, and with many turns they can reach tens of MB. Catting the whole file and grepping isn't just wasteful; it risks clogging the pipeline.

What matters is that the entrypoint field is always written in the metadata line at the top of the file. When Claude Code starts a session, the first thing it records is that session's attribute information. A line like {"type":"system","session_id":"...","entrypoint":"sdk-cli",...} comes in the first few lines. 15 lines captures it with room to spare.

The grep string being '"entrypoint":"sdk-cli"' (including double quotes) is also deliberate. To rule out the string entrypoint appearing in a comment or as some other value, we match in JSON context — the form where key and value are joined by a colon.

The double guard [ -n "$tpath" ] && [ -f "$tpath" ] && matters too. If tpath is an empty string (which I actually hit in an early bug described later), -f evaluates an empty path and errors. Guarding both the case where the variable is empty and the case where the file it points to doesn't exist secures fail-open behavior (no false blocking).

The Call to Embed a Python Heredoc in Bash

The part that extracts the most recent assistant text embeds Python inside bash as a heredoc.

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
)
Enter fullscreen mode Exit fullscreen mode

There are two reasons I didn't make it a separate .py file. First, this logic is never called from anywhere other than self_audit_stop.sh. Making it a standalone file invites the misunderstanding that it's "logic that could be used from who-knows-where." Second, don't grow the file count in the hook directory. The hook set is already 17 files. If the responsibility is contained within one script, deletion, updating, and moving take one operation each.

What's worth noting in the code is the role condition.

if o.get("type") == "assistant" or o.get("role") == "assistant":
Enter fullscreen mode Exit fullscreen mode

We look at both type and role because Claude Code's transcript format wobbles across versions. Older format is like {"type":"assistant",...}, newer is like {"role":"assistant",...}. Look at only one and you suddenly get a "can't retrieve the latest message" failure after a version bump.

Handling both the list and str cases for content is for the same reason. On turns with tool calls mixed in, content is an array. On text-only turns there are cases where it stays a string. isinstance(c, list) checks for an array first, and only blocks with type=="text" are extracted. If it's a string, add it directly. The design ensures the last assistant text is correctly retrieved in either format.

Wrapping the whole thing in an outer try-except also matters. If the parser fails for any reason, msgs stays an empty list and it returns print(""). Downstream, [ -z "$last" ] && exit 0 fails open. The policy is: a parser failure never blocks on audit.

Why 6 Patterns for the Audit Marker Vocabulary

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

At first I tried detecting with the single word 「監査」. But that produced far too many false positives. Sentences like 「監査ログを確認しました」 or 「監査不要だと判断します」 also match. Meanwhile, there were cases where genuinely needed audit reports weren't written and didn't use that word, so they passed through.

The current 6 patterns are vocabulary derived from the output format of the self-audit. The structure of the audit report I require from Claude Code is a table of 3 categories: 「潰した / 既に堅牢 / あえて見送り」. If any of those words appears in the last assistant message, that's evidence the audit report was actually written. 「三層」 and 「予測できる不具合」 are alternate audit format expressions, added to cover variations.

printf '%s' is used to avoid echo's escape expansion. If the assistant text contains \n or \t, echo will interpret them. printf '%s' outputs the string as-is.

How exit 2 Reaches the Model

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

Claude Code hooks control behavior via exit codes. exit 0 is pass and silent. exit 1 is a warning (the turn proceeds). exit 2 is "a message to the model" — what you write to stderr is injected verbatim as feedback to the model on the next turn.

This lets a single script achieve both "display to the human" and "notification to the model" at once. The human watching the screen sees the ⚠️ message and notices. At the same time the model receives the fact that "I forgot the audit" as feedback and corrects itself on the next turn. A hook can control the model's autonomous behavior from the outside — that's the biggest reason I chose the Stop hook.


Where I Got Stuck

Stuck ①: I Got the Flag Deletion Timing Wrong and Created an Infinite Nag

Symptom: The audit nag kept appearing even on turns where no file was changed. A ⚠️ arriving on a turn where I only said "think about this for a second."

Cause: The initial implementation wrote rm -f "$flag" only at the end of the script — right before exit 2. When the Python parser failed and fell open, or when an audit marker was detected and it exited via exit 0, the flag stayed behind. On the next turn's Stop hook firing, it reacted to the leftover flag from the previous turn and nagged. And since that turn did nothing, there was no audit marker in the assistant text either. The result was consecutive nags.

Fix: I moved rm -f "$flag" to right after the Python parser call, before the marker check (currently line 55).

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

By enforcing the principle "whatever the check result, delete this turn's flag," the leftover-flag problem was eradicated. Flags for subsequent turns are the responsibility of audit_flag_set.sh to set anew. It's a design that clarifies flag ownership.

Stuck ②: tpath Was Empty and It Errored

Symptom: Occasionally the hook exited with an error and head: : No such file or directory was left in the log.

Cause: In the early get() function call, an empty string is returned when transcript_path isn't included in the JSON. Running head -15 "" as-is produces a shell error.

The first implementation looked like this.

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

When tpath is empty, head -15 "" errors, but since 2>/dev/null discards the error, grep receives nothing and returns exit 1 (no match). As a result the sdk-cli check failed and audit nags appeared in unattended sessions. What made this hard to spot was the behavior of "no error is emitted, but it misbehaves."

Fix: I added the double guard [ -n "$tpath" ] && [ -f "$tpath" ] &&. This explicitly guards both the case where the variable is empty and the case where the file it points to doesn't exist. If either is false, the whole condition is false, the sdk-cli check is skipped, and it continues as a human session (fail-open direction — no false blocking even in the worst case).

Stuck ③: The Audit Became Ritual and I Got Numb to the Nag

Symptom: I'd developed the habit of hitting enter without reading the content when the ⚠️ arrived. Having learned that just satisfying the form of "I audited it" makes the nag disappear, the content hollowed out.

Cause: Back when there was no cap on fire count, a 10-turn session produced up to 10 nags. The first 2–3 get serious attention, but from the 5th onward it becomes "here it comes again." That's cognitive wear. You lose the ability to distinguish whether an audit nag is "genuinely needed" or "the usual thing."

The 2026-07-11 performance audit surfaced this problem. The declining quality of audit reports was detected from the conversation logs.

Fix: I introduced a counter file /tmp/claude-audit-prompted-${sid} and limited firing to at most 2 per session.

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
Enter fullscreen mode Exit fullscreen mode

The number 2 came out of experiment. At 1, "misses" occur (cases that pass on turn 1 but neglect the audit on later turns). At 3 or more, the "again?" feeling returns. 2 was the boundary line between "a reminder" and "too much."

Stuck ④: Audit Reports Got Long and Buried the Main Point

Symptom: As a result of prompting for audits, Claude started returning audit tables of 20+ lines. Because it carefully wrote one line for each of the six dimensions 「並行/失敗時/冪等/境界/秘密値/実検証」, the summary of the actual implementation result got pushed off screen.

Cause: Enumerating the dimensions in the nag message made Claude interpret it as "I should report on all dimensions evenly." The dimension list was meant as a guide for "what to check," but it functioned as a template for "what to write."

This is the 2026-07-11 feedback: "Too much time is spent on the audit report, and the essential output is buried. The audit is an annotation to the main point and must not be longer than the main point."

Fix: I made the length constraint explicit at the end of the nag message.

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

By making explicit escape hatches — "3 lines or fewer" and "one line is fine if it's minor" — the granularity of the audit came to adjust to context. Sometimes replying with a single 「監査不要:出力の変更のみ」 is the correct move. Having the nag permit that draws out substantive judgment instead of empty ritual.

Stuck ⑤: The Python Parser Couldn't Handle content Variations

Symptom: On certain turns — turns with ToolUse and ToolResult mixed in — last came out empty, failing open and letting a missing audit slip by.

Cause: The initial parser only assumed the case where an assistant message's content is a string.

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

On turns containing ToolUse, content is an array. It takes a form like [{"type":"tool_use","id":"..."},{"type":"text","text":"...監査..."}]. Appending that array wholesale fails to extract the text portion, and msgs[-1] becomes an array object. Passing it to grep doesn't match.

Fix: I changed it to check for an array with isinstance(c, list) and pick out only the blocks with type=="text". Lines 27–52 of the current code are that. Walk each element of the array and join only the text blocks. String cases get added as-is. To confirm that the last assistant text is correctly extracted in either format, I collected 7 kinds of real session transcripts and tested against them.

The most useful thing for debugging the parser was the following one-liner. It reads your own session's .jsonl and lets you check what type the assistant's content arrives as.

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
Enter fullscreen mode Exit fullscreen mode

Transcript paths are stored under ~/.claude/projects/ in per-session-ID directories. Looking at real data tells you instantly "which variations should I be assuming."


All five failures above were cases of "it looked like it was working but wasn't working correctly." Misfires and misses of the audit nag happen quietly, without throwing errors. Pinning down the cause from vague feelings like "somehow there seem to be a lot of nags" or "somehow audit report quality seems to have dropped" required a habit of looking at logs quantitatively. Now I detect anomalies early by checking the launchd logs flowing into ~/Library/Logs/ and the leftover /tmp/claude-audit-* files on a weekly basis.

Pitfalls

In the earlier sections I explained the cause and fix for each case. Here I organize comprehensively the "holes I actually fell into during implementation." So that people doing the same implementation don't trip in the same places, they're ordered not by when I experienced them but by "hardest to detect first."

① Deleting the flag too late → infinite nags from leftover flags

In the earliest implementation I wrote rm -f "$flag" only right before exit 2. When an audit marker was found and it exited via exit 0, or when it failed open with python3 returning an empty string — on either path the flag remains. The next turn's Stop hook picks up the old flag and mistakes it for "there was a change this turn." A classic leftover bug where ⚠️ keeps appearing even on turns where nothing was changed. The fix boils down to one thing: "delete it unconditionally right after the Python parser call, before the decision logic."

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

② Even when tpath is empty, 2>/dev/null hides the error

head -15 "" 2>/dev/null emits a shell error but 2>/dev/null discards it. grep receives empty input and returns exit 1 (no match). As a result the sdk-cli check fails and audit nags appear in unattended sessions. No error appears and the behavior is subtly off — the hardest pattern to notice. This is exactly why the double guard [ -n "$tpath" ] && [ -f "$tpath" ] is needed.

③ Forgetting the execute permission on the hook file causes a silent skip

Forget chmod +x and the hook appears to fire, but the shell just exits with a permission error. The registration in settings.json goes through and nothing is left in the log. You get only the symptom "the hook isn't working" and no clue why. Checking the execute bit with ls -la ~/.claude/hooks/ is the first step.

④ A grep pattern too broad causes false positives

The first pattern was the single word '監査'. Sentences like 「監査ログを確認しました」 or 「監査不要と判断します」 also matched, letting turns without an audit report slip through. Conversely, turns written with different phrasing not containing 「監査」 weren't caught. The current 6 patterns — '監査|潰した|既に堅牢|あえて見送り|セルフ監査|三層|予測できる不具合' — are all narrowed to audit format vocabulary. Vague verbs like 「確認しました」 are not included.

⑤ Confusing exit 1 with exit 2 means feedback never reaches the model

Claude Code hooks behave differently across three values: exit 0 (pass, silent), exit 1 (warning, turn proceeds), exit 2 (send stderr content to the model). There was a period when I had it at exit 1, and the state persisted where the ⚠️ appeared on the human's screen but never reached the model. Claude can't recognize its own nag, so it doesn't write the audit on the next turn either. The nag doesn't function unless a human manually follows up every time. When using it as a block-and-feedback pair, it must be exit 2.

⑥ The launchd environment has a poor PATH

Unlike a normal terminal, jobs launched by launchd don't have /usr/local/bin or ~/.nvm/ in PATH. Writing python3 in the script fails silently with "command not found." That's why self_audit_stop.sh uses the full path /usr/bin/python3. When writing external commands into a hook script, you need the habit of always using full paths or explicit PATH settings.

⑦ Counter files in /tmp/ reset on OS restart

macOS empties /tmp/ at boot. The counter disappears, so the "max 2 per session" limit also resets on every restart. Long-term, it functions as "a constraint valid only while the session continues." I accept this as spec — there's no need to carry the 2-per-session rule over into the next day's session after a restart as "a continuation of yesterday." Still, it's worth leaving in a comment so that when an unintended restart happens you can understand "why the cap was reset."

echo's escape expansion skews grep results

When the assistant text contains \n or \t, echo "$last" interprets the escape sequences. Since the text hits grep in a transformed state, cases arise where the audit marker is present but doesn't match. printf '%s' "$last" outputs without interpreting escapes, so this problem doesn't occur. Making it a habit to use printf '%s' for string pipes inside bash as a rule eradicates this class of bug.

⑨ The Python parser doesn't handle content type variations

On turns with ToolUse and ToolResult mixed in, content is a list. On text-only turns it's a string. Because I initially only assumed strings, when an array arrived msgs came out empty and it failed open — a situation where a missing audit slipped by. Only by testing against 7 kinds of real transcripts did I grasp all the patterns. The lesson: "testing with real data beats code review."

⑩ Sessions with an empty session_id exist occasionally

Depending on Claude Code's startup timing, there are cases where session_id isn't included in stdin at the moment the Stop hook fires. The first condition of [ -n "$sid" ] && [ -f "$flag" ] filters it out, but if you're unaware of this you get the phenomenon "for some reason it doesn't work even though the flag exists." When session_id is empty, the flag name becomes /tmp/claude-audit-pending- (empty sid part), the existence check doesn't pass, and it fails open. It's the intended behavior, but when debugging, checking variable expansion with set -x is the fast route.

⑪ Doing the sdk-cli check on the turn right after session start can find no file

The transcript file is generated at the same time the session starts, but very rarely not a single line has been written at the moment the first Stop hook fires. [ -f "$tpath" ] passes but head -15 returns empty. Since grep doesn't match, the sdk-cli check fails and it's judged a human session when it's actually unattended. In this case the counter is 0, so one nag appears. It can't be prevented completely, but the impact is limited to "one misfire on the first occurrence."


Best Practices

These are the rules that solidified in the process of getting the implementation onto stable operation. Recorded along with code snippets.

1. Always design hooks to fail open

When a hook errors midway, "false blocking (stopping something that shouldn't be stopped)" has a bigger impact on the system than "false skipping (letting through something that shouldn't be)." Missing one audit nag doesn't break the environment. Continuously blocking a normal interactive session due to a parser bug is far more destructive. Placing escape routes like [ -z "$last" ] && exit 0 at each checkpoint is the key to long-term stability.

2. Launch attributes are concentrated in the first few lines — read them with head -N

Claude Code transcripts are .jsonl. Session attributes (entrypoint, session_id) are always written in the first 1–3 lines. There's no need to read an entire file that reaches several MB. head -15 is a buffer with room, on the premise that "15 lines will reliably capture it." It avoids the cost of streaming a large file through a pipe while taking only the information needed for the decision.

3. Match grep strings in JSON context

grep -q '"entrypoint":"sdk-cli"'
Enter fullscreen mode Exit fullscreen mode

The word entrypoint can appear in other field names or comments. Matching in the form where key and value are joined by a JSON colon eliminates string false positives. Wrapping double quotes in single quotes is the idiom for minimizing shell escaping.

4. Express state through the flag's "existence," not its "content"

/tmp/claude-audit-pending-${sid} may be empty inside. Create it with touch, delete it with rm -f. Existence = "there was a change this turn," absence = "no change." This design lets the flag-setting audit_flag_set.sh be 3 lines. Communication between the two scripts is limited to file existence alone, so data format mismatches are structurally impossible.

5. Defend with a double guard

[ -n "$tpath" ] && [ -f "$tpath" ] && head -15 "$tpath" ...
Enter fullscreen mode Exit fullscreen mode

The case of an empty variable and the case of a nonexistent file are different things. Write both -n and -f so that either one short-circuits out. With only one, the bug "an empty-string variable gets interpreted as a file path" quietly slips in.

6. Give counters a default with the cat || echo 0 pattern

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

On the first run when the file doesn't exist, 0 is returned. || catches cat's failure (missing file, read error) and returns the default value. This one line lets you handle the counter without caring whether the file exists.

7. Set the fire cap to "2, not 1"

At 1 per session, you get the miss of "passed on turn 1 but neglected the audit on later turns." At 3 or more, the cognitive wear of "again?" returns. The number 2 was obtained by experiment as the boundary between reminder and excess. The optimum may differ in your environment, but 2 works as a starting point.

8. Bind audit markers to format vocabulary

Broad words (「確認」「完了」「報告」) produce many false positives. Vocabulary specific to the audit format — 「潰した」「既に堅牢」「あえて見送り」 — is evidence that an audit report was actually written. The point is to keep the script's marker patterns in sync with the audit format defined in your instructions to Claude. Change the format and you update the marker patterns.

9. Write external commands with full paths

Hooks also get called in unattended runs via launchd. That environment differs from a normal shell PATH. Use full paths like /usr/bin/python3 instead of python3, and /bin/bash. Making it a habit to verify after writing a script with env -i bash <script> (testing with empty environment variables) lets you kill PATH-dependent bugs in advance.

10. Embed Python logic as a heredoc

Python that's never called from outside this script doesn't need to be a separate file. The hook directory currently has 17 files. Don't grow it further; confine responsibility to one script. Deletion, moving, and updating each complete in one operation.

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

The single quotes in <<'PY' disable shell expansion inside the heredoc. Even if the Python code contains $ or `, no escaping is needed.

11. Leave comments about real incidents in the script

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

Comments that tell your future self "why this code exists" are harder to forget when written with the incident and the numbers rather than an abstract explanation. The concreteness of "19 items" and "overnight" backs the judgment that "this check must not be removed."

12. Debug with real transcripts

Testing a hook's Python parser on paper can't keep up with the type variations in real transcripts. Investigating your own session data directly with the one-liner below is faster.

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
Enter fullscreen mode Exit fullscreen mode

This single command lets you confirm "the array content case," "the string case," and "the null case" against real data. It's faster than making test data and covers the full range of production variations.

13. Use printf '%s' instead of echo

When passing a variable into a pipe, some implementations of echo "$var" perform escape interpretation equivalent to the -e option. printf '%s' "$var" avoids that and outputs the variable's content as-is. Use this one whenever you're passing text to another command inside bash.


Summary

The problem this implementation solved, in one sentence: human-facing audit logic leaking into a machine-facing automation path.

Claude Code's Stop hook doesn't distinguish between a human interactive session and an Agent SDK automated run via launchd. The same script gets called at the end of either kind of turn. That's correct as design — a hook should be a general-purpose primitive with no need to be aware of its launcher. The problem is that the logic layered on top is "written assuming only humans."

On the morning of 2026-07-12, the 19 audit nags stacked up in the log showed me that structural problem in numbers. The ai-portraits pipeline ran twice, at 13:28 and 17:00, and the Stop hook fired every time a turn completed in each run. Nags piled up in a log nobody reads, and the CLI pipeline exited with an error.

The solution was simple. The hook itself reads the entrypoint field in the first 15 lines of the transcript and skips silently when it detects sdk-cli. On top of that, cap firing at 2 even in human sessions, preventing the audit nag from hollowing out through familiarity. A 67-line shell script, passing through 5 decision points, achieves the behavior "only when needed, only to a human who can read it, at most twice."

This structure isn't a Claude Code-specific problem. The "human notification logic leaking into the unattended execution path" problem happens with Make and with GitHub Actions. What's different is that Claude Code provides a simple primitive in the hook, and that this hook can become a feedback loop to the model through a single exit 2.

What supports this autonomous environment is an accumulation of exactly this: "small 47–67-line scripts that run only in the right context." Each one is unglamorous, but as the total of the accumulated environment, the mechanism where "Claude moves on its own and finishes on its own" works.


Written by **Lily* — I ship iOS apps and automate my content stack with Claude Code.
Follow along: Portfolio · X · GitHub*

Top comments (0)