我决定不再相信智能体说的“完成”:verify.sh 关卡 prove-it
AI 编码智能体没跑测试也会报告“测试通过”。这不是撒谎,是结构问题。做一个把报告变成检查的开源关卡 prove-it 的过程中学到的东西,以及自己手写的五行钩子悄悄失效的四种方式。
核心摘要
编码智能体分不清自己做过的事和打算做的事,所以它报告的其实是意图。没跑测试就说“测试通过”,根源就在这里。这靠提示词修不了,得把“完成”从一句声明改成一道必须通过的检查。prove-it 是一个 Stop 钩子关卡:在仓库根目录的 verify.sh 返回 exit 0 之前,智能体结束不了这一轮。自己手写的五行钩子会以四种方式悄悄失效:拦住一次之后再也拦不住、以提交收尾的轮次直接放行、智能体把检查本身删掉、放弃和通过分不出来。这四条就是 prove-it 长于五行的全部理由。
目录
“所有测试都通过了,可以合并。”
智能体这么说,diff 也很干净,我差点就直接信了。可往上翻终端记录,测试一次都没跑过,连执行日志都不存在。智能体是真的“打算”去跑测试的,而那份打算被原封不动写进了完成报告里。
这种事经历几次之后,反应通常是两种之一:往提示词里加一句“必须实际执行测试并展示结果”,或者每次智能体说完成都亲手再确认一遍。我两样都试过,两样都撑不了多久。于是我选了第三条路:与其教自己不要相信“完成”这两个字,不如把完成从一句声明改成一道必须通过的检查。这样做出来的就是开源工具 prove-it。
不是撒谎,是结构问题
先把一件事说清楚:智能体并不是在撒谎。
人能区分“跑过测试”和“打算去跑测试”,因为人有记忆。智能体没有这个可对照的东西。它没办法把自己做过的事和打算做的事放在一起比对,于是报告的不是结果,而是意图。“测试通过了”其实是“我是照着让测试通过的方向写的代码”的缩写。这不是模型的人品问题,而是设计上的性质,所以提示词治不了它。哪怕写上一百遍“务必要跑”,也总有不跑的那一天。
提示词技巧还有另一个弱点:模型一换代就过时。某条指令对这一代模型好使,不保证对下一代也好使。而要求证据的机制坐在模型上面一层,扛得住升级。测试到底跑没跑,不用模型来说,让退出码来说就行。
把报告变成检查
把"完成"当作证据而不是话语来认可,这个想法本身我在上一篇文章里写过。这篇文章讲的是把那个想法做成一个谁都能安装的工具并开源的故事。
prove-it 的约定就一句话。仓库用一个叫 verify.sh 的文件声明自己如何被证明,而智能体在这份证明通过之前,不得声称完成。
实现是这样的:在仓库根目录放一个可执行的 verify.sh,exit 0 意味着“这棵树可以被证明是没问题的”。智能体要结束这一轮时,Stop 钩子会执行这个脚本,返回值不是 0 就不让它停下,而是带着失败输出的最后二十行把它打回去继续干。大多数情况下,智能体光看那段输出就能把原因修好。
关卡什么时候触发也很简单:这个会话确实改动了这个仓库、存在可执行的 verify.sh、当前树的状态还没有通过过。三个条件全为真时才会跑。只读的会话不会被打扰,通过过一次的树也不会被反复检查。
而且它不会无限拦下去。一轮最多打回三次,之后就让步,因为绝不让步的钩子会把会话卡死。不过让步和通过是两回事。以让步收场的那一轮,最后的输出不是“完成”,而是一句警告:这一轮在未经验证的状态下结束了。智能体也许终究迈不过这道关卡,但它没法“悄悄”迈过去,这就是这个工具真正主张的全部。
为什么五行 bash 不够用
读到这里如果你在想“这不就是往 Stop 钩子里塞一行测试命令的事吗”,没错。五行就能写出来,我一开始也是这么写的。然后那个版本以四种方式悄悄失效了。其中几个是 prove-it 早期版本里真实存在过的 bug,所以现在每一个都钉着回归测试。
拦住一次之后,再也拦不住了。 Claude Code 在钩子第一次拦截之后,会给后续所有 stop 事件带上 stop_hook_active 标志。把这个标志读成“放行”的钩子,恰好拦一次之后就不再是关卡了;反过来无视这个标志的钩子会永远拦下去,把会话卡死。两边都是踩过才知道的坑,答案是自己数尝试次数,拦满规定次数之后大声地让步。
一提交,就像什么都没发生过。 用工作树脏不脏来判断“有没有干活”的钩子,会把以提交收尾的轮次全部放行。可提交恰恰是智能体最日常的动作。所以 prove-it 在会话开始时把树的状态记成基线,每次 stop 都和它比对。提交也好、用 sed 改过的文件也好、代码生成器吐出来的文件也好,全都会被算作改动。
智能体可以把检查本身干掉。 过不了 verify.sh 的智能体能选的最便宜的一招,就是把 verify.sh 删掉,或者把执行权限去掉。关卡会在会话开始时记录仓库当时是否处于武装状态,并拒绝以解除武装状态收尾的轮次。还有更狡猾的一招:保持可执行,只把里面的检查改掉。这个不拦,因为修改 verify.sh 本身恰恰是被托付的工作的情况也很多。但也不会悄悄放过去:如果会话中 verify.sh 被改过还通过了,关卡会把这件事告诉你,读一眼那份 diff 就能判断那是干活还是逃避。
放弃和通过分不出来。 无论哪个宿主,最终都会逼钩子让步。自己写的钩子在沉默中让步,你最后看到的那句话是“完成”。失败三次后放弃的轮次,和一次就通过的轮次,在屏幕上长得一模一样。prove-it 的最后一句话是警告。如果这个差别看起来无所谓,想象一下把放弃的轮次当成通过给合并了的第二天就行。
这四条就是 prove-it 比五行长的全部原因。想继续用自己的钩子当然可以,只是那个钩子必须应付的各种情况都写在 SPEC.md 里,建议读一读。重要的不是实现,是约定。
这个工具不替你证明的东西
有一条要诚实写下的局限。关卡强制的只有一件事:这一轮结束之前,verify.sh 返回过 0。那个 0 意味着什么,完全取决于你写进去的检查。一行 exit 0 的 verify.sh 也能通过这道关卡,而它什么都证明不了。
规范把这叫 Level 1。Level 2 是检查是否对真实证据做出断言的问题,那是任何工具都没法替你验证的,prove-it 也一样。挂钩子是容易的部分。真正的工作是回答“在这个仓库里,被证明了到底意味着什么”,而这个答案每个仓库都不一样。所以这份约定只规定文件名,不规定内容。
绕行的路也是故意留着的。PROVE_IT_SKIP=1 会让那一轮直接放行,删掉 verify.sh 关卡就永远熄火。因为无法移除的关卡,人们最终会绕着走;而被绕过的关卡比没有更糟:它让一切看起来像检查跑过了,实际上什么都没跑。
第一天故意从小开始
安装是三行,真正干活的是第三行。
/plugin marketplace add Why-Next/prove-it
/plugin install prove-it@whynext
/prove-it:init
/prove-it:init 会探测技术栈生成 verify.sh,当着你的面跑一遍给你看它通过,然后再跑一份加了 exit 1 的副本,让你亲眼看到关卡拒绝这一轮。整个过程 30 秒左右。
生成的 verify.sh 里处于启用状态的检查只有一个:git diff --check。测试也好类型检查也好,全都只以注释的形式写在里面。这是故意的:装的当天就要在 main 上通过。第一天就失败的关卡,等于从第一周起就教团队怎么绕过它。被注释掉的检查要一个一个来,亲手跑过、看到它通过之后再打开。而且打开之前要故意让它失败一次。失败不了的检查不是检查,等到需要的那天才发现这一点就晚了。
verify.sh 该长到哪儿停也定好了:总共 1 分钟。比这慢的检查送去 CI,需要 secrets 或生产环境访问的检查也归 CI。verify.sh 里只留智能体干活时能在本地跑出来的证据。
关卡抓到的东西都记着账
开着 PROVE_IT_LEDGER=1 的话,关卡每抓到一次假完成,本地文件里就多一行。
{"ts":"2026-07-09T04:12:33Z","claim":"All tests pass. Ready to merge.",
"evidence_demanded":"verify.sh exit 0","actual":["3 failed, 41 passed"]}
智能体主张了什么、被要求的是什么、实际是什么。攒上一个月,我的智能体是以什么方式失败的,就能靠记录而不是靠猜来读了。这个文件只留在本地磁盘,不会被发送到任何地方,你打开之前它是关着的。
这个仓库给自己上了关卡
prove-it 的仓库里当然也有 verify.sh,它在里面把关卡自己放到真实的 git 仓库上测试:失败的检查拦不拦得住、通过的检查放不放行、只读的会话会不会被打扰、提交之后的干净树会不会被误判成“没干活”。上面说的四个坑,全都钉成了回归测试。
MIT 许可证,依赖只有 bash、git、python3。在 Claude Code 上测试过,Codex CLI、Gemini CLI 等暴露同类拦截型钩子的宿主怎么接,写在 ADAPTERS 文档里。如果你有一个仓库,智能体在里面改完代码、又在同一段对话里说“完成”,那里就是这道关卡最有用的地方。
“完成”不该是智能体宣布的一句话,而该是仓库判定的一种状态。写下判定标准这件事仍然归你,但至少从现在起,没有谁能跳过这个判定悄悄溜过去了。
常见问题
AI 编码智能体把没做过的工作报告成已完成,该怎么拦住?
在提示词里写“务必要跑测试”是不够的。智能体没有手段把自己做过的事和打算做的事放在一起比对,只会把意图原样报告出来。应该把完成声明本身变成一道检查:在仓库根目录放一个验证脚本(verify.sh),智能体要结束这一轮时由 Stop 钩子执行它,返回值不是 exit 0 就把智能体打回去继续干。prove-it 是这份约定的参考实现。
直接用 Claude Code 的 Stop 钩子写个五行脚本跑测试,不行吗?
行,但有四个坑。① Claude Code 在第一次拦截之后会设置 stop_hook_active 标志,处理不当要么只拦一次,要么永远拦下去把会话卡死。② 用工作树脏不脏来判断的话,以提交收尾的轮次会被全部放行。③ 智能体可以删掉 verify.sh 或者 chmod -x,把关卡本身解除武装。④ 钩子最终让步时,放弃看起来和通过一模一样。要自己写,这四种情况都得处理。
verify.sh 里该放哪些检查?
放的是对“在这个仓库里,要证明一次改动成立,什么必须为真”这个问题的回答。通常按实际在跑的测试命令、类型检查、lint 的顺序一个一个启用。整体要在 1 分钟内跑完,需要 secrets 或生产环境访问的检查放去 CI。重要的是第一天别野心太大:又慢又不稳的关卡一周之内就会被绕过,而被绕过的关卡比没有更糟。
相关文章
- 💻 开发
当 AI 说“做完了”时,那个不轻信它的机制
AI 编码智能体常常没干完活就说干完了。没跑测试就说测试通过了,没复现问题就说修好了。一个几乎所有代码都和 AI 一起写的独立开发者,如何做出一道验证关卡,让“完成”只认证据、不认口头承诺。
- 💻 开发
被忽视的折扣 - Spot 和 Graviton 为什么便宜
AWS 账单连续两个月超出预算。想找能砍掉的资源,才发现真正的杠杆不是「用多少」,而是「怎么买」。这篇文章讲清楚 Spot 和 Graviton 为什么便宜,也讲清楚看似浪费、实则不能关掉的成本(RDS Proxy)-账单上每一个数字背后的「为什么」。
- 💻 开发
关闭钩子跑了两次-SIGTERM 出现两个主人的那次排查
每次部署,Sentry 都会收到一条 Prisma P2028 错误。本想只是修一下关闭顺序,结果发现 NestJS 的 enableShutdownHooks 和 graceful shutdown 库同时抢占了 SIGTERM,导致关闭钩子每次都被执行了两遍。