钩子最难的地方是它不报错:格式错了、字段拼错了,表现都是「没生效」, 和「根本没触发」长得一模一样。 所以要有一套固定顺序:先看退出码、再看 stderr、最后翻日志文件。 没有日志的钩子,出问题只能靠猜。
前面九节讲怎么把钩子写对,这一节讲它不对的时候怎么查。 这一节没有新机制,全是实操。但它可能是整章最常用的一节——因为钩子出错的时候特别多。
一、钩子能拿到什么
它拿到的是「这次事件的全部上下文」:会话标识、事件名、工具名与参数、项目根目录。 会话标识用来区分是哪一次会话触发的——日志里没有它,多个会话的记录会混在一起。 事件名用来确认这次是哪个事件。一个脚本挂在多个事件上时,它得知道自己这次是谁。
工具名与参数是判断的依据:要拦危险命令,就得读命令内容;要保护敏感文件,就得读路径。 项目根目录用来拼路径。别写死绝对路径——钩子会被不同项目复用,写死了换个项目就废。
二、四个场景,三个静默失败
演示里四个场景:退出码一、什么都没输出;退出码零、stdout 混了调试信息;退出码零、字段拼错; 退出码零、JSON 正确。 后三个都是「看起来跑了,其实没生效」。它们的共同点是退出码零——主循环以为一切正常。 而第一个(退出码一)反而问题最小:它至少被当成了拦截,你能看到「怎么被拦了」。
所以「静默失败」比「明确报错」难查得多。而钩子的大部分错误恰恰是静默的。 这就是为什么这一节的重点不是「怎么写对」,是「怎么发现写错了」。
三、三板斧
第一板斧:看退出码。非零就是拦截(或者出错),先分清是哪一种。 第二板斧:看 stderr。stdout 是给主循环的协议通道,调试信息只能走 stderr,不然会把 JSON 冲掉。 第三板斧:写日志文件。钩子里把入参和返回值原样落一行到文件,出问题时翻文件比猜快得多。
顺序不能反:先看退没退、再看说什么、最后看细节。反过来会陷进细节里,而问题往往在第一层。 三板斧之外还有一条:先确认它有没有被触发。 日志里没有记录,说明是事件或匹配写错了,不是脚本写错了——这两个方向的修法完全不同。
四、三个最常见的原因
第一个:脚本没有执行权限。表现是「什么都没发生」,退出码甚至可能是零。 第二个:路径写的是相对路径。钩子的工作目录未必是项目根,相对路径就飘了。 第三个:stdout 里有别的东西。一行 print、一个警告、一个库的版本提示,都能让 JSON 解析失败。
三个原因有个共同点:都不是逻辑问题,是环境问题。所以查的时候先查环境,再查逻辑。 一个习惯很值:钩子写完之后先手动跑一遍,把入参喂给它,看它输出什么。 这一步能挡掉上面三个原因里的两个。
五、为什么日志是必须的
因为钩子运行在一个你看不见的地方:它不是交互式命令,没有回显,没有报错弹窗。 没有日志,你能观察到的只有「行为变了」或者「行为没变」。这两种现象对应好几种原因,靠猜效率极低。 有日志,一切变成「看一行字」:触发了吗、拿到了什么、返回了什么、退出码几。
日志的写法也要克制:一行 JSON 就够,别打一堆。它会被高频调用,写太多反而拖慢主流程。 一句话:钩子不报错,所以它必须有日志。这不是最佳实践,这是最低要求。
六、动手 Demo:四个调试场景,怎么一眼看出问题在哪
四个场景:退出码一没输出、stdout 混了调试信息、字段拼错、一切正常。 逐个诊断,然后给一套固定顺序的排查办法。
CASES = [
(u"退出码 1,什么都没输出", u"当成拦截,但没有原因"),
(u"退出码 0,stdout 混了调试信息", u"整段不是 JSON,解析失败"),
(u"退出码 0,JSON 里 decision 拼错", u"字段不认识,等于没返回"),
(u"退出码 0,JSON 正确,reason 写了", u"正常"),
]
实跑输出:
--------------------------------------------------------------------
四个调试场景,怎么一眼看出问题在哪
--------------------------------------------------------------------
钩子最难的地方是「它不报错」。格式错了、字段拼错了,表现都是「没生效」。
所以得有一套固定的排查顺序。
① 钩子能拿到的环境变量
--------------------------------------------------------------------
| 变量 | 用途 |
|---|---|
| 会话标识 | 区分是哪一次会话触发的 |
| 事件名 | 确认这次是哪个事件 |
| 工具名与参数 | 知道自己要判的是什么 |
| 项目根目录 | 拼路径用,别写死绝对路径 |
② 四个场景,逐个诊断
--------------------------------------------------------------------
| 场景 | 诊断 |
|---|---|
| 退出码 1,什么都没输出 | 当成拦截,但没有原因 |
| 退出码 0,stdout 打了调试信息再打 JSON | 整段不是 JSON,解析失败 |
| 退出码 0,JSON 里 decision 拼错 | 字段不认识,等于没返回 |
| 退出码 0,JSON 正确,reason 写了 | 正常 |
四个场景里有三个是「看起来跑了,其实没生效」。它们的共同点是:退出码零——主循环以为一切正常。
③ 调试三板斧
--------------------------------------------------------------------
第一板斧:看退出码。非零就是拦截(或者出错),先分清是哪一种。
第二板斧:看 stderr。stdout 是给主循环的协议通道,调试信息只能走 stderr,不然会把 JSON 冲掉。
第三板斧:写日志文件。钩子里把入参和返回值原样落一行到文件,出问题时翻这个文件比猜快得多。
三板斧的顺序不能反:先看退没退、再看说什么、最后看细节。
④ 三个最常见的原因
--------------------------------------------------------------------
第一个:脚本没有执行权限。表现是「什么都没发生」,退出码甚至可能是零。
第二个:路径写的是相对路径。钩子的工作目录未必是项目根,相对路径就飘了。
第三个:stdout 里有别的东西。一行 print、一个警告,都能让 JSON 解析失败。
! 一句话:钩子不报错,所以它必须有日志。没有日志的钩子,出问题只能靠猜。
①钩子拿到的是「这次事件的全部上下文」:会话标识、事件名、工具名与参数、项目根目录。 项目根目录那个最该记住:别写死绝对路径,钩子会被不同项目复用,写死了换个项目就废。 ②四个场景里三个是「看起来跑了,其实没生效」,共同点是退出码零——主循环以为一切正常。 而退出码一那个反而问题最小:它至少被当成了拦截,你能看到「怎么被拦了」。 所以静默失败比明确报错难查得多。 ③三板斧:看退出码、看 stderr、写日志文件。顺序不能反——先看退没退、再看说什么、最后看细节。 三板斧之外还有一条:先确认它有没有被触发。 日志里没有记录,说明是事件或匹配写错了,不是脚本写错了,这两个方向的修法完全不同。 ④三个最常见的原因:没有执行权限、用了相对路径、stdout 里有别的东西。 三个都不是逻辑问题,是环境问题。 所以一个习惯很值:钩子写完之后先手动跑一遍,把入参喂给它,看它输出什么。 这一步能挡掉三个原因里的两个。
30 秒实验
- 给钩子加一行日志,记下「时间、会话标识、事件名、返回了什么」。 再跑一遍第 ② 步的四个场景,日志能不能区分它们?
- 把日志改成往 stdout 打,重跑一遍。为什么这一改会让所有钩子失效?
- 把钩子里的路径改成相对路径,重跑一遍。它在什么情况下还能跑对?为什么不能依赖这个?
常见误区
- 用 print 调试钩子。stdout 是协议通道,print 会把 JSON 冲掉,钩子直接失效。
- 不写日志,靠「行为变没变」判断钩子有没有生效。两种现象对应好几种原因,猜不完。
- 把绝对路径写死在钩子里。换个项目就废,而且报错信息还看不出是路径问题。
- 先怀疑逻辑,不先确认有没有被触发。没触发和写错了,修法完全相反。
- 在钩子里打一大堆调试输出。它被高频调用,写太多反而拖慢主流程。
自测三问(点开对答案)
1. 为什么静默失败比明确报错难查? —— 因为明确报错至少你知道「它跑了、被拦了」; 静默失败的现象是「没生效」,和「根本没触发」长得一模一样。 2. 调试三板斧的顺序是什么?为什么不能反? —— 看退出码、看 stderr、翻日志文件。先看退没退、再看说什么、最后看细节; 反过来会陷进细节,而问题往往在第一层。 3. 为什么钩子必须有日志? —— 因为它运行在你看不见的地方,没有回显也没有报错弹窗。 没有日志,你能观察到的只有「行为变没变」,而这两种现象对应好几种原因。查错的办法有了,最后一节收口:把这套东西落成一套方法论,四个判据。
- ☐ (本篇完,下一篇 5.11 工程设计方法论)
相关笔记
- 返回值协议:返回值协议
- 工程设计方法论:四个判据
- Claude Code 橙皮书 · 索引
- 🐎 Claude Code 实战 · 索引