本教程教你用 Claude Code 的 Stop 钩子打造一个"自愈式"执行内核——当 Agent 自认为干完活想收工时,钩子强行检查"测试是否真的通过、任务是否真的完成",没达标就把 reason 反馈回去逼它继续干,循环往复直到满足退出条件。再叠加 headless -p 模式的结构化 JSON 输出与会话 ID 多阶段串联,就能造出一台夜里无人值守、自己把自己逼到测试全绿为止的代码守夜人。适合已掌握 Claude Code 基础用法、想进阶自动化与批处理的开发者与运维工程师。
/goal、--session-id 均已稳定;本教程基于 2026 年 7 月的 code.claude.com 官方文档)pytest / Node npm test 均可,本文以 pytest 演示)jq 解析命令行 JSONclaude 交互模式,并理解工具调用循环很多人把 Claude Code 丢进 -p 跑批量任务,第二天一看日志:Agent 输出了一句"我已修改 auth.py,测试应该能通过了",然后进程退出,退出码 0。你点开 CI——红的。
这就是"假完工(false completion)":Agent 在文本里声称完成,但从未真正执行验证。原因在于默认的 Agent 循环只在"模型不再发起工具调用、输出纯文本"时就判定一轮结束。模型"觉得"做完了,循环就停了,没人复核。
要治这个病,得在"想停"这个动作上插一道闸——这就是 Stop 钩子存在的意义。
关键认知:Stop 钩子不是"通知",而是"否决权"。它能在 Agent 准备收工时喊停,并把一段
reason文本喂回给模型,让模型带着这条新信息继续下一轮。这构成了一个闭环:声称完成 → 钩子验证 → 不达标则反馈 → 继续修 → 再次声称完成 → …… → 达标放行。这就是"自愈"。
Claude Code 的钩子事件按触发节奏分三类(来自官方 hooks 参考):
SessionStart、SessionEndUserPromptSubmit、Stop、StopFailurePreToolUse、PostToolUseStop 在"主 Agent 结束响应"时触发。它收到的输入除了公共字段(session_id、transcript_path、cwd、hook_event_name),还有一个决定生死的关键字段——stop_hook_active。
这个字段是防死循环的保险栓:
stop_hook_active 为 false。你的钩子若判定"没做完",返回 {"decision":"block","reason":"..."},Agent 被迫继续。stop_hook_active 会变成 true,告诉你"上一轮我已经因为你的 block 续命过了"。true 还无限 block,就会陷入"永远不准停"的死循环(这是 GitHub issue #2956 的经典坑)。正确做法是:stop_hook_active 为 true 时,最多再 block 一次(或直接放行),给系统一个逃生出口。
/goal 命令秒变自愈 Agent如果你只是临时要让 Claude"干到某条件成立才准停",官方给了一个零配置快捷方式——/goal 命令。官方文档原话:
The
/goalcommand is a built-in shortcut for a session-scoped prompt-based Stop hook. Use it when you want Claude to keep working until a condition holds without writing hook configuration.
操作说明(交互模式):
> /goal pytest 全部通过且覆盖率不低于 80%
> 把 auth 模块从同步改造为 async,并补齐测试
预期结果:Claude 开始改造代码,每当它想结束时,系统会用你给的 goal 文本作为 Stop 钩子的判定依据自检;不满足就继续。最终输出里会看到多轮"Goal not yet met, continuing…"的内部提示,直到 pytest 真的全绿。
常见问题:
| 现象 | 原因与对策 |
|---|---|
/goal 报 unknown command |
版本过旧,升级 claude 到 ≥ 2.0 |
| Claude 反复改同一处不收敛 | goal 表述太模糊,改成可机器验证的条件(如"pytest 退出码为 0") |
触发 stop_hook_active 后还在死磕 |
/goal 内置了放行逻辑,但极复杂任务仍可能绕圈;必要时手动 Esc 打断 |
/goal 适合临时任务。但当你要长期、可复用、跨项目地部署自愈逻辑,就得自己写 Stop 钩子。
目标:Agent 想停时,钩子真实地跑一遍 pytest,依据测试结果决定放行还是反馈。
4.1 钩子脚本 scripts/stop_check_tests.py:
#!/usr/bin/env python3
# Read Stop hook JSON from stdin, run tests, block if they fail.
import json, sys, subprocess
payload = json.load(sys.stdin)
# The escape hatch: if we already forced a continuation last turn, let it stop
# to avoid an infinite loop (see Claude Code issue #2956).
if payload.get("stop_hook_active"):
sys.exit(0)
# Run the test suite deterministically, capture result
proc = subprocess.run(
["pytest", "-q", "--tb=line"],
cwd=payload.get("cwd", "."),
capture_output=True, text=True,
)
if proc.returncode == 0:
# Tests green -> allow stop
sys.exit(0)
# Tests red -> block and feed the failure back to Claude as the reason
tail = "\n".join(proc.stdout.splitlines()[-25:])
reason = (
"测试套件仍未通过,不允许结束。请根据下列失败信息继续修复,"
"然后再次尝试完成:\n\n" + tail
)
print(json.dumps({"decision": "block", "reason": reason}))
4.2 在 settings.json 注册钩子(项目级 .claude/settings.json 或用户级):
{
"hooks": {
"Stop": [
{
"matcher": "",
"hooks": [
{ "type": "command", "command": "python3 scripts/stop_check_tests.py" }
]
}
]
}
}
预期结果:交互模式下让 Claude"重构 utils 并保证测试通过"。每当它输出"完成"想停,你会看到终端短暂出现"Stop hook blocked: 测试套件仍未通过…",随后 Claude 自动开始下一轮修复,直到 pytest 真的返回 0 才真正收工。
常见问题:
matcher 为空字符串(匹配所有)且 JSON 层级正确——hooks.Stop[].hooks[] 是双层结构。stop_hook_active,或测试有根本性失败 Agent 永远修不好。脚本里的 stop_hook_active 分支是必须的逃生门;再叠加下一步的 --max-turns 做硬上限。decision:block 的 JSON)才阻断;其他非零退出码是非阻断错误,只会在 transcript 里留一条 hook error 提示。别用 sys.exit(1) 表达"失败",那不会阻止停止。Stop 是"轮末总检",PostToolUse 则是"每次工具调用后即时反馈"。两者的 block 含义不同(官方强调):
PreToolUse 的 block:阻止该工具执行。PostToolUse 的 block:工具已执行完,但把 reason 作为自动反馈喂给 Claude,驱动它在下一轮自我修正。典型场景:Claude 每次写完文件,钩子立刻跑 ruff/mypy,有 lint 错误就反馈,让它在还没"声称完成"前就先自纠。
钩子脚本 scripts/post_edit_lint.py(节选):
import json, sys, subprocess
payload = json.load(sys.stdin)
# Only check after file-writing tools
if payload.get("tool_name") not in ("Edit", "Write", "MultiEdit"):
sys.exit(0)
path = payload.get("tool_input", {}).get("file_path", "")
if not path.endswith(".py"):
sys.exit(0)
proc = subprocess.run(["ruff", "check", path], capture_output=True, text=True)
if proc.returncode != 0:
print(json.dumps({
"decision": "block",
"reason": f"ruff 发现以下问题,请修复后再继续:\n{proc.stdout}"
}))
settings.json 里用 matcher 限定工具:
{
"hooks": {
"PostToolUse": [
{
"matcher": "Edit|Write|MultiEdit",
"hooks": [
{ "type": "command", "command": "python3 scripts/post_edit_lint.py" }
]
}
]
}
}
PostToolUse 与 Stop 叠加,就构成了双层自愈:写完即查 lint(微观)、收工前必过测试(宏观)。
-p 模式——让自愈内核无人值守交互模式适合调试,真正跑批量要靠 headless。关键在 --output-format json,它的输出是结构化的,可被脚本解析:
claude -p "重构 auth 模块为 async 并保证 pytest 全绿" \
--output-format json \
--max-turns 40
返回的 JSON 核心字段:
{
"type": "result",
"subtype": "success",
"result": "已完成重构,所有测试通过……",
"session_id": "abc123def-...",
"is_error": false,
"num_turns": 12,
"cost_usd": 0.1842,
"total_cost_usd": 0.1842,
"duration_ms": 96300
}
三个字段是调度大脑:
is_error:本次运行是否异常退出(API 错误、超预算等)。num_turns:跑了多少轮工具循环——接近 --max-turns 上限说明大概率没收敛。cost_usd / total_cost_usd:真实花费,用于成本感知调度。用 jq 提取做决策:
result=$(claude -p "$TASK" --output-format json --max-turns 40)
is_error=$(echo "$result" | jq -r '.is_error')
turns=$(echo "$result" | jq -r '.num_turns')
cost=$(echo "$result" | jq -r '.total_cost_usd')
# num_turns 顶到上限 => 强烈疑似"假完工"或卡死,标记重试
if [ "$turns" -ge 40 ]; then
echo "WARN: 触达 turn 上限,可能未收敛,cost=\$cost"
fi
常见问题:
--max-turns 撞顶直接断:headless 下触达上限会强制结束,此时 Stop 钩子的自愈也可能被打断。给 --max-turns 留足余量(如默认任务给 40,重构类给 80)。--output-format stream-json,输出是多行 JSON 事件流,最后一行才是 type:"result";要解析结果用 --output-format json(单对象)最省心。cost_usd 累加写进日志,并在外层脚本设一个"单任务成本上限"熔断。复杂任务往往要拆成"分析 → 重构 → 验证 → 写报告"多个 -p 调用。每次重启会话会丢上下文——解决办法是用会话 ID 把多阶段串成一条流水线,把"会话"当状态载体,无需外部状态文件。
# 阶段 1:分析,拿到 session_id
stage1=$(claude -p "分析 auth 模块,列出所有同步阻塞点" \
--output-format json)
SID=$(echo "$stage1" | jq -r '.session_id')
echo "$stage1" | jq -r '.result' > reports/01_analysis.md
# 阶段 2:用同一 session 继续重构(带着阶段1的上下文)
claude -p "根据上面的分析,开始重构" \
--resume "$SID" --output-format json --max-turns 60 \
| jq -r '.result' > reports/02_refactor.md
# 阶段 3:验证 + 报告(仍在同一 session)
claude -p "跑完整测试套件,生成验收报告" \
--resume "$SID" --output-format json \
| jq -r '.result' > reports/03_report.md
--resume <session_id> 让新一次 -p 调用恢复指定会话的全部上下文并追加;--continue(-c)则是恢复"当前项目最近一次"会话。需要从某个里程碑分叉探索时,还有 --fork。
常见问题:
--resume 找不到会话:会话按项目目录(cwd)隔离,必须在同一项目根目录下调用;跨目录会查不到。PreCompact/PostCompact。可在阶段间主动让 Claude"把当前结论写进 reports/xx.md 并清点待办",把状态外化到文件,降低对会话记忆的依赖。nightwatch.sh把前七步拼成一台完整的"无人值守自愈执行器"。需求:夜里给它一个重构任务,它必须把测试跑到全绿、lint 全清才能交付,否则循环续修,并在过程中受成本与轮次双熔断保护;最后汇总一份报告。
目录结构:
project/
├── .claude/
│ └── settings.json # 注册 Stop / PostToolUse 钩子
├── scripts/
│ ├── stop_check_tests.py # 轮末:测试不过不准停
│ ├── post_edit_lint.py # 写后:lint 不过即反馈
│ └── nightwatch.sh # 编排主脚本
└── reports/
编排主脚本 scripts/nightwatch.sh:
#!/usr/bin/env bash
# Overnight self-healing refactor runner.
set -u
TASK="${1:?用法: nightwatch.sh \"<重构任务描述>\"}"
MAX_TURNS="${MAX_TURNS:-80}"
COST_CAP="${COST_CAP:-5.0}" # 单任务成本上限(美元)
mkdir -p reports
echo "[$(date +%H:%M)] 启动自愈任务: $TASK"
result=$(claude -p "$TASK" \
--output-format json \
--max-turns "$MAX_TURNS")
is_error=$(echo "$result" | jq -r '.is_error')
turns=$(echo "$result" | jq -r '.num_turns')
cost=$(echo "$result" | jq -r '.total_cost_usd')
sid=$(echo "$result" | jq -r '.session_id')
echo "$result" | jq -r '.result' > reports/final.md
echo "turns=$turns cost=\$$cost is_error=$is_error sid=$sid"
# —— 三道熔断判定 ——
if [ "$is_error" = "true" ]; then
echo "FATAL: Agent 异常退出"; exit 2
fi
if awk "BEGIN{exit !($cost > $COST_CAP)}"; then
echo "FATAL: 超成本上限 (\$$cost > \$$COST_CAP)"; exit 3
fi
if [ "$turns" -ge "$MAX_TURNS" ]; then
echo "WARN: 触达轮次上限,疑似未收敛——结果需人工复核"
exit 4
fi
# —— 复核 Stop 钩子的"放行"是否可信:独立再跑一次测试 ——
if pytest -q >/dev/null 2>&1; then
echo "OK: 自愈完成,测试全绿 ✅"; exit 0
else
echo "FAIL: Agent 声称完成但测试仍红,需介入"; exit 1
fi
运行:
chmod +x scripts/nightwatch.sh
MAX_TURNS=80 COST_CAP=5 \
./scripts/nightwatch.sh "把 services/ 下所有同步数据库调用改为 async,补齐测试,保证 pytest 全绿"
预期结果:脚本启动后 Agent 进入自愈循环——Stop 钩子在每个"想停"的瞬间用 pytest 复核,失败就反馈 tail 信息逼它续修,PostToolUse 钩子在每次写文件后即时 lint。最终 reports/final.md 落地交付,退出码反映真实状态(0=全绿交付,1=声称完成但测试仍红,4=撞轮次上限)。
这个案例的精髓在于:退出码不再依赖 Agent 的"自述",而依赖脚本侧的客观复测。Agent 再怎么"假完工",也过不了最后一道独立 pytest。
自愈循环(时序):
sequenceDiagram
participant U as 编排脚本/用户
participant CC as Claude Code Agent
participant S as Stop 钩子
participant T as 测试/ lint
U->>CC: claude -p "任务" --max-turns 80
loop 自愈循环
CC->>CC: 调用工具修改代码
CC->>T: (PostToolUse) 写后即时 lint
T-->>CC: reason 反馈(若有问题)
CC->>S: Agent 想结束响应 (Stop)
S->>T: 客观复跑 pytest
alt 测试通过
S-->>CC: 放行 (exit 0)
else 测试未过 且 stop_hook_active=false
S-->>CC: block + reason=失败tail
Note over CC: 带着失败信息继续修
else stop_hook_active=true
S-->>CC: 放行(防死循环逃生门)
end
end
CC-->>U: result JSON (is_error/num_turns/cost)
U->>T: 独立复核 pytest
T-->>U: 真实退出码
多阶段流水线(架构):
flowchart LR
A[阶段1: 分析<br/>--output-format json] -->|session_id| B[阶段2: 重构<br/>--resume SID]
B -->|同一 session| C[阶段3: 验证+报告<br/>--resume SID]
A --> R1[reports/01_analysis.md]
B --> R2[reports/02_refactor.md]
C --> R3[reports/03_report.md]
subgraph 每阶段内部
H1[Stop 钩子<br/>测试闸] --- H2[PostToolUse 钩子<br/>lint 闸]
end
C --> M{熔断判定<br/>is_error / cost / turns}
M -->|全绿| OK[✅ 交付]
M -->|超限| WARN[⚠ 人工复核]
code.claude.com/docs/en/hooks——事件全集、JSON 输入输出 schema、exit code 2 在各事件下的行为表,是写钩子的权威依据。code.claude.com/docs/en/hooks-guide——含 Stop/SubagentStop 的 reason 反馈、PreToolUse 阻断的端到端示例。code.claude.com/docs/en/headless——-p、输出格式、--resume、成本跟踪的可编程用法。code.claude.com/docs/en/sessions——--continue、--resume、--fork 的区别与跨会话上下文。/goal 命令:会话级 prompt-based Stop 钩子的零配置快捷方式,适合临时自愈任务。/goal 快速体验 30 分钟,手写 Stop 钩子 60 分钟,headless + 会话串联 60 分钟,完整跑通守夜人案例 + 排障 45 分钟。num_turns/cost 解释为什么某次运行"没收敛"。console.log 残留""git diff 通过 code-review 技能",体会 reason 反馈的通用性。stop_hook_active 的权威定义-p、--output-format json、--resume、成本与轮次字段stop_hook_active 防循环实践本文由「51学通信」原创整理 · 站长爱卫生(微信:gprshome201101)
[!tip] 51学通信知识星球 星主:爱卫生(微信:gprshome201101)——公众号「51学通信」创办者,19 年通信行业运维与讲师经验,著有《GPRS网络信令实例详解》《EPC原理与实践》《5G核心网原理与实践》三本纸质书(各大电商平台有售)。 星球定位:深耕 5G 核心网、IMS、NFV/AI、云原生等 ICT 领域,主打运维岗位与信令分析。 当前规模:5200 篇主题 / 990 个回答,198 元/年。 加入福利:加入星球后请加爱卫生微信,拉您进知识星球年度陪伴与答疑群——不限量回复您的各种问题,另有群内分享。 AI 加持:51学通信知识星球已作为 skill 接入 WorkBuddy,使用 deepseek-4-flash 在海量星球帖子中精准找贴、找视频、找 FAQ 和案例,做总结、找文档和 PPT 等场景全覆盖。
