<!-- title: Stop钩子造自愈Agent跑通测试 -->

## 概述

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

## 前置条件

- Claude Code ≥ 2.0（Stop 钩子、`/goal`、`--session-id` 均已稳定；本教程基于 2026 年 7 月的 `code.claude.com` 官方文档）
- 一个有测试套件的真实项目（Python `pytest` / Node `npm test` 均可，本文以 pytest 演示）
- 基础的 Bash 与 JSON 阅读能力；会用 `jq` 解析命令行 JSON
- 已在项目根目录跑通 `claude` 交互模式，并理解工具调用循环

---

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

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

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

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

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

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

Claude Code 的钩子事件按触发节奏分三类（来自官方 hooks 参考）：

- **每会话一次**：`SessionStart`、`SessionEnd`
- **每轮一次**：`UserPromptSubmit`、`Stop`、`StopFailure`
- **每次工具调用**：`PreToolUse`、`PostToolUse`

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

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

- 第一次 Stop 触发时，`stop_hook_active` 为 `false`。你的钩子若判定"没做完"，返回 `{"decision":"block","reason":"..."}`，Agent 被迫继续。
- Agent 又干了一轮，再次想停，**这一次 Stop 输入里的 `stop_hook_active` 会变成 `true`**，告诉你"上一轮我已经因为你的 block 续命过了"。
- 钩子必须检查这个字段：如果为 `true` 还无限 block，就会陷入"永远不准停"的死循环（这是 GitHub issue #2956 的经典坑）。

正确做法是：**`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.

**操作说明**（交互模式）：

```text
> /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`：

```python
#!/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` 或用户级）：

```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` 做硬上限。
- **stderr 误伤**：官方约定——**只有 exit code 2**（或显式 `decision:block` 的 JSON）才阻断；其他非零退出码是非阻断错误，只会在 transcript 里留一条 `hook error` 提示。别用 `sys.exit(1)` 表达"失败"，那不会阻止停止。

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

Stop 是"轮末总检"，`PostToolUse` 则是"每次工具调用后即时反馈"。两者的 block 含义不同（官方强调）：

- `PreToolUse` 的 block：**阻止该工具执行**。
- `PostToolUse` 的 block：工具已执行完，但把 `reason` 作为**自动反馈**喂给 Claude，驱动它在下一轮自我修正。

典型场景：Claude 每次写完文件，钩子立刻跑 `ruff`/`mypy`，有 lint 错误就反馈，让它在还没"声称完成"前就先自纠。

**钩子脚本** `scripts/post_edit_lint.py`（节选）：

```python
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` 限定工具：

```json
{
  "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`，它的输出是结构化的，可被脚本解析：

```bash
claude -p "重构 auth 模块为 async 并保证 pytest 全绿" \
  --output-format json \
  --max-turns 40
```

返回的 JSON 核心字段：

```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` 提取做决策：

```bash
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）。
- **stdout 被流式消息污染**：若用 `--output-format stream-json`，输出是多行 JSON 事件流，最后一行才是 `type:"result"`；要解析结果用 `--output-format json`（单对象）最省心。
- **成本失控**：自愈循环天然烧 token。务必把 `cost_usd` 累加写进日志，并在外层脚本设一个"单任务成本上限"熔断。

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

复杂任务往往要拆成"分析 → 重构 → 验证 → 写报告"多个 `-p` 调用。每次重启会话会丢上下文——解决办法是用**会话 ID 把多阶段串成一条流水线**，把"会话"当状态载体，无需外部状态文件。

```bash
# 阶段 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 全清才能交付，否则循环续修，并在过程中受成本与轮次双熔断保护；最后汇总一份报告。

**目录结构**：

```text
project/
├── .claude/
│   └── settings.json          # 注册 Stop / PostToolUse 钩子
├── scripts/
│   ├── stop_check_tests.py    # 轮末：测试不过不准停
│   ├── post_edit_lint.py      # 写后：lint 不过即反馈
│   └── nightwatch.sh          # 编排主脚本
└── reports/
```

**编排主脚本** `scripts/nightwatch.sh`：

```bash
#!/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
```

**运行**：

```bash
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`。

## 工作流程图

**自愈循环（时序）**：

```mermaid
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: 真实退出码
```

**多阶段流水线（架构）**：

```mermaid
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[⚠ 人工复核]
```

## 延伸阅读

- **官方 hooks 参考**：`code.claude.com/docs/en/hooks`——事件全集、JSON 输入输出 schema、exit code 2 在各事件下的行为表，是写钩子的权威依据。
- **官方 hooks 实战指南**：`code.claude.com/docs/en/hooks-guide`——含 Stop/SubagentStop 的 reason 反馈、PreToolUse 阻断的端到端示例。
- **headless 与 Agent SDK**：`code.claude.com/docs/en/headless`——`-p`、输出格式、`--resume`、成本跟踪的可编程用法。
- **会话管理**：`code.claude.com/docs/en/sessions`——`--continue`、`--resume`、`--fork` 的区别与跨会话上下文。
- **`/goal` 命令**：会话级 prompt-based Stop 钩子的零配置快捷方式，适合临时自愈任务。

## 训练营适用性评估

- **适合训练营类型**：进阶/自动化方向，2–3 天的"Claude Code 工程化"或"AI 运维自动化"训练营。不适合纯零基础营——学员需先具备第一步到第三步的基础。
- **教学时长**：建议 **3.5–4 小时**。讲解 + 动手：钩子事件图谱 30 分钟，`/goal` 快速体验 30 分钟，手写 Stop 钩子 60 分钟，headless + 会话串联 60 分钟，完整跑通守夜人案例 + 排障 45 分钟。
- **考核点**：学员能否独立让一个"假完工"的 Agent 在 Stop 钩子约束下真正跑到测试全绿，并能用 `num_turns`/`cost` 解释为什么某次运行"没收敛"。
- **延伸练习**：把 Stop 钩子的判定条件从"pytest 通过"换成"覆盖率 ≥ 阈值""无 `console.log` 残留""git diff 通过 code-review 技能"，体会 reason 反馈的通用性。

## 参考来源

1. [Hooks reference — Claude Code Docs](https://code.claude.com/docs/en/hooks) — 钩子事件全集、输入输出 schema、exit code 2 与 `stop_hook_active` 的权威定义
2. [Automate actions with hooks — Claude Code Docs](https://code.claude.com/docs/en/hooks-guide) — Stop/SubagentStop 的 reason 反馈机制与 PreToolUse 阻断示例
3. [Run Claude Code programmatically — Claude Code Docs](https://code.claude.com/docs/en/headless) — headless `-p`、`--output-format json`、`--resume`、成本与轮次字段
4. [Claude Code Headless Mode: The Definitive Guide to claude -p — Build This Now](https://www.buildthisnow.com/blog/guide/development/claude-code-headless-mode) — jq 解析、stdin 管道、CI/CD 集成模式
5. [Stop hook with exit code 2 behavior — anthropics/claude-code Issue #10412](https://github.com/anthropics/claude-code/issues/10412) 与 [Issue #2956 死循环](https://github.com/anthropics/claude-code/issues/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学通信知识星球海报-扫码加入](https://wiki.500nian.com/images/zhixingqiu_qrcode.png)
