Backend
5 Verbal Corrections Didn't Stick, So a 46-Line Stop Hook Made the Mistake Physically Impossible
Lily DEV Community
1 views
I corrected the same Claude Code mistake more than five times. It never stuck. Then I replaced the correction with a 46-line shell script, and the mistake has not come back once. Some background: I started freelancing in college at around ¥100,000 a month, stacked side gigs until I reached ¥600,000, got laid off and dropped to zero, and spent the next six months building an autonomous Claude Code environment that now does ¥1.2 million a month in revenue. The single strongest lesson from that whole run is this: repeatedly correcting the same mistake by talking is a waste of time.
Why This Mechanism Works
The Real Reason Verbal Corrections Don't Stick
I told Claude Code, "Don't use && in ! handoff commands." It didn't stick. By July 16, 2026 I had pointed it out more than five times. Every time it answered "Understood." And the next session, the same command showed up again.
This is not Claude being lazy. It's an architecture problem.
Each Claude Code session is independent. What you taught it in the previous session ("this is not OK") does not exist when the next session starts. Yes, anything in CLAUDE.md is injected every time, but whether a single line of caution reliably functions amid that volume of rules is something you can judge from the actual code output. It did not function.
The reason verbal corrections don't stick is simple. A correction lives in context, and context disappears with the session.
What Happens When You Chain Blocking Commands
Claude Code's ! command launches a shell and runs the command. If you chain a blocking, process-occupying command (say, starting a server with an --auth option) using &&, the first command never returns, it sits waiting, and the following commands never execute. The entire session freezes.
The correct approach is to use & (background execution) so control passes to the next command. && means "run the next command only after the previous one exits successfully," a sequential execution, so it can never work after a blocking process.
# 壊れる型(セッションが固まる)
! npx wrangler dev --local && open http://localhost:8787
# 正しい型(バックグラウンドで起動してから次へ)
! npx wrangler dev --local & sleep 2 && open http://localhost:8787
The difference looks small, but the debugging cost is enormous. Waiting several minutes before realizing it's frozen, force-killing it, trying again. This was happening every single time.
Change the Environment, Not the Work
Five verbal corrections didn't fix it. So the only option left was to make it physically impossible.
Claude Code has a feature called the Stop hook. It lets you run an arbitrary shell script right before Claude returns its response. If that script returns exit 2, Claude's response is blocked before it reaches the user, and an error message is displayed.
This is not a rule. It's code. Code doesn't disappear in the next session. It doesn't disappear across sessions. No matter how Claude labels the output as a "handoff command," if it contains && it physically cannot pass.
The moment I changed the problem from "point out, fix, repeat" to "environment design," the same failure never happened again.
This is the fundamental philosophy for stacking automation as a solo developer. If you're following a reproducible procedure, turn it into code. If Claude keeps repeating a mistake, build an environment that physically seals off that mistake.
You've probably had the same experience. "I told you this before." "You said you understood." "Why is this happening again?" That feeling is correct. The problem isn't communication. It's design.
The Overall Flow
System Architecture Overview
You register the Stop hook in Claude Code's settings file (.claude/settings.json or ~/.claude/settings.json). The registered script launches the moment Claude finishes generating a response. The script receives JSON on stdin containing the transcript path.
The script in question, bang_handoff_amp_guard.sh, is 46 lines. It does one thing: read the last assistant message, and block if any line starting with ! contains &&.
Claude が応答を生成
│
▼
Stop hook が起動
stdin に JSON(transcript_path を含む)が渡される
│
▼
transcript_path を取り出す
│
├── パス無し / ファイル無し → exit 0(フェイルオープン・通過)
│
▼
transcript JSONL を Python でパース
全 assistant メッセージを収集
│
▼
最後の assistant メッセージだけ取り出す
│
▼
行ごとにコードフェンス(```
)・バッククォートを除去
│
▼
正規表現チェック: ^!\s かつ && を含む行があるか
│
├── ある → stderr にエラー出力 → exit 2(ブロック)
└── ない → exit 0(通過・Claude の応答がユーザーへ届く)
The Actual Code Structure and the Intent Behind Each Part
Let's look at the script in three blocks.
Block 1: Getting the transcript path and failing open
bash
input=$(cat)
tpath=$(printf '%s' "$input" | /usr/bin/python3 -c \
"import sys,json;print(json.load(sys.stdin).get('transcript_path',''))" 2>/dev/null)
[ -n "$tpath" ] && [ -f "$tpath" ] || exit 0
Claude Code streams JSON into the Stop hook's stdin. The transcript_path key holds the absolute path of the transcript file. Python extracts it, and if the path is empty or the file doesn't exist, the script passes through with exit 0.
This is fail-open design. If the script can't read the transcript for any reason, Claude's response goes through. A false block that halts your work is far more expensive than a freeze caused by &&. So the principle is "if you can't read it, let it through silently."
Block 2: Parsing the transcript and extracting the last message
python
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
last = msgs[-1] if msgs else ""
Claude Code transcripts are JSONL (one JSON object per line). Each line is parsed with json.loads(), and entries whose type or role is "assistant" are picked up. content can be either a list (multiple blocks) or a string, so both shapes are handled.
All assistant messages are accumulated in the msgs list, and only the final one is pulled into last. Past conversation history is irrelevant. Only the current response is inspected.
Block 3: Detection pattern and output
bash
bt = chr(96) # backtick をコード内で直接書かない
for ln in last.splitlines():
s = ln.strip().strip(bt).strip() # コードフェンス/バッククォート除去
if re.match(r'^!\s', s) and '&&' in s:
print(s)
break
bash
echo "🚫 ! ハンドオフに && を使った(本人が5回以上指摘済)。Claude Codeの ! セッションでは \
&& でブロッキング系(--auth等)を連鎖すると固まって通らない。\
>> && を単一 & に直して出し直せ <<。該当行: ${bad}" >&2
exit 2
Detection happens in two stages.
First, code fences (backticks) are stripped from each line. Claude sometimes writes commands inside code blocks. Even when wrapped in `
, if the contents are an actual command, they need to be a detection target. This is where the bt = chr(96) trick comes in. Because the script itself may be evaluated by a shell, writing a backtick directly inside it can cause a self-parsing error. chr(96) generates the backtick character dynamically to avoid that.
After stripping, the regex ^!\s confirms the line starts with ! (exclamation mark plus whitespace), and then it checks whether && is present. When both are true, that line is printed to stdout and the Python script exits.
On the bash side, that output lands in the bad variable. If it's non-empty, exit 2. If empty, exit 0.
The error message literally includes the note "the user has pointed this out more than 5 times (2026-07-16)." This is context for future me (or future Claude). Why this script exists and why this is a rule are self-explained inside the code, both in comments and in the error message.
Registering It in the Settings File
The Stop hook goes in the hooks section of ~/.claude/settings.json.
json
{
"hooks": {
"Stop": [
{
"matcher": "",
"hooks": [
{
"type": "command",
"command": "~/.claude/hooks/bang_handoff_amp_guard.sh"
}
]
}
]
}
}
The Stop event fires immediately after Claude completes a response. Setting matcher to an empty string runs it for every response. If you only want it applied to a specific project, write it in that project's .claude/settings.json. For global application, use ~/.claude/settings.json. This script lives in ~/.claude/hooks/ and is registered globally, because the && problem occurs across projects.
The script needs execute permission.
bash
chmod +x ~/.claude/hooks/bang_handoff_amp_guard.sh
That completes the setup. From the next session on, the instant Claude tries to output a ! handoff command containing &&, its response is blocked and an error message appears on stderr. Claude has no choice but to re-issue the response (fixing && to &).
A problem I kept pointing out verbally five times physically vanished with a 46-line script.
Why It Has to Be Written This Way
The previous section covered the overall structure and the roles of the three blocks. This section digs into the design decisions that make it "run robustly" rather than just "run." Every one of these came from getting stuck and realizing it afterward.
Why stdin Is Captured Into a Variable First
bash
input=$(cat)
tpath=$(printf '%s' "$input" | /usr/bin/python3 -c "..." 2>/dev/null)
The first line puts cat into the variable input. Since the Stop hook's JSON comes from standard input, piping cat straight into Python would be a one-liner. The reason not to do that: stdin can only be read once.
Piping it directly consumes stdin entirely. When you want to pass the same input to another Python call during debugging, or extend the script to extract multiple values, you're stuck. Receiving everything first with input=$(cat) and reusing it via printf '%s' "$input" as many times as needed is the safe pattern for handling stdin in shell scripts.
Why /usr/bin/python3 Is Written as a Full Path
bash
tpath=$(printf '%s' "$input" | /usr/bin/python3 -c "..." 2>/dev/null)
bad=$(/usr/bin/python3 - "$tpath" <<'PY'
Both places use /usr/bin/python3 rather than python3.
The shell that launches the Stop hook is not a normal terminal session. Neither ~/.zshrc nor ~/.bash_profile is loaded, so $PATH is in a minimal state. Tools installed via nvm are naturally invisible. So are pyenv and Homebrew Python.
Using just the command name python3 makes you completely dependent on the hook's shell environment. On macOS, the system Python is always at /usr/bin/python3 as long as the Xcode command line tools are installed. Specifying the full path gives you portability independent of the environment.
Verification is easy.
bash
/usr/bin/python3 --version
If this command works, the same path can be used inside the hook.
The Single Quotes on the Heredoc Are a Lifeline
bash
bad=$(/usr/bin/python3 - "$tpath" <<'PY'
import sys, json, re
...
if re.match(r'^!\s', s) and '&&' in s:
PY
)
The heredoc marker is 'PY' (with single quotes). The difference from <<PY (no quotes) is fundamental.
With unquoted <<PY, $variables and backticks inside the heredoc are expanded by the shell. For example, the \s in the regex r'^!\s' gets converted by the shell into s. Python receives the pattern r'^!s'. The \s that should match whitespace is lost, and the entire detection logic breaks.
With <<'PY', the whole heredoc is treated as a literal string. You can pass any Python code through unchanged. When embedding Python in a shell script, single quotes are an absolute rule.
Why Both type and role Are Checked
python
if o.get("type") == "assistant" or o.get("role") == "assistant":
Two fields are checked with or. This is because Claude Code's transcript JSONL uses different formats depending on version and session length.
Older entries have a role: "assistant" field. Newer entries use a type: "assistant" field. Both can coexist in a single transcript file. In long sessions, it's normal for the first half to be in the old format and the second half in the new one.
If you only look at type, messages with role: "assistant" never get added to the msgs list, and you can no longer correctly identify the last assistant message.
Claude Code's internal format can change with version upgrades. An implementation that depends on a single specific field may break with every update. Checking multiple fields as fallbacks is the safe way to write it.
Why the Backtick Is Generated Indirectly With chr(96)
python
bt = chr(96) # backtick をコード内で直接書かない
for ln in last.splitlines():
s = ln.strip().strip(bt).strip()
ASCII code 96 is the backtick. The reason not to write it directly: since the script is embedded in a shell heredoc, there's a risk that if it's ever ported into another shell context (via eval, sourced from another script, and so on), a literal backtick causes a self-parsing error.
Generating the character at runtime with chr(96) means not a single backtick appears in the script body. On top of that, using str.strip(bt) lets it correctly handle "commands wrapped in code fences."
Even when Claude wraps a command in inline backticks, for example output like "Please run the following command: ! npx wrangler dev --local && open http://localhost:8787 ", the regex check runs after the backticks are stripped, so nothing slips through.
Where I Got Stuck
The finished 46 lines look tidy, but I got stuck four times before it worked. Each one is written as symptom, cause, fix.
Stuck #1: Writing It With grep Gave a Zero Detection Rate
The first implementation
Thinking as simply as possible, I wrote this.
bash
if grep -qE '^\! .* &&' "$tpath"; then
echo "🚫 && を使わないでください" >&2
exit 2
fi
Manual testing in the terminal worked. Then I had Claude actually output a command containing &&, and it passed. Not blocked.
Cause
The transcript is JSONL. Each line is one JSON object, and the actual text is stored JSON-encoded as the value of the "text" field. Grepping the file directly means looking at the raw, pre-decoded bytes.
Inside the JSON, ! may appear as !. && may have been converted to &&. Newlines are stored as the two literal characters \n (backslash and n). The grep pattern ^\! .* && tries to match a single line of text, but one line of JSONL is an entire JSON object. Because inner newlines are stored as the two characters \n, the actual command line never appears to grep as "one line."
Fix
Parse the JSON with Python, extract the text field, then apply the regex. As long as you look at decoded real text, JSON escaping is not an issue. An implementation that scans the transcript directly with grep is fundamentally wrong.
Stuck #2: Fail-Open Had Turned Into "Always Pass"
Symptom
After reconfiguring the Stop hook and having Claude output a ! command with &&, it still wasn't blocked. It was passing through with exit 0. Adding echo "hook起動" >&2 at the top for debugging showed output, so the hook itself was definitely running. Yet it never reached exit 2.
Cause
I hadn't noticed because 2>/dev/null was discarding python3's errors. What was actually happening:
console
/usr/bin/python3: command not found
This error was being emitted. I had been calling python3 (without the full path) from the hook's shell, so it couldn't be found. python3 not found leads to tpath being an empty string, which triggers fail-open at [ -n "$tpath" ] || exit 0, and everything passes. That was the chain.
Fail-open design is the right direction, but it showed up here as the side effect of "hiding the mistake."
Fix
First, confirm /usr/bin/python3 --version in the hook's shell. Hooks launch from launchd or internal processes, so PATH differs from normal. Write the confirmed full path directly into the script. When debugging, temporarily remove 2>/dev/null and look at stderr. Just being able to see the errors makes root-causing dramatically faster.
Stuck #3: The Heredoc Was Destroying the Python Code
Symptom
The python3 full-path problem was fixed. But the bad variable was always empty, and even commands containing && didn't produce exit 2. Removing 2>/dev/null and checking stderr:
python
File "<stdin>", line 1
if re.match(r'^!s', s) and '&&' in s:
^
SyntaxError: invalid syntax
r'^!\s' had become r'^!s'.
Cause
I had written the heredoc as <<PY (unquoted). The regex r'^!\s' in the Python code contains \s. Bash processes backslashes as escapes inside unquoted heredocs, so the \ in \s vanished, leaving just s. Python received r'^!s', an entirely different pattern. The \s that should match whitespace was gone, and no line matched.
Fix
Change <<PY to <<'PY'. That's all. One character (') fundamentally changes the behavior. Always put single quotes on heredocs that embed Python. The same problem could bite in other scripts too, so I drilled it into muscle memory: heredocs for embedded Python require single quotes.
Stuck #4: Blocking Didn't Fire in Certain Sessions Only
Symptom
It basically worked. But in longer sessions (30+ conversation turns), when Claude wrote a && command, it sometimes passed. In short sessions it was blocked reliably.
Cause
I looked directly at the actual transcript to check.
bash
python3 -c "
import json
for l in open('/path/to/transcript.jsonl'):
l = l.strip()
if not l: continue
o = json.loads(l)
print(list(o.keys())[:4])
"
The output showed entries early in the session had the structure ['role', 'content', ...], while later entries had ['type', 'message', ...]. The first implementation only checked o.get("type") == "assistant", so all the early role: "assistant" entries were skipped.
In long sessions, there may be no type: "assistant" messages, or the last message may be in role: "assistant" form. When that happens, msgs is empty, last = "", nothing is detected, exit 0. That's the path it took.
Fix
python
if o.get("type") == "assistant" or o.get("role") == "assistant":
Check both fields with or. Even if a Claude Code upgrade changes the format, as long as either field holds "assistant", it gets picked up.
Four times stuck, one or two lines added each time, and the result is today's 46 lines. Every line of code can answer "why does this exist." /usr/bin/python3 because without the full path it isn't found in the hook environment. <<'PY' because without single quotes the Python code breaks. or o.get("role") so old-format entries don't slip through. The fail-open exit 0 to prevent false blocks.
Code that can't say "why it's written this way" can't be fixed when it breaks. The memory of getting stuck became the design rationale.
A Common Debugging Procedure for When a Hook "Somehow Passes Everything"
Every Stop hook problem reduces to the pattern "fail-open is silently letting it through." Here are the three things to check first.
1. Confirm the hook itself is launching
Add one line at the top of the script.
bash
echo "[DEBUG] hook起動 tpath=${tpath}" >&2
If this line doesn't appear on stderr after Claude's response, the hook itself isn't launching. Either the path in the settings file is wrong or the execute permission is missing.
`bash
ls -la ~/.claude/hooks/bang_handoff_amp_guard.sh
→ -rwxr-xr-x になっているか確認。rが抜けていたら chmod +x
`
2. Remove 2>/dev/null and look at the errors
Fail-open design "passes silently," so internal errors never surface. Temporarily remove 2>/dev/null.
`bash
変更前
tpath=$(printf '%s' "$input" | /usr/bin/python3 -c "..." 2>/dev/null)
デバッグ用(確認後に戻す)
tpath=$(printf '%s' "$input" | /usr/bin/python3 -c "...")
`
If you see command not found or SyntaxError on stderr, you have your cause. Be sure to restore 2>/dev/null when you're done.
3. Look directly at the transcript structure
bash
python3 -c "
import json, sys
for l in open(sys.argv[1]):
l = l.strip()
if not l: continue
o = json.loads(l)
role = o.get('role') or o.get('type', '?')
if role == 'assistant':
print('--- assistant entry keys:', list(o.keys()))
" ~/.claude/projects/.../transcript.jsonl
This lets you verify against the real file which field, type or role, is in use, and whether content is a list or a string. When you suspect a Claude Code version change has altered the format, one look here tells you immediately.
Check these three and the cause of the "pass-through" will always be one of them. Hook problems aren't hard if you understand the mechanism. What gets you stuck is always being slow to notice that it's "silently passing."
Pitfalls
The previous part dug into the four times I got stuck. This section lists the additional traps that surfaced gradually during ongoing operation. Use it as a checklist for "why doesn't it work" and "why isn't it blocking."
A syntax error in settings.json silently disables the hook
Trailing commas, missing closing brackets, unquoted key names. Claude Code doesn't always surface these parse errors prominently at startup. The result is that the hook never launches at all, and you remain unaware of why it isn't working. After changing the settings, always run this check.
`bash
echo '{"transcript_path":""}' | ~/.claude/hooks/bang_handoff_amp_guard.sh; echo "exit: $?"
exit: 0 が返れば hook 自体は起動している
`
The ~ tilde in the script path isn't expanded
Even if you write ~/.claude/hooks/bang_handoff_amp_guard.sh in the command field of settings.json, depending on the shell that launches the hook, tilde expansion may not happen. A path passed with ~ intact doesn't exist as a file, so it's silently disabled. Get the full path with realpath ~/.claude/hooks/bang_handoff_amp_guard.sh and write it directly into settings.json. A clean rule: use tildes only in commands you run directly in a terminal, never in configuration meant for a program to read.
Forgetting chmod +x
Without execute permission, the script won't launch. Check with ls -la ~/.claude/hooks/bang_handoff_amp_guard.sh that it shows -rwxr-xr-x. If the x bits aren't set, like -rw-r--r--, the hook silently does nothing. This is the most common cause of "why doesn't it work." Make chmod +x a reflex immediately after creating any new script.
Overlooking the difference between exit 1 and exit 2
To block in a Stop hook you must return exit 2. Returning exit 1 may not block the response depending on the Claude Code version. This difference isn't written prominently in the official docs, but I've confirmed it in actual behavior. When you write your own hook and blocking doesn't work, check the exit code first.
Execution order when multiple Stop hooks are registered
When multiple hooks are listed in the hooks.Stop array, they run in order from the front. As soon as the first hook returns exit 2, the block is final and subsequent hooks don't run. When lining up multiple guard hooks, put the lightest and most reliable one first. Placing heavy processing first can leave all the other hooks unexecuted.
Registering the same hook in both global and project settings
Writing the same hook in both ~/.claude/settings.json and .claude/settings.json runs it twice. The performance impact is small, but the error message prints twice and readability suffers. Problems that occur across projects (like this && issue) should go in global only. Project-specific constraints go in the project's .claude/settings.json. That separation is clear.
Running network calls or heavy processing inside the hook
The Stop hook runs while Claude's response is "blocked." Putting external API calls or slow processing in the hook puts all of Claude Code in a waiting state for that duration. A hook should be limited to synchronous work on the order of "read a file, check a regex, return exit 0 or exit 2." These 46 lines only read a file and run a Python script, and execution time is normally under 100ms.
Treating an entire code block as a single line
Claude sometimes writes commands as multi-line code blocks.
shell
bash
! npx wrangler dev --local && open http://localhost:8787
markdown
When processing line by line with splitlines(), the
`bash` (fence line) and the actual command line arrive separately. The fence line doesn't match `^!\s` so it's skipped, and the command line is correctly detected. But if you write your own script, you need to either explicitly skip lines starting with `
or strip() the backticks. In bang_handoff_amp_guard.sh, strip(bt) handles this role (lines 34 to 36).
Confusion when the bad variable contains multiple lines
If you forget the break after print(s) in the Python script, multiple matching lines are all printed. exit 2 still works correctly even when bad contains newline-separated text, but the error message becomes multi-line and hard to read. In the actual script, print(s) and break go together (lines 38 to 39). Stopping at the first hit is deliberate.
The hook only inspects ! commands in the text response
Commands Claude runs through the Bash tool are outside the Stop hook's scope. The Stop hook only inspects lines starting with ! within Claude's response text. If you have a problem with Bash-tool processing, you need a different event such as a PostToolUse hook. Without understanding this difference, you'll face the question "why doesn't it stop when && appears in the Bash tool?" That's by design.
^!\s doesn't match full-width spaces
The \s in re.match(r'^!\s', s) matches half-width spaces, tabs, and newlines. Commands written with a full-width exclamation mark (!) or full-width spaces aren't detected. This case almost never occurs in practice, but if Claude uses full-width characters for whatever reason, it passes. If needed you can add normalization with s.replace('!', '!') or s = unicodedata.normalize('NFKC', s).
Best Practices
These are the rules that crystallized while building 46 lines that actually work. Use them as a baseline so you don't hit the same snags when writing a new guard hook of the same kind.
1. Make fail-open a design principle
The script can't read, can't parse, can't find Python. In all these cases, return exit 0. The cost of a false block is higher than the cost of a missed detection. Your work stops. One && getting through is less of a loss than your work grinding to a halt.
bash
[ -n "$tpath" ] && [ -f "$tpath" ] || exit 0
Stick with fail-open, then separately debug "why is it passing." That's the correct order.
2. Call Python by full path: /usr/bin/python3
The shell that launches the hook doesn't read ~/.zshrc. nvm, pyenv, and Homebrew Python are not in the PATH. Confirm in the terminal that /usr/bin/python3 --version works before writing it. Using just the command name means it isn't found in the hook environment and silently passes via fail-open.
3. Heredocs with embedded Python must always be <<'PY'
With <<PY (unquoted), backslashes are consumed by the shell. The regex's \s becomes s and the detection logic breaks. With <<'PY' (single-quoted), the entire heredoc is passed as a literal string. This one-character difference fundamentally changes behavior. When embedding Python code in a heredoc, add single quotes unconditionally.
4. Check both type and role with or
python
if o.get("type") == "assistant" or o.get("role") == "assistant":
Field names in Claude Code's transcript JSONL change with version and conversation length. Looking at only one causes missed detections in certain session configurations. If one changes, the other covers it. This is the minimum defense for making a hook resilient to Claude Code updates.
5. Generate backticks indirectly with chr(96)
Writing a literal backtick in the script body risks a self-parsing error when run via eval or sourced from another script. Generate the character at runtime with bt = chr(96) and use it as ln.strip().strip(bt).strip(). A script body with zero backticks is the safe state.
6. Capture standard input into a variable first with input=$(cat)
stdin can only be read once. Take it all in at the start with input=$(cat), and reuse it as many times as needed with printf '%s' "$input". This is the universal pattern for handling stdin in shell scripts. If you later extend the script to extract multiple values, capturing into a variable from the start means you won't get stuck.
7. Put the reason, the date, and the fix all in the error message
bash
echo "🚫 ! ハンドオフに && を使った(本人が5回以上指摘済)。... >> && を単一 & に直して出し直せ <<。該当行: ${bad}" >&2
This message is an instruction to future Claude (including in every session from now on). If "why it was blocked" and "how to get through" are in the message, Claude immediately returns a corrected response. With a vague error message (something like "an error occurred"), Claude can't understand why it stopped and either repeats the same content or stays stuck. The "already pointed out" note and the date are in the message as an explanation for future sessions that have no context.
8. Register global problems as global hooks
The && problem occurs regardless of project. Writing it only in a project's .claude/settings.json won't prevent it in other projects. Register cross-project habitual mistakes as global hooks in ~/.claude/settings.json. "It's prevented in this project but showed up again elsewhere" is a registration-location problem.
9. After changing settings, always trigger it deliberately to confirm
After changing settings.json, have Claude deliberately write a ! command containing && and confirm it's blocked.
plaintext
「次のコマンドをそのまま出力してください: ! npx wrangler dev --local && open http://localhost:8787」
If it isn't blocked, the settings aren't taking effect. Skip this check and you'll stay in a "thinks it's working but isn't" state, only finding out when the next real && shows up. It's the same habit as a post-deploy smoke test.
10. Remove 2>/dev/null while debugging
As a side effect of fail-open design, internal errors are silently hidden. When you feel it's "somehow passing," first temporarily remove 2>/dev/null and expose stderr. command not found or SyntaxError shows up as-is and the cause is immediately clear. Restore it when you're done. Leaving it removed pollutes stderr during normal operation.
11. Keep hook scripts together in ~/.claude/hooks/
Scattering scripts across projects means you quickly lose track of what's where. Collect all hook scripts under ~/.claude/hooks/ and reference them from settings.json. Name files so "what it prevents" is obvious at a glance, like bang_handoff_amp_guard.sh. Generic names like hook1.sh or guard.sh won't mean anything to you three months from now.
12. Inspect only the last assistant message
python
last = msgs[-1] if msgs else ""
Scanning all messages picks up leftover ! commands from past conversation and causes false blocks. Narrowing to the final entry with msgs[-1] so only the current response is inspected is a deliberate choice. && in past messages is irrelevant. The only thing that matters is "what Claude wrote right now."
13. Write reasons in the error message rather than in the code
Comments in the script body may be deleted in the future. The error message is what Claude reads when it receives it, so it can't be deleted. Write design rationale not as # comments but in the body of the echo "..." >&2 error message. That's why "the user has pointed this out more than 5 times (2026-07-16)" appears both in lines 1 to 4 of bang_handoff_amp_guard.sh (comments) and in line 44 (error message). Even if the comment isn't read, the error message always is.
Summary
Five verbal corrections didn't fix it. A 46-line shell script physically sealed it off with code.
The Stop hook is a mechanism that "intercepts right before Claude returns a response." Parse the transcript JSONL with Python, extract only the last assistant message, and if a line matching ^!\s contains &&, block with exit 2. The structure is simple.
But to make "a simple thing work reliably across sessions," these design decisions were necessary: fail-open, the /usr/bin/python3 full path, <<'PY' single quotes, checking both type and role, and chr(96) indirect generation. The four times I got stuck, and the additional traps noticed in operation, were all things "you could get past in five minutes if you knew." I wrote this so you don't have to spend that time.
There are things Claude won't fix no matter how many times you say them. That's an architecture problem, not Claude being lazy and not a flaw in how you phrased the correction. Sessions are independent per conversation, corrections live in context, and context disappears with the session. The solution isn't repeating the correction. It's "building an environment where it physically cannot pass." That is the core of the Stop hook design philosophy.
The reason the same failures don't repeat in a ¥1.2 million-a-month autonomous environment is that I keep stacking up "fix it with code, not with words." One script cuts off one repetition. Keep doing that, and you get the environment I have today.
What's the one mistake your agent keeps making that you're still correcting by hand instead of with a hook?
The full picture of the system, the breakdown of the ¥1.2 million a month, and the 30-day procedure are compiled in a paid note.
📕 Claude Code自律環境で、実際どう稼ぐか ― 仕組み・実例・始め方・サポート
Written by **Lily* — I ship iOS apps and automate my content stack with Claude Code.
Follow along: Portfolio · X · GitHub*
Read original: https://dev.to/bokuwalily/5-verbal-corrections-didnt-stick-so-a-46-line-stop-hook-made-the-mistake-physically-impossible-3d4n
← Previous
Shipping AI-Generated Features as Stacked PRs: A Complete Spec Kit + gh stack Tutorial
Next →
What Happens When You pip install a Malicious Python Package?
Related
Wabe Labs Is Born, and Building With Blueprints
Backend
0
DEV Community
Host Sleep Is Not Job Cancel: Dual-Residency Resume for Local Agents
Backend
0
DEV Community
Open-PR: một AI agent review PR nói chuyện như đồng nghiệp, không như một con bot
Backend
2
DEV Community
Was TinyStories the Domain or the Vocabulary?
Backend
2
DEV Community
Comments0
No comments yet — be the first