get-shit-done `/gsd:health` 一致性检查:归档里程碑阶段引发的 W002 误报(3652)修复全解析
导读:本文围绕 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.md、ROADMAP.md、STATE.md、config.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[] |
严重问题(含 code、message、fix、repairable) |
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 shipped、Decision from Phase 12 这类文字,都会被视为一条“被引用的阶段号”,并与一个**合法阶段集合(validPhases)**做比对。凡是不在集合内、且非前导零变体(如 03 可等价于 3)的引用,就会输出:
[W002] STATE.md references phase N, but only phases ... are declared
这里的关键是 validPhases 由哪些来源构成。在本修复之前,该集合由两部分并集组成:
- 磁盘上的活跃阶段目录:即
.planning/phases/下所有目录,通过PHASE_TOKEN_FROM_DIR_RE抽取阶段号; - 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-current → 64;64A-... → 64A;64.1-... → 64.1;CK-64-foo → 64 |
MILESTONE_ARCHIVE_DIR_RE |
识别归档里程碑目录 | v1.3a-phases、v2.0-phases |
项目代码前缀(project-code prefix)是这套系统里常见的命名约定:例如某项目采用 CK-64-prior-shipped 这样的目录名,CK- 是项目代号,真正的阶段号是 64。旧实现如果临时用 /^\d+/- 风格的正则去扫归档目录,就会漏掉 CK-64-... 这类目录,重新引入误报。因此在代码注释与回归测试中都强调了:W002 与 W006 必须共享同一套常量,防止 W006 原有的归档扫描在修改中退化为 ad-hoc 正则(详见 validate.test.ts 的 #3652 用例说明)。
此外,校验对阶段号的归一化仍然保留了历史宽容度:整数前缀允许前导零(03 ↔ 3、03.1 ↔ 3.1),但带字母后缀的令牌(如 3A)必须精确匹配、绝不折叠为 3,以免把不同阶段误判为同一个。
六、W006/W007 的一致性闭环:归档不仅是 W002 的“补丁”
值得说明的是,归档目录回溯在这套校验体系里是一以贯之的设计原则,而非 W002 的专属补丁:
- W006(
Phase 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-phase 与 v1.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。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0627
Hy4-previewHy4 preview 是由腾讯混元团队研发的新一代混合专家(MoE)旗舰模型。模型总参数量 770B,每个 token 激活 49B,主干共包含78层,第一层采用标准 FFN,其余 77 层均为 MoE 结构,每层包含 256 个路由专家与 1 个共享专家,每个 token 激活 top-8 路由专家及共享专家。主干之外原生内置 1 层 MTP(总参数量 10B,激活 0.7B)以支持投机解码。Python00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00