首页 › tutorials › Stop钩子造自愈Agent跑通测试

概述

本教程教你用 Claude Code 的 Stop 钩子打造一个"自愈式"执行内核——当 Agent 自认为干完活想收工时,钩子强行检查"测试是否真的通过、任务是否真的完成",没达标就把 reason 反馈回去逼它继续干,循环往复直到满足退出条件。再叠加 headless -p 模式的结构化 JSON 输出与会话 ID 多阶段串联,就能造出一台夜里无人值守、自己把自己逼到测试全绿为止的代码守夜人。适合已掌握 Claude Code 基础用法、想进阶自动化与批处理的开发者与运维工程师。

前置条件


第一步:先看清"假完工"这个病灶

很多人把 Claude Code 丢进 -p 跑批量任务,第二天一看日志:Agent 输出了一句"我已修改 auth.py,测试应该能通过了",然后进程退出,退出码 0。你点开 CI——红的。

这就是"假完工(false completion)":Agent 在文本里声称完成,但从未真正执行验证。原因在于默认的 Agent 循环只在"模型不再发起工具调用、输出纯文本"时就判定一轮结束。模型"觉得"做完了,循环就停了,没人复核。

要治这个病,得在"想停"这个动作上插一道闸——这就是 Stop 钩子存在的意义。

关键认知:Stop 钩子不是"通知",而是"否决权"。它能在 Agent 准备收工时喊停,并把一段 reason 文本喂回给模型,让模型带着这条新信息继续下一轮。这构成了一个闭环:声称完成 → 钩子验证 → 不达标则反馈 → 继续修 → 再次声称完成 → …… → 达标放行。这就是"自愈"。

第二步:理解 Stop 事件与 stop_hook_active 防死循环

Claude Code 的钩子事件按触发节奏分三类(来自官方 hooks 参考):

Stop 在"主 Agent 结束响应"时触发。它收到的输入除了公共字段(session_id、transcript_path、cwd、hook_event_name),还有一个决定生死的关键字段——stop_hook_active。

这个字段是防死循环的保险栓:

正确做法是:stop_hook_active 为 true 时,最多再 block 一次(或直接放行),给系统一个逃生出口。

第三步:最短路径——/goal 命令秒变自愈 Agent

如果你只是临时要让 Claude"干到某条件成立才准停",官方给了一个零配置快捷方式——/goal 命令。官方文档原话:

The /goal command 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 钩子。

第四步:写一个真正的 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 才真正收工。

常见问题:

第五步:PostToolUse 钩子——工具级自愈反馈

Stop 是"轮末总检",PostToolUse 则是"每次工具调用后即时反馈"。两者的 block 含义不同(官方强调):

典型场景: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(微观)、收工前必过测试(宏观)。

第六步:headless -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
}

三个字段是调度大脑:

用 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

常见问题:

第七步:会话 ID 串联多阶段流水线

复杂任务往往要拆成"分析 → 重构 → 验证 → 写报告"多个 -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。

常见问题:


实战案例:过夜重构守夜人 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[⚠ 人工复核]

延伸阅读

训练营适用性评估

参考来源

  1. Hooks reference — Claude Code Docs — 钩子事件全集、输入输出 schema、exit code 2 与 stop_hook_active 的权威定义
  2. Automate actions with hooks — Claude Code Docs — Stop/SubagentStop 的 reason 反馈机制与 PreToolUse 阻断示例
  3. Run Claude Code programmatically — Claude Code Docs — headless -p、--output-format json、--resume、成本与轮次字段
  4. Claude Code Headless Mode: The Definitive Guide to claude -p — Build This Now — jq 解析、stdin 管道、CI/CD 集成模式
  5. Stop hook with exit code 2 behavior — anthropics/claude-code Issue #10412 与 Issue #2956 死循环 — Stop 钩子强制续跑的真实坑与 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 等场景全覆盖。

51学通信知识星球海报-扫码加入