IStarry

为什么每个阶段都要停下来验收:「不猜测」与 Phase-STOP

把项目切成 Phase,每个 Phase 走完固定 8 步就强制停机等验收;无法确定的业务含义一律标记「待确认」交回人,而不是猜。记录这套阶段纪律的形状、它的代价,以及它什么时候不该用。

IStarry

8 min read

为什么每个阶段都要停下来验收:「不猜测」与 Phase-STOP

0. 一个反直觉的观察:写得快是风险,不是产能

第一次把实现交给一个能一轮写完整个模块的 AI 编码代理,几乎所有人都会经历同一种兴奋:它在一轮对话里就能写完一个模块,还能顺手把文档也更新了。但把它放到"要交付给客户、要落在生产数据库上"的场景里,性质就变了:

  • 写得越快,越审不动。 一次生成 3000 行,认真审完的时间往往比分 10 次、每次 300 行加起来还多——一次大改没有"中间态"可供比对,只能整体相信或整体怀疑。
  • 越自信,越容易放弃校验。 大块产出从来不说"这块我不太确定",它看上去语法正确、命名合理、注释齐全。最大的失败模式是看起来对、实际语义错,而这类错误在本地跑一次是看不出来的。
  • 越能自己补全上下文,越容易替你做决定。 字段含义不清?挑一个"合理"的解释往下写是最省事的。这些猜测会静默沉淀进数据模型,等发现时已经很难改。

所以要解决的问题本质上是给"快"装一个强制限速器。而这个限速器不能写在提示词里靠自觉——提示词是软的,制度是硬的。本项目的硬制度只有两条,但它们撑起了后面所有内容(AGENTS.md §3 执行铁律):

  1. 不跨 Phase:严格按 Phase 0 → 1 → 2 → 3 → 4 → 5 → 6 推进,禁止一次性生成整个项目。
  2. 每个 Phase 循环:分析 → 实现 → 测试 → 修复 → 更新文档 → Git commit(+tag)→ 阶段报告 → STOP 等待验收

第 3 条紧跟着补了一句听起来多余的话:

  1. 上一 Phase 未验收,不进入下一 Phase;即使下一阶段很容易

"即使下一阶段很容易"是整条铁律的重心。因为跳过阶段门禁的诱因从来不是"难",而恰恰是"顺手"。


1. Phase 循环的 8 步:每一步都在生产一种可检验的东西

把铁律 2 展开,一个 Phase 循环是 8 步(AGENTS.md:29docs/roadmap.md §2 门禁同样表述为"测试通过→文档更新→commit+tag→阶段验收报告→STOP 等用户指令"):

#步骤产出的东西由谁完成
1分析要改哪些文件、影响哪些数据、前提是什么实现者
2实现代码实现者
3测试测试 + 测试输出实现者
4修复修完的代码 + 为什么原来会红实现者
5更新文档与实现同步的文档实现者
6Git commit(+tag)一个可回退的点实现者
7阶段报告做了什么、验证结论、没做什么实现者
8STOP 等待验收一个明确的"停"

这张表里,真正稀缺的资源是第 8 步。前 7 步实现者能连着做完,而且比人做快得多;只有第 8 步必须由人执行,而人一天能给出的高质量验收次数是有限的。

这就是这套流程的成本结构,也是它有效的原因。 重点不是"用工具加速开发",而是"把前 7 步交给实现者连着做完,然后把人锁在最后一步当闸门"。

1.1 第 3 步"测试"要能证明自己会失败

第 3 步和第 4 步之间藏着一个很容易被糊弄过去的环节:测试到底是真在测,还是只在打印绿字? 本项目的做法是把"先红后绿"写进治理:先构造一个必然失败的用例(证明它真的能失败),再去修。最彻底的一次自证出现在 v1.5 审查的 Phase 0——当用例因为缺少真实数据文件而被改成 t.Skip 跳过时,用户批准的前提是必须自证"未掩盖真实失败"AGENTS.md 决策 22 的 D-3)。

治理里有一句话值得单独抄下来:允许跳过,但必须证明"跳过不是为了让红的变绿"。

1.2 第 7 步"阶段报告"必须包含"没做什么"

阶段报告最常见的写法是"完成了 A、B、C",这会让人误以为 A、B、C 都是充分验证过的。本项目后期的阶段报告里固定带两节:验证汇总仍未完成(诚实清单)。例如最终发布 v1.6.0 之后,文档里仍然明确列着 6 项没做的事,第一条就是 Win7 SP1 实机回归——它需要一台真的 Win7 机器和 Chrome 109 / Firefox ESR 115,当前环境无法完成(docs/review-v1.5.md 附录 K.5)。

一份敢写"没做什么"的报告,才是可信的报告。 反过来,如果一份阶段报告全是完成项,首先该怀疑的不是写报告的人偷懒,而是坏消息被过滤掉了。


2. 为什么"上一阶段没验收就不许进下一阶段"

这条规则经常被当成形式主义。它的真正价值在三种场景里才显现出来。

2.1 因为错误会复利

在 Phase N 里一个未确认的假设("这个字段大概率是日期"),到了 Phase N+1 会变成两处依赖它的代码,N+2 会变成数据模型的一部分,N+3 会变成已经导进生产库的数据。

停机的意义是把发现的成本锁在最小:在 Phase N 停一次,成本是"改一个字段解释";在 Phase N+3 才发现,成本是"改模型 + 数据迁移 + 重新导入 + 通知用户"。

2.2 因为"下一阶段很容易"是最危险的诱因

实现过程中有一种很自然的倾向:一旦判断下一步只是"顺手把 UI 也改了",就顺手改掉了。这个动作在工程上并非不可接受,但它越过了验收边界——原本要审的是"导入解析器",现在还得连带审"UI 有没有被顺手动到"。

项目里的真实对应:v1.4 界面改版的决策 ④ 是 "先只批准 Phase 0–1",Phase 2–6 需要阶段性验收后另行批准(AGENTS.md 决策 20)。这不是信任问题,而是用户明确知道"一旦授权一次性做完,我就失去中途叫停的能力"。

2.3 因为验收是唯一能注入"业务真相"的时刻

有些东西不是从代码里推得出来的。比如:外借单的"预计归还日期"整列为空,这到底是数据缺口还是业务事实?审查报告把它列为待确认事项(docs/review-v1.5.md §9 第 2 项),得到的答复是:这是业务事实,业务上确实没有具体归还日期,不需要补录AGENTS.md 决策 22 的 D-5)。

同一份 §9 里还有个更典型的:班组字典 0 条,看起来像"功能空转",很容易顺手得出"既然没用,不如删掉"的结论。答复是:班组字典为空是因为系统目前只为测试、正式数据还没导入,禁止据此删改班组相关功能。

这两个答复都不是"技术判断",而是"事实判断"——只能由人来给。 如果不停机问一句,答案就只能由推断补上,而推断错起来往往很有逻辑。


3. 让 85 次提交可回溯:tag 命名与 Conventional Commits

停机点如果不留痕,所谓的"阶段"就只是一种对话里的临时说法,一周后没人知道"当时那个 Phase 3"到底停在了哪一版。所以停机必须绑定两个机械化的产物。

3.1 tag:14 个发布标签,短形式命名

铁律 9 规定了命名:提交信息遵循 Conventional Commits;版本标签采用短形式 v0.1v1.6不再使用 -phaseN / -fix-pN 后缀AGENTS.md:36)。

实测当前仓库的 tag 分布((git tag).Count = 14):

批次tag用途
首轮 Phase 0–5v0.1v0.6每个 Phase 一个阶段版本
首轮发布v1.0首个完整版本
版本发布v1.1 v1.2 v1.3 v1.4 v1.5 v1.5.1 v1.6对外发布点(v1.5.1 是 v1.5 的补丁发布,.1 是补丁号)

一次命名的往返值得记录。 开发期间,那些"大改版内部的分阶段停止点"曾用 v1.4.0-phase0v1.5.1-phase1v1.6.0-phase5 这类带 Phase 号的长形式命名(峰值共 31 个 tag)。后来做的规整是:发布点只留短形式,中间回退点改由提交哈希承担。

这次规整的代价不小——全仓有 60 处引用那些后缀名,分布在 15 个文件里。所以"给 tag 改个名字"从来不是一次本地操作,而是一次跨文档同步。 处理方式是分层的:历史记录类文档(roadmap.md、审查报告、Phase 0 的五份)保留当时的 tag 名并指向映射表;交付物(本系列)统一改用新名。旧名与新名的完整映射、被删标签的提交哈希与附注 message,都留在仓库的 docs/tag-plan.md——任何一步都可还原。

3.2 tag 不是装饰:它真的会在裁决后被移动

一个很能说明"tag 与交付物一一对应"的细节:v1.5.1 发布后,用户又批准了 D-10(在 Dashboard 与外借页各加一行逾期口径说明)。这条属于 v1.5.1 的范围但尚未交付,于是处理方式是重建交付产物,并把 tag v1.5.1 移到含该说明的提交AGENTS.md §6 Phase 4 段)。实测:

$ git log -1 --format='%h %ad %s' --date=short v1.5.1
5398803 2026-09-19 feat(ui): Dashboard 与外借页加逾期口径说明(D-10)· v1.5.1 产物重建
$ git merge-base --is-ancestor 40fb727 5398803   # 原发布提交是移动后提交的祖先
(退出码 0)

tag 被移动本身不重要,重要的是它被记录下来了。 静默移动的 tag 会毁掉所有引用它的文档;写进治理文件的 tag 移动,反而是可用信息。

3.3 Conventional Commits:让"这周改了什么"可以按类型检索

85 次提交的类型分布(git log --format='%s'type / type(scope) 前缀统计,含子类型后合计正好 85):

类型前缀次数
feat(含 feat(import) / feat(web) / feat(export) 等)39
docs(含 docs(blog) / docs(ui)19
fix(含 fix(web) / fix(api) / fix(release)10
test / release / chore / refactor / style5 / 4 / 4 / 2 / 2

没有一条提交使用这 8 类之外的前缀。 也就是说 type 字段是真的在当检索键用,而不是装饰。

有意思的是 docs 占 19 次,紧随 feat 之后。这不是文档写得勤,而是"更新文档"是 Phase 循环的第 5 步——它被写进了制度,所以它一定会被做。制度的作用就是把"应该做但总被跳过的事"变成流程中不可跳过的一步。 另外,提交信息是英文 type + 中文描述(例:fix: 修复清空重导外键顺序、外借分页口径与补录全不选(v1.5 审查 Phase 1)):前缀给机器看、可稳定检索,描述给人看、能写清业务语义。

3.4 一个反例:commit 哈希不该被引用

2026-09-12,仓库因为要转为公开而做了一次历史重写(移除真实台账与一个 26.5 MB 的构建残留)。这次重写静默废掉了文档里所有 commit 哈希交叉引用——哈希是根据提交内容算出来的,历史一改,内容全变,哈希全变。实测(git cat-file -e "<hash>^{commit}"):source-map 里列出的 12 个历史哈希 全部不存在

tag 不受影响——当时 31 个 tag 全部有效(后经 2026-09-19 命名规整收敛为 14 个,见 §3.1)。原因很简单:tag 指向的对象被重写时,git 会重写 tag 引用;而文档里那些手抄的哈希是纯文本,没有任何机制会跟着改,也没有任何机制会告警。

所以本项目的结论是:凡是要"指向某个版本"的地方,一律用 tag,不用 commit 哈希。 顺带一提,AGENTS.md §6 里按提交信息反查修正后的 11 个哈希本次实测全部有效——但它们仍然是哈希,下一次历史重写会以完全相同的方式让它们集体失效。

这条教训可以脱离本项目使用:文档里的任何"内容派生标识"(哈希、行号、行数、版本快照)都有半衰期,而且失效时不会报错。 稳定的锚点只有两类——不可变对象(tag)与推导命令(git log --grep=...)。


4. 「待确认清单」:把不确定性变成可交付物

铁律 7 是这套流程里最反直觉的一条:

不猜测:字段含义、业务规则无法确定时一律标记「待确认」并交用户确认,绝不擅自定夺。

它的实现形态就是一个表格。本项目在 Phase 0 结束时交出了 W-1…W-15 共 15 项待确认清单(docs/import-rules.md §8),每项四列:编号 / 问题 / 影响范围 / 状态。它的价值不在"问了 15 个问题",而在三个工程属性。

第一,它把"我不知道"从口头承认变成了有编号的资产。 后续所有决策都可以写"W-3 已定,见决策 16",而不是"上次说过那个重复编号的事"——清单编号成了决策的引用坐标。

第二,它标出了阻塞级别。 docs/roadmap.md:13 直接把 W-1…W-15 分级,并要求"Phase 1 结束前须答复 W-1/2/3/4/5(至少 W-1/2),否则 Phase 2 导入无法放行"。

注意那个括号——"至少 W-1/W-2"。这是显式的降级授权:即使你不能答复全部 5 个,只要答复这两个,导入就能开工。这比"等你把所有问题答完"务实,也比"我先把能做的做了"安全。

第三,它允许"未启用"和"已定默认"这两种非答复式收口。 15 条里实际出现五种状态形态:

状态形态例子
已定(用户答复)W-1:原样保留为类别;无类别行归入新类别「其他」(决策 16)
已定后被取代W-3/W-4:2026-09-08 已定 → 2026-09-09 被决策 18 取代(旧条目保留作记录)
采用默认值W-6:导入操作人 =「系统导入」;W-13:财务数量不落库
未启用(依赖未做)W-8 / W-10 / W-11:因"借出历史暂不导入"而暂不落地
无输入即无事项W-14:没有报废/维修清单 → 默认全部在库;如有请提供

"被取代"这一栏是清单机制里最容易被忽略、也最有价值的部分。 决策会变,但清单条目不许删——W-3 的状态栏里明写着"已定 2026-09-08(决策 16)→ 2026-09-09 被决策 18 取代"。半年后有人问"为什么编号不再唯一",这条记录就是答案的起点。同样的写法在决策基线里也有:决策 16 的开头直接挂着"⚠️ 2026-09-09 起部分被决策 18 取代……本条目保留作为旧决策记录"(AGENTS.md:66)。

决策的考古价值,来自它没有被删。


5. 实现比规范更严:我保留了这个严格版本

前面讲的都是"流程如何约束产出"。这一节讲流程如何接住一次"实现比规范更严"——因为偏离不一定是坏事,坏事是偏离没被发现、或者被发现时已经被掩盖。

5.1 事实

v1.5 审查修复的 Phase 3 要加一个安全中转站,用途是堵住一个已实测的漏洞:当时没有任何 CORS 中间件和来源校验,恶意网页可以用一次无预检的简单请求直接命中危险接口——实测这个请求被接受并写入了数据(docs/review-v1.5.md 附录 A.5)。分阶段提示词对该中转站的要求写得很具体,其中一条是:

c. GET 请求不受限制(静态资源与查询接口不受影响)。

——《设备管理系统 v1.5 审查修复——分阶段 DSH-Codex 修改 Prompt.md:391》

提示词的验收清单也照此写死了预期:

- GET /api/equipment 带任意 Origin → 仍 200

——同上 :398

而实现没有照做。 新增的 internal/api/guard.go 里,Origin 校验在最前面,且不区分请求方法

func apiGuard() gin.HandlerFunc {
	return func(c *gin.Context) {
		if origin := c.GetHeader("Origin"); origin != "" && !sameOrigin(origin, c.Request.Host) {
			writeError(c, http.StatusForbidden, "拒绝跨站请求:Origin 与本次访问不同源")
			return
		}
		// …后面才是不限方法的 Content-Type 校验
		c.Next()
	}
}

也就是说:GET /api/equipmentOrigin: http://evil.example 拿到的是 403,而提示词要求的预期是 200。测试用例名甚至就直接叫 TestGuardOriginCheckedForGetToo(跨站 Origin 的 GET 也被拒)。

5.2 偏离的理由,以及"我偏离了"被写在了哪里

关键在于:这次偏离没有被藏起来。三处都写了:

在代码里(internal/api/guard_test.go:128-136)——

// TestGuardOriginCheckedForGetToo 跨站 Origin 的 GET 也被拒(403)。
//
// 与分阶段提示词 Phase 3 §一.1.c(原写"GET 不受限制")的**有意偏离**,理由:
//   - 正常使用不受影响:顶层导航(点链接)与本机脚本/curl 都不带 Origin;
//     SPA 自身的同源 GET 即便带 Origin 也与 Host 一致;
//   - GET 并非全部无副作用:/api/export/flow 会写一条 EXPORT_EQUIPMENT_FLOW 审计行,
//     跨站 GET 可据此污染审计日志(并触发一次全库导出)。
//
// 若用户希望改为字面口径,只需把 Origin 校验包在 method != GET 的条件里(3 行)。

理由是可核对的,不是形容词。GET /api/export/flow 确实会写审计行(internal/api/flow_export.go:45-54Action: "EXPORT_EQUIPMENT_FLOW")——所以"放行跨站 GET"并不等于"放行只读操作",它等于留下一条可被恶意页面触发的写路径(哪怕写进去的只是一行审计)。

在阶段报告里(docs/review-v1.5.md 附录 G.1),同一件事被写成了一节独立的 「有意偏离提示词」,并补上了代价判断:"代价仅是别的网站无法用 <img>/<script> 直接读本机接口,对本系统(无跨站集成需求)无损。"

而那段注释的最后一句,是这次偏离处理里最值得学的一笔:

若用户希望改为字面口径,只需把 Origin 校验包在 method != GET 的条件里(3 行)。

这不是把"偏离规格"变成既成事实的辩论,而是把它变成了一个带成本标注的选项。 要否决,改 3 行;要保留,什么也不用做。

5.3 裁决:保留严格实现

用户选了"保留严格实现"(AGENTS.md 决策 22 的 D-8,2026-09-18 确认"选项 A"):

D-8 跨站 GET 亦校验 Origin(2026-09-18 用户确认「选项 A」):Phase 3 实现比提示词字面更严——对 GET 也校验 Origin(提示词原文是"GET 不受限制")。保留严格实现。理由:GET /api/export/flow 会写审计行,放行跨站 GET 等于留一条可被恶意页面触发的写路径;本系统无任何跨站集成需求,代价为零。

5.4 从这个案例里能提炼出的规则

规则一:实现偏离规范本身不是事故,"偏离没被标注"才是事故。 这次偏离被发现,不是因为有人去逐行 diff 提示词,而是因为**"我有意偏离"被写在了三处(代码注释、测试名、阶段报告)**。如果这次偏离没有被写出来,验收材料上仍是一个"全部验收项通过"的报告——而提示词里那条 GET → 仍 200 的预期,就在无人知情的情况下变成了一句假话。

规则二:偏离必须附带"否决成本"。 "我做得更严了,理由如下"是辩论;"我做得更严了,否决它需要改 3 行"是决策。后者才能让人在 30 秒内做完裁决。

规则三:裁决权必须在人手里,但规格错了不一定要改回规格。 D-8 的结论是"保留实现、修正对规格的理解",不是改回代码去迎合提示词。提示词也会错。 它写"GET 不受限制"时没料到导出接口会写审计行,发现这件事的是实现者。若流程规定"实现必须与提示词逐字一致",这次就会把一个已被证明更安全的实现改回去。

规则四:偏离只有在"阶段停机 + 阶段报告"的框架内才是可控的。 如果不是分阶段的(Phase 3 做完就停机、出报告、等验收),"有意偏离"最多出现在代码注释里,而不会出现在你读的那份验收材料里。停机点不只是让产出慢下来,它还把"我发现的问题"强制暴露到验收材料里。


6. 代价与不适用:这套流程什么时候是错的

前面五节都在讲"好用"。这一节必须讲清楚它什么时候不好用,否则这篇文章就变成了方法论推销。

6.1 代价一:人是吞吐瓶颈,而且是不可扩容的那个

Phase 循环的 8 步里,7 步能自动化,第 8 步不能。于是项目速度的上限变成"用户一天能认真验收多少次",而不是"AI 一天能写多少代码"。

这个瓶颈在本项目里有个很直接的证据:v1.5 审查抛出了 P0 × 4 · P1 × 16 · 冗余与死代码 30+ 处 · 文档与现实不符 9 处docs/review-v1.5.md §8 逐条表列 9 行;同文件 §0 摘要写作"8 处",两处不一致——以逐条表为准)。这些条目没有一条能自动收口,全部需要用户逐条裁决。

最终它们被压缩成 D-1…D-10 共 10 条决策AGENTS.md 决策 22),再用 Phase 0–7 共 8 个 Phase 分两批发布(Phase 0–4 → v1.5.1;Phase 5–7 → v1.6.0,见 D-1)。审查一周,收口两周 —— 这就是流程的实际形状。

6.2 代价二:每个 Phase 都要为"可回溯"付税

  • tag 的税:85 次提交配了 14 个 tag(命名规整前是 31 个),约每 6 次提交一个版本标记。这不是免费的——每个 Phase 都要想清楚"这个 tag 以后还有用吗",而开发期那些 v1.4.0-phase0 式的中间回退点 tag,大概率永远不会被 git checkout——它们最终也确实被清理掉了(见 §3.1)。
  • 文档的税docs 类型提交 19 次,占总提交的 22.4%。写文档的时间是从写代码的时间里扣出来的。
  • 报告与哈希的税:阶段报告要附基线库哈希("生产库保护铁律"),tag 要维护,交付物要在两次发布点重建。这些动作都不产出功能,只产出可信度

6.3 代价三:历史重写会一次性废掉你所有的交叉引用(亲历)

第 3.4 节那条教训的另一种表述:这套流程鼓励你写"可核对"的锚点,而锚点自己会过期

  • 12 个 commit 哈希在一次历史重写后集体失效,无任何告警。
  • 4 处行号错误来自"测量工具混用"(用 Get-Content 的数组下标核对行号,本环境实测偏移 4–7 行)。
  • 审查报告自己更正过 3 条结论(docs/review-v1.5.md 附录 C):把一条原本报 P0 的问题下调为 P1;发现客户端计时工具(PowerShell Invoke-WebRequest)与真实服务端耗时相差近 5 倍——测量工具本身也需要被验证

所以"每个数字必须有出处"这条写作铁律,是有维护成本的。 收益是可信度,代价是每次引用前都得重跑验证命令。写这篇稿子时就重跑了一遍:(git tag).Count = 14(命名规整后)✅,git cat-file -e 逐个哈希 ✅。

6.4 代价四:它会让"探索"变得很别扭

Phase-STOP 的前提是"你知道这个阶段要做完什么"。当你在探索一个还不知道形状的东西时,这个前提不成立——你没法为一个还不存在的阶段写验收标准。

判断标准很简单:如果这个 Phase 的验收标准你自己现在写不出来,那它不该被切成 Phase。

顺带把"验收标准"本身说清楚:它得在阶段开工前由人写出来,而且不能只写功能断言——只写"功能正确",会系统性漏掉"错误输入会怎样";还要写清这个阶段明确不做什么。这三样写不出来的阶段,就不该被切成 Phase。

6.5 明确不适用清单

场景为什么不该用该用什么
一次性脚本(数据清洗、格式转换、临时统计)没有第二阶段,没有回归面直接写完跑一遍,看输出对不对
探索性原型 / 技术验证("这个库能不能用")目的是获得信息,不是产出可维护资产设一个时间盒,跑完就丢
只有你一个人看、且随时可重生成的产物(草稿、示意图、一次性查询)可回溯的价值低于记账成本直接产出
纯文本/配置类的小改动(改一句提示、调一个阈值)停机开销大于改错重来的开销改 + 跑测试 + 提交

反过来,什么必须用:任何会写进生产数据库改变已有数据的语义对外交付承诺的动作。本项目里就是那条最硬的边界(AGENTS.md 决策 22 的"生产库保护铁律"):

任何实测只在副本上进行;阶段报告必须附两个基线库哈希。

适用边界应该由"错了能不能撤销"来划,而不是由"这件事重不重要"来划。 导入事故里"先试着导一下"这类操作,如果有这条规则就会发生在副本上,而不是在唯一的那份真实数据库上。


Related Articles

Knowledge Relations