那个反复出现的 bug

最早让我察觉出问题的,不是一次崩溃。是一个模式。

blockly-platform是我用 Claude Code 从头到尾做出来的第一个真东西——一个基于 Blockly、面向大学编程练习的平台,全程只靠 Claude Code 的 Telegram 通道驱动。没有打开任何编辑器,我本机上也没 check out 过这个仓库,就只有一条聊天记录。我描述我想要什么,Claude Code 在一台我从没直接看过的机器上把它建出来,而我判断结果的方式,就是在部署好的应用里点来点去。

3 月 22 号,首页打开是空的。GET /api/exercises/published返回 403。我在聊天里说了这事;几条消息之后,Claude Code 说修好了——这个端点没被加进 Spring Security 的permitAll()列表。我往下走,去试分类筛选。一样是空的,一样 403,一样漏在同一个permitAll()列表里——同一个文件,同一类修法,只是不同的行。接着是练习详情页。同样的剧情,同一天里第三次。三天后,点赞按钮也不工作了——根因,又一次:POST /api/exercises/*/like同样从没被加进白名单。四次,一个文件,一个反复出现的缺口。

这些都不是难搞的 bug。每一个单独拎出来,都是一个称职的工程师想都不用想就能改的一行修改。等我注意到这个模式之后,真正让我难受的是:它正在发生的时候,我没能注意到。我没有 diff 可以翻,没有一个文件可以扫一眼然后想"等等,这一模一样的一类问题,我们不是刚刚才修过两次吗?"我有的只是一段聊天记录和一个可以戳一戳的在线应用。从我坐的位置看过去,第四次修复和第一次长得一模一样:一条消息,告诉我这事解决了。

就是从那一刻起,我开始怀疑,问题不出在模型身上。而是没有任何一方——模型也好,我也好——手上有任何东西可看。

为什么纯聊天式 vibe coding 会失灵

这个模式比"AI 犯了个错"更有意思的地方在于:那四次修复,每一次都是对的。Claude Code 读了报错,找到缺失的permitAll()条目,加上去,然后往下走——每一次,单独看,诊断完全正确,修法也完全正确。把那四轮里的任何一轮单独拿出来打分,它都及格。

失效的不是某一个决定。而是缺了任何能把这四个决定彼此串起来的东西。一个人类工程师,一周之内第三次因为同一类漏洞去碰SecurityConfig.java,会停下来想"要不我干脆把整个文件都过一遍"——不是因为他比 Claude Code 聪明,而是因为他之前就在这儿栽过,还记得。纯聊天、靠 Telegram 驱动的 vibe coding,给不了任何一方那种连续性。Claude Code 看到的是当前这一轮的报错,修的是当前这一轮的报错。我看到的是一条写着"修好了"的消息,然后转头去处理清单上的下一件事。我们俩谁都没有把这个文件随时间变化的形状握在手里,因为我们俩谁都没在看这个文件——我看的是一块手机屏幕,而模型的上下文是这一轮窗口里有的那些东西,不是一种"这个文件有个反复出现的毛病"的累积感受。

这是第一层。第二层更让我担心,因为在纯聊天这个模式里,它压根没有一个显而易见的解法:Claude Code 的一部分决定,是会回到我这儿来批准的。可我从没读过这套代码的结构——我从没打开过这个仓库,从没看过SecurityConfig.java,从没看过练习、提交、判分这几块是怎么拼在一起的。所以当一个决定真的落到我手上时,我批准它所依据的信息,跟模型手上的差不多是一样的:除了当前这条消息,什么都没有。"人在环路里"只有在人手上握着某些模型没有的东西时,才真的增加了一层监督。我手上没有。

把这个说法的边界讲清楚:这一切并不意味着纯聊天驱动就总是错的。对那种小的、用完就扔的东西,它没问题——根本没有什么需要你去跟踪的。这个失效模式,恰恰是在复杂度和风险往上爬的时候才冒出来——一旦一个项目有了足够多的活动部件,"文件随时间变化的形状"才真的值得去跟踪。

仓库本身佐证了这一点,而且不只是靠我的记忆。blockly-platform从一个空仓库到一个合并进主线、能跑的平台,用了 23 天、80 个 commit——很快,而且看得出来快。里面找不到任何设计文档,没有架构说明,没有任何东西记录一个决定为什么这么做——只有一个RUNBOOK.md、一个TODO.md,还有一个读起来像是"事后才发现的一堆事"清单的ERROR_LOG.md。至于CLAUDE.md——那个本该告诉 Claude Code 这个项目自己的规矩、诸如"加端点的时候别忘了安全配置"之类的文件——直到 4 月 2 号才存在。ERROR_LOG.md里每一条带日期的记录,全部十八条,包括那四次permitAll()的遗漏,都落在 3 月 22 号到 3 月 28 号之间。护栏是在它们本该拦下的那些 bug 之后才到的,不是之前。

重做

接替它的那个项目,programming-learning-platform,是在blockly-platform的 pull request 合并的同一天开始的。这不是我刻意安排的巧合——它就是第一个项目真正结束、而从中得到的教训开始起作用的那个点。它也不是一个另起炉灶的想法:它自己的CLAUDE.md把它描述成一个面向 BlocklyPython 练习的平台,是同一个工具从地基往上重建,而不是从零开始的一个新东西。

这一次我一口气改了两件事。第一件是可见性:我换到了 IntelliJ IDEA 的远程开发模式,项目 check out 在、也运行在跟之前同一类的远程主机上,但这回上面挂着一个真正的编辑器——我能看到文件树,能打开一个类,能在一个 diff 落地之前把它读一遍。第二件是流程:我打开了superpowers技能,并把它作为不可商量的规则写进项目的CLAUDE.md——“每个任务:Brainstorm → Plan → Implement(TDD)。不许跳过 brainstorm。没有计划不许写代码。没有一个失败的测试之前不许动实现。”——而且在项目明确的红线里,又把"不许跳过 Superpowers"单独列了一遍,生怕第一次说得还不够清楚。

这个差别体现在仓库的形状上,不只是体现在我对当时感觉的记忆里。programming-learning-platform已经积累了 458 个 commit,三个多月之后仍然活跃。它有一个真正的docs/树:一份 PRD、按角色划分的用户流程、一份架构文档、按优先级拆分的功能规格——还有一个docs/superpowers/plans/目录,里面装着四十四份带日期的实现计划,从它头两周之内一直排到这个月初,每一份都是一次 brainstorm 加 plan 步骤的记录,而这些步骤都发生在对应的代码之前blockly-platform留下的唯一书面痕迹,是一份"已经出过的岔子"的日志;而这一个,留下的是一串刻意做出的、有先后顺序、在被实现之前就做好的决定的书面痕迹。

它起作用了,而且是在最要紧的那个意义上:我不再盲批东西了,因为我终于有了一个能真正去读的 diff,还有一份可以拿来对着 diff 检查的计划。

它也带来了我没预料到的摩擦,而且我想说清楚,这是我自己在一个特定时间、一套特定设置下的体验,不是对产品本身下的定论。IntelliJ IDEA 的远程开发客户端,哪怕真正的计算完全发生在服务器上,在我本机上还是觉得沉——比我对一个本该是"远程工作的一扇薄窗"的东西所预期的,明显更沉。而 JetBrains 的 Claude Code 插件有一些缺口,恰好卡在可见性本该给你换来的那一样东西上:清楚的沟通。我没法给一条消息附上图片——贴不了一张坏掉的布局截图,也贴不了一张标注过的示意图——所以有些我本来五秒钟就能展示出来的东西,反而要用好几段话去描述,而描述里出错的空间也更大了。

可见性和流程已经把真正的问题解决了。而我用来把它们包起来的那个工具,还在跟我较劲。

那套不再跟我较劲的设置

一个一直在跑类似远程设置的朋友,建议我在项目做到一半的时候把 IntelliJ 换成 VS Code——具体说,是用 VS Code 的 Remote-SSH 插件直接在远程主机上管理programming-learning-platform,让 Claude Code 插件跑在同一条连接上,而不是用 JetBrains 那套自己的远程协议加它自己的插件。

可见性和流程都没有任何变化,而这恰恰是重点:文件树还在,diff 还是会在我批准任何东西之前落到我面前,superpowers那套 brainstorm-plan-TDD 的循环原封不动地延续了下来,因为它活在项目的CLAUDE.md里,而不是活在恰好开着的那个编辑器里。变的是包在这两样东西外面的那个工具。客户端更轻了——它不再像 IDEA 那样在我本机上明显发沉。而且 Claude Code 插件让我可以直接给一条消息附上截图,对一个围绕可视化积木编辑器和判分界面搭起来的平台来说,这件事的意义比听上去要大:一个坏掉的 Blockly 工具箱,或者一个错位的判分面板,是一张五秒钟的截图加一句两个词的问题,而不是用三段话去试着把一个布局描述出来。

关于为什么这个特定组合——VS Code 的 Remote-SSH 加上 Claude Code 插件——值得你放心把长时间、可以走开的工作交给它,它背后的机制我之前已经写过:这套流程能扛住你笔记本睡眠,而算力从头到尾都不碰你的本机。这块地我就不在这儿重走一遍了。对这篇文章来说,要紧的是更窄的一点:这是第一套让可见性、流程、以及工具本身三者朝同一个方向使劲的设置,而不是三个里有两个在跟第三个较劲。

最后一公里

可见性和流程都稳了之后,最后一块拼图,是那块真正让我能走开的:知道 Claude Code什么时候需要我,以及能在不用坐在桌前的情况下回答它。

什么时候这一半,是claude-code-notify,一个我在受够了两件事之后做出来的工具:要么守着一个终端当保姆,要么二十分钟后才发现,一个被 TDD 拖得很长的任务,这一整段时间都卡在等我签字上。它会在真正要紧的那些时刻在 Telegram 上戳我——一轮任务真正完成了(包括后台工作在内),或者它需要我输入,或者它报错挂了。怎么回答这一半,是 Remote Control(/remote-control),它让我可以在手机上的 Claude App 里批准提示、回答问题,而不用重新打开笔记本。收到戳说 Claude 需要我,从兜里掏出手机回答——这就是那套工作流程,而关于它可以怎样悄无声息地失灵,我另外写过一篇:如果底层那个远程会话活得不够久,等你回答的时候它已经不在了。

值得把这一层是什么、不是什么讲精确。它不是修好permitAll()那个问题的东西——修好它的是可见性,以及superpowers那套"先计划再写代码"的纪律。notify 和 Remote Control 加上来的,是一种能离开键盘、又不丢掉上面那两样中任何一样的能力:我照样看得到 diff,Claude Code 照样跟着计划走,我只是不必坐在那儿,这两件事也照样在发生。要是把同一套"走开"层拴到第一个项目那种纯 Telegram 的设置上,它什么都修不好——你只会更快地被通知:一个你根本没能力评估的决定,已经做完了。顺序很重要:先有可见性和流程,走开的便利是搭在这个地基之上的,不是拿它来顶替这个地基。

再看一次那个 bug

我有时会想,如果第四次permitAll()的遗漏——点赞按钮那次——发生在后来那套设置下,而不是第一套下,会怎么样。我诚实的猜测是:它照样会被写出来,因为在一个新端点上忘掉一条安全规则,对任何人、无论是人还是模型,都是很容易犯一次的错。但它不会需要前面三次先发生才被逮住,因为一个计划步骤会把"加上这个端点和它的安全规则"当成一个整体写出来,而一个 diff 会把两半都摆出来——或者只摆出一半,就在那儿,在我批准之前。这个 bug 不会需要一个人去在一周的聊天消息里注意到一个模式。它只会需要一个人,或者一个模型,去注意到一个 diff 里的一处缺口——这是个小得多的要求,而且是可见性真正让它变得可能的那种。

这就是整个转变,藏在所有这些工具名字底下的那个转变。纯聊天式的 vibe coding 之所以失败,不是因为模型不擅长它的活儿——blockly-platform里每一次单独的permitAll()修复都是对的。它失败,是因为过了某个规模之后,牵涉其中的任何一方,模型也好、人也好,面前都没有足够的东西,去注意到一个错误第二次发生,更别说在第四次之前把它拦下来。可见性,以及一套逼着你先规划再动手的流程,并不会让 Claude Code 变得更聪明。它们做的是给正在盯着的那一方,无论是模型还是人,一样能真正去盯的东西。