DeepDojo.AI发布 2026-11-12English

Claude Code Hook 不触发怎么排查

Claude CodeAI 编程

你写了一个检查脚本,放进 .claude/hooks/,在终端里手动运行,输出一切正常。可接下来 Claude Code 编辑了十几个文件,这个脚本一次也没跑过。你改了脚本、加了日志,还是没有任何动静。

“Hook 没反应”是个很模糊的描述,背后可能是四层中的任意一层出了问题:

  • 注册:脚本放在了 .claude/hooks/,但没有在 Settings 里登记;
  • 触发:选错了事件,或者 Matcher 没匹配上这次的工具;
  • 输入:脚本读错了事件 JSON 的字段,遇到边界输入就出错;
  • 结果:退出码和输出不符合约定,Claude Code 只报了个错就继续了。

开头这个例子卡在第一层:.claude/hooks/ 只是存脚本的地方,手动运行成功,不代表它已经注册。改脚本解决不了注册问题。所以排查 Hook 要先定位层级,再动手:用 /hooks 看注册,用日志看匹配,用最小 JSON 单独测脚本,最后核对退出码和两路输出。下面先把 Hook 的链路讲清楚,再按四层逐个查。

01 Hook 的链路:事件、Matcher、处理器、结果

Hook 在生命周期事件发生时运行处理器,适合编辑后格式化、执行前检查和等待确认时通知。触发无需依赖模型记住某句指令,但能否阻止操作,仍取决于事件时机和处理结果。

Hook 监听 Claude Code 运行中的事件,把事件 JSON 交给处理器。处理器就是实际做事的那部分:运行命令、调用 HTTP 服务,或请模型判断。链路是“事件 → Matcher → 处理器 → 结果”。

Hook 从生命周期事件经过 Matcher 和 Handler 到控制结果的完整链路

Claude Code 的 Hooks 参考文档对 Hook 的定义是:用户定义的 Shell 命令、HTTP 端点、MCP 工具调用、模型提示或子代理,在 Claude Code 生命周期的特定节点自动执行。

02 按时机选对事件

先判断需要在操作前拦截,还是在操作后处理结果,再选择事件:

阶段常用事件典型用途
会话开始与结束SessionStart、SessionEnd初始化环境、清理临时资源、记录会话
用户提交输入UserPromptSubmit注入动态背景、拒绝不合规输入
工具执行前PreToolUse、PermissionRequest检查命令、拦截操作、处理权限请求
工具执行后PostToolUse、PostToolUseFailure、PostToolBatch格式化、记录结果、归纳一批工具调用
回复结束Stop、StopFailure检查是否可以结束、记录 API 失败
等待用户Notification权限请求、完成等待等桌面通知
子代理SubagentStart、SubagentStop初始化代理资源、审计代理结果
配置与环境ConfigChange、InstructionsLoaded、CwdChanged、FileChanged审计配置、观察指令加载、刷新环境
上下文压缩PreCompact、PostCompact压缩前检查、压缩后记录摘要

需要阻止命令时,使用执行前的 PreToolUse;到 PostToolUse 时操作已经发生。Stop 只表示一次回复结束,不能据此宣布整个任务完成;用它阻止 Claude 结束时,要防止反复触发,做法见Claude Code 修复循环的五个停止条件。

03 第一层:确认已经注册

项目 Hook 在 .claude/settings.json 的 hooks 字段中注册,结构分三层:

hooks
  └─ 事件,例如 PreToolUse
      └─ Matcher 组,例如 Edit|Write
          └─ hooks:实际运行的处理器

Hook 可以放在不同范围:

位置适用范围
~/.claude/settings.json你的所有项目,例如个人桌面通知
.claude/settings.json团队共享的当前项目自动化
.claude/settings.local.json当前项目中的个人试验或本机差异
Managed Settings组织统一部署且不可由个人覆盖的策略
Skill frontmatter调用后注册,持续到会话结束
Agent frontmatter仅在该子代理运行期间生效

.claude/hooks/ 只存脚本,手动运行成功不代表已注册。普通 Hook 在 Settings 注册,插件使用 hooks/hooks.json。打开 /hooks,看它是否已注册,核对事件、来源和处理器,再触发事件。

04 第二层:Matcher 有没有匹配上

Matcher 负责挑出要处理的事件。它看什么,要看事件类型:可能是工具名、通知类型、代理名称,也可能是启动方式。

工具名可以用 | 或逗号连接多个精确值:

Edit|Write
Read, Grep, Glob

想“只处理 Edit”,很容易写出 Edit.*。可 Matcher 遇到正则字符后,按 JavaScript 正则匹配,默认也不要求匹配完整名称,于是 NotebookEdit 也可能进来。Hooks 文档用的正是这个例子:Edit.* 同时匹配 Edit 和 NotebookEdit,需要整串匹配时,用 ^ 和 $ 包起来,写成 ^Edit$。

工具事件还可以在具体 handler 上使用 if,按权限规则语法进一步过滤工具参数:

{
  "matcher": "Bash",
  "hooks": [
    {
      "type": "command",
      "if": "Bash(git *)",
      "command": "python3",
      "args": ["${CLAUDE_PROJECT_DIR}/.claude/hooks/check-git.py"]
    }
  ]
}

示例中,Matcher 先挑出 Bash 调用,if 再看里面是不是 Git 命令,减少无关的脚本启动。若本意是禁止某个命令,还得写 permissions.deny。筛选处理器不能替代权限判断。

还有一类事件根本不看 Matcher。官方列出的包括 Stop、UserPromptSubmit、PostToolBatch 等,它们每次发生都会触发。先查事件接受什么字段,再填写过滤条件。Matcher 里的大小写也要核对,比如把 Edit 写成了 edit。排查配置的官方文档还列了两个“Hook 从不触发”的常见原因:Matcher 写成了 JSON 数组而不是字符串;在 v2.1.191 之前的版本里用逗号分隔,旧版本会把逗号当普通字符,"Edit,Write" 什么也匹配不到。

05 第三层和第四层:输入与结果

把最小事件 JSON 传给脚本,检查字段位置和边界输入。例如单独测试一个路径保护脚本:

printf '%s' '{"cwd":"/tmp/demo","tool_input":{"file_path":"/tmp/demo/.env"}}' \
  | CLAUDE_PROJECT_DIR=/tmp/demo python3 .claude/hooks/protect-files.py
printf '%s\n' "$?"

这样不经过 Claude Code,也不会实际写文件,就能看清脚本对各种输入的反应。

结果层要一起看退出码、stdout、stderr,检查所依赖的命令是否在系统中可用,判断到底拦住了,还是只报错后继续。退出码 1 在多数事件里只是不阻断的错误,这一点最容易误判,退出码和输出的完整约定见用 Hook 保护 .env 和密钥文件。

06 用日志代替猜测

要看 Claude Code 里实际怎样执行,用 claude --debug-file /tmp/claude-hooks.log 启动。CLI 参考文档说明,--debug-file 会把调试日志写到指定文件,并自动开启调试模式。日志能查匹配项、运行命令、退出码和两路输出。

配置问题先跑 /doctor;文件改了几秒后,/hooks 还没更新,再重启排除文件监视遗漏。statusMessage 可能一闪而过,没看到提示不能证明处理器没有运行。应以日志中的匹配、调用和结果为依据。

把四层排查连起来:

  1. 注册:打开 /hooks,看它是否已注册,核对事件、来源和处理器。
  2. 触发:查实际发生了哪个事件,看 Matcher 是否写对、大小写是否一致。
  3. 输入:把最小事件 JSON 传给脚本,检查字段位置和边界输入。
  4. 结果:一起看退出码、stdout、stderr 和依赖命令,判断拦住了还是只报错。

共享 Hook 要说明依赖、支持的系统及失败是否阻断,再用无害样本验证允许和拒绝。开头那个从没跑过的脚本,在 /hooks 里一看就知道:它只是放进了目录,还没登记。先定位层级,再改脚本,排查就不会在错的地方打转。