SKILL 27 / ORCHESTRATION · DEEP-DIVE

Hooks 与确定性自动化

33 个事件 × 5 种 hook 类型 × 3 档退出码 · 抄一条官方插件的注册表 · 别把判断题钉成钩子

2026-09-28 · BigCat

上期结尾那句「确定性的事别派 agent 去做」欠了一笔账。官方给 hooks 的定义只有一句要紧:deterministic control: certain actions always happen rather than relying on the LLM to choose to run them。模型会忘、会跳过、会「觉得这次不用」;钩子不会。这期从一条能跑的 Prettier 配置起手,拆到官方 security-guidance 插件的 hook 注册表,再点名六个社区工具。

前置 → Skill 26 Subagent 与 Skill 怎么分工(隔离 vs 注入两条路)

// WHY THIS MATTERS

判断「该不该写成 hook」只有一个问题:这件事需要判断吗。需要判断 → 交给模型(prompt / skill / subagent)。不需要判断、每次都一样、漏一次就出事 → 钉成钩子。「我在 CLAUDE.md 里写了『改完代码一定要跑 format』」不是自动化,是祈祷——它进的是模型的 context,不是执行路径。本期顺序照旧:先给能抄的配置,再拆机制,再点名可装可读的条目,判据放后半程。

// 01

它替你干掉什么:把「每次都要做」从模型的自由意志里拿走

论断:hook 买的不是能力,是必然性。同一件事写进 CLAUDE.md 是「大概率会做」,写进 hook 是「一定发生」——差别在漏掉的那百分之三上。

第一条立刻能跑:改完自动 format

官方指南里逐字给了这段,放进项目根的 .claude/settings.json,Claude 每次用 Edit / Write 改完文件就自动跑 Prettier:

// .claude/settings.json
{
  "hooks": {
    "PostToolUse": [
      {
        "matcher": "Edit|Write",
        "hooks": [
          { "type": "command",
            "command": "jq -r '.tool_input.file_path' | xargs npx prettier --write" }
        ]
      }
    ]
  }
}

事件数据是一段 JSON 打到 stdin,所以官方所有 Bash 示例都用 jq 取字段(brew install jq / apt-get install jq)。什么时候别写这条:如果文件也可能被 Bash 命令改写,Edit|Write 这个 matcher 就漏了——官方让你改用 FileChanged 钩子盯具体文件名。

第二条:把不该动的文件挡住

写进 .claude/hooks/protect-files.sh(记得 chmod +x),在 PreToolUse 上注册成 "$CLAUDE_PROJECT_DIR"/.claude/hooks/protect-files.sh:

INPUT=$(cat)
FILE_PATH=$(echo "$INPUT" | jq -r '.tool_input.file_path // empty')
# 命中就 exit 2:动作被拦下,stderr 作为反馈回给模型
case "$FILE_PATH" in
  *.env|*/secrets/*) echo "该路径受保护,禁止写入" >&2; exit 2 ;;
esac
exit 0

第三条:不想自己写,装官方那个

/plugin install security-guidance@claude-plugins-official
# 提示 marketplace 找不到就先加:
/plugin marketplace add anthropics/claude-plugins-official

security-guidance 是官方唯一一个把自己整个建在 hooks 上的插件,装完什么都不用喊。它替你干掉的是:注入、不安全反序列化、dangerouslySetInnerHTML 这类问题从「等 PR review 抓」提前到「写下去的那一刻」。什么时候别装:它每个改了文件的 turn 会多一次模型调用、每次 commit 再多一次 agentic 审查(默认 Claude Opus 4.7),这是真金白银;不在 git 仓库里时后两层直接跳过;cloud session 不加载 plugin。嫌贵可以只留免费那层——ENABLE_CODE_SECURITY_REVIEW=0 关掉全部模型审查,保留纯字符串匹配的逐次编辑检查。

// 02

机制:33 个事件 × 5 种类型 × 3 档退出码

论断:hook 的全部表达力就在这三张表里。记住它们,你不需要背任何教程。

事件:不只是 Pre/Post ToolUse

官方 reference 的事件表现在列了 33 个。社区文章里常见的「13 个生命周期事件」是旧的。几个容易被忽略但很好用的:

事件 作用(选摘) ──────────────────────────────────────────────────────────── PreToolUse 工具执行前,可拦(exit 2) PostToolUse 工具成功后,不可撤销(东西已经做完了) PostToolUseFailure 工具失败后 —— 自动重试/降级的挂点 UserPromptSubmit 你的 prompt 进模型前,可改可拦 SessionStart matcher: startup|resume|clear|compact|fork PreCompact 压缩前备份 transcript FileChanged matcher 是字面文件名(.envrc|.env),谁写的都算 CwdChanged 切目录时重载环境变量(配 CLAUDE_ENV_FILE) ConfigChange 配置被改,可审计可拦 Stop / SubagentStop 一轮/子代理结束,可以「不许停」 SessionEnd 全部 SessionEnd 钩子共享 1.5 秒预算

类型:5 种,后两种是「反确定性」的

退出码:只有三档

0 = 成功,stdout 按 JSON 解析(解析不了就当纯文本,部分事件会注入 Claude 上下文)。2 = 拦截,stderr 是拦截理由;能被拦的事件是有限几个(PreToolUse、UserPromptSubmit、Stop、SubagentStop、PreModelSwitch 等),SessionStart 这类拦不住,exit 2 只是把 stderr 显示给你。其余 = 非阻塞错误,动作照常进行,transcript 里出现一条 hook error。

配置放哪,决定了谁受管

位置 作用域 能否共享 ────────────────────────────────────────────────────────────────── ~/.claude/settings.json 你的全部项目 否 .claude/settings.json 单个项目 可提交进仓库 .claude/settings.local.json 单个项目 否(gitignore) managed policy settings 整个组织 管理员控制 plugin 的 hooks/hooks.json 插件启用时 随插件分发 skill frontmatter skill 被调用之后 写在 skill 文件里 subagent frontmatter 该 subagent 运行时 写在 agent 文件里

/hooks 命令可以按事件浏览全部已配置钩子,但它只读——增删改要直接编辑 JSON。全局关闭是 "disableAllHooks": true;管理员那边还有个 allowManagedHooksOnly,能把你自己的钩子全部禁掉只留托管的。

抄一份真实的注册表

security-guidance 把自己拆成五条注册,这张表值得当模板背下来(源码在 GitHub 上,目录里就是 .claude-plugin/ + hooks/ + tests/):

SessionStart → 起 Python 虚拟环境(准备工作) UserPromptSubmit → 记下工作树基线(为了 diff) PostToolUse on Edit/Write/NotebookEdit → 纯字符串模式匹配,零模型调用 Stop → 后台跑一次整 turn 的 diff 审查 PostToolUse on Bash(限 git commit/push)→ 更深的 agentic 审查

三件事可以直接学:贵的放后面(免费的字符串匹配放逐次编辑,花钱的模型审查放 Stop 和 commit);基线在前、判定在后(UserPromptSubmit 存快照,Stop 才 diff);审查者不是写代码那个实例(另起一次调用、全新 context)。

worktree 里 $CLAUDE_PROJECT_DIR 不跟着走。它永远指向会话启动时的项目根,所以 "$CLAUDE_PROJECT_DIR"/.claude/hooks/x.sh 跑的还是主 checkout 里那个脚本——这是对的。要知道 Claude 现在在哪个目录干活,读输入 JSON 里的 cwd 字段,它才跟着 worktree 和 cd 走。
// 03

真实用法与边界:六个可装可读的条目

论断:写 hook 最烦的从来不是逻辑,是每个脚本都要重写一遍 stdin 解析、退出码和事件分发。下面这几个各自消掉了一段样板。

① GowayLee/cchooks · Python SDK(132★)

pip install cchooks   # 或 uv add cchooks
from cchooks import create_context, PreToolUseContext
c = create_context()
assert isinstance(c, PreToolUseContext)
if c.tool_name == "Write" and ".env" in c.tool_input.get("file_path", ""):
    c.output.exit_deny("Nope! .env files are protected")
else:
    c.output.exit_success()

替你干掉:手写 json.load(sys.stdin) 和记退出码语义,事件类型是带类型的 context 对象。覆盖 9 种事件。什么时候别装:你的钩子就一行 jq | xargs——为它引一个包不划算;它那 9 种事件也远少于官方现在的 33 种,冷门事件得自己裸写。(据 GitHub 仓库页,2026-09)

② syou6162/cchook · 把 JSON 换成 YAML(34★)

go install github.com/syou6162/cchook@latest
PostToolUse:
  - matcher: "Write|Edit"
    conditions:
      - type: file_extension
        value: ".go"
    actions:
      - type: command
        command: "gofmt -w {.tool_input.file_path}"

替你干掉:settings.json 里那坨转义地狱,条件(按扩展名、按模式)用声明式写,带 dry-run。什么时候别装:团队要共享配置时,多一层非官方格式就多一层「新人看不懂」;星数也说明它还是个小众工具。(据 GitHub 仓库页,2026-09)

③ johnlindquist/claude-hooks · TypeScript(393★)

npx claude-hooks   # 生成 .claude/hooks/index.ts,执行需要 Bun

替你干掉:payload 的类型提示与自动补全,全部事件写在一个 index.ts 里分发。什么时候别装:它把运行时依赖绑到了 Bun——CI 或队友机器上没有 Bun,钩子就静默失败(而 hook 失败默认不阻塞,你不开 debug 根本不知道)。(据 GitHub 仓库页,2026-09)

④ disler/claude-code-hooks-mastery · 教学仓(3.9k★)

这个仓用 Astral uv 的单文件 Python 脚本把当时的每个事件都实现了一遍,含 PreCompact 前备份 transcript、拦截 subagent 完成这类花活。替你干掉:「这个事件的 JSON 长什么样」——照着读比翻文档快。什么时候别装:它是范本不是产品,README 自述覆盖 13 个事件,而官方 reference 现在是 33 个;整个 .claude 目录拷进你的项目会连它的 output styles、status line 一起带进来。(据 GitHub 仓库页,2026-09)

⑤ disler/claude-code-hooks-multi-agent-observability · 看板(1.5k★)

它把所有事件通过 HTTP POST 打到一个 Bun 服务器,再 WebSocket 推到 Vue 客户端,多个并发 agent 的流向实时可见。替你干掉:「五个 agent 同时在跑,谁卡住了」这种睁眼瞎。什么时候别装:要 uv + Bun + 一个常驻服务器,单人单会话纯属过度工程;官方 http 类型钩子现在原生支持 POST 到 endpoint,自己接个日志服务可能比搬这套轻。(据 GitHub 仓库页,2026-09)

⑥ pre-commit/pre-commit · 不在 Claude 里的那一半(15.6k★)

pre-commit(pip install pre-commit → pre-commit install,配置写在 .pre-commit-config.yaml)管的是 git 提交这一刻,和 Claude 的钩子不是替代关系是接力关系:Claude hook 只覆盖 Claude 干的事,你自己在终端敲的 commit 它一概看不见(官方明说 ! shell escape 里的 commit 也不算)。什么时候别装:只有你一个人、且所有改动都过 Claude——那多一层会重复跑同样的 linter。

三条判据,用来在上面这些之间取舍

  1. 这件事需要判断吗?不需要 → command 钩子。需要但只看事件数据就能判 → prompt。要读代码才能判 → agent(且知道它是实验性的)。
  2. 漏一次的代价是什么?代价只是难看(没格式化)→ PostToolUse 补。代价是出事(写了 secret)→ PreToolUse + exit 2 拦在前面,因为 PostToolUse 撤不回来。
  3. 只管 Claude 还是管所有人?只管 Claude → Claude 钩子。要管住所有提交 → pre-commit 或 CI。两边都要 → 两边都写,且让 Claude 侧调用同一个脚本,别维护两份规则。
// 04

代价与替代品:钩子会把不确定性从前门赶出去,再从后门请回来

论断:hooks 最大的风险不是写错,是你以为它在跑。它没沙箱、默认不阻塞、失败只在 debug 日志里说一声。

五笔账

最讽刺的一条代价:prompt 和 agent 两种 hook 类型,把你刚赶出去的不确定性又请了回来。它们有用(判断题本来就该模型判),但要认清楚——一个由 Haiku 判 ok: true/false 的 Stop 钩子不是确定性保障,它是在另一个地方多问了一次模型。真要「一定发生」,只有 command 那一档。

不写 hook,你还能怎么办

  1. 权限系统。要的是「绝对不许碰」而不是「碰了记一笔」——用 allow/deny 规则,那才是硬边界。
  2. pre-commit / CI。要管的是所有人的提交而不是 Claude 的编辑,钩子挂错层了。
  3. CLAUDE.md 或 skill。规则本身需要理解和变通(「新代码尽量写测试」),钉死反而变成噪音——每次都触发的告警等于没有告警。
  4. 什么都不做。一件事一个月发生一次,写钩子的时间比手动做十年还长。

// 一页选购卡

先装这三个:① /plugin install security-guidance@claude-plugins-official——官方唯一整个建在 hooks 上的插件,装完即用,嫌贵就 ENABLE_CODE_SECURITY_REVIEW=0 只留免费那层。② 上面那段 Prettier 的 PostToolUse 配置(连同 jq)——五行 JSON,今天就能进 .claude/settings.json。③ pre-commit(.pre-commit-config.yaml)——补上 Claude 钩子照不到的那一半:你自己敲的 commit。

按需再看:Hooks reference(33 个事件全表、5 种类型字段、退出码语义)· security-guidance 源码(从 hook 里起一次独立模型调用再把结果喂回会话的完整写法)· cchooks(Python,消掉 stdin 样板)· cchook(YAML 配置 + dry-run)· claude-hooks(TypeScript 类型安全,要 Bun)· claude-code-hooks-mastery(各事件 JSON 长什么样,当字典查)· 多 agent 观测看板(并发 agent 的实时流向)。

明确别做:用 if 条件当安全边界(那是权限系统的活)· 指望 Edit|Write 抓到 Bash 写的文件 · 用 PostToolUse 拦本该拦在 PreToolUse 的事 · 装插件不看它注册了哪些钩子 · 在 SessionEnd 里做超过 1.5 秒的事 · 把 prompt/agent 钩子当成确定性保障。

// 术语表

hook(钩子)
在 Claude Code 生命周期的某个点上自动执行的用户自定义动作。官方定义的关键词是 deterministic control——不依赖模型「决定去做」。
matcher(匹配器)
决定某个事件的哪些实例触发钩子。工具事件里是工具名或正则(Edit|Write、mcp__.*__write.*),SessionStart 里是 startup|resume|clear|compact|fork,FileChanged 里是字面文件名而非正则。
exit 2(阻塞退出)
钩子以 2 退出 = 拦下这个动作,stderr 作为理由。只在可阻塞事件上成立(PreToolUse、UserPromptSubmit、Stop 等)。
additionalContext
钩子 JSON 输出里的字段,把一段文本塞进 Claude 的视野(以 system reminder 的形式)。钩子影响模型的主要通道之一。
$CLAUDE_PROJECT_DIR vs cwd
前者是会话启动的项目根,进 worktree 也不动,用来定位脚本;后者在输入 JSON 里,跟着 worktree 和 cd 走,用来知道现在在哪干活。
prompt hook / agent hook
用模型(默认 Haiku)或 subagent 来做判断的钩子类型,返回 {"ok": bool, "reason": str}。agent 类型官方标记为实验性。
disableAllHooks
settings 里的总开关。对应的管理员侧开关是 allowManagedHooksOnly:只跑托管钩子,禁掉用户自己的。

// 三个想不通的地方

既然 hooks 是为了「不依赖模型判断」,为什么官方又加了 prompt 和 agent 两种要模型判断的类型?
因为确定性真正稀缺的地方不是判断本身,是触发时机。「每次 Claude 说完话都要检查任务是否真的做完了」——检查这件事需要判断,但每次都检查不需要。hook 保证的是后者:时机必然、位置固定、不会被模型「觉得这次不用」跳过。至于那一刻是跑 gofmt 还是问 Haiku,是第二个问题。所以这两个类型没有背叛设计,它们把「必然发生」和「必然正确」拆开了——只是用的人容易把它们当成一回事。
security-guidance 用 Stop 钩子在后台跑审查,那模型岂不是可以一直被打断到跑不完?
官方给了闸:end-of-turn 审查每轮最多覆盖 30 个改动文件,连续最多重新提示三次就交还给你;commit/push 审查按滚动一小时封顶 20 次。这是 Stop 钩子这类「不许停」机制的通用难题——Claude Code 自己也在 Stop 钩子连续阻塞太多次时直接结束本轮并警告。凡是能让模型「继续」的钩子,都必须自带次数上限,否则你写的不是自动化,是一个自己给自己派活的永动机。
官方一边说「hook 没有沙箱,像对待 dotfiles 一样小心」,一边让 plugin、skill、subagent 都能带钩子,这不矛盾吗?
矛盾是真的,而且它就是 Skill 30 要讲的那个问题的前哨。可以这么理解职责划分:分发机制负责让钩子可见(/hooks 能列出全部已配置钩子及其来源文件),审查责任留给你。官方那句「安全是共同责任」不是免责声明式的客套——在这个设计里,唯一能判断「这个钩子该不该跑」的是知道自己项目里有什么的人。所以装插件前跑一次 /hooks、装完再跑一次看多出了什么,是这套机制里唯一真正属于你的那道关。

// 延伸阅读