Flowness 工程进展:从需求到交付 · 阅读系列导读 →
Harness 模式 · 第 1 期
今年 4 月,Claude Code 的 GitHub 仓库里有人开了一个 issue(#42796,两千多人点赞、五百多条评论),标题的意思是:2 月的更新以后,Claude Code 没法用来做复杂的工程任务。楼主在“Claude 实际做了什么”一节列了一串,第四条是:
Claims completion against instructions
不顾指令,声称已经完成。
撞上这种事,常见的应对是在提示词里加一句“做完要自己跑一遍测试”,然后看到测试全绿就放心。可测试也是同一个 Agent 写的。它说“做完了”,它写的测试说“通过了”,这是同一个来源说的两句话。
我们让 Agent 重写过自己系统里编排器的一块,编排器负责派活、判断 Agent 死活、失败后重派。验收报告里写着:验收 Agent 自己找了 20 处把代码故意改坏,其中 5 处改完,编排器相关的 1621 条测试一条没红。
让写代码的 Agent 给自己验收,测试全绿证明不了什么。提示词写得再严也补不上,得把验收从它手里拿出来,做四件事:换人(验收交给没看过实现过程的 Agent)、收权(验收的只读)、改坏(先证明测试会红)、走默认入口(只认从用户平时走的那条路跑出来的证据)。
这四件事落到 Claude Code 里,是一个只读的验收 Agent、两道 hook(Claude Code 在调用工具前、收工前自动跑的脚本),再加一道推送前的检查。文末附一套在真的 Claude Code 里跑通过的配置;它管得住什么、管不住什么,第八节开头说清。
一、再派一个 Agent 复查,为什么还不够
让 Agent 自查不管用,很多人早有体会:自查发生在同一段上下文里,前一秒的推理原样摆在面前,它重读一遍,看到的还是自己想写的那个版本。所以更常见的做法,是再派一个子 Agent 来复查。子 Agent 是主 Agent 派出去、带着自己的上下文单干一件事的 Agent。
这个做法方向是对的:换一个 Agent 来看,它至少没有替自己的代码辩护的理由。可常见的派法是把自己的推理和总结写进派活的那段话,说一句“帮我检查一下”。复查的 Agent 读到的是同一段推理,看到的是“为什么这样写是对的”,很容易被说服;它手里还有编辑权限,发现问题会顺手改掉,报告里就没了这一处。测试也帮不上忙,它出自写代码的那个 Agent,只回答写的人想到的问题。
三种做法里,核查的和做事的都共用了一样东西,同一个 Agent、同一套理解写出来的测试,或者同一段推理。“已完成”“全绿”“复查通过”叠三遍,也还是一个视角。
体育比赛不让运动员兼当裁判,企业内控管这叫职责分离:做事的和核查的分开。这条老规矩起作用,靠的是几个前提。
二、老方法,新补丁:运动员不当裁判
职责分离靠三个前提:复核的人读到的东西和经手的人不一样;复核的人改不了原始记录,只能指出问题;两个人不共用同一套盲区。搬到 Agent 身上,这三条都得自己造。前两条能拿工具造回来:派验收时不给起草的推理(做法一,换人),验收 Agent 只给读的权限(做法二,收权)。
第三条最难造回来。换了名字的同一个模型,没有替谁遮掩的理由,却有同一套盲区,写的时候看不出的,验的时候多半也看不出;在子 Agent 的定义里写一行 model: 给它换一个模型能好一些,盲区也未必就错开了。所以独立验收不能只靠验收者的眼光,结论要落到别人能复核的证据上:测试先证明自己会红(做法三,改坏),功能从用户平时走的入口真跑一遍(做法四,走默认入口)。
改坏借的是软件测试里一个老办法,变异测试。它回答的是“谁来检查测试本身”:故意把代码改坏,看测试能不能发现。把一个大于号改成大于等于,或者删掉一行,测试照样全绿,多半说明这行代码没人守着。Python 有 mutmut,JavaScript 有 Stryker,都是现成工具。
第一步,换一个没看过过程的人。
三、做法一:换人,只给完成标准和改动
换人,是把验收交给一个没看过实现过程的 Agent,只给它两样东西:完成标准,和这次改了哪些文件。写代码时的思路、试过又放弃的方案都不给,起草者自己写的报告也不附。完成标准最好在写代码之前由你定下来,写进任务说明,写成能核对的句子,比如“用户敲某条命令,能看到某个结果”,别写成“功能完善”。
报告不附,是因为它是写代码的 Agent 对自己的总结,装着它的理解,也装着它的盲区。验收者先读了它,查到的多半是它已经想到的;只拿着完成标准,才得自己从“用户要什么”出发找路,走到作者没想到的地方。
落到 Claude Code 里,验收者就是一个子 Agent。它看不到主 Agent 的对话;除了 CLAUDE.md 和它自己的定义文件,主 Agent 派它时写进去什么,它才知道什么,所以关键在派的那段话。检查办法:找到派验收时传进去的那段话(Claude Code 把会话记在 ~/.claude/projects/ 下的 .jsonl 文件里,派验收那次 Agent 调用的 prompt 字段就是它),单独拿出来读,里面要是出现“我是这样实现的”“我考虑过”,起草的思路就跟着一起交出去了。
换人管的是验收者读到什么。它手里能做什么,是下一件事。
四、做法二:收权,验收的只读、能说“查不清”
验收者顺手修掉一个 bug,再跑一遍测试,全绿,报告里写“通过”:这一处错从报告里消失了,写代码的 Agent 也不知道自己错在哪。
收权分三件事。
第一,验收 Agent 只读,在仓库里唯一能写的是结论。在 Claude Code 里,它的工具只给读文件、搜索、跑命令这几样(Read、Grep、Glob、Bash),外加一个只用来写结论的 Write。可 Bash 能跑命令,也就能写文件,所以还要一道 PreToolUse hook(每次调用工具之前自动跑的脚本)。子 Agent 发起的调用,hook 收到的输入里带 agent_type,值就是定义文件里的名字,它靠这个认出验收 Agent:验收 Agent 在仓库里只能写 .verdicts/,主 Agent 不能碰 .verdicts/。Bash 命令它只能粗略认出写操作,挡得住顺手,挡不住存心绕过;脚本和认法都在附录。
给验收 Agent 一个 Write,也是实测里踩出来的:第一次跑整条链路时它没有 Write,只能用 Bash 写结论,被这道 hook 拦了。
第二,结论三态:通过、不通过、查不清。只许二选一的时候,证据不够的情况总得塞进其中一格,塞进“通过”最省事。“查不清”给了它一个如实交代的出口,也给了你一个信号:这里要人来看。测试环境起不来、数据拿不到、完成标准有两种读法,都该落进这一格。三种结论各有去处:通过就放行;不通过就退回写代码的 Agent,附上验收列出的问题;查不清交给人。
第三,验收 Agent 起不来或没跑完,要明确记下来。最麻烦的状态是验收悄悄没跑,记录看上去却和跑过一样。照文末的配置,验收 Agent 做不下去就记查不清,证据里写“独立验收未跑完”。
到这里,验收者本身管住了。它拿来下结论的那批测试,还得另外证明靠得住。
五、做法三:改坏,先证明测试会红
先看文末附录那套配置自己抓到的一次。我们在一个记待办的命令行小程序上让 Claude 加一个 count 命令,打印还没完成的待办条数。它写好实现,新加一条测试,整套 12 条全绿,收工被拦,派了验收。验收 Agent 把“还没完成”那处条件反过来,改成数已经完成的,测试照样全绿:测试里加了 2 条待办、完成 1 条,完成的和没完成的恰好都是 1。验收判不通过,报告里写着改法:把测试改成不对称,比如加 3 条、完成 1 条,期望 2。Claude 照改,复验时同一处改坏就红了,这次判了通过。
测试全绿只说明,在测试想到的那些情况里,代码的行为和测试的预期一致。至于测试有没有本事发现错误,绿色什么也没说。一条只调用、不断言的测试,只要代码不抛异常,就永远是绿的。
改坏,是先证明测试会红,再信它的绿。办法很普通:删掉一个条件,去掉一个参数,让一个判断永远不成立,让一个函数永远返回空。每改一处,跑一遍测试,至少要有一条变红。改完仍然全绿的那一处,就是没人守的代码,将来真写错了,测试也不会告诉你。
mutmut、Stryker 这类工具默认会把整个代码库里能改的地方都改一遍,跑一次很久。验收用不着这么全,只改 Agent 这次改动的那几行就够了,尤其留意判断“这件事不做”的那些分支:跳过、拒绝、提前返回。它们出错时最安静,程序不报错,只是该做的事没做。
守着这一行的测试,不一定在跟改动相关的小测试集里。所以每处先用小测试集跑,没红的再换更大的一组,还不红才算真没人守。
还有一条容易漏:改坏脚本一处都没改成,或者找不到要跑的测试,结果也是“零处没守住”,看上去和全部守住一样。脚本得把“什么都没查”当成失败。
可是测试会红,只说明它守得住它调到的那段代码;用户平时走的那条路调不调得到这段代码,它不管。
六、做法四:走默认入口,只认用户那条路上的证据
Agent 写测试有个习惯:为了好测,直接 import 内部函数来调,或者注入一个环境变量,把某段逻辑打开。这样的测试又快又稳,拿来守住每一行没问题;可它测的是一条用户不走的路,拿来证明“功能跑通了”就不够。于是会出现:新功能的代码在,测试全绿,用户从命令行敲下去却调不到它,因为它根本没注册进命令表。
默认入口,是用户平时真正走的那条调用路径。走默认入口,就是验收只认从这条路径跑出来的证据。按项目形态分四种:
- Web 服务:从 HTTP 入口打一遍,访问日志里有这条请求才算,直接 import 函数调不算;
- 命令行工具:从命令行入口跑,用户敲什么,检查就敲什么;
- 库:从包对外导出的接口调一遍,直接 import 内部模块不算;
- 跑不起来的(要起数据库或第三方服务):做静态检查,在路由表或注册表里 grep 得到新函数;它只证明接上了,证明不了跑通了,真跑放进依赖齐全的 CI。
写不出这条检查,说明新功能还没有一条从用户那里通进来的路。这时验收记不通过,理由写“已存在,未接线”。这比笼统一句“不通过”准确:写代码的 Agent 知道要补的是接线,不用推倒重写。
这一步和改坏查的是两件事。改坏问:代码错了,测试知不知道。走默认入口问:系统真跑起来,做没做设计写的事。本站写过一篇《Agent 知道工具的名字,为什么仍不知道自己能做什么》,可以对照读;这里只讲验收时怎么查。这两种查法一个替不了另一个,第七节有一对现成的例子。
七、我们的证据:两份报告,各抓到一样
只派其中一个验收,就只拿到一半:改坏只告诉你这里没人守,真跑才告诉你这里真坏了。10 月 10 日,我们在自己的系统里用 Agent 重写了编排器的一块,写完的同一版代码交给两个验收:A 做第五节的改坏,B 做第六节的真跑。两份报告落在同一处:一个 Agent 异常停掉、要重试时,系统该接着它留下的运行记录(transcript)往下做。
A 干了 5 小时 17 分。它自己挑地方把代码改坏,先跑相关的 135 条测试,没红的再换编排器相关的 1621 条,没上全量。开头那 5 处就是它找出来的,这一处在里面:把那段判断改成永远回答“没有可接的”,测试照样全绿。
B 只用了 26 分钟。它在沙箱里把编排器跑起来,启动一个 Agent,等它留下运行记录,再把它停掉。在这一版里,像这样停掉的 Agent 没人接着做,系统从头另派了一个,和 A 改出来的结果一样,只是这回没人动代码。B 判不通过。
真正的毛病在上游:系统只记得最近一次看到了什么,Agent 像这样被停掉以后,最近一次已经看不到它,系统便当它从没留下过运行记录,那段判断只好回答“没有可接的”。测试环境把每一次看到的都留着,比真环境记性好,所以一直绿;这类错改坏查不出,真跑才撞得上。
返修分两步:先照 B 的报告改上游,让系统记住“见过它的运行记录”,判断一行没动,复验时同样停掉的 Agent 被接着做完了;再照 A 的报告补测试,实现一行没动,复验重新改坏 10 处,那 5 处都在内,全红。
四个做法,都靠主 Agent 自己记得去派、去跑。它忘了,或者觉得这次改动小、不用验,前面这些就一样都不会发生。所以再加两道脚本:收工时没有验收结论,先拦一下,提醒它去派验收;推送时没有通过的结论,就推不上去。
八、照着搭
照着搭的文件都在附录,一共六份。先说最要紧的一条:收工那道 Stop hook 只拦一下、提个醒,挡不住。我们实测里,多数时候是这样的:
- Claude 改完收工,被拦下,它去派验收;
- 验收在后台跑,它停下来等结论,这会再触发一次收工检查;为了不让它卡死在收工上,这次放行,界面上亮一句提醒,日志记一行;
- 结论作为一条新消息回来,把它叫醒:通过就收工;不通过,它接着改,改完收工又会被拦。
真正挡住没验收的代码的,是推送前那道 pre-push:推送的版本没有 pass 结论,就推不上去。六份合起来管得住什么、管不住什么:
| 要管住的事 | 附录这套 |
|---|---|
| 派验收时夹带起草的推理 | 不保证,靠主 Agent 派活那段话;第三节有检查办法 |
| 完成标准出自写代码的 Agent | 不保证;你不事先写好,就是它替你写 |
| 主 Agent 亲手写验收结论 | 拦(写入 hook) |
| 验收 Agent 改仓库里的文件 | 拦(写入 hook,Bash 只认得常见写法) |
| 没验收就收工 | 只提醒(第一次拦一下,见上) |
| 推送的版本没有 pass 结论 | 拦(pre-push,--no-verify 能跳过) |
| 结论里写的证据是真的 | 不核:hook 只认结论是 pass、出自验收 Agent,证据要你看结论时自己核 |
| 每类改法都改一遍 | 不保证,验收 Agent 自己挑 |
| 从默认入口真跑一遍 | 不保证,验收 Agent 照定义自己跑 |
存心绕过,比如用 python3 -c 写文件、改掉 hook 脚本本身 |
拦不住,要在 Agent 碰不到的 CI 里重跑测试;改坏和入口检查要先写成脚本才进得了 CI |
Stop hook 判的是整个工作区,不看 git diff:Agent 改完一提交,diff 就空了,新建的文件 diff 也看不到。它把整个工作区算成一串校验值(改一个字节就变),去找以这个值命名的 pass 结论。判断顺序和你自己认可过的状态怎么免验,见附录。
改坏和走默认入口,附录这套由验收 Agent 照定义里的步骤自己动手。想更稳,可以换成脚本,照第五、六节的规则写。
搭完以后,故意犯一次,看它拦不拦:
- 改一行代码,叫 Claude 不验收、直接收工:看它被拦下,提示里要它先派验收 Agent。它要是听你的、再收工一次,这次会放行,但界面上会出现那句提醒。
- 让它派验收,交回 pass 后再收工:放行。
- 发一句新话,只问一个问题、不改代码:收工不被拦。
- 再改一行,让 Claude 先
git commit再收工:照样被拦。 - 不验收就
git push:被 pre-push 拒掉。
九、代价与不适用
这一套不便宜。第七节那块代码,两个验收 5 个多小时,两个复验将近 3 小时,Agent 运行时长相加 8 个多小时。改坏也费机器,改几处就要多跑几轮测试;还会撞上等价变异(改了也不改变程序行为的改法),白白多来回一轮。我们愿意付,因为这类错平时最难看见:程序不报错,只是该做的事没做。
改坏只能证明测试会红,证明不了测试测得对:测试要是把错误的行为当成预期写死了,改坏代码它照样会红。
不是每个项目都要全装:
- 几十行的小改动、一次性脚本、还在试方向的原型,不值得装:装上以后,一行改动、改个 README 也会拦;
- 日常小改多的仓库,只在交给 Agent 做大块改动时挂上 Stop hook;
- 每次合入都有人逐行审代码,Stop hook 可以先拆,改坏留着,它查的是人眼最难看出来的那件事。
下次它说“做完了”
回到开头那一条:Claims completion against instructions。Agent 说“做完了”,它写的测试说“通过了”,是同一个来源说的两句话。换一个验收 Agent,只换掉了一半来源:上下文和权限换了,模型默认还是同一个,盲区也还在。另一半要换成不靠眼光的东西:一份先证明过自己会红的测试,一次从默认入口真跑出来的记录。看验收结论时,就找这两样:指得出,这句“通过”才和写代码的那句“做完了”不是一回事。
今天能做的第一步:拿自己最近一次“全绿”的改动,挑一个新加的判断删掉,再跑一遍测试。没有一条变红,这个判断多半没人守。
这是“Harness 模式”的第 1 期。Harness 指包在 Agent 外面、约束它怎么干活的那一套:规则、hook、检查脚本、调度。这个系列按 Agent 系统从“一个 Claude Code,人在旁边看”到“在后台跑了几个月”分五级来排,每升一级少信一样东西;这一篇在第一级,要少信的是 Agent 的自述。
后面几期会讲:后台 Agent 没动静的时候,哪些信号信得过。
Flowness 是我们自己做的分布式 Harness,也就是让很多个 Agent 节点共用一份事件记录(按时间记下每个决定和结果)、一起完成复杂任务的那套系统。它已经持续运行超过 6 个月,真实用在生产交付中,烧了粗略估计 1400 亿 Claude token。这个系列里的模式,都是从它遇到过的问题里整理出来的。
附录:最小可跑配置
六份各管一段:settings.json 把两道 hook 挂上,并禁止 Agent 动 git 标签;verifier.md 定义验收 Agent;guard-verifier-write.sh 是拦写入的 hook;stop-gate.sh 是 Stop hook,state-hash.sh 替它算工作区的状态;pre-push-gate.sh 装成 git 的 pre-push,推送没有 pass 结论的提交就拒推。
六份放进一个 git 仓库就能用,在 Claude Code 2.1.293 上整条链路跑通过。实测的仓库里只有这六份、一份 .gitignore 和一个记待办的 Python 小程序,没有 CLAUDE.md 规则,指令里一个字没提验收。完成标准没人另写,主 Agent 派验收时,把我们给它的那句任务改写成需求交了过去。我们实测了十次。和这里逐字相同的六份跑的是最后一次,前几次用的是早先版本,stop-gate.sh 和 state-hash.sh 一字没变。十次的第一次收工都被拦下;有三次它先读了 hook 脚本,先派了验收再收工,这时结论还没回来,所以照样被拦。验收 Agent 都在后台跑:实测都是用 claude --bg 起的后台 Agent,那里的 Agent 工具默认把子 Agent 放后台,Claude 派的时候没特意要求。结论回来之前它又收工一次,放行并亮了提醒;拿到 pass 后收工,放行。所以正常流程里那句提醒几乎每次都会亮,它是一条记录,不是关卡;关卡是 pre-push。交互式会话里验收要是在前台跑完才回来,那句提醒就未必每次亮,这一点我们没测。第七节那两个验收是在我们自己的系统里派的,不在这十次里,用的也不是这套配置。
改坏和走默认入口由验收 Agent 自己动手。它自己挑改法,挑到什么算什么,不会像脚本那样把几类固定的改法逐行都试一遍:几次实测它每次都挑了“把条件反过来”,所以抓到了第五节那个例子,但没有一次挑“把返回值改成空”,只有这种改法才抓得到的测试漏洞,这样挑就查不出来。你手上有变异测试工具或入口检查,可以写进 verifier.md 的第 2、3 步。例子是 Python 项目,测试命令换成你自己的;JS 项目去掉 -B 那串参数;拷副本时 node_modules 可以不拷,但要在副本里建个软链接指回原仓库那份,不然副本里的测试一跑就报找不到模块,每处改坏看上去都红了。
拦写入的 hook 怎么认:Write 看路径;Bash 只能看命令字符串,它先像 shell 那样切词,引号里的 > 不算,再认出重定向 >、rm、mv、cp、tee、touch 这类命令和 sed -i(写在 for、if 里的也认),把要写的路径拿出来比对,cp 只看目标;会改工作区的几个 git 子命令,验收 Agent 一律不许跑。验收 Agent 在仓库外只能写临时目录,改坏就在那里做。它认不全:python3 -c 里打开文件来写就认不出;路径写成变量,它看不见里面装的是什么,验收 Agent 用了一律拦,主 Agent 的命令里出现 .verdicts 又往带变量的路径写,也拦。实测里验收 Agent 几乎每次都先用一两回变量,被拦下,换成写全的路径就放行;verifier.md 里那条“路径写全”是为这个加的,加了以后它也还是会先撞上一两回。引号里的 > 不算写入,是我们复核脚本时测出来以后改的:原来验收 Agent 用 sed 把 > 改成 >= 也会被拦。三个脚本里的 python3 都带 -I:不带的话,仓库根有 json.py 这类文件时会被先导入,hook 崩掉,拦截悄悄失效,这是我们测脚本时撞上的。
一轮,指 Claude 收到一条消息到它停下来;这条消息可以是你发的一句话,也可以是后台验收交回来的结论。被拦下以后,它中间派验收、改代码都还算同一轮。所以验收判了不通过以后,要看结论到的时候它停没停:它先停了,结论作为新消息回来,算新的一轮,改完再收工照常被拦;它还没停,自己读到了结论,改完再收工只提醒,这次改动要是没再复验,要到推送时才被挡下。十次实测里,两次判了不通过,结论都是作为新消息回来的(一次就是第五节开头那个例子);另有一次主 Agent 停下后,我们手改一行、再发一句话,新一轮的第一次收工也照常被拦;它还没停就读到结论的那种,我们在更早一次实测里见过。
Stop hook 的判断顺序:
- 当前状态和
verified标签记下的一样,放行。这个标签只由人在终端里挪(所以禁止 Agent 跑git tag),意思是“这个状态我认了”;你自己手改了代码,或者git pull进别人的改动,不想为此派一次验收,就先提交,再挪一下它。 - 有以这个状态值命名的结论文件,
verdict为pass、author为verifier,放行。author只是文件里的一个词,它算数,靠的是写入 hook 把主 Agent 挡在.verdicts/外面。 - 都不满足,这是这一轮第一次收工:拦下,把缺什么回给 Claude,它去派验收。
- 同一轮里再收工,还是没有 pass:为防死循环放行,记一行日志,界面上亮一句提醒。“同一轮”认的是 Claude Code 传给 hook 的
stop_hook_active。
用之前做四件事:
.gitignore里写上所有跑起来会往仓库里写的东西:.verdicts/、测试缓存(Python 是__pycache__/和.pytest_cache/)、程序自己的数据文件。漏了.verdicts/,写结论本身就改了状态,结论永远对不上;漏了缓存或数据文件,验收之后再跑测试、用程序,状态一变,结论又对不上。- 新建的仓库先交互式打开一次 Claude Code,接受对这个目录的信任,项目设置才会完整生效;脚本放好后跑一次
chmod +x scripts/*。 - 先提交一次,在终端里跑
git tag verified定基线。以后每次验收通过、你看过结论,提交代码,再跑git tag -f verified挪基线。 - 装 pre-push:
ln -s ../../scripts/pre-push-gate.sh .git/hooks/pre-push。团队里每人都要装。
.claude/settings.json:禁止 Agent 跑 git tag 和 git update-ref,在调用工具前挂写入 hook,在收工时挂 Stop hook。
{
"permissions": {
"deny": [
"Bash(git tag:*)",
"Bash(git update-ref:*)"
]
},
"hooks": {
"PreToolUse": [
{
"matcher": "Write|Edit|MultiEdit|Bash",
"hooks": [
{
"type": "command",
"command":
"\"$CLAUDE_PROJECT_DIR\"/scripts/guard-verifier-write.sh"
}
]
}
],
"Stop": [
{
"hooks": [
{
"type": "command",
"command":
"\"$CLAUDE_PROJECT_DIR\"/scripts/stop-gate.sh"
}
]
}
]
}
}
.claude/agents/verifier.md:验收 Agent 的定义。在仓库里只能写结论,改坏在临时目录的副本里做。
---
name: verifier
description: Independent acceptance check of code changes.
tools: Read, Grep, Glob, Bash, Write
---
You are the verifier. You did not write this code. Judge it.
You receive only the done-criteria and the list of
changed files. Do not ask for the author's reasoning.
Rules:
- Never edit code or tests in this repo. Never run git
commit, checkout, stash, or anything else that
changes files here outside `.verdicts/`.
- Write paths out in full in Bash commands, no shell
variables: the write guard cannot see what a
variable holds and blocks it.
- Do these three, in order, and read the real output:
1. Run the tests: `python3 -m pytest -q`.
2. Prove the tests can fail. Run `mktemp -d` as a
command of its own and read the path it prints.
COPY below means that path, typed out in full.
Copy the repo there: `cp -R . COPY`. In COPY,
break each changed line one at a time (flip a
comparison, return None, drop a branch), always
by absolute path under COPY, and run the tests
there in a subshell that never reuses bytecode
(a stale .pyc can hide a same-length edit):
(cd COPY && python3 -B \
-X pycache_prefix=COPY/.nopyc -m pytest -q)
Every break must turn at least one test red.
Never break the real files; the guard blocks it.
3. Run the new feature the way a user would, from
the real entry point (the CLI command, the HTTP
route). Importing the function does not count.
- Check each done-criterion against what you saw.
Do not trust claims, only command output.
Verdict, one of:
- pass: tests pass, every break was caught, the
feature works from the entry point, and every
criterion is met.
- fail: a test fails, a break survived, the feature
is not reachable (say "已存在,未接线"), or a
criterion is not met.
- unclear: you could not run something or could not
tell. Write "独立验收未跑完" in evidence.
Write the verdict only after all checks are done:
1. Run `scripts/state-hash.sh` with Bash. Its output
is `<hash>`.
2. Use the Write tool (not Bash) to create
`.verdicts/<hash>.json`. Write makes the folder
itself, so do not run mkdir.
{"verdict": "pass",
"author": "verifier",
"state_hash": "<hash>",
"evidence": ["pytest -q: 12 passed",
"breaks: 3 made, 3 caught",
"entry: python3 -m app count -> 2"]}
`author` is always the string "verifier". `evidence`
lists every check you ran and its result. Finish
by reporting the verdict and the evidence to the caller.
scripts/guard-verifier-write.sh:拦写入的 hook。验收 Agent 只能写 .verdicts/ 和临时目录,主 Agent 不能写 .verdicts/。
#!/usr/bin/env bash
# PreToolUse hook; exit 2 blocks. The verifier writes
# only .verdicts/ and temp dirs; the main agent never
# writes .verdicts/. Subagent calls carry agent_id and
# agent_type; the verifier is known by agent_type.
IN=$(cat)
read -r -d '' PY <<'EOF2'
import json, os, re, shlex, sys, tempfile
d = json.load(sys.stdin)
who = d.get("agent_type") or "" if d.get("agent_id") else ""
is_v = who == "verifier"
ti, tool = d.get("tool_input") or {}, d.get("tool_name")
env = os.environ.get("CLAUDE_PROJECT_DIR")
root = os.path.realpath(env or d.get("cwd") or ".")
base = d.get("cwd") or root
HID = re.compile(r"\$[{\w(]|`|(^|/)~") # $VAR, ~
R = os.path.realpath
def under(p, *dirs):
q = R(os.path.join(base, p)) + os.sep
return any(q.startswith(R(x) + os.sep) for x in dirs)
V = os.path.join(root, ".verdicts")
def ok(p): # inside .verdicts/
return under(p, V)
def bad_v(p): # not a place the verifier may write
if HID.search(p): return True # can't see it
if under(p, root): return not ok(p)
return not under(p, tempfile.gettempdir(), "/tmp")
def deny(why):
w = who or "main agent"
sys.stderr.write("BLOCKED (%s): %s\n" % (w, why))
sys.exit(2)
W = set("rm mv cp tee touch mkdir truncate install dd "
"chmod ln patch".split())
G = set("commit checkout reset stash add restore clean "
"merge rebase apply switch push pull rm mv "
"cherry-pick revert am update-ref".split())
PRE = set("sudo command env xargs nice nohup time "
"! { do then else if elif while until".split())
OPS, SKIP = "();<>|&\n", ("-", "/dev/null")
def body(cmd): # drop heredoc bodies
out, ends = [], []
for ln in cmd.split("\n"):
if ends:
if ln.strip() == ends[0]: ends.pop(0)
continue
out.append(ln)
ends += [m[1] for m in re.findall(
r"(?<!<)<<-?\s*(['\"]?)(\w+)\1", ln)]
return "\n".join(out)
def words(cmd): # a quoted > stays inside its word
src = body(cmd).replace("\\\n", "") # joined lines
src = re.sub(r"(?<=\S)#", "\x01", src) # in-word #
s = shlex.shlex(src, posix=True,
punctuation_chars=OPS)
s.whitespace, s.whitespace_split = " \t\r", True
try: return [w.replace("\x01", "#") for w in s]
except ValueError: return cmd.split()
def one(t, cur, out): # one simple command
while t and ("=" in t[0] or t[0] in PRE):
t = t[1:]
if not t: return cur
c, a = t[0], [x for x in t[1:] if x[:1] != "-"]
J = lambda xs: out.extend(os.path.join(cur, x)
for x in xs)
if c == "cd":
nd = os.path.join(cur, a[0]) if a else root
ok_cd = HID.search(nd) or os.path.isdir(nd)
return nd if ok_cd else cur # a failed cd stays
if c in ("cp", "ln", "install"):
to = [t[k + 1] for k in range(len(t) - 1)
if t[k] == "-t"]
J(to or a[-1:]) # only the destination
elif c == "sed":
if any(re.match(r"-[A-Za-z]*i|--in-place", x)
for x in t):
e = any(x in ("-e", "-f") or x[:6] in
("--expr", "--file") for x in t)
fs = [x for x in a if x] # drop BSD -i ''
fs = fs if e else fs[1:] # [0]: the script
fs = [os.path.join(cur, x) for x in fs]
out.extend(x for x in fs if HID.search(x)
or os.path.isfile(x))
elif c == "dd":
J([x[3:] for x in t if x[:3] == "of="])
elif c in W:
J(a or ["?"])
elif c == "git":
k = 1
while k < len(t) and t[k][:1] == "-":
k += 2 if t[k] in ("-C", "-c") else 1
if k < len(t) and t[k] in G: J(["?"])
return cur
def targets(cmd): # paths a command may write
ws = []
for w in words(cmd) + [";"]:
if w and all(ch in OPS for ch in w):
ws += re.findall(r"&>>|&>|>&|>>|>\||&&|\|\|"
r"|<<<|<<|[;()|&<>\n]", w)
else:
ws.append(w)
out, cur, t = [], base, []
st = [] # cd inside ( ) ends with the subshell
i = 0
while i < len(ws):
w, i = ws[i], i + 1
op = w != "" and all(ch in OPS for ch in w)
if op and ">" in w: # redirect: next word
n, i = (ws[i] if i < len(ws) else ""), i + 1
if t and t[-1].isdigit(): t.pop()
if not n.isdigit() and n not in SKIP:
out.append(os.path.join(cur, n))
elif op: # end of one command
cur, t = one(t, cur, out), []
for ch in w:
if ch == "(": st.append(cur)
if ch == ")" and st: cur = st.pop()
else:
t.append(w)
return out
if tool in ("Write", "Edit", "MultiEdit"):
p = ti.get("file_path") or ""
if is_v and bad_v(p):
deny("verifier is read-only, cannot write " + p)
if not is_v and ok(p):
deny("only the verifier writes .verdicts/")
elif tool == "Bash":
cmd = ti.get("command", "")
ts = targets(cmd)
interp = re.search(
r"\b(python3?|node|perl|ruby|ba?sh)\b", cmd)
hid = ".verdicts" in cmd and (
interp or any(HID.search(p) for p in ts))
if is_v and [p for p in ts if bad_v(p)]:
deny("verifier is read-only, writes %s" % ts)
if not is_v and (any(ok(p) for p in ts) or hid):
deny("only the verifier writes .verdicts/")
EOF2
exec python3 -I -c "$PY" <<< "$IN"
scripts/stop-gate.sh:收工前的检查,没有对得上当前状态的 pass 结论就拦一次。
#!/usr/bin/env bash
# Stop hook: no "done" without a verifier pass.
# Pass = state equals the `verified` tag, or
# .verdicts/<state-hash>.json says pass by verifier.
here=$(cd "$(dirname "$0")" && pwd)
IN=$(cat)
cd "${CLAUDE_PROJECT_DIR:-.}" || exit 0
active=$(python3 -I -c '
import json, sys
print(json.load(sys.stdin).get("stop_hook_active"))
' <<< "$IN" 2>/dev/null)
hash=$("$here"/state-hash.sh) || hash=unknown # 算不出就拦
base=$(git rev-parse -q --verify 'verified^{tree}')
[ "$hash" = "$base" ] && exit 0
file=".verdicts/$hash.json"
missing=$(python3 -I - "$file" 2>&1 <<'PY'
import json, os, sys
p = sys.argv[1]
if not os.path.exists(p):
sys.exit("- no verdict file for the current state")
try:
v = json.load(open(p))
except Exception as e:
sys.exit(f"- verdict file is not valid JSON ({e})")
if v.get("verdict") != "pass":
print(f"- verdict is {v.get('verdict')!r}, not 'pass'")
if v.get("author") != "verifier":
a = v.get("author")
print(f"- author is {a!r}, not 'verifier'")
PY
)
[ -z "$missing" ] && exit 0
if [ "$active" = "True" ]; then
# Second stop: let go (no endless loop), but say so.
mkdir -p .verdicts
echo "$(date '+%F %T') $hash" >> .verdicts/unverified.log
printf '{"systemMessage": "%s(状态 %s)"}\n' \
"本轮改动没有通过独立验收就收工了" "${hash:0:8}"
exit 0
fi
{
echo "Stop blocked: state $hash is not verified."
echo "$missing"
echo "Run the verifier subagent on the current state."
echo "Never write .verdicts/ yourself."
} >&2
exit 2
scripts/state-hash.sh:把整个工作区算成一串校验值,没提交的改动、新文件、删掉的文件都算在内。
#!/usr/bin/env bash
# Print the tree hash of the whole working state:
# HEAD + every edit, new file and deletion. Ignored
# files (.verdicts/, caches) are left out.
cd "${CLAUDE_PROJECT_DIR:-.}" || exit 1
tmp=$(mktemp -d)
export GIT_INDEX_FILE="$tmp/index" # must not exist yet
git read-tree HEAD && git add -A && git write-tree
rc=$?
rm -rf "$tmp"
exit $rc
scripts/pre-push-gate.sh:推送前的检查,推送的提交没有 pass 结论就拒推。
#!/usr/bin/env bash
# git pre-push hook: refuse to push a commit whose tree
# has no verifier pass. Install it once:
# ln -s ../../scripts/pre-push-gate.sh \
# .git/hooks/pre-push
# `git push --no-verify` skips it; CI is the hard fence.
cd "$(git rev-parse --show-toplevel)" || exit 1
base=$(git rev-parse -q --verify 'verified^{tree}')
zero=0000000000000000000000000000000000000000
while read -r lref lsha rref rsha; do
[ "$lsha" = "$zero" ] && continue
t=$(git rev-parse "$lsha^{tree}")
[ "$t" = "$base" ] && continue
python3 -I - ".verdicts/$t.json" <<'PY' && continue
import json, sys
try:
v = json.load(open(sys.argv[1]))
except Exception:
sys.exit(1)
good = v.get("verdict") == "pass" \
and v.get("author") == "verifier"
sys.exit(0 if good else 1)
PY
echo "push refused: $lref (tree ${t:0:8})" \
"has no verifier pass" >&2
exit 1
done
exit 0