{"slug": "19-audit-nags-in-one-night-making-a-claude-code-stop-hook-detect-unattended", "title": "19 Audit Nags in One Night: Making a Claude Code Stop Hook Detect Unattended Sessions", "summary": "A developer resolved a critical flaw in their autonomous Claude Code setup where Stop hooks triggered audit notifications during unattended sessions, causing 19 log nags overnight. By reading the transcript's entrypoint field, the hook now distinguishes between human-initiated and SDK-CLI sessions, skipping nags for automated runs.", "body_md": "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.\n\nThe 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.**\n\nClaude 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.\n\nThis 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.\n\nThe problem is that the exact same `Stop` hook fires during **unattended automated runs** launched through launchd using the Agent SDK CLI.\n\nMy 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.\n\n**On the morning of 2026-07-12, I opened the logs and found 19 audit nags stacked up overnight.**\n\nThat's the real-world incident preserved in the script's comment:\n\n```\n# entrypoint=sdk-cli(launchd等の無人自動化がAgent SDK経由で起動)は監査ナグを読む人間がおらず\n# 単発実行で次ターンも無いため無音スキップ(2026-07-12: 一晩でsdk-cli自動化19件がstopspamを埋めた)\n```\n\nThe 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.**\n\nWhether 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.\n\nThere'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.\n\nTo understand why Stop hooks misfire in automated environments, you need a grasp of how Claude Code operates.\n\nA 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\"`.\n\nA 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.\n\nThe 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.\n\n```\n# セッション毎に最大2回まで。連発すると監査が儀式化して本題を壊す(2026-07-11パフォーマンス監査)\nprompted=\"/tmp/claude-audit-prompted-${sid}\"\ncount=$(cat \"$prompted\" 2>/dev/null || echo 0)\nif [ \"$count\" -ge 2 ]; then rm -f \"$flag\"; exit 0; fi\n```\n\nCombining 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.\n\nClaude 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.\n\nThe 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.\n\nAlso, 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.\n\nLet's look at the script's control flow first. Grasping the whole before diving into implementation details makes each component's role clear.\n\n```\nStopイベント発火\n      │\n      ▼\nsession_id・transcript_path を stdin から取得\n      │\n      ├─ flagファイル (/tmp/claude-audit-pending-{sid}) が無い\n      │        → exit 0（そのターンはファイル変更なし・監査不要）\n      │\n      ├─ flagファイルあり → transcript_path を head -15 で読む\n      │        │\n      │        ├─ \"entrypoint\":\"sdk-cli\" が見つかる\n      │        │        → flag削除・exit 0（無人セッション・無音スキップ）\n      │        │\n      │        └─ 見つからない（人間セッション）\n      │                 │\n      │                 ├─ prompted カウンタ ≥ 2\n      │                 │        → flag削除・exit 0（発火上限・無音）\n      │                 │\n      │                 └─ カウンタ < 2\n      │                          │\n      │                          ├─ 直近assistantテキストに監査マーカーあり\n      │                          │        → exit 0（合格・無音）\n      │                          │\n      │                          └─ マーカーなし\n      │                                   → カウンタ+1・exit 2（ブロック＋ナグ）\n      │\n      ▼\n （次ターンへ）\n```\n\nThere 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.\n\nThe 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.\"\n\nThe Stop hook only checks whether this file exists.\n\n```\nflag=\"/tmp/claude-audit-pending-${sid}\"\n[ -n \"$sid\" ] && [ -f \"$flag\" ] || exit 0   # 変更が無かったターン=何もしない\n```\n\nNo 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).\n\nIf the flag exists, we move to the next check. This is the core of sdk-cli detection.\n\n```\nif [ -n \"$tpath\" ] && [ -f \"$tpath\" ] && head -15 \"$tpath\" 2>/dev/null | grep -q '\"entrypoint\":\"sdk-cli\"'; then\n  rm -f \"$flag\"\n  exit 0\nfi\n```\n\n`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.\n\nIf `grep -q '\"entrypoint\":\"sdk-cli\"'` matches, delete the flag and `exit 0`. In unattended sessions, no audit nag appears at all.\n\nBefore 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.\n\nIf the sdk-cli check passes (i.e., it's judged a human session), the next step checks the fire count.\n\n```\nprompted=\"/tmp/claude-audit-prompted-${sid}\"\ncount=$(cat \"$prompted\" 2>/dev/null || echo 0)\nif [ \"$count\" -ge 2 ]; then rm -f \"$flag\"; exit 0; fi\n```\n\nThe 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.\n\nIf the counter is 2 or more, skip silently. The \"max 2 per session\" limit keeps audit nags from flooding even long sessions.\n\nOnce the firing conditions are met, we extract the most recent assistant message and look for an audit marker.\n\n```\nif printf '%s' \"$last\" | grep -qE '監査|潰した|既に堅牢|あえて見送り|セルフ監査|三層|予測できる不具合'; then\n  exit 0\nfi\n```\n\nA 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.\n\nThe 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.\n\nIf no marker is found, increment the counter and return `exit 2`.\n\n```\necho $((count + 1)) > \"$prompted\"\necho \"⚠️ セルフ監査未実施。実装/配線したなら敵対的監査(並行/失敗時/冪等/境界/秘密値/実検証)を済ませ、報告は**3行以内**で(要点のみ・表や長文禁止=2026-07-11フィードバック)。軽微なら『監査不要:理由』の一言で良い。\" >&2\nexit 2\n```\n\nWriting 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.\"\n\nThe 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.\n\nOne implementation note. The flag file is designed to **always be deleted at every stage of checking.**\n\n```\nrm -f \"$flag\"                       # 単発: このターンのflagは必ず消す(ループ防止)\n```\n\nThis 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.\n\nThe 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.\n\nLooking at `audit_flag_set.sh` is surprisingly simple.\n\n``` bash\n#!/bin/bash\n# PostToolUse(Write|Edit): このターンでファイル変更があった印をセッション別に立てる。\n# Stopフック(self_audit_stop.sh)が拾って、セルフ監査の出し忘れを促す。\nsid=$(/usr/bin/python3 -c 'import sys,json;print(json.load(sys.stdin).get(\"session_id\",\"\"))' 2>/dev/null)\n[ -n \"$sid\" ] && touch \"/tmp/claude-audit-pending-${sid}\" 2>/dev/null\nexit 0\n```\n\nAgainst 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.\"\n\nIn `settings.json`, this hook is a PostToolUse registration using a `Write|Edit` matcher.\n\n```\n{\n  \"matcher\": \"Write|Edit\",\n  \"hooks\": [\n    {\n      \"type\": \"command\",\n      \"command\": \"~/.claude/hooks/audit_flag_set.sh\"\n    }\n  ]\n}\n```\n\nIf 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.\n\nThere'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.\n\n`head -15` Works\nHere's the core of sdk-cli detection.\n\n```\nif [ -n \"$tpath\" ] && [ -f \"$tpath\" ] && head -15 \"$tpath\" 2>/dev/null | grep -q '\"entrypoint\":\"sdk-cli\"'; then\n```\n\nWhy `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.\n\nWhat 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.\n\nThe 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.\n\nThe 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).\n\nThe part that extracts the most recent assistant text embeds Python inside bash as a heredoc.\n\n``` python\nlast=$(/usr/bin/python3 - \"$tpath\" <<'PY'\nimport sys, json\nmsgs = []\ntry:\n    for line in open(sys.argv[1], encoding=\"utf-8\"):\n        line = line.strip()\n        if not line:\n            continue\n        try:\n            o = json.loads(line)\n        except Exception:\n            continue\n        if o.get(\"type\") == \"assistant\" or o.get(\"role\") == \"assistant\":\n            m = o.get(\"message\", o)\n            c = m.get(\"content\")\n            if isinstance(c, list):\n                for b in c:\n                    if isinstance(b, dict) and b.get(\"type\") == \"text\":\n                        msgs.append(b.get(\"text\", \"\"))\n            elif isinstance(c, str):\n                msgs.append(c)\nexcept Exception:\n    pass\nprint(msgs[-1] if msgs else \"\")\nPY\n)\n```\n\nThere 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.\n\nWhat's worth noting in the code is the `role` condition.\n\n```\nif o.get(\"type\") == \"assistant\" or o.get(\"role\") == \"assistant\":\n```\n\nWe 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.\n\nHandling 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.**\n\nWrapping 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.**\n\n```\nif printf '%s' \"$last\" | grep -qE '監査|潰した|既に堅牢|あえて見送り|セルフ監査|三層|予測できる不具合'; then\n```\n\nAt 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.\n\nThe 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.\n\n`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.\n\n```\necho \"⚠️ セルフ監査未実施。...\" >&2\nexit 2\n```\n\nClaude 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.\n\nThis 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.\n\n**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.\"\n\n**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.\n\n**Fix**: I moved `rm -f \"$flag\"` to right after the Python parser call, before the marker check (currently line 55).\n\n```\nrm -f \"$flag\"                       # 単発: このターンのflagは必ず消す(ループ防止)\n[ -z \"$last\" ] && exit 0            # 読めなければフェイルオープン(誤ブロックしない)\n```\n\nBy 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.\n\n`tpath` Was Empty and It Errored\n**Symptom**: Occasionally the hook exited with an error and `head: : No such file or directory` was left in the log.\n\n**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.\n\nThe first implementation looked like this.\n\n```\n# 初期の壊れたバージョン\nhead -15 \"$tpath\" 2>/dev/null | grep -q '\"entrypoint\":\"sdk-cli\"'\n```\n\nWhen `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.\"\n\n**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).\n\n**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.\n\n**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.\"\n\nThe 2026-07-11 performance audit surfaced this problem. The declining quality of audit reports was detected from the conversation logs.\n\n**Fix**: I introduced a counter file `/tmp/claude-audit-prompted-${sid}` and limited firing to at most 2 per session.\n\n```\nprompted=\"/tmp/claude-audit-prompted-${sid}\"\ncount=$(cat \"$prompted\" 2>/dev/null || echo 0)\nif [ \"$count\" -ge 2 ]; then rm -f \"$flag\"; exit 0; fi\n```\n\nThe 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.\"\n\n**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.\n\n**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.\"\n\nThis 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.\"\n\n**Fix**: I made the length constraint explicit at the end of the nag message.\n\n```\necho \"⚠️ セルフ監査未実施。実装/配線したなら敵対的監査(並行/失敗時/冪等/境界/秘密値/実検証)を済ませ、報告は**3行以内**で(要点のみ・表や長文禁止=2026-07-11フィードバック)。軽微なら『監査不要:理由』の一言で良い。\" >&2\n```\n\nBy 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.\n\n**Symptom**: On certain turns — turns with ToolUse and ToolResult mixed in — `last` came out empty, failing open and letting a missing audit slip by.\n\n**Cause**: The initial parser only assumed the case where an assistant message's `content` is a string.\n\n```\n# 初期の壊れたバージョン\nif o.get(\"role\") == \"assistant\":\n    msgs.append(o.get(\"content\", \"\"))\n```\n\nOn 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.\n\n**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.\n\nThe 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.\n\n``` python\npython3 -c \"\nimport json, sys\nfor line in open(sys.argv[1]):\n    o = json.loads(line.strip()) if line.strip() else {}\n    if o.get('role') == 'assistant' or o.get('type') == 'assistant':\n        c = o.get('message', o).get('content')\n        print(type(c).__name__, repr(c)[:80])\n\" ~/.claude/projects/*/transcripts/*.jsonl | head -20\n```\n\nTranscript paths are stored under `~/.claude/projects/` in per-session-ID directories. Looking at real data tells you instantly \"which variations should I be assuming.\"\n\nAll 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.\n\nIn 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.\"\n\n**① Deleting the flag too late → infinite nags from leftover flags**\n\nIn 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.\"\n\n```\nrm -f \"$flag\"   # どのパスを通っても必ずここで消す\n```\n\n**② Even when `tpath` is empty, `2>/dev/null` hides the error**\n\n`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.\n\n**③ Forgetting the execute permission on the hook file causes a silent skip**\n\nForget `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.\n\n**④ A grep pattern too broad causes false positives**\n\nThe 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.\n\n**⑤ Confusing `exit 1` with `exit 2` means feedback never reaches the model**\n\nClaude 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`.\n\n**⑥ The launchd environment has a poor PATH**\n\nUnlike 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.\n\n**⑦ Counter files in `/tmp/` reset on OS restart**\n\nmacOS 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.\"\n\n**⑧ `echo`'s escape expansion skews grep results**\n\nWhen 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.\n\n**⑨ The Python parser doesn't handle `content` type variations**\n\nOn 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.\"\n\n**⑩ Sessions with an empty `session_id` exist occasionally**\n\nDepending 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.\n\n**⑪ Doing the sdk-cli check on the turn right after session start can find no file**\n\nThe 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.\"\n\nThese are the rules that solidified in the process of getting the implementation onto stable operation. Recorded along with code snippets.\n\n**1. Always design hooks to fail open**\n\nWhen 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.\n\n**2. Launch attributes are concentrated in the first few lines — read them with `head -N`**\n\nClaude 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.\n\n**3. Match grep strings in JSON context**\n\n```\ngrep -q '\"entrypoint\":\"sdk-cli\"'\n```\n\nThe 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.\n\n**4. Express state through the flag's \"existence,\" not its \"content\"**\n\n`/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.\n\n**5. Defend with a double guard**\n\n```\n[ -n \"$tpath\" ] && [ -f \"$tpath\" ] && head -15 \"$tpath\" ...\n```\n\nThe 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.\n\n**6. Give counters a default with the `cat || echo 0` pattern**\n\n```\ncount=$(cat \"$prompted\" 2>/dev/null || echo 0)\n```\n\nOn 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.\n\n**7. Set the fire cap to \"2, not 1\"**\n\nAt 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.\n\n**8. Bind audit markers to format vocabulary**\n\nBroad 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.\n\n**9. Write external commands with full paths**\n\nHooks 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.\n\n**10. Embed Python logic as a heredoc**\n\nPython 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.\n\n```\nlast=$(/usr/bin/python3 - \"$tpath\" <<'PY'\n# ここにpythonコードを書く\nPY\n)\n```\n\nThe single quotes in `<<'PY'` disable shell expansion inside the heredoc. Even if the Python code contains `$` or `` ` ``, no escaping is needed.\n\n**11. Leave comments about real incidents in the script**\n\n```\n# 2026-07-12: 一晩でsdk-cli自動化19件がstopspamを埋めた\n```\n\nComments 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.\"\n\n**12. Debug with real transcripts**\n\nTesting 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.\n\n``` python\npython3 -c \"\nimport json, sys\nfor line in open(sys.argv[1]):\n    o = json.loads(line.strip()) if line.strip() else {}\n    if o.get('role') == 'assistant' or o.get('type') == 'assistant':\n        c = o.get('message', o).get('content')\n        print(type(c).__name__, repr(c)[:80])\n\" ~/.claude/projects/*/transcripts/*.jsonl | head -20\n```\n\nThis 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.\n\n**13. Use `printf '%s'` instead of `echo`**\n\nWhen 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.\n\nThe problem this implementation solved, in one sentence: **human-facing audit logic leaking into a machine-facing automation path.**\n\nClaude 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.\"\n\nOn 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.\n\nThe 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.\"\n\nThis 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`.\n\nWhat 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.\n\n*Written by **Lily** — I ship iOS apps and automate my content stack with Claude Code.\n\nFollow along: [Portfolio](https://bokuwalily.com) · [X](https://x.com/bokuwalily) · [GitHub](https://github.com/bokuwalily)*", "url": "https://wpnews.pro/news/19-audit-nags-in-one-night-making-a-claude-code-stop-hook-detect-unattended", "canonical_source": "https://dev.to/bokuwalily/19-audit-nags-in-one-night-making-a-claude-code-stop-hook-detect-unattended-sessions-12ni", "published_at": "2026-09-07 00:00:03+00:00", "updated_at": "2026-09-07 00:33:39.000069+00:00", "lang": "en", "topics": ["developer-tools", "ai-agents", "ai-tools"], "entities": ["Claude Code", "Agent SDK", "launchd", "ai-portraits"], "alternates": {"html": "https://wpnews.pro/news/19-audit-nags-in-one-night-making-a-claude-code-stop-hook-detect-unattended", "markdown": "https://wpnews.pro/news/19-audit-nags-in-one-night-making-a-claude-code-stop-hook-detect-unattended.md", "text": "https://wpnews.pro/news/19-audit-nags-in-one-night-making-a-claude-code-stop-hook-detect-unattended.txt", "jsonld": "https://wpnews.pro/news/19-audit-nags-in-one-night-making-a-claude-code-stop-hook-detect-unattended.jsonld"}}