{"slug": "67-lines-of-bash-that-won-t-let-an-llm-say-done-a-stop-hook-that-reads-the-jsonl", "title": "67 Lines of Bash That Won't Let an LLM Say Done: A Stop Hook That Reads the Transcript JSONL", "summary": "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.", "body_md": "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.\n\nOnce you start handing implementation work to an LLM, you hit the same wall every time.\n\n\"Implemented.\" \"Tests pass.\" \"Done.\"\n\nYou 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.\n\nPoint 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.\n\nThe fix is to force the habit of the LLM auditing its own output at the hardware level.\n\nThe 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.\n\nA 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.\n\nForget 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.\n\nThere's one more important design decision. **Turns with no work in them must keep the hook silent.**\n\nIf 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.\n\nMaking \"only turns with changes\" work takes two hooks cooperating.\n\nThe 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.\n\nOn top of that, there's handling for one more pitfall: **unattended automation sessions running through sdk-cli.**\n\nAutomation 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.\n\nThere'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.\n\nIf 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.\n\nFirst, the full picture of the hook interplay as an ASCII diagram.\n\n```\nClaude Code セッション\n│\n├─ ツール実行（Write / Edit）\n│    │\n│    └─ PostToolUseフック ─► audit_flag_set.sh\n│                               └─ touch /tmp/claude-audit-pending-{sid}\n│\n│  （LLMが「完了」と言おうとする）\n│\n└─ Stop イベント\n     │\n     └─ Stopフック ─► self_audit_stop.sh\n                         │\n                         ├─ フラグ確認: /tmp/claude-audit-pending-{sid}\n                         │    なし → exit 0（黙って通す）\n                         │    あり ↓\n                         │\n                         ├─ entrypoint確認（sdk-cli？）\n                         │    sdk-cli → rm flag; exit 0（無人スキップ）\n                         │    human ↓\n                         │\n                         ├─ 回数確認: /tmp/claude-audit-prompted-{sid}\n                         │    ≥ 2 → rm flag; exit 0（上限到達）\n                         │    < 2 ↓\n                         │\n                         ├─ transcript JSONL を Python でパース\n                         │    └─ 最後の assistant テキストを抽出\n                         │\n                         ├─ 監査キーワード検索\n                         │    あり → exit 0（合格・無音）\n                         │    なし ↓\n                         │\n                         └─ exit 2（ブロック）\n                              └─ stderr に促しメッセージ → LLMへ\nbash\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\nThis 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`.\n\nThe point is the clarity of its responsibility: it only raises the flag. All judgment is delegated to the Stop hook.\n\nThe Stop hook is 67 lines. Let's read each block in order.\n\n**① Getting the session ID and transcript path**\n\n``` python\ninput=$(cat)\nget() { /usr/bin/python3 -c \"import sys,json;print(json.load(sys.stdin).get('$1',''))\" 2>/dev/null; }\nsid=$(printf '%s' \"$input\" | get session_id)\ntpath=$(printf '%s' \"$input\" | get transcript_path)\n```\n\n`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.\n\n**② Flag check (skipping turns with no changes)**\n\n```\nflag=\"/tmp/claude-audit-pending-${sid}\"\n[ -n \"$sid\" ] && [ -f \"$flag\" ] || exit 0\n```\n\nIf the `$flag` file doesn't exist, `exit 0` immediately. That passes over every turn where no file was touched.\n\n**③ sdk-cli detection (skipping unattended automation)**\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\nIt 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).\n\n**④ Cap of two 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\nA 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).\n\n**⑤ Parsing the JSONL directly in Python: extracting the last assistant text**\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\nThis 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`.\n\nWhen `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.**\n\nSwallowing 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.\n\n**⑥ Keyword search and blocking**\n\n```\nrm -f \"$flag\"\n[ -z \"$last\" ] && exit 0\n\nif printf '%s' \"$last\" | grep -qE '監査|潰した|既に堅牢|あえて見送り|セルフ監査|三層|予測できる不具合'; then\n  exit 0\nfi\n\necho $((count + 1)) > \"$prompted\"\necho \"⚠️ セルフ監査未実施。実装/配線したなら敵対的監査(並行/失敗時/冪等/境界/秘密値/実検証)を済ませ、報告は**3行以内**で(要点のみ・表や長文禁止=2026-07-11フィードバック)。軽微なら『監査不要:理由』の一言で良い。\" >&2\nexit 2\n```\n\nThe flag gets deleted here, first (loop prevention). If `$last` is empty, the read failed, so it fails open and passes.\n\nThe 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`.\n\nA 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.\n\nThe `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.\n\n`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.\n\nIf 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.\n\nWith 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.\n\n`|| exit 0`, `2>/dev/null`, and `try / except: pass` are scattered across several places in the code. This is deliberate fail-open design.\n\n```\n[ -z \"$last\" ] && exit 0            # 読めなければフェイルオープン\nexcept Exception:\n    pass\nprint(msgs[-1] if msgs else \"\")\n```\n\nSituations 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.**\n\nThe 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.\n\nPass 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.\n\n```\nif printf '%s' \"$last\" | grep -qE '監査|潰した|既に堅牢|あえて見送り|セルフ監査|三層|予測できる不具合'; then\n  exit 0\nfi\n```\n\nLooking at the seven patterns, they fall into three broad groups.\n\n**「監査」 (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.\n\n**「潰した」 (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.\n\n**「三層」 (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.\"\n\n**Marker keywords should be reviewed periodically.** If the model starts trying to pass with nothing but \"I audited it,\" swap in more specific wording.\n\n`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.\n\n```\n{\n  \"hooks\": {\n    \"PostToolUse\": [\n      {\n        \"matcher\": \"Write|Edit\",\n        \"hooks\": [\n          {\n            \"type\": \"command\",\n            \"command\": \"~/.claude/hooks/audit_flag_set.sh\"\n          }\n        ]\n      }\n    ],\n    \"Stop\": [\n      {\n        \"matcher\": \"*\",\n        \"hooks\": [\n          {\n            \"type\": \"command\",\n            \"command\": \"~/.claude/hooks/self_audit_stop.sh\"\n          }\n        ]\n      }\n    ]\n  }\n}\n```\n\nWriting `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.\n\nIf you want to include MultiEdit, use `Write|Edit|MultiEdit`. I use MultiEdit rarely in my environment, so it's off by default.\n\nThe very first version had no PostToolUse hook. It was just a Stop hook that demanded an audit every single time.\n\n```\n# 最初期の誤った設計（フラグなし）\nlast=$(python3 でJSONLを読む)\nif printf '%s' \"$last\" | grep -qE '監査|...'; then\n  exit 0\nfi\nexit 2\n```\n\n**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.\n\n**Cause**: there was no mechanism distinguishing \"a turn that changed something\" from \"a turn that was just talk.\"\n\n**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.\n\nThe 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.\n\nI 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.\n\nThe morning after I added the Stop hook, the logs looked wrong. There were 19 lines of \"⚠️ セルフ監査未実施。\" in stderr.\n\n**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.\n\n**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.\n\n**Fix**: add a check that reads the first 15 lines of the transcript JSONL and looks for `\"entrypoint\":\"sdk-cli\"`.\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\nReading 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\"`.\n\nThe 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.\n\nAfter 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.\n\n**Symptom in detail**:\n\n`exit 2`\n`rm -f \"$flag\"` was too far down, the previous flag was still there\nThis 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.\"\n\n```\n# 誤った設計（フラグ消しのタイミングが遅い）\nif grep -qE '監査|...'; then\n  rm -f \"$flag\"   # 合格時だけ消す\n  exit 0\nfi\nexit 2            # 不合格時はフラグが残る\n```\n\n**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.\n\n**Fix**: change the design so the flag is always deleted before reaching the `exit 2` branch.\n\n```\nrm -f \"$flag\"                       # 単発: このターンのflagは必ず消す(ループ防止)\n[ -z \"$last\" ] && exit 0\n\nif printf '%s' \"$last\" | grep -qE '監査|...'; then\n  exit 0\nfi\n\necho $((count + 1)) > \"$prompted\"\nexit 2\n```\n\nThe 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.\n\nOn top of that, I added the cap of two per session.\n\n```\ncount=$(cat \"$prompted\" 2>/dev/null || echo 0)\nif [ \"$count\" -ge 2 ]; then rm -f \"$flag\"; exit 0; fi\n```\n\nWrite 1 or 2 into the counter file, and go quiet at 2 or more. That completes the safety valve against loops.\n\nThe Python parsing part wasn't straightforward either. The initial implementation was this:\n\n```\n# 誤った実装（contentが文字列前提）\nc = m.get(\"content\")\nif isinstance(c, str):\n    msgs.append(c)\n```\n\nIn 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.\n\n```\n{\n  \"role\": \"assistant\",\n  \"content\": [\n    {\"type\": \"text\", \"text\": \"ここで実装しました。\"},\n    {\"type\": \"tool_use\", \"id\": \"...\", \"name\": \"Write\", \"input\": {...}}\n  ]\n}\n```\n\nWith 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.\n\nThe 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.\n\nAfter the fix it handles both arrays and strings.\n\n```\nc = m.get(\"content\")\nif isinstance(c, list):\n    for b in c:\n        if isinstance(b, dict) and b.get(\"type\") == \"text\":\n            msgs.append(b.get(\"text\", \"\"))\nelif isinstance(c, str):\n    msgs.append(c)\n```\n\nFor 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.\n\nThe original message on `exit 2` was this:\n\n```\nセルフ監査を実施してください。以下の観点を表形式で報告してください。\n| 観点 | 状態 | 詳細 |\n|------|------|------|\n| 並行 | | |\n| 失敗時 | | |\n| 冪等 | | |\n（中略）\n```\n\nNearly 200 characters, including an 8-line table template.\n\n**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.\n\nThe 2026-07-11 feedback was \"no tables, no long text, three lines max.\"\n\n**Fix**: compress the message into a single-line instruction.\n\n```\necho \"⚠️ セルフ監査未実施。実装/配線したなら敵対的監査(並行/失敗時/冪等/境界/秘密値/実検証)を済ませ、報告は**3行以内**で(要点のみ・表や長文禁止=2026-07-11フィードバック)。軽微なら『監査不要:理由』の一言で良い。\" >&2\n```\n\nCreating 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.\"\n\n**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.\n\nThe 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.\n\n**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).\n\n**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`.\n\n**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.\n\n**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.\n\n**`/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`).\n\n**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.\n\n**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.\n\n**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.\n\n**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 .`.\n\n**`/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.\n\n**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.\n\n**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.\n\n**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.\n\n**1. Pin Python to the full path `/usr/bin/python3`**\n\nThe 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.\"\n\n**2. Delete the flag before the check (the key to loop prevention)**\n\n```\nrm -f \"$flag\"   # 単発: このターンのflagは必ず消す(ループ防止)\n[ -z \"$last\" ] && exit 0\n```\n\nWith 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.\n\n**3. Be rigorous about the fail-open principle**\n\nWhen 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.\n\n**4. Use per-session flags to prevent interference between windows**\n\n```\nflag=\"/tmp/claude-audit-pending-${sid}\"\n```\n\nEmbedding `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.\n\n**5. Read only the head with `head -15`**\n\n```\nhead -15 \"$tpath\" 2>/dev/null | grep -q '\"entrypoint\":\"sdk-cli\"'\n```\n\nClaude 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.\n\n**6. Restrict matcher to `Write|Edit`**\n\nWith `*`, 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`.\n\n**7. Prevent repeats with a per-session cap (2)**\n\n```\ncount=$(cat \"$prompted\" 2>/dev/null || echo 0)\nif [ \"$count\" -ge 2 ]; then rm -f \"$flag\"; exit 0; fi\n```\n\nIf 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.\n\n**8. Detect and skip unattended sessions**\n\nThere'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).\n\n**9. Keep the block message short, and provide an escape hatch**\n\n```\necho \"⚠️ セルフ監査未実施。...軽微なら『監査不要:理由』の一言で良い。\" >&2\n```\n\nSend 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.\n\n**10. Make the keywords \"words that describe a judgment\"**\n\nOf 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.\n\n**11. Leave the incident date in a comment**\n\n```\n# 2026-07-12: 一晩でsdk-cli自動化19件がstopspamを埋めた\n```\n\nTell 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.\n\n**12. Validate settings.json with `jq`**\n\n```\njq . ~/.claude/settings.json\n```\n\nAfter 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.\n\n**13. Debug by dumping to a temp file**\n\n```\n# デバッグ時のみ追加。終わったら必ず削除\nprintf '%s' \"$input\" > /tmp/hook-debug-input.json\necho \"DEBUG: sid=$sid tpath=$tpath\" >&2\n```\n\nDebug 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.\n\n**14. Make the hook itself subject to audit**\n\nDon'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.\n\n\"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.\n\nThe Stop hook plus PostToolUse hook combination plants one physical fence there.\n\nSix 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.\n\nSince 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.\n\nWhat 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.\n\nWhat's the one word or phrase you'd grep for to prove your own agent actually did the work?\n\nI've written up the full picture of the system, the ¥1.2M/month breakdown, and the 30-day procedure in a paid note.\n\n📕 [Claude Code自律環境で、実際どう稼ぐか ― 仕組み・実例・始め方・サポート](https://note.com/bokuwalily/n/n849b3a07784a)\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/67-lines-of-bash-that-won-t-let-an-llm-say-done-a-stop-hook-that-reads-the-jsonl", "canonical_source": "https://dev.to/bokuwalily/67-lines-of-bash-that-wont-let-an-llm-say-done-a-stop-hook-that-reads-the-transcript-jsonl-1co9", "published_at": "2026-09-08 11:00:09+00:00", "updated_at": "2026-09-08 11:32:04.006368+00:00", "lang": "en", "topics": ["developer-tools", "ai-agents", "ai-tools", "machine-learning"], "entities": [], "alternates": {"html": "https://wpnews.pro/news/67-lines-of-bash-that-won-t-let-an-llm-say-done-a-stop-hook-that-reads-the-jsonl", "markdown": "https://wpnews.pro/news/67-lines-of-bash-that-won-t-let-an-llm-say-done-a-stop-hook-that-reads-the-jsonl.md", "text": "https://wpnews.pro/news/67-lines-of-bash-that-won-t-let-an-llm-say-done-a-stop-hook-that-reads-the-jsonl.txt", "jsonld": "https://wpnews.pro/news/67-lines-of-bash-that-won-t-let-an-llm-say-done-a-stop-hook-that-reads-the-jsonl.jsonld"}}