首页 › tutorials › Hooks九事件编织质量门禁闭环

用Hooks织一张全程质量门禁网:Claude Code全生命周期自动化实战

概述

本教程聚焦 Claude Code 的 Hooks(钩子) 系统——一组在 Agent 生命周期固定节点上"确定性"执行的脚本。与"在 CLAUDE.md 里写一句『记得跑测试』然后祈祷模型照做"不同,Hooks 保证某些动作一定会发生。我们将沿着会话从启动到结束的完整事件链,用 SessionStart、UserPromptSubmit、PreToolUse、PostToolUse、Notification、Stop、PreCompact、SessionEnd 等九个事件,亲手织出一张"自格式化 + 防破坏 + 质量门禁 + 全程审计 + 上下文抢救"的自动化闭环。适用人群:已能独立使用 Claude Code 完成 CRUD、希望把团队规范"焊死"进工具链的中高级开发者与 DevOps/平台工程师。

前置条件

第 0 步:建立 Hooks 心智模型

理解一句话:Hooks 把"希望发生的事"变成"必然发生的事"。 模型可能忘记、可能偷懒,但一个挂在 PreToolUse 上、exit 2 的 shell 脚本,无论模型多想跑 rm -rf /,都会被硬性拦下。

Claude Code 在 2026 年的 Hooks 系统提供 五类处理器(handler type),都挂在 settings.json 的 hooks 键下:

type 运行方式 典型场景
command 执行 shell 命令,读 stdin、写 stdout/stderr 99% 的确定性规则
http POST 事件 JSON 到一个 URL 团队共享审计服务
mcp_tool 调用已连接 MCP 服务器的工具 复用既有 MCP 能力
prompt 单轮 LLM 判定(默认 Haiku,30s 超时) 需要"判断"而非死规则
agent 多轮子代理验证(可读文件、跑命令,60s/最多50轮) 要核对代码库真实状态

退出码三态语义(所有事件通用):

预期结果:理解到——确定性靠 command,模糊判断靠 prompt,深度核对靠 agent。本教程主走 command,在第 7 步引入 agent 做质量门禁。

第 1 步:三层作用域与最小骨架

Hooks 可挂在五个位置,作用域由小到大:

位置 作用域 可共享
~/.claude/settings.json 你所有项目 否(本机)
.claude/settings.json 单个项目 是(可提交进仓库)
.claude/settings.local.json 单个项目 否(自动 gitignore)
企业托管策略 全组织 是(管理员控制)
插件 hooks/hooks.json 插件启用时 是(随插件分发)

先建项目级骨架。在仓库根创建 .claude/settings.json:

{
  "hooks": {
    "SessionStart": [],
    "UserPromptSubmit": [],
    "PreToolUse": [],
    "PostToolUse": [],
    "Notification": [],
    "Stop": [],
    "PreCompact": [],
    "SessionEnd": []
  }
}

操作说明:保存后,在 Claude Code 内输入 /hooks,应能看到按事件分组的空列表。

常见问题:/hooks 显示为空——多半是 JSON 非法(不允许尾逗号、不允许注释)。用 jq . .claude/settings.json 校验。另注意:个别版本(如 v2.1.120 在 VSCode 原生扩展下)会静默丢弃 ~/.claude/settings.json 里的用户级 Hooks,遇到就改挂项目级。

第 2 步:SessionStart——会话自举与上下文续命

SessionStart 在会话开始、/clear、resume、compact 时触发,matcher 取 startup|resume|clear|compact。它不可阻断,但 stdout 会被注入上下文。

两个高频用法:自举环境与压缩后续命。先做自举——把 direnv 注入到 Bash 工具的执行前导(CLAUDE_ENV_FILE):

"SessionStart": [
  {
    "hooks": [
      { "type": "command", "command": "direnv export bash > \"$CLAUDE_ENV_FILE\"" }
    ]
  }
]

CLAUDE_ENV_FILE 的内容会被 Claude Code 作为脚本前导拼到每条 Bash 命令前,于是 Bash 工具也能"继承"目录级环境变量,省去反复 export。

预期结果:进入项目后第一条 Bash 命令即可用 .envrc 里的变量。

再做"压缩续命"——上下文压缩会丢细节,用 compact matcher 把关键约定重新塞回去:

{
  "matcher": "compact",
  "hooks": [
    {
      "type": "command",
      "command": "echo '提醒:用 ruff format 而非 black;提交前必须 pytest 全绿;当前迭代=支付重构。'"
    }
  ]
}

常见问题:每次 startup 都注入大段文本会浪费 token——静态长文优先写进 CLAUDE.md,Hook 只放"动态、易变"的少量上下文(如 git log --oneline -5)。

第 3 步:UserPromptSubmit——提示词级注入与拦截

在你提交一条 prompt、模型处理之前触发,无 matcher,默认超时仅 30s。exit 2 可阻断处理;更优雅的是返回 additionalContext 注入上下文:

"UserPromptSubmit": [
  {
    "hooks": [
      {
        "type": "command",
        "command": "git log --oneline -3 | sed 's/^/最近提交: /'"
      }
    ]
  }
]

预期结果:每次提问时,模型都能"看到"最近三条提交,减少让它翻历史的需求。

常见问题:脚本卡住会拖垮体验(30s 超时)。任何可能阻塞的命令(如联网请求)都别放在这里。

第 4 步:PreToolUse——最硬的护栏

在工具执行之前触发,可阻断。matcher 匹配工具名(Bash、Edit|Write、或正则 mcp__memory__.*)。这是把安全策略"焊死"的核心。

先拦最危险的 rm -rf。写脚本 .claude/hooks/block-rm-rf.sh:

#!/bin/bash
INPUT=$(cat)
COMMAND=$(echo "$INPUT" | jq -r '.tool_input.command // ""')
if echo "$COMMAND" | grep -Eq 'rm[[:space:]]+-[a-zA-Z]*r'; then
  echo "阻断:禁止 rm -rf,请改用逐项删除或 git clean。" >&2
  exit 2
fi
exit 0

chmod +x 后注册:

"PreToolUse": [
  {
    "matcher": "Bash",
    "hooks": [
      { "type": "command", "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/block-rm-rf.sh" }
    ]
  }
]

进阶——结构化 JSON 输出比 exit 2 更可控,可区分 allow/deny/ask/defer 并附带理由:

#!/bin/bash
INPUT=$(cat)
CMD=$(echo "$INPUT" | jq -r '.tool_input.command // ""')
if echo "$CMD" | grep -q 'git push'; then
  cat <<'EOF'
{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"ask","permissionDecisionReason":"推送需人工二次确认"}}
EOF
  exit 0
fi
exit 0

关键铁律:Hook 能"收紧"权限,但不能放宽。即便 Hook 返回 allow,settings 里的 deny 规则依然生效;反过来,deny 的 Hook 即便在 bypassPermissions 或 --dangerously-skip-permissions 模式下也照拦不误。所以 PreToolUse 是"用户无法绕过的最后一道墙"。

常见问题:脚本不触发——八成是 matcher 写错(大小写敏感,必须精确 Bash),或脚本没 chmod +x,或路径用了相对路径(改用 $CLAUDE_PROJECT_DIR 或绝对路径)。

第 5 步:PostToolUse——自动格式化与审计落盘

工具成功执行后触发。注意:它无法撤销已发生的动作,只能"善后"或"反馈"。

自动格式化是最经典用法。Edit|Write matcher + 提取 file_path 喂给 ruff:

"PostToolUse": [
  {
    "matcher": "Edit|Write",
    "hooks": [
      {
        "type": "command",
        "command": "jq -r '.tool_input.file_path' | xargs -r ruff format"
      }
    ]
  }
]

再叠一条审计:把每条 Bash 命令记进日志(注意多 Hook 时最严格者胜的合并规则):

{
  "matcher": "Bash",
  "hooks": [
    {
      "type": "command",
      "command": "jq -c '{ts: now|todate, cmd: .tool_input.command, cwd: .cwd}' >> \"$CLAUDE_PROJECT_DIR/.claude/audit.log\""
    }
  ]
}

多 Hook 合并语义:同一事件下所有匹配 Hook 并行跑完再合并。PreToolUse 的权限决策按 deny > defer > ask > allow 取最严;additionalContext 则全部保留。所以一条审计 Hook(exit 0)和一条拦截 Hook(exit 2)可共存——审计照写,命令照样被拦。

常见问题:PostToolUse 用 decision: "block" 虽能把 reason 喂回模型,但文件已改完,无法回滚。要"事前阻断"永远用 PreToolUse。

第 6 步:Notification——别再盯着终端

Notification 在 Claude 等待输入或权限确认时触发。挂一个桌面通知,你就能切去干别的:

"Notification": [
  {
    "matcher": "",
    "hooks": [
      {
        "type": "command",
        "command": "notify-send 'Claude Code' '需要你的输入'"
      }
    ]
  }
]

macOS 用 osascript -e 'display notification ...',Windows 用 PowerShell 的 MessageBox。matcher 可精细到 permission_prompt(只权限确认时响)或 idle_prompt(只在任务完成等下一条 prompt 时响)。

常见问题:macOS 上 osascript 走的是"脚本编辑器"App,若它没有通知权限会静默失败。先在终端跑一次 osascript -e 'display notification "test"',再到"系统设置 > 通知 > 脚本编辑器"打开权限。

第 7 步:Stop——质量门禁(本文核心)

Stop 在 Claude 结束回应时触发,无 matcher。返回 {"decision":"block","reason":"..."} 或 exit 2 可阻止停止、强制继续。这正是"质量门禁"的落点:不让 Claude 收工,除非测试全绿。

两种实现强度:

弱门禁(command + Stop)——直接跑 pytest,失败就 block:

"Stop": [
  {
    "hooks": [
      {
        "type": "command",
        "command": "cd \"$CLAUDE_PROJECT_DIR\" && pytest -q >/tmp/stopgate.log 2>&1 || { echo '测试未通过,详见 /tmp/stopgate.log' >&2; exit 2; }"
      }
    ]
  }
]

强门禁(agent 型 Hook)——交给一个能读代码、跑命令的子代理去核对:

"Stop": [
  {
    "hooks": [
      {
        "type": "agent",
        "prompt": "运行 ruff check 与 pytest。若有任何失败,返回 {\"ok\": false, \"reason\": \"具体失败摘要\"};全绿返回 {\"ok\": true}。"
      }
    ]
  }
]

agent 型 Hook 返回 ok:false 时,reason 会被当成下一步指令喂回主 Claude,驱动它去修复。

致命陷阱——死循环与上限:Stop 一旦 block,模型继续干,干完又触发 Stop,极易死循环。Claude Code 的保护是:连续 block 超过 8 次且无进展,会强行覆盖 Hook 放行。正确写法是读 stop_hook_active 字段,若已 true 则放行:

#!/bin/bash
INPUT=$(cat)
if [ "$(echo "$INPUT" | jq -r '.stop_hook_active')" = "true" ]; then
  exit 0   # 已经在续跑中,别再拦
fi
cd "$CLAUDE_PROJECT_DIR" && pytest -q || { echo '测试未过,请修复后收尾' >&2; exit 2; }

确实需要更多轮,用环境变量 CLAUDE_CODE_STOP_HOOK_BLOCK_CAP 调高上限。

常见问题:Stop Hook 不触发——它只在"模型主动结束回合"时触发,用户打断(Ctrl+C)不触发;API 报错触发的是独立的 StopFailure 事件(其 matcher 可按 rate_limit/overloaded/billing_error 等过滤)。

第 8 步:PreCompact——上下文抢救

PreCompact 在压缩之前触发。趁压缩还没把对话摘要化,把"绝不能丢"的关键状态落盘或回灌:

"PreCompact": [
  {
    "hooks": [
      {
        "type": "command",
        "command": "git diff --stat | head -20 | sed 's/^/未提交改动: /'; echo '当前任务=实现支付回调;下一步=补单测'"
      }
    ]
  }
]

PostCompact 则在压缩后触发,二者配合可做"压缩前快照 + 压缩后恢复"。matcher 支持 manual/auto。

第 9 步:SessionEnd——清理与归档

SessionEnd 在会话终止时触发,matcher 取 clear|resume|logout|prompt_input_exit|...。典型用途是清理临时文件与归档审计:

"SessionEnd": [
  {
    "matcher": "clear",
    "hooks": [
      { "type": "command", "command": "rm -f /tmp/claude-scratch-*.txt" }
    ]
  }
]

只在你跑 /clear 时清理,正常退出不触发。

第 10 步:组合、调试与排障

调试三板斧:

  1. /hooks:可视化所有已注册 Hook,按事件分组,最先看这个。
  2. Ctrl+O 转录视图:每个触发的 Hook 一行摘要——成功静默、阻断显示 stderr、非阻断错误显示首行 stderr。
  3. --debug-file:claude --debug-file /tmp/claude.log 启动,另开终端 tail -f,可看完整匹配/退出码/stdout/stderr。

手动测脚本(强烈建议提交前都测一遍):

echo '{"tool_name":"Bash","tool_input":{"command":"ls"}}' | ./.claude/hooks/block-rm-rf.sh
echo $?   # 应为 0
echo '{"tool_name":"Bash","tool_input":{"command":"rm -rf /tmp/x"}}' | ./.claude/hooks/block-rm-rf.sh
echo $?   # 应为 2

JSON 校验失败排查:shell-form 命令经 sh -c 跑,若你的 ~/.bashrc 里有无条件 echo,输出会拼到 JSON 前面导致解析失败。把 profile 里的 echo 包一层:

if [[ $- == *i* ]]; then echo "Shell ready"; fi

$- 含 i 表示交互式,Hook 跑在非交互 shell,echo 被跳过。

关停开关:"disableAllHooks": true 一键全关(托管策略里的 Hook 需要在托管层也设才彻底关);权限、hooks、apiKeyHelper 改完无需重启即热加载。

实战案例:Python 仓库"质量门禁闭环"

目标:一个支付服务仓库,达到——编辑即格式化、.env/main 分支不可碰、收尾前 pytest+ruff 必绿、每条命令有审计、压缩不丢任务。完整 settings.json:

{
  "hooks": {
    "SessionStart": [
      {
        "matcher": "compact",
        "hooks": [
          { "type": "command",
            "command": "echo '项目=pay-service;规范=ruff format;收尾前 pytest 全绿;当前迭代=回调幂等'" }
        ]
      }
    ],
    "PreToolUse": [
      {
        "matcher": "Bash",
        "hooks": [
          { "type": "command",
            "command": "\"$CLAUDE_PROJECT_DIR/.claude/hooks/guard.sh\"" }
        ]
      },
      {
        "matcher": "Edit|Write",
        "hooks": [
          { "type": "command",
            "command": "\"$CLAUDE_PROJECT_DIR/.claude/hooks/protect.sh\"" }
        ]
      }
    ],
    "PostToolUse": [
      {
        "matcher": "Edit|Write",
        "hooks": [
          { "type": "command",
            "command": "jq -r '.tool_input.file_path' | xargs -r ruff format" }
        ]
      },
      {
        "matcher": "Bash",
        "hooks": [
          { "type": "command",
            "command": "jq -c '{ts: now|todate, cmd: .tool_input.command}' >> \"$CLAUDE_PROJECT_DIR/.claude/audit.log\"" }
        ]
      }
    ],
    "Stop": [
      {
        "hooks": [
          { "type": "command",
            "command": "\"$CLAUDE_PROJECT_DIR/.claude/hooks/quality-gate.sh\"" }
        ]
      }
    ],
    "Notification": [
      {
        "matcher": "idle_prompt",
        "hooks": [
          { "type": "command",
            "command": "notify-send 'Claude Code' '任务完成,等你下一步'" }
        ]
      }
    ]
  }
}

guard.sh(拦 rm -rf、拦 git push origin main):

#!/bin/bash
INPUT=$(cat); CMD=$(echo "$INPUT" | jq -r '.tool_input.command // ""')
if echo "$CMD" | grep -Eq 'rm[[:space:]]+-[a-zA-Z]*r'; then
  echo "阻断:禁止 rm -rf" >&2; exit 2
fi
if echo "$CMD" | grep -Eq 'git push[[:space:]].*main'; then
  echo "阻断:禁止直推 main,请走 PR" >&2; exit 2
fi
exit 0

protect.sh(拦 .env 改写):

#!/bin/bash
INPUT=$(cat); FP=$(echo "$INPUT" | jq -r '.tool_input.file_path // ""')
case "$FP" in
  *.env|*.env.*) echo "阻断:禁止改写 $FP" >&2; exit 2 ;;
esac
exit 0

quality-gate.sh(带 stop_hook_active 守卫的收尾门禁):

#!/bin/bash
INPUT=$(cat)
[ "$(echo "$INPUT" | jq -r '.stop_hook_active')" = "true" ] && exit 0
cd "$CLAUDE_PROJECT_DIR" || exit 0
ruff check . >/tmp/qg.log 2>&1 && pytest -q >>/tmp/qg.log 2>&1 || {
  echo "质量门禁未过,摘要:" >&2; tail -5 /tmp/qg.log >&2; exit 2
}
exit 0

运行效果:你让 Claude"给 refund.py 加幂等键",它编辑完自动 ruff format;想 git push origin main 被拦;收尾时 pytest/ruff 没过就被打回继续修,过了才放行;全程 Bash 命令沉淀在 audit.log;上下文压缩后任务上下文被重新注入。这就是一张"用户无法绕过、模型无需记忆"的确定性质量网。

flowchart TD
    A[SessionStart<br/>自举环境/压缩续命] --> B[UserPromptSubmit<br/>注入最近提交]
    B --> C[PreToolUse<br/>护栏: rm-rf/.env/main]
    C -->|exit 2 阻断| Z[打回模型重试]
    C -->|放行| D[工具执行]
    D --> E[PostToolUse<br/>自动格式化+审计]
    E --> F{Stop 质量门禁}
    F -->|ruff/pytest 未过| G[decision:block<br/>强制继续修复]
    G --> C
    F -->|全绿放行| H[Notification<br/>桌面提醒]
    I[PreCompact<br/>抢救上下文] -.压缩前.-> A
    H --> J[SessionEnd<br/>清理/归档审计]
    style C fill:#ffe0e0
    style F fill:#fff3cd
    style Z fill:#ffe0e0

延伸阅读

训练营适用性评估

参考来源

  1. Automate actions with hooks — Claude Code Docs — 官方 Hooks 实战指南,含全部事件表、五种 handler、退出码语义与排障
  2. Hooks reference — Claude Code Docs — 官方参考:每个事件的精确 JSON 输入/输出 schema、matcher、超时
  3. Claude Code & Agent SDK Hooks (2026) — Morph — 30 个事件的 stdin/exit code/matcher 速查,含 .env 防护范式
  4. disler/claude-code-hooks-mastery — GitHub — 逐事件可运行示例仓库,适合对照动手
  5. Claude Code's Hooks: Stop Hoping Claude Remembers Your Standards — Towards AI — 八大事件 + 三例(format-on-edit、保护文件、回合末质量门禁)的工程化解读

[!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学通信知识星球海报-扫码加入