<!-- title: 钩子闭环让-p批处理跑成自驱动 -->

## 概述

本教程教你用 Claude Code 的**生命周期钩子（hooks）**把无头 `-p` 批处理跑成一个"自驱动"流水线：写完即测、压缩前存档、未完自动续跑、卡住自动呼叫人类。学完你能让一个跨几十个文件的代码迁移任务在无人值守下跑到全部测试变绿。适合已经会用 `-p` 跑单条命令、想把它扩展成可靠批处理流水线的工程师与 DevOps。

## 前置条件

- 已安装 Claude Code，能在终端跑 `claude -p "hello"` 并拿到输出
- 一台装了 `jq`、`git`、`python3`（或 `node`）的 Linux/macOS 机器
- 一个有测试可跑的真实代码仓库（本教程用 Python，方法通吃任何语言）
- 已读过 Claude Code hooks 基本概念，知道 `settings.json` 在哪

## 一、为什么 `-p` 批处理总是会"半路趴窝"

很多人第一反应是把 `-p` 塞进 `for` 循环跑批处理：

```bash
for f in src/**/*.py; do
  claude -p "给 $f 加类型注解并补测试" 
done
```

跑起来很快就会发现三类致命问题：

1. **跑到一半自己停了**——模型觉得"差不多了"就 `Stop`，剩下十几个文件没动，循环却以为它干完了。
2. **上下文爆掉被压缩**——长任务触发自动压缩（compaction），压缩后模型忘了任务清单和进度，开始重复劳动或漏改。
3. **卡在权限/等待上挂死**——某条命令需要确认或网络阻塞，整个 `-p` 静默挂在那里，既不报错也不前进。

这三类问题的共同点是：它们都发生在**生命周期事件**上。而 Claude Code 恰好为每一个关键事件留了钩子。本教程的核心思路就是——**用钩子把这三道墙全部拆掉**，让批处理真正"自驱动"。

## 二、七类钩子事件地图

先把武器库摆清楚。Claude Code 的钩子都配在 `.claude/settings.json` 的 `hooks` 字段下，按事件名分组：

| 事件 | 触发时机 | 本教程用途 |
|------|----------|-----------|
| `SessionStart` | 一次会话开始 | 注入任务清单 + 规则 |
| `PreToolUse` | 调用工具前 | 拦危险命令（护栏） |
| `PostToolUse` | 工具执行后 | **写完即测**，失败回灌 |
| `PreCompact` | 上下文压缩前 | **存档进度**，防遗忘 |
| `Notification` | 需要人介入时 | **卡住呼叫人类** |
| `Stop` | 模型打算停时 | **未完续跑** |
| `SessionEnd` | 会话结束 | 收尾汇总 |

每个钩子都是一段 shell 命令，通过 **stdin 收到一段 JSON**，通过**退出码和 stdout JSON** 反控 Claude。两条关键约定记牢：

- **退出码 `2`**：把 `stderr` 当成"工具错误"回灌给模型，触发它自我纠正。
- **stdout 输出 JSON**：用 `decision`/`reason` 或 `hookSpecificOutput` 精细控制（放行、阻断、追加上下文）。

## 三、整体架构

```mermaid
flowchart TD
    A[run-batch.sh 编排器] -->|逐文件/并行| B[claude -p 无头会话]
    B --> C{SessionStart}
    C -->|注入清单| D[模型干活]
    D -->|Edit/Write| E[PostToolUse 写完即测]
    E -->|测试失败 exit 2| D
    E -->|测试绿| D
    D -->|上下文满| F[PreCompact 存档]
    F --> D
    D -->|需要权限| G[Notification 呼叫人]
    D -->|想停| H{Stop 钩子}
    H -->|未完 block| D
    H -->|已完成 allow| I[SessionEnd 收尾]
    I --> A
```

闭环的精髓在右下角：`Stop` 不让停 → 回到干活；`PostToolUse` 测试不过 → 回到干活。模型被这两股力量拽着，直到任务真正完成。

## 四、搭项目骨架

```bash
mkdir -p auto-migrate/.claude/state/checkpoints auto-migrate/hooks
cd auto-migrate
git init -q && touch src/app.py src/util.py   # 你的真实代码替换这里
mkdir -p tests
```

`.claude/settings.json` 是整条流水线的"控制台"，一次写好所有钩子：

```json
{
  "hooks": {
    "SessionStart": [{
      "hooks": [{ "type": "command", "command": "bash hooks/on-session-start.sh" }]
    }],
    "PreToolUse": [{
      "matcher": "Bash",
      "hooks": [{ "type": "command", "command": "bash hooks/guard.sh" }]
    }],
    "PostToolUse": [{
      "matcher": "Edit|Write|MultiEdit",
      "hooks": [{ "type": "command", "command": "bash hooks/post-tool-use.sh" }]
    }],
    "PreCompact": [{
      "hooks": [{ "type": "command", "command": "bash hooks/pre-compact.sh" }]
    }],
    "Notification": [{
      "hooks": [{ "type": "command", "command": "bash hooks/on-notify.sh" }]
    }],
    "Stop": [{
      "hooks": [{ "type": "command", "command": "bash hooks/on-stop.sh" }]
    }]
  }
}
```

> `matcher` 是正则，`Edit|Write|MultiEdit` 表示这三种工具执行后才触发。`PreToolUse` 的 `Bash` matcher 让我们能审查每条命令。

## 五、Step 1：SessionStart 把任务清单喂进去

无头模式下没有人在对话里唠叨，所以必须把"要干啥、干到什么程度"在会话一开就钉死。`hooks/on-session-start.sh`：

```bash
#!/usr/bin/env bash
cat <<'EOF'
{
  "hookSpecificOutput": {
    "hookEventName": "SessionStart",
    "additionalContext": "项目目标：为 src/ 下所有 .py 补全类型注解并写对应测试。验收标准：(1) 每个函数参数和返回值都有注解；(2) pytest 全绿；(3) 完成后在 .claude/state/ 写 done.marker。每改完一个文件就把它从 .claude/state/tasklist.txt 划掉。不要提前停止。"
  }
}
EOF
```

这段 `additionalContext` 会被注入到模型上下文最前面。**预期结果**：模型第一轮就知道边界条件，不会瞎改也不会提前收工。

## 六、Step 2：PostToolUse 造"写完即测"自反馈

这是整条流水线最值钱的一环。每次模型 `Edit`/`Write` 完一个文件，钩子立刻格式化 + 跑该文件相关测试；**失败就用退出码 2 把错误回灌**，模型把它当成工具报错，于是自动接着改。`hooks/post-tool-use.sh`：

```bash
#!/usr/bin/env bash
input=$(cat)
file=$(echo "$input" | jq -r '.tool_input.file_path // empty')

case "$file" in
  *.py)
    ruff format "$file" >/dev/null 2>&1 || true
    ruff check --fix "$file" >/dev/null 2>&1 || true
    # 推断对应测试文件
    base=$(basename "$file" .py)
    if [ -f "tests/test_${base}.py" ]; then
      log=$(python -m pytest -q "tests/test_${base}.py" 2>&1 | tail -8)
      if echo "$log" | grep -q "passed"; then
        echo "[auto-check] $file OK" >&2   # 普通提示
      else
        echo "[auto-check] $file 测试失败：$log" >&2
        exit 2   # 关键：回灌为工具错误，逼模型自纠
      fi
    fi
    ;;
esac
exit 0
```

**预期结果**：模型改完 `util.py`，钩子跑 `test_util.py`，红了就吐出失败堆栈，模型下一轮立刻去修，形成"改→测→改"的闭环，完全不需要人盯着。**常见坑**：`exit 2` 的内容走 `stderr`，务必把可执行的错误信息（断言、行号）塞进 `stderr` 而不是 `stdout`。

## 七、Step 3：PreCompact 压缩前先存档

长任务必然触发上下文压缩，压缩会把早期细节扔掉。对策：**压缩前一秒把进度落盘**，并叮嘱模型压缩后先读盘。`hooks/pre-compact.sh`：

```bash
#!/usr/bin/env bash
ckpt=".claude/state/checkpoints/last.md"
{
  echo "# 压缩前快照（$(date -Iseconds)）"
  echo "## 已改动文件"
  git diff --stat 2>/dev/null | tail -15
  echo "## 未完成项"
  grep -v '^x ' .claude/state/tasklist.txt 2>/dev/null || echo "（无）"
} > "$ckpt"

cat <<EOF
{
  "hookSpecificOutput": {
    "hookEventName": "PreCompact",
    "additionalContext": "即将压缩上下文。压缩后请立即读取 .claude/state/checkpoints/last.md 和 .claude/state/tasklist.txt 恢复进度，从下一个未完成项继续，不要重做已完成的。"
  }
}
EOF
```

**预期结果**：哪怕任务跑到第 40 轮触发压缩，模型也能在压缩后精确接续，而不是从零开始。这一步是把"健忘"变成"自带存档"。

## 八、Step 4：Stop 钩子——没干完就别停

这是让批处理"自驱动"的发动机。模型每次想停，`on-stop.sh` 检查 `done.marker` 是否存在：不存在就返回 `decision: "block"`，模型被迫继续。`hooks/on-stop.sh`：

```bash
#!/usr/bin/env bash
input=$(cat)
stop_active=$(echo "$input" | jq -r '.stop_hook_active // false')

# 防死循环：Claude Code 续跑后再次 Stop 时 stop_hook_active=true，放行
if [ "$stop_active" = "true" ] || [ -f .claude/state/done.marker ]; then
  exit 0
fi

cat <<'EOF'
{
  "decision": "block",
  "reason": "任务尚未完成：done.marker 不存在。请读取 .claude/state/tasklist.txt，处理下一个未勾选项，运行 pytest 直到全绿，全部完成后写 .claude/state/done.marker 再停。"
}
EOF
```

**关键机制**：Claude Code 在因 Stop 钩子续跑后，下一次触发 Stop 时会在 payload 里带 `stop_hook_active: true`，这就是天然的熔断器，避免无限续跑。所以脚本第一行判断就是"已经在续跑了，这次就让它停"。**预期结果**：单次 `-p` 调用能自动跨越多次"想停"，一直跑到 `done.marker` 落地。

## 九、Step 5：Notification——真卡住就呼叫人类

有些情况必须人来定夺（要花钱的命令、需要确认的外部调用）。`Notification` 事件在模型等待时触发，钩子负责"摇人"。`hooks/on-notify.sh`：

```bash
#!/usr/bin/env bash
input=$(cat)
msg=$(echo "$input" | jq -r '.message // "Claude Code 需要介入"')
printf '\a'   # 终端响铃
# 可选：推送到 Slack / 企业微信 / Bark
if [ -n "${SLACK_HOOK:-}" ]; then
  curl -s -X POST "$SLACK_HOOK" -H 'content-type: application/json' \
    --data "{\"text\":\":rotating_light: $msg\"}" >/dev/null 2>&1 || true
fi
```

**预期结果**：模型不会静默挂死，而是主动把"我卡在 X 上"推给你。配合下一步的 `--max-turns`，挂死也只会挂到上限就退出。

## 十、Step 6：PreToolUse 护栏挡住危险命令

批处理无人值守，最怕 `rm -rf` 之类误伤。一条最简护栏 `hooks/guard.sh`：

```bash
#!/usr/bin/env bash
input=$(cat)
cmd=$(echo "$input" | jq -r '.tool_input.command // ""')
if echo "$cmd" | grep -qE 'rm -rf|sudo |>|>>( *)/|curl.*\| *sh'; then
  cat <<'EOF'
{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"deny","permissionDecisionReason":"护栏拦截：批处理模式禁止破坏性命令。"}}
EOF
  exit 0
fi
exit 0
```

## 十一、Step 7：批处理编排器跑并行

最后用 `xargs -P` 做有界并行，每个文件起一个独立 `-p` 会话，互不污染。`run-batch.sh`：

```bash
#!/usr/bin/env bash
set -euo pipefail
JOBS="${JOBS:-2}"
mkdir -p .claude/state

find src -name '*.py' ! -path '*/__pycache__/*' | \
xargs -P "$JOBS" -I{} bash -c '
  f="{}"; base=$(basename "$f" .py)
  echo "$f" >> .claude/state/tasklist.txt
  claude -p \
    --permission-mode acceptEdits \
    --allowedTools "Edit Read Write Bash(pytest:*) Bash(ruff:*)" \
    --max-turns 25 \
    --output-format json \
    --session-id "migrate-$base" \
    --add-dir "$PWD" \
    "为 $f 补全类型注解，并保证对应测试通过。完成所有工作后创建 .claude/state/done.marker。" \
    > ".claude/state/run-$base.json" 2> ".claude/state/run-$base.err" \
    || echo "$base FAILED(exit $?)" >> .claude/state/failures.log
'
echo "批处理结束。失败项见 .claude/state/failures.log"
```

**预期结果**：`JOBS=2` 时两个文件并行处理；每个会话有独立 `--session-id`，可事后用 `claude --resume migrate-util` 接着跑。`--max-turns 25` 是硬熔断，配合 Stop 钩子的软续跑，双保险。

## 十二、钩子事件时序

把一次完整循环拆成时序看更清楚：

```mermaid
sequenceDiagram
    participant O as 编排器
    participant C as claude -p
    participant H as Hooks
    O->>C: claude -p "迁移 util.py"
    C->>H: SessionStart
    H-->>C: 注入任务清单
    C->>C: Edit util.py
    C->>H: PostToolUse(Edit)
    H->>H: ruff + pytest
    alt 测试失败
        H-->>C: exit 2 + 失败堆栈
        C->>C: 自纠继续改
    else 测试绿
        H-->>C: OK
    end
    C->>H: Stop（想停）
    alt done.marker 不存在
        H-->>C: block + 续跑理由
    else 已完成
        H-->>C: allow，会话结束
    end
```

## 十三、实战案例：跨模块批量加类型注解 + 补测试

**输入仓库**：`src/` 下 12 个无注解的 Python 模块，`tests/` 下只有 3 个测试文件。

**一条命令启动**：

```bash
chmod +x hooks/*.sh run-batch.sh
JOBS=3 ./run-batch.sh
```

**观察现象**：
- `.claude/state/run-*.json` 里能看到每个会话的完整轨迹（`--output-format json`）
- 任一会话触发压缩时，`checkpoints/last.md` 被刷新
- 真卡住时终端响铃 / Slack 收到推送
- 全部完成后 `done.marker` 落地，`pytest` 全绿

**结果核验**：

```bash
python -m pytest -q                    # 应全绿
git diff --stat                        # 看 12 个文件都被注解覆盖
cat .claude/state/failures.log 2>/dev/null || echo "无失败"
```

## 十四、安全与成本护栏（必读）

无人值守 = 失控风险放大，务必上四道闸：

1. **`--max-turns` 硬上限**：单会话最多 N 轮，到点必停，防止 Stop 续跑把钱包烧穿。
2. **`--allowedTools` 白名单**：只给 `Edit Read Write Bash(pytest:*)`，堵死任意 Bash。
3. **PreToolUse 护栏**：哪怕白名单漏了，`guard.sh` 再拦一道破坏性命令。
4. **`stop_hook_active` 熔断**：Stop 钩子必须判断它，否则就是死循环。本教程的脚本已处理。

建议先用 `JOBS=1 --max-turns 5` 在一个小文件上干跑一次，确认 `run-*.json` 行为符合预期，再放大并发。

## 十五、常见问题

**Q1：Stop 钩子不触发续跑？**  
检查 `settings.json` 的 JSON 是否合法（`jq . .claude/settings.json` 能解析）；确认脚本可执行（`chmod +x`）；确认输出的是合法 JSON 且字段名是 `decision`/`reason`。

**Q2：PostToolUse 把所有 Edit 都跑测试，太慢？**  
缩小 `matcher` 或在脚本里只对 `tests/` 已存在的文件跑，新文件跳过自动测；或改成只跑 `ruff`，把完整 `pytest` 留给 Stop 前一次性跑。

**Q3：并行会话之间抢同一个文件？**  
按文件粒度分派（本教程就是一文件一会话），避免两个会话改同一文件。`--session-id` 用文件名保证可追溯。

**Q4：`--permission-mode acceptEdits` 安全吗？**  
配合 `--allowedTools` 白名单 + PreToolUse 护栏是安全的。绝对不要在无人值守时用 `bypassPermissions`。

## 十六、延伸阅读

- **Hooks reference**（code.claude.com/docs/en/hooks）——所有事件的字段与退出码权威表
- **Run Claude Code programmatically**（code.claude.com/docs/en/headless）——`-p` 全部标志位
- **Automate actions with hooks**（code.claude.com/docs/en/hooks-guide）——带例子的快速上手
- **Agent SDK hooks**（code.claude.com/docs/en/agent-sdk/hooks）——把同一套钩子用于 SDK 编排

## 十七、训练营适用性评估

- **企业内训 / DevOps 自动化训练营**：高度适配。本教程产出的 `run-batch.sh` + 钩子套件可直接用于真实代码迁移、依赖升级、批量加测试等场景。建议课时 **4 小时**（1 小时讲事件模型 + 2 小时动手搭六钩子 + 1 小时调通并行与护栏）。
- **AI 编程入门营**：偏难。需先掌握 `-p` 单条命令与 `settings.json` 基础，建议作为第 3–4 天的进阶主题。
- **代码质量 / 重构专项营**：极适配。把"写完即测 + 未完续跑"作为自动化重构的核心范式，配合真实遗留代码仓库做结业项目。
- **不建议场景**：完全没接触过命令行或 Claude Code 的零基础学员；先补"装通首个 CLI"再回来。

## 参考来源

1. [Hooks reference — Claude Code Docs](https://code.claude.com/docs/en/hooks) — 钩子事件、配置 schema、JSON 输入输出与退出码权威参考
2. [Run Claude Code programmatically — Claude Code Docs](https://code.claude.com/docs/en/headless) — `-p` 无头模式、`--output-format`、`--permission-mode`、`--max-turns`、`--session-id` 等标志位
3. [Automate actions with hooks — Claude Code Docs](https://code.claude.com/docs/en/hooks-guide) — 含 matcher、PreToolUse/PostToolUse 实例的快速上手指南
4. [Intercept and control agent behavior with hooks — Agent SDK](https://code.claude.com/docs/en/agent-sdk/hooks) — 在 SDK 编程场景下复用同一套钩子机制
5. [How to configure hooks — Claude Blog](https://claude.com/blog/how-to-configure-hooks) — 官方博客对 Stop 续跑、PostToolUse 自动化的实战讲解

> [!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)
