67 Lines of Bash That Won't Let an LLM Say Done: A Stop Hook That Reads the Transcript JSONL A developer has created a 67-line Bash stop hook that prevents large language models from prematurely declaring tasks complete during autonomous coding sessions. The hook, paired with a PostToolUse hook, reads the session transcript and blocks session termination with exit 2 if required audit keywords are absent, forcing the LLM to self-audit its output. The system includes safeguards such as silent operation for non-work turns, skipping unattended sdk-cli sessions, and a cap of two audit prompts per session to avoid loops. Back when I was a student pulling in ¥100k a month, I thought delegating work came down to three things: ask politely, check in on progress, trust the person. I don't think that anymore. At ¥1.2M a month in revenue, I know trust is something your systems guarantee, not something you extend. LLMs are no different. Once you start handing implementation work to an LLM, you hit the same wall every time. "Implemented." "Tests pass." "Done." You run it after those three lines and it's broken. Or it does run, but the error handling that actually mattered is missing. It divides by zero at the boundary. It races under concurrency. This state — claiming done without being done — is the single biggest source of noise in LLM-driven development. Point it out and it gets fixed. But to point it out, you have to follow every detail yourself. That makes the cost of human review too high, and the whole point of autonomy evaporates. The fix is to force the habit of the LLM auditing its own output at the hardware level. The key word there is force , not habit . If you write "always self-audit" in a prompt, the LLM will comply some fraction of the time. Some fraction is not 100%. As the session gets longer the instruction gets diluted, and depending on the model's mood it just gets skipped. Trying to instill a habit through prompting is like guaranteeing workplace safety with a poster on the wall. A Stop hook combined with a PostToolUse hook solves this structurally. The moment the LLM tries to end the session, a shell script reads its output text, and if the keywords aren't there it blocks with exit 2 . The LLM experiences its own "done" declaration being rejected by its own harness. Forget the prompt, swap the model, run a session for ten hours — the hook still fires. Because the environment demands the audit, the audit stops being a habit and becomes a physical precondition. There's one more important design decision. Turns with no work in them must keep the hook silent. If every turn where you just talked to the LLM, or just investigated something without writing anything, comes back with "please audit," that's not an audit — it's noise. In fact, the first version fired an audit request on every single turn. A performance review on 2026-07-11 concluded that "the audit has become a ritual and is wrecking the actual work," and the design changed to fire only on turns where something changed. Making "only turns with changes" work takes two hooks cooperating. The PostToolUse hook audit flag set.sh receives the "a file was written or edited" event and raises a flag file; the Stop hook self audit stop.sh picks that flag up. No flag, stay quiet. Flag present, scrutinize the output text. That two-hook pairing gives you a structure that is silent when it should be silent and only speaks up when an audit is actually warranted. On top of that, there's handling for one more pitfall: unattended automation sessions running through sdk-cli. Automation batches that run at 2 AM under launchd, pipeline runs through the Agent SDK — there is no human to read an audit request in those sessions. On 2026-07-12, 19 sdk-cli automation runs fired overnight and stacked up 19 audit requests in stderr. Since then, the hook reads the first 15 lines of the session and skips immediately if "entrypoint":"sdk-cli" is present. The call is: only speak to sessions that have a human in them. There's also a cap of two prompts per session . If "please audit" keeps appearing within the same session, every model response risks triggering another audit and looping. A per-session counter file makes it go quiet past two — a safety valve. If I had to compress why this works into one sentence: don't hope people follow the rules, build a structure where the rules can't be broken. Autonomy is not about trusting the LLM; it's about forcing the LLM into a state where it can be trusted. First, the full picture of the hook interplay as an ASCII diagram. Claude Code セッション │ ├─ ツール実行(Write / Edit) │ │ │ └─ PostToolUseフック ─► audit flag set.sh │ └─ touch /tmp/claude-audit-pending-{sid} │ │ (LLMが「完了」と言おうとする) │ └─ Stop イベント │ └─ Stopフック ─► self audit stop.sh │ ├─ フラグ確認: /tmp/claude-audit-pending-{sid} │ なし → exit 0(黙って通す) │ あり ↓ │ ├─ entrypoint確認(sdk-cli?) │ sdk-cli → rm flag; exit 0(無人スキップ) │ human ↓ │ ├─ 回数確認: /tmp/claude-audit-prompted-{sid} │ ≥ 2 → rm flag; exit 0(上限到達) │ < 2 ↓ │ ├─ transcript JSONL を Python でパース │ └─ 最後の assistant テキストを抽出 │ ├─ 監査キーワード検索 │ あり → exit 0(合格・無音) │ なし ↓ │ └─ exit 2(ブロック) └─ stderr に促しメッセージ → LLMへ bash /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 This hook is simple. It's called every time Claude Code runs the Write or Edit tool, takes JSON from stdin, pulls out session id , and creates a zero-byte file at /tmp/claude-audit-pending-{sid} . A failed touch is ignored 2 /dev/null , and it always passes with exit 0 . The point is the clarity of its responsibility: it only raises the flag. All judgment is delegated to the Stop hook. The Stop hook is 67 lines. Let's read each block in order. ① Getting the session ID and transcript path python input=$ cat get { /usr/bin/python3 -c "import sys,json;print json.load sys.stdin .get '$1','' " 2 /dev/null; } sid=$ printf '%s' "$input" | get session id tpath=$ printf '%s' "$input" | get transcript path input=$ cat buffers stdin, and the get function extracts JSON fields. /usr/bin/python3 is specified as a full path because the environment a shell hook runs in can have a minimal PATH. ② Flag check skipping turns with no changes flag="/tmp/claude-audit-pending-${sid}" -n "$sid" && -f "$flag" || exit 0 If the $flag file doesn't exist, exit 0 immediately. That passes over every turn where no file was touched. ③ sdk-cli detection skipping unattended automation if -n "$tpath" && -f "$tpath" && head -15 "$tpath" 2 /dev/null | grep -q '"entrypoint":"sdk-cli"'; then rm -f "$flag" exit 0 fi It reads the first 15 lines of the transcript JSONL and silently skips if "entrypoint":"sdk-cli" is present. Because the JSONL metadata block is clustered in the first few lines, head -15 is enough to decide without reading the whole file. The flag is deleted before exiting so it isn't carried over to the next session . ④ Cap of two 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 A number is written to /tmp/claude-audit-prompted-{sid} , and at 2 or more the hook goes quiet. The counter is incremented with $ count + 1 and rewritten when blocking see below . ⑤ Parsing the JSONL directly in Python: extracting the last assistant text 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 This is the core of the hook. Claude Code transcripts are stored as JSONL one object per line . Python reads line by line, looks for lines where type == "assistant" or role == "assistant" , and pulls out content . When content is an array the rich format mixed with tool calls , it collects only the blocks with type == "text" . When it's a string, it appends it as is. Finally it returns msgs -1 — only the most recent assistant text message. Swallowing errors with try / except is deliberate fail-open design. If the transcript is corrupted, if reading fails — don't block. It's a design that tolerates a miss over a false positive. ⑥ Keyword search and blocking rm -f "$flag" -z "$last" && exit 0 if printf '%s' "$last" | grep -qE '監査|潰した|既に堅牢|あえて見送り|セルフ監査|三層|予測できる不具合'; then exit 0 fi echo $ count + 1 "$prompted" echo "⚠️ セルフ監査未実施。実装/配線したなら敵対的監査 並行/失敗時/冪等/境界/秘密値/実検証 を済ませ、報告は 3行以内 で 要点のみ・表や長文禁止=2026-07-11フィードバック 。軽微なら『監査不要:理由』の一言で良い。" &2 exit 2 The flag gets deleted here, first loop prevention . If $last is empty, the read failed, so it fails open and passes. The keyword regex is seven patterns: 監査|潰した|既に堅牢|あえて見送り|セルフ監査|三層|予測できる不具合 . If any one of them is present, exit 0 pass . If none are, it increments the counter, writes a prompting message to stderr, and blocks with exit 2 . A note on why exit 2 means block. Claude Code hooks control behavior via exit codes. exit 0 is a normal pass; exit 2 means "return feedback to the model and prompt it to continue." Whatever you wrote to stderr is passed through to the model as feedback, and the model reads why it was stopped and rewrites its audit. The 2026-07-11フィードバック date annotation in that message is straight from the actual code. Originally it demanded a long tabular audit; feedback came in saying "no tables, no long text, three lines max," and the message wording was updated. The hook's comments are the record of that history. self audit stop.sh is written in Bash. Claude Code hooks can be written in Node in any environment that has a package.json , but I deliberately use shell. The reason: the environment a hook launches in is a minimal shell. If you look at hooks.json , ECC Everything Claude Code hooks are almost all Node one-liners. Node is fine, but for Node to run, node has to be on PATH. In my environment Node is managed by nvm, so PATH works in an interactive shell where .zshrc is loaded , but not in a minimal shell launched via launchd. ECC solves this by embedding the Node executable path into the entry point itself, but writing that into every custom hook is overkill. With a shell script, /bin/bash reliably gets you /bin/bash . Only the Python invocation specifies the full path /usr/bin/python3 so it doesn't depend on PATH — remember that one thing and everything else is shell builtins. Zero dependencies and reliable execution is the single most important property for an audit hook. If the hook doesn't run, the audit is bypassed, so the hook itself stays as simple as possible. || exit 0 , 2 /dev/null , and try / except: pass are scattered across several places in the code. This is deliberate fail-open design. -z "$last" && exit 0 読めなければフェイルオープン except Exception: pass print msgs -1 if msgs else "" Situations where the transcript can't be read really do happen. When a session dies suddenly and leaves an incomplete JSONL, when filesystem permissions change, when a Unicode error surfaces mid-parse in Python. Returning exit 2 in those failure modes means the model gets blocked without having done anything wrong. The cost of a false block is higher than the cost of a miss. A missed audit can be picked up again on the next turn if the flag is raised . But a false block burns extra turns while the model tries to interpret "why was I stopped," and in some cases it destroys trust in the hook. Once it's judged as "the hook misfired again," that hook is effectively dead. Pass errors through silently. Distrust in the hook does more long-term damage than a skipped audit. That judgment is the basis of fail-open design. if printf '%s' "$last" | grep -qE '監査|潰した|既に堅牢|あえて見送り|セルフ監査|三層|予測できる不具合'; then exit 0 fi Looking at the seven patterns, they fall into three broad groups. 「監査」 audit and 「セルフ監査」 self-audit are the most direct markers. My CLAUDE.md instructs the model to "complete an adversarial self-audit," so if it complies it will necessarily write a sentence containing that word. 「潰した」 killed it , 「既に堅牢」 already robust , and 「あえて見送り」 deliberately deferred are the vocabulary of the three-way report. The instruction is to report in three categories — killed it handled / already robust no action needed / deliberately deferred intentionally postponed — so if any of these appear, the audit was performed. 「三層」 three-layer and 「予測できる不具合」 predictable defects are auxiliary markers. They come out naturally during a deep audit, but they're also candidates for future removal. When a word is too specific, there's a risk the model learns the shortcut "include this word and I get through." Marker keywords should be reviewed periodically. If the model starts trying to pass with nothing but "I audited it," swap in more specific wording. audit flag set.sh and self audit stop.sh get registered in the hooks section of Claude Code's user-level configuration equivalent to ~/.claude/settings.json . The actual configuration format is as follows. { "hooks": { "PostToolUse": { "matcher": "Write|Edit", "hooks": { "type": "command", "command": "~/.claude/hooks/audit flag set.sh" } } , "Stop": { "matcher": " ", "hooks": { "type": "command", "command": "~/.claude/hooks/self audit stop.sh" } } } } Writing Write|Edit in matcher is the key. With it also reacts to Bash tool execution and raises the flag, causing the misfire of "an audit is demanded just because I ran a command." Since you only want audits on turns that wrote files, match only file-writing tools. If you want to include MultiEdit, use Write|Edit|MultiEdit . I use MultiEdit rarely in my environment, so it's off by default. The very first version had no PostToolUse hook. It was just a Stop hook that demanded an audit every single time. 最初期の誤った設計(フラグなし) last=$ python3 でJSONLを読む if printf '%s' "$last" | grep -qE '監査|...'; then exit 0 fi exit 2 Symptom : "Please perform a self-audit" appeared on turns that were just conversation, turns that were just investigation, turns that only read existing code. A one-hour session would stack up 20+ audit requests, with "please audit" cutting into the middle of the actual discussion. Cause : there was no mechanism distinguishing "a turn that changed something" from "a turn that was just talk." Fix : change the design so the PostToolUse hook raises /tmp/claude-audit-pending-{sid} and the Stop hook checks for that flag first. No flag, immediate exit 0 . That narrows it to turns where a file write actually happened. The note in my CLAUDE.md that "a performance review on 2026-07-11 concluded that the audit has become a ritual and is wrecking the actual work" is the record of what prompted this rework. When the audit fires every time, both the model and I get numb to it — "here we go again" — and it degrades into a routine where a token one-liner gets written. Firing rarely but reliably preserves the weight of the audit. I have an image-generation automation batch that runs at 2 AM under launchd the ai-portraits automation recorded in my Obsidian hot.md . That batch was an unattended flow: spin up a Claude Code session through the Agent SDK, run the processing, exit. The morning after I added the Stop hook, the logs looked wrong. There were 19 lines of "⚠️ セルフ監査未実施。" in stderr. Symptom : audit requests fired on every unattended automation session and piled up in stderr where nobody reads them. Meaningless warnings kept accumulating for nine hours until I found them. Cause : the hook didn't distinguish who was running it. Interactive human sessions and unattended batch sessions via the Agent SDK went through identical logic. Fix : add a check that reads the first 15 lines of the transcript JSONL and looks for "entrypoint":"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 Reading only the head with head -15 works because the JSONL metadata is clustered at the top. There's no need to read the whole file; 15 lines is plenty to decide. Claude Code writes this flag at session start, and for the sdk-cli entry point the value is "sdk-cli" . The comment left in the code, 2026-07-12: 一晩でsdk-cli自動化19件がstopspamを埋めた , is the record of that incident. It happened the day right after I wrote the hook, so I keep it as a memorial to the design gap. After adding the flag and sdk-cli detection, a new problem showed up: a loop where the turn after writing an audit demanded an audit again. Symptom in detail : exit 2 rm -f "$flag" was too far down, the previous flag was still there This wasn't actually a case of forgetting to delete the flag — the cause was that the initial implementation deleted the flag in the wrong place. At first I'd written the flow as "delete the flag if the keyword is found / leave it and exit 2 if it isn't." 誤った設計(フラグ消しのタイミングが遅い) if grep -qE '監査|...'; then rm -f "$flag" 合格時だけ消す exit 0 fi exit 2 不合格時はフラグが残る Symptom : fail and block → model writes the audit → next Stop still sees the flag → block again. "Please audit" comes out repeatedly within the same session. Fix : change the design so the flag is always deleted before reaching the exit 2 branch. rm -f "$flag" 単発: このターンのflagは必ず消す ループ防止 -z "$last" && exit 0 if printf '%s' "$last" | grep -qE '監査|...'; then exit 0 fi echo $ count + 1 "$prompted" exit 2 The flag is deleted the moment the Stop hook runs, regardless of the check's outcome. The only way the same session ID's flag exists at the next Stop is if a file was written again on that turn. On top of that, I added the cap of two per session. count=$ cat "$prompted" 2 /dev/null || echo 0 if "$count" -ge 2 ; then rm -f "$flag"; exit 0; fi Write 1 or 2 into the counter file, and go quiet at 2 or more. That completes the safety valve against loops. The Python parsing part wasn't straightforward either. The initial implementation was this: 誤った実装(contentが文字列前提) c = m.get "content" if isinstance c, str : msgs.append c In Claude Code transcripts, content is a string for text-only responses. But on turns mixed with tool calls that is, the classic "read the code, then implement" turn , content is an array. { "role": "assistant", "content": {"type": "text", "text": "ここで実装しました。"}, {"type": "tool use", "id": "...", "name": "Write", "input": {...}} } With that format, isinstance c, str is false and no text is captured. msgs stays empty, reaches print msgs -1 if msgs else "" , and returns an empty string. An empty string fails open into exit 0 , so no audit request ever appears. In other words, a silent bug where exactly the turns that need an audit always slip through. The symptom I noticed was "no audit requests at all on implementation turns." Investigation turns passing through silently is correct, but implementation turns doing the same is wrong. I debugged the hook, looked at the contents of $last , found an empty string, traced the Python logic, and spotted the missing array handling. After the fix it handles both arrays and strings. 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 For arrays it collects only the type == "text" blocks and ignores the tool-call blocks. After switching to this logic, audit requests reliably fire on implementation turns. The original message on exit 2 was this: セルフ監査を実施してください。以下の観点を表形式で報告してください。 | 観点 | 状態 | 詳細 | |------|------|------| | 並行 | | | | 失敗時 | | | | 冪等 | | | (中略) Nearly 200 characters, including an 8-line table template. Symptom : the model started trying to fill in the table and wrote a 15–20 line audit report every time. The reports themselves were thorough, but even "a minor fix touching three files" produced a 20-line table. The actual implementation discussion got buried under audit tables, and rereading the session became difficult. The 2026-07-11 feedback was "no tables, no long text, three lines max." Fix : compress the message into a single-line instruction. echo "⚠️ セルフ監査未実施。実装/配線したなら敵対的監査 並行/失敗時/冪等/境界/秘密値/実検証 を済ませ、報告は 3行以内 で 要点のみ・表や長文禁止=2026-07-11フィードバック 。軽微なら『監査不要:理由』の一言で良い。" &2 Creating the escape hatch — "if it's minor, one line of 『監査不要:理由』 no audit needed: reason is fine" — is what made it work. With that in place, genuinely minor changes resolve in one line like "no audit needed: constant change only." Important changes produce one or two lines like "killed it: concurrent writes handled via atomic rename." Enumerating the dimensions while imposing a "three lines max" constraint shifted behavior from "a long table to formally clear the audit" to "flag it in one line if there's a real problem." It's a change that raises the quality of the audit, not its volume. The five snags above were about implementation logic. Here I'll cover the configuration, environment, and operational points that are easy to trip over. I'll list the ones I actually hit and the ones I've seen in the community. Using ~ in a path inside settings.json : shell ~ expansion may not happen in the minimal environment where the hook is invoked. Write command paths in settings.json as fully expanded absolute paths. Specify them directly as /Users/your-username/.claude/hooks/audit flag set.sh this article uses ~ notation for readability, but the actual file uses absolute paths . Forgetting chmod +x : "the hook is registered, the file is there, but nothing happens" is mostly this. Check with ls -la ~/.claude/hooks/ that it shows -rwxr-xr-x . If not, grant it with chmod +x ~/.claude/hooks/ .sh . If it's under Git management, either git add --chmod=+x or put .sh eol=lf text eol=lf and the exec-bit setting into .gitattributes . Getting the settings.json format wrong : hooks.PostToolUse is a doubly nested structure — objects inside an array, each containing another hooks array. A mistake in the JSON structure is silently ignored by Claude Code with no error. After registering a hook, always format and eyeball it with jq . ~/.claude/settings.json . Without jq , python3 -m json.tool ~/.claude/settings.json also works. Writing Write|Edit|Bash in matcher : the flag is also raised on Bash tool execution grep, running commands , so audit requests appear on turns that only examined the code. Restrict it to Write|Edit or Write|Edit|MultiEdit . Whether to add MultiEdit depends on how often you use it. /usr/bin/python3 doesn't exist : on macOS without Xcode Command Line Tools installed, /usr/bin/python3 itself is in a state where it asks "do you want to install it?" If that runs inside a hook, an interactive prompt appears and it stalls. Either run xcode-select --install beforehand, or rewrite it to the full path of Homebrew's python3 e.g. /opt/homebrew/bin/python3 . The hook passes through on the very first turn after session start : even when tpath is passed, there are cases where the transcript file is written after the hook. -f "$tpath" is false, last is empty, and it fails open. There's little real harm on the first turn since it's a "just started" state. The JSONL ends with an incomplete line : when the LLM's response is long, writing to the transcript and executing the Stop hook can race. The final line ends up as truncated JSON and json.loads throws. Because try / except: continue skips per line, there's no real harm — it judges using the previous complete assistant message. Turns where content is None : for assistant messages containing only tool calls no text blocks , m.get "content" returns None . Neither isinstance None, list nor isinstance None, str is true, so nothing is appended to msgs . This is intended behavior — turns with no text pass through. The session ID comes back as an empty string : rarely, the stdin JSON doesn't include session id . Since the condition -n "$sid" && -f "$flag" || exit 0 skips immediately when $sid is empty, there's no real harm, but the hook enters a "quietly does nothing" state. If you notice the symptom, dump input to a temp file and inspect it with jq . . /tmp flags accumulate under old session IDs : /tmp/claude-audit-pending- and /tmp/claude-audit-prompted- are not deleted automatically when Claude Code exits. Rebooting the OS clears them, but on a machine left running for weeks a lot of junk piles up. Clean up roughly monthly with rm -f /tmp/claude-audit- , or add a line to the hook's startup that deletes old files. The audit keywords get learned by the model and become hollow : once the model learns it can pass with a single line containing the word "監査," you start getting blanks like "監査:問題なし" audit: no issues . When you see that signal, update the keywords. Words that describe an actual judgment , like 「潰した」「既に堅牢」「あえて見送り」, are harder to hollow out. Review the vocabulary every 2–3 months. Multiple Stop hooks interfere : if another Stop hook returns exit 2 first, Claude Code may stop the hook chain there implementation-dependent . Since hooks run in registration order, put the audit hook at the end of the array. Alternatively, consolidate multiple pieces of logic into a single Stop hook. Forgetting to remove stderr debug output : when debugging a hook you add echo "DEBUG: sid=$sid" &2 , and if you forget to remove it, debug lines contaminate production stderr. Either control it with a debug-only flag variable DEBUG HOOK=1 as an environment variable, or make sure to delete it after debugging. 1. Pin Python to the full path /usr/bin/python3 The minimal shell environment a hook runs in doesn't have PATH set up. Writing just python3 gets you a silent skip on "not found." Only the full-path /usr/bin/python3 is safe. That's why the comment in this hook says "full path." 2. Delete the flag before the check the key to loop prevention rm -f "$flag" 単発: このターンのflagは必ず消す ループ防止 -z "$last" && exit 0 With a "delete only on pass" design you get the loop: fail and block → model writes the audit → the flag is still there at the next Stop → block again. Always delete the flag at the moment the Stop hook launches. The only way the flag is raised next time is if a file was written again on that turn. 3. Be rigorous about the fail-open principle When the hook terminates abnormally, choose exit 0 pass . Accumulated distrust in the hook does more long-term damage. A missed audit can be picked up on the next turn. A false block from the hook translates directly into "the hook misfired again," and from then on the hook is effectively disabled. Safety-critical hooks preventing secrets from being committed, etc. should be fail-closed instead. Split the design policy by role. 4. Use per-session flags to prevent interference between windows flag="/tmp/claude-audit-pending-${sid}" Embedding session id in the filename means no crosstalk even with multiple Claude Code windows open at once. Same for the counter file claude-audit-prompted-${sid} . If session id is absent, $sid is empty and the -n "$sid" condition skips immediately — another safety valve. 5. Read only the head with head -15 head -15 "$tpath" 2 /dev/null | grep -q '"entrypoint":"sdk-cli"' Claude Code's JSONL writes the session metadata block including entry-point information in the first few lines. Rather than cat the whole file, head -15 is enough to decide. Even for enormous sessions hundreds of KB , the hook's startup cost stays near zero. 6. Restrict matcher to Write|Edit With , the PostToolUse hook also runs on Bash command execution, Read, and Grep, raising the flag on "investigation-only turns." If you want to catch only turns that wrote files, use Write|Edit . To include MultiEdit, use Write|Edit|MultiEdit . 7. Prevent repeats with a per-session cap 2 count=$ cat "$prompted" 2 /dev/null || echo 0 if "$count" -ge 2 ; then rm -f "$flag"; exit 0; fi If three or more audit requests appear in the same session, the model starts perceiving the audit as a periodic routine and responds formulaically. Go quiet past two. "Firing rarely but reliably" preserves the weight of the audit. 8. Detect and skip unattended sessions There's no human to read an audit request in unattended sessions run via launchd, the Agent SDK, or cron. If "entrypoint":"sdk-cli" appears in the first 15 lines of the transcript, delete the flag and exit 0 . Without this, overnight batch stderr fills up with audit requests an incident where 19 piled up in one night actually happened . 9. Keep the block message short, and provide an escape hatch echo "⚠️ セルフ監査未実施。...軽微なら『監査不要:理由』の一言で良い。" &2 Send a 200-character table template and you get a 20-line audit back every time. Providing the escape hatch "one line is fine if it's minor" means important changes produce a one-liner like "killed it: handled via atomic rename," and minor changes produce "no audit needed: constant change only." It's a question of audit quality, not quantity. 10. Make the keywords "words that describe a judgment" Of the seven patterns 監査|潰した|既に堅牢|あえて見送り|セルフ監査|三層|予測できる不具合 , the three hardest to hollow out are 「潰した」「既に堅牢」「あえて見送り」. They're the vocabulary of the three-way report — words that don't come out naturally without an actual judgment behind them. Keeping only "監査" and dropping the rest just increases the shortcut of passing with a single "audited" line. 11. Leave the incident date in a comment 2026-07-12: 一晩でsdk-cli自動化19件がstopspamを埋めた Tell your future self and future models why this code is here. Hooks tend to become opaque when read later "why head -15 here?" "why a cap of 2?" . Leaving one line with the incident's date and symptom drastically lowers the cost of modifying it six months from now. 12. Validate settings.json with jq jq . ~/.claude/settings.json After registering a hook, format it with jq and eyeball it. The doubly nested array structure of hooks.PostToolUse .hooks in particular is easy to get wrong, and a JSON structure mistake is silently ignored by Claude Code, so it's hard to notice. If it doesn't work, check here first. 13. Debug by dumping to a temp file デバッグ時のみ追加。終わったら必ず削除 printf '%s' "$input" /tmp/hook-debug-input.json echo "DEBUG: sid=$sid tpath=$tpath" &2 Debug output to stderr is fed back to the model, so forgetting to remove it becomes extra noise in production. Always delete it once debugging is done. Dumping stdin to /tmp/hook-debug-input.json and viewing it with jq . shows you the full JSON being passed to the hook. 14. Make the hook itself subject to audit Don't operate on "the hook should be working" without verifying it. About once a week, deliberately write a file and confirm that an audit request appears. Or check the timestamps of /tmp/claude-audit-prompted- to see when an audit was last requested. If the hook is quietly broken, "done" keeps passing with no audit. "Done" is one of the most dangerous words an LLM can output. It exists as text, but it guarantees nothing about reality. "It works." "Tests pass." "Implementation complete." Reading those three lines, feeling reassured, and finding it broken in production the next morning — every developer using LLMs goes through it at least once. The Stop hook plus PostToolUse hook combination plants one physical fence there. Six lines of audit flag set.sh raise a flag on every file change; 67 lines of self audit stop.sh pick that flag up, parse the transcript JSONL directly in Python, look for seven keywords in the most recent assistant text, and block with exit 2 if none are found. Rather than asking "please always audit" in a prompt, the harness makes the audit a physical precondition for passing. Since introducing this, the frequency of "it said done but it was broken" dropped by a subjective 70–80%. The remaining 20–30% is either keyword hollowing blanks like "audit: no issues" or failure modes where the hook silently skipped. Both are addressable with periodic keyword updates and checking the hook logs. What matters most in a ¥1.2M/month autonomous setup isn't "believing the LLM is working correctly" — it's "having a structure that lets you confirm whether the LLM is working correctly." Trust is guaranteed by systems. That's the most fundamental thing that changed since I was a student earning ¥100k a month. What's the one word or phrase you'd grep for to prove your own agent actually did the work? I've written up the full picture of the system, the ¥1.2M/month breakdown, and the 30-day procedure in a paid note. 📕 Claude Code自律環境で、実際どう稼ぐか ― 仕組み・実例・始め方・サポート https://note.com/bokuwalily/n/n849b3a07784a Written by Lily — I ship iOS apps and automate my content stack with Claude Code. Follow along: Portfolio https://bokuwalily.com · X https://x.com/bokuwalily · GitHub https://github.com/bokuwalily