首页
/ get-shit-done `/gsd:health` 一致性检查:归档里程碑阶段引发的 W002 误报(3652)修复全解析

get-shit-done `/gsd:health` 一致性检查:归档里程碑阶段引发的 W002 误报(3652)修复全解析

2026-09-07 09:47:43作者:谭伦延

导读:本文围绕 get-shit-done 仓库中的变更记录 lucky-lynx-wave.md 展开,深入剖析健康检查工具 /gsd:health 在跨里程碑演进场景下的一处经典误报缺陷(issue #3652,随 PR #3655 修复):当一个里程碑通过 /gsd:complete-milestone 归档后,STATE.md 正文对历史阶段的引用曾持续触发 W002 告警,导致项目长期处于 degraded 状态。读完本文,你将理解 W002/W006 校验的合法性来源、里程碑归档目录(milestones/vX.Y-phases/)在源码与测试中的处理方式,并掌握如何通过 gsd-sdk query validate.health 亲手验证该修复。

一、背景:健康检查如何判定 .planning/ 的“阶段引用合法性”

get-shit-done(简称 gsd)是一个面向 Claude Code 的轻量级元提示(meta-prompting)、上下文工程与规范驱动开发(spec-driven development)系统。它的核心工程工件集中在项目根目录的 .planning/ 下:PROJECT.mdROADMAP.mdSTATE.mdconfig.json,以及按 NN-name 规范命名的阶段目录(如 01-setup)。

/gsd:health 是用于诊断这一套目录结构完整性的斜杠命令,其入口定义在 health.md,实际流程委托给 health.md。工作流最终通过 SDK 查询接口执行底层诊断:

gsd-sdk query validate.health [--repair] [--backfill]

输出为 JSON,包含:

字段 含义
status healthy / degraded / broken 三态
errors[] 严重问题(含 codemessagefixrepairable
warnings[] 非严重告警(如 W002)
info[] 信息性提示
repairable_count 可自动修复的问题数
repairs_performed[] --repair 模式下实际执行的动作

状态判定逻辑非常直观(见 validate.ts):只要存在任意 errors 即为 broken;若没有错误但存在 warnings,则为 degraded;两者皆空才是 healthy这意味着任何一条本不该出现的告警,都会让项目长期停留在 degraded 状态——这正是 #3652 缺陷影响如此显著的直接原因。

二、W002 的判定规则:STATE.md 中的阶段引用需要“来源背书”

W002 属于健康检查中的 Check 4:STATE.md exists and references valid phases。它从 STATE.md 全文中用正则抽取所有阶段引用:

/[Pp]hase\s+(\d+[A-Z]?(?:\.\d+)*)/g

也就是说,STATE.md 的叙述性正文里只要出现 Phase 19 shippedDecision from Phase 12 这类文字,都会被视为一条“被引用的阶段号”,并与一个**合法阶段集合(validPhases)**做比对。凡是不在集合内、且非前导零变体(如 03 可等价于 3)的引用,就会输出:

[W002] STATE.md references phase N, but only phases ... are declared

这里的关键是 validPhases 由哪些来源构成。在本修复之前,该集合由两部分并集组成:

  1. 磁盘上的活跃阶段目录:即 .planning/phases/ 下所有目录,通过 PHASE_TOKEN_FROM_DIR_RE 抽取阶段号;
  2. ROADMAP.md 中声明过的所有阶段标题:用 /#{2,4}\s*Phase\s+(\d+[A-Z]?(?:\.\d+)*)\s*:/gi 扫描全文件(这一“以 ROADMAP 为阶段权威”的设计源于更早的 bug #2633 修复,见 validate.test.ts 中的回归说明)。

三、缺陷复现:跨里程碑归档后,历史阶段引用“无处安放”

项目的里程碑演进遵循固定的生命周期:当一个里程碑收尾后,/gsd:complete-milestone(命令见 complete-milestone.md)会把该里程碑的阶段目录整体搬移到归档路径:

.planning/
├── phases/                       # 当前里程碑的扁平阶段目录
│   └── 23-current/
└── milestones/
    ├── v1.3a-phases/             # 归档里程碑 A
    │   └── 12-old-phase/
    └── v1.3b-phases/             # 归档里程碑 B
        ├── 19-alpha/
        ├── 20-beta/
        └── ...

与此同时,ROADMAP.md 中已发布里程碑的 #### Phase N: 标题会被折叠进 <details> 折叠块,甚至改写为 - Phase 12: archived 这种列表条目,而不再是可被标题正则匹配的标题格式。

问题随之而来:STATE.md 的正文叙事段(## Recent## Decisions## Deferred Items)天然会保留跨里程碑的历史叙述——例如 ## Decisions 里写着 “Decision from Phase 12 still applies”。而 validPhases 的两个来源此时都失效了:

  • 磁盘扫描只看 .planning/phases/(活跃目录),看不到已搬进 milestones/v1.3a-phases/12-old-phase
  • ROADMAP 标题扫描匹配不到被折叠进 <details>、或被改写成列表项的历史阶段。

于是每提到一个历史阶段号,就产生一条 W002,而告警噪音会随着项目生命周期内累计的阶段总数线性增长——里程碑归档得越多,degraded 越成为常态。

四、修复方案:把“里程碑归档目录”并集进合法阶段集

变更记录(PR #3655)给出的修复思路非常直接:在 Check 4 的 validPhases 计算中,额外并集所有里程碑归档目录下出现的阶段,从而对 W002 也生效——此前的 W006(ROADMAP 阶段在磁盘上找不到目录)已经做过类似的归档回溯。

实现上,两条实现路径都新增/复用了同一个辅助函数 forEachArchivedPhaseToken。以 SDK 端的 validate.ts 为例,它先列出所有归档目录,再逐个抽取其中的阶段号并回调:

// Check 4 (W002) 新增的归档并集:任何 milestones/vX.Y-phases/ 下
// 的阶段目录都算合法阶段来源(Bug #3652)
await forEachArchivedPhaseToken(planBase, (token) => validPhases.add(token));

配合注释明确写道:归档后 #### Phase N: 标题被折叠,磁盘活跃阶段目录与 ROADMAP 标题扫描都覆盖不到,因此需要把归档目录中的阶段目录视为合法位置。CJS 运行时端(cmdValidateHealth,位于 verify.cjs)也做了完全对应的并集操作,两条实现保持端口级对等(port parity)。

归档阶段令牌如何抽取:forEachArchivedPhaseToken 的实现

async function forEachArchivedPhaseToken(
  planBase: string,
  onPhase: (token: string) => void,
): Promise<void> {
  for (const archiveDir of await listMilestoneArchiveDirs(planBase)) {
    try {
      const entries = await readdir(archiveDir, { withFileTypes: true });
      for (const e of entries) {
        if (!e.isDirectory()) continue;
        const m = e.name.match(PHASE_TOKEN_FROM_DIR_RE);
        if (m) onPhase(m[1]);
      }
    } catch { /* archive dir absent/unreadable */ }
  }
}

listMilestoneArchiveDirs 负责发现归档目录:它列出 .planning/milestones/ 下匹配 MILESTONE_ARCHIVE_DIR_RE 的子目录,并按版本号做数值排序(保证 v1.10 排在 v1.2 之后,而非字典序排在前面)。

五、两个共享正则:归档目录识别与项目代码前缀剥离

修复的另一个关键点是强调对既有共享常量的复用,而不是在 Check 4 里新写一套临时正则:

const PHASE_TOKEN_FROM_DIR_RE = /^(?:[A-Z]{1,6}-)?(\d+[A-Z]?(?:\.\d+)*)(?:-|$)/i;
const MILESTONE_ARCHIVE_DIR_RE = /^v\d+.*-phases$/i;
常量 用途 匹配示例
PHASE_TOKEN_FROM_DIR_RE 从阶段目录名中剥离可选的项目代码前缀并抽出阶段令牌 64-current6464A-...64A64.1-...64.1CK-64-foo64
MILESTONE_ARCHIVE_DIR_RE 识别归档里程碑目录 v1.3a-phasesv2.0-phases

项目代码前缀(project-code prefix)是这套系统里常见的命名约定:例如某项目采用 CK-64-prior-shipped 这样的目录名,CK- 是项目代号,真正的阶段号是 64。旧实现如果临时用 /^\d+/- 风格的正则去扫归档目录,就会漏掉 CK-64-... 这类目录,重新引入误报。因此在代码注释与回归测试中都强调了:W002 与 W006 必须共享同一套常量,防止 W006 原有的归档扫描在修改中退化为 ad-hoc 正则(详见 validate.test.ts 的 #3652 用例说明)。

此外,校验对阶段号的归一化仍然保留了历史宽容度:整数前缀允许前导零(03303.13.1),但带字母后缀的令牌(如 3A)必须精确匹配、绝不折叠为 3,以免把不同阶段误判为同一个。

六、W006/W007 的一致性闭环:归档不仅是 W002 的“补丁”

值得说明的是,归档目录回溯在这套校验体系里是一以贯之的设计原则,而非 W002 的专属补丁:

  • W006Phase N in ROADMAP.md but no directory on disk,Check 8)很早就已把归档里程碑阶段目录并入 diskPhases,用于覆盖 ROADMAP 中指向历史已归档阶段的标题(validate.test.ts 中的 #3473 回归用例对此有覆盖)。
  • W007(磁盘存在但 ROADMAP 未声明的阶段)则反向处理:只对“活跃”磁盘阶段发告警,避免归档阶段目录触发 W007(对应 #3560)。
  • 一致性检查处理器 validateConsistency(同样位于 validate.ts)通过 collectPhaseRoots 同时把扁平 .planning/phases/ 与“当前里程碑归档目录”(由 getActiveMilestoneArchiveDir 依据 STATE.md 的 milestone 字段解析,回退到版本号最高的归档)都作为合法阶段根进行扫描。

#3652 的修复只是把这条原则真正补全到了 Check 4(W002) 上,使两条检查路径对归档阶段的认知完全对齐。

七、回归测试:两种典型归档形态都被覆盖

本修复在 validate.test.ts 中有完整的回归测试,构造了两个高度贴近真实场景的夹具:

用例一:多个历史归档的 STATE.md 正文引用不再告警。 测试创建活跃阶段 23-current,同时创建 v1.3a-phases/12-old-phasev1.3b-phases/19..22 等归档目录;ROADMAP 中历史里程碑被折叠进 <details> 并写成 - Phase 12: archived 列表项;STATE.md 的 ## Recent / ## Decisions / ## Deferred Items 分别引用 Phase 19、12、19。断言结果为 W002 列表为空。

用例二:项目代码前缀归档目录的识别。 测试创建 CK-65-current(活跃)与 v2.0-phases/CK-64-prior-shipped(归档),ROADMAP 的 <details> 内保留 #### Phase 64: Prior shipped 标题,STATE.md 记录 - Phase 64 shipped。断言既不触发 W002,也不触发指向 Phase 64 的 W006——证明归档扫描确实沿用了共享正则,成功剥离了 CK- 前缀。

这两个用例从“纯叙述引用”和“前缀命名 + 标题共存”两个维度,锁死了 W002 误报的回归路径。

八、实操验证:如何确认你的项目处于修复后的行为

无论你是想复现旧缺陷,还是验证当前 SDK 行为,都可以直接在项目根目录执行(两种入口等价,SDK 查询是现代实现):

# 无修复参数的完整健康诊断
gsd-sdk query validate.health

# 若此前暴露过 W002 噪音,确认它来自归档阶段而非真实问题
gsd-sdk query validate.health --repair   # 仅修复可安全自动修复项

输出解读要点:

  • 关注 status 是否为 healthy(无 degraded / broken);
  • 若仍存在 W002,检查 message 中的阶段号:如果该阶段号确实存在于某个 .planning/milestones/vX.Y-phases/ 目录下,则说明运行的是修复前的旧版本 SDK;
  • 构造与回归测试相同的目录形态(在 milestones/ 下手工放置一个 vX.Y-phases/<phase>-... 目录),即可做一次“最小可复现验证”。

对历史遗留噪音项目,运行修复后的 /gsd:health 应当能一次性清除所有由归档阶段产生的 W002,将状态从长期 degraded 恢复为 healthy。注意:W002 属于不可自动修复项(见 health.md 的错误码表,其建议动作为人工 Review STATE.md),真正的解决之道就是让校验器正确认识归档阶段,这正是 #3655 所完成的。

九、总结:把“归档”当作一等公民,校验才不会误伤历史

从 #2633(以 ROADMAP 为阶段权威)到 #3473/#3560(W006/W007 对归档目录的识别),再到 #3652/#3655(W002 补全归档并集),get-shit-done 的健康检查走过了一条清晰的演进路径:STATE.md 是带历史包袱的“活文档”,任何把“磁盘现状”误当“全部合法状态”的校验,都会在里程碑归档后产生系统性误报。 修复的关键不是简单放宽校验,而是建立统一的“归档阶段目录”来源(共享 PHASE_TOKEN_FROM_DIR_RE / MILESTONE_ARCHIVE_DIR_RE 常量 + forEachArchivedPhaseToken 遍历原语),并让 W002 与 W006 在两侧实现(TypeScript SDK 与 CJS 运行时)共享同一套认知。

对开发者而言,这条修复同时提供了一个可复用的工程范式:当你为“历史数据”编写一致性校验时,应当先回答——历史数据在归档后的权威载体是什么,再把它们显式并集进合法域,而不是反复豁免特例。

相关参考文件:变更记录 lucky-lynx-wave.md、健康命令 health.md、工作流 health.md、SDK 实现 validate.ts、CJS 对等实现 verify.cjs、回归测试 validate.test.ts

登录后查看全文
热门项目推荐
相关项目推荐