首页 › tutorials › 钩子闭环让-p批处理跑成自驱动

概述

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

前置条件

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

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

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。两条关键约定记牢:

三、整体架构

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 测试不过 → 回到干活。模型被这两股力量拽着,直到任务真正完成。

四、搭项目骨架

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 是整条流水线的"控制台",一次写好所有钩子:

{
  "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:

#!/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:

#!/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:

#!/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:

#!/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:

#!/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:

#!/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:

#!/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 钩子的软续跑,双保险。

十二、钩子事件时序

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

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 个测试文件。

一条命令启动:

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 全绿

结果核验:

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。

十六、延伸阅读

十七、训练营适用性评估

参考来源

  1. Hooks reference — Claude Code Docs — 钩子事件、配置 schema、JSON 输入输出与退出码权威参考
  2. Run Claude Code programmatically — Claude Code Docs — -p 无头模式、--output-format、--permission-mode、--max-turns、--session-id 等标志位
  3. Automate actions with hooks — Claude Code Docs — 含 matcher、PreToolUse/PostToolUse 实例的快速上手指南
  4. Intercept and control agent behavior with hooks — Agent SDK — 在 SDK 编程场景下复用同一套钩子机制
  5. How to configure hooks — Claude Blog — 官方博客对 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学通信知识星球海报-扫码加入