GSD 里程碑阶段过滤器修复解析:让 CK-01 前缀阶段目录正确计入数字 ROADMAP 里程碑
本篇基于 get-shit-done(GSD)仓库中的 changeset 文档 .changeset/3600-milestone-phase-filter-project-code.md,完整解析 #3600 缺陷的产生机理与修复方案:当项目启用了 project_code(阶段目录形如 CK-01-name)时,init.new-milestone 等命令的阶段计数为什么漏掉这些目录、getMilestonePhaseFilter / isDirInMilestone 的三级匹配逻辑如何演进,以及 CJS 运行时与 SDK 双实现如何保持一致、测试如何锁死该行为契约。
背景:GSD 的里程碑、ROADMAP 与阶段目录命名
GSD 是一个面向 Claude Code 的轻量级元提示(meta-prompting)、上下文工程与规格驱动开发系统。它以 .planning/ 目录下的结构化文件作为项目状态的单一事实来源,其中最关键的两个文件是:
ROADMAP.md:以## Current Milestone: vX.Y.Z - 名称标记当前里程碑,其下用### Phase N: 阶段名这样的标题列出里程碑内的各个阶段;phases/目录:每个阶段对应一个子目录,目录名通常为NN-名称(如01-foundation)。
当项目配置了 project_code(例如 CK)后,阶段目录会带上项目代码前缀,形如 CK-01-foundation、CK-02-api。这个前缀的识别规则集中在 normalizePhaseName 中:
function normalizePhaseName(phase) {
const str = String(phase);
// Strip optional project_code prefix (e.g., 'CK-01' → '01')
const stripped = str.replace(/^[A-Z]{1,6}-(?=\d)/, '');
// Standard numeric phases: 1, 01, 12A, 12.1
const match = stripped.match(/^(\d+)([A-Z])?((?:\.\d+)*)/i);
...
}
即:前缀形态为 ^[A-Z]{1,6}-(?=\d)(1~6 位大写字母 + 连字符,且紧跟数字),剥离后若剩余部分是数字阶段号(支持零填充、字母后缀如 12A、小数阶段号如 12.1),则归一化为补零两位的数字形式;否则视为自定义阶段 ID 原样返回。#3600 的修复正是复用了这一"权威"前缀识别规则,避免两套正则漂移。
缺陷表现:CK-01-name 目录被里程碑过滤器跳过
changeset 文档记录的核心问题是:getMilestonePhaseFilter 之前会把 .planning/phases/CK-01-name 这类目录直接跳过——尽管当前里程碑的 ROADMAP 里明明写着 ### Phase 1:。
这个过滤器是 GSD 各里程碑相关命令判断"某阶段目录是否属于当前里程碑"的唯一入口。它读取 ROADMAP.md,抽取当前里程碑(支持 versionOverride 指定版本段落),用正则 #{2,4}\s*Phase\s+([\w][\w.-]*)\s*: 收集该里程碑下所有阶段号,归一化后放入一个 Set,然后返回一个谓词函数 isDirInMilestone(dirName),供调用方过滤 phases/ 下的子目录。
修复前 isDirInMilestone 只有两级匹配(对照 CJS 实现):
- 数字匹配:
dirName.match(/^0*(\d+[A-Za-z]?(?:\.\d+)*)/)——要求目录名以数字开头(允许零填充)。CK-01-name以字母C开头,此级必然失败; - 自定义 ID 匹配:
dirName.match(/^([A-Za-z][A-Za-z0-9]*(?:-[A-Za-z0-9]+)*)/)取出整段CK-01-name,然后拿完整名称去和里程碑 Set 中的裸 token(如1)比对,必然不相等。
两级都不中,CK-01-name 就被判定"不属于当前里程碑"。于是所有共享该过滤器的命令全部受影响:
init.new-milestone:新建里程碑时统计上一里程碑遗留的阶段目录数(phase_dir_count),带前缀目录被漏计;phase complete:按里程碑口径核对阶段完成情况;verify-work:验证工作产物时圈定当前里程碑的阶段范围;validate-health:健康检查时统计阶段/计划数量。
在 SDK 侧的状态构建路径 中也能看到这个过滤器的实际用法:buildStateFrontmatter 先取 getMilestonePhaseFilter 的谓词,readdir 出 phases/ 全部子目录后 .filter(isDirInMilestone),再逐目录统计 plan/summary 数量生成 STATE.md 的进度 frontmatter——目录漏判会直接导致进度失真。
#3600 修复:strip-and-retry 第三级匹配
修复没有改前两级逻辑,而是在其后追加了第三级 strip-and-retry 路径(见 core.cjs 中 isDirInMilestone 与 SDK 孪生实现):
// #3600: project-code-prefixed directory (`CK-01-name`) against a
// numeric ROADMAP heading (`### Phase 1:`). Strip the same prefix
// shape `normalizePhaseName` recognises (`^[A-Z]{1,6}-(?=\d)`) and
// retry the numeric match.
const stripped = dirName.replace(/^[A-Z]{1,6}-(?=\d)/i, '');
if (stripped !== dirName) {
const sm = stripped.match(/^0*(\d+[A-Za-z]?(?:\.\d+)*)/);
if (sm && normalized.has(sm[1].toLowerCase())) return true;
}
三个设计要点值得注意:
- 复用
normalizePhaseName的前缀正则。剥离用的^[A-Z]{1,6}-(?=\d)与全局阶段名归一化规则完全一致,保证"目录名 → 阶段号"的映射在整套系统里只有一个权威定义; - 放在自定义 ID 匹配之后执行。源码注释明确说明:如果 ROADMAP 使用
### Phase PROJ-42:这种带前缀标题,PROJ-42目录仍应优先走既有的自定义 ID 路径命中;strip-and-retry 只在里程碑以裸数字形式(Phase 1:)为键时才兜底生效,不会引入误判; stripped !== dirName守卫。只有确实剥离成功(即目录名符合前缀形态)才进入重试分支,无前缀目录的匹配行为与修复前完全一致,零回归风险。
谓词函数同时保留了两个元信息属性:isDirInMilestone.phaseCount(ROADMAP 中该里程碑声明的阶段数)与 isDirInMilestone.missingExplicitVersion(显式版本覆盖未命中时的标记),供上层命令区分"目录缺失"与"ROADMAP 未声明"两种语义。
双实现落地与调用方清单
changeset 文档强调该修复同时落入 CJS 运行时与 SDK 孪生实现:
- CJS 运行时:get-shit-done/bin/lib/core.cjs 的 isDirInMilestone(
getMilestonePhaseFilter的闭包内部); - SDK 孪生:sdk/src/query/state.ts。
从源码结构看,getMilestonePhaseFilter 的调用方分布在 CJS 侧的 milestone.cjs、init.cjs、phase.cjs、state.cjs、commands.cjs、uat.cjs,以及 SDK 侧的 phase-lifecycle.ts、init.ts、state-mutation.ts、progress.ts、uat.ts——即 changeset 所列的 init.new-milestone、phase complete、verify-work、validate-health 等命令共用这一份判定逻辑,修复一处谓词,全部调用方同时受益,无需逐个命令打补丁。
测试契约:正向计数、反向排除与跨 issue 兼容性
该行为的回归测试集中在 tests/milestone-archive.test.cjs,构成完整的正反契约:
test('init.new-milestone counts CK-NN-name dirs against numeric `Phase N:` headings', () => {
writeConfig(tmpDir, { project_code: 'CK' });
writeState(tmpDir, 'v1.0.0');
writeRoadmap(tmpDir, [
'# Roadmap', '',
'## Current Milestone: v1.0.0 - Test', '',
'### Phase 1: Discovery', '**Goal:** GoalOne', '',
'### Phase 2: Build', '**Goal:** GoalTwo', '',
].join('\n'));
ensurePhaseDir(tmpDir, 'CK-01-discovery');
ensurePhaseDir(tmpDir, 'CK-02-build');
const r = runGsdTools(['init', 'new-milestone', '--json'], tmpDir);
assert.strictEqual(JSON.parse(r.output).phase_dir_count, 2, ...);
});
正向用例验证了 CK-01-discovery、CK-02-build 两个带前缀目录对 Phase 1: / Phase 2: 数字标题全部命中(phase_dir_count === 2);反向用例则确保边界不被放大——CK-99-backlog、CK-100-future 这类目录号不在当前里程碑中的带前缀目录必须被排除(计数保持为 1),防止 strip-and-retry 退化成"凡是带前缀就算数"。
此外还有两组相邻测试守护了修复不破坏既有契约:
- tests/phase.test.cjs:
find-phase 01能解析出CK-01-foundation前缀目录并抽取数字阶段号01;phases list对带前缀目录按数字序正确排序(CK-01<CK-02<CK-03); - tests/bug-3599-roadmap-get-phase-project-code-prefix.test.cjs:守护 #3537 契约——
CK-01目录形态必须能解析到### Phase 1:散文标题,同时裸数字42不得误匹配### Phase PROJ-42:,即"带前缀 ID 精确命中、跨前缀不串号"。
小结:如何验证与排查同类问题
如果在你使用 GSD 的项目中遇到"里程碑阶段数/计划数统计偏少",可以按以下路径自查:
- 确认
STATE.md/ROADMAP.md的里程碑标题与phases/目录命名形态(是否启用project_code前缀、ROADMAP 用的是数字标题还是自定义 ID 标题); - 运行
init new-milestone --json观察phase_dir_count,对照 tests/milestone-archive.test.cjs 的构造方式手工搭建最小.planning结构复现; - 核对本地版本是否包含 #3600 修复:检查 get-shit-done/bin/lib/core.cjs 与 sdk/src/query/state.ts 中
isDirInMilestone是否含有标注#3600的 strip-and-retry 分支。
从源码结构看,这类"目录名形态 vs ROADMAP 散文形态"的桥接问题在 GSD 中不止一处(如 #3537 的零填充容忍、#3599 的项目代码前缀),但它们的解法高度一致:把形态归一化收敛到少数几个共享的正则(前缀识别 ^[A-Z]{1,6}-(?=\d)、数字形态 ^0*(\d+[A-Za-z]?(?:\.\d+)*)),再在每个判定入口做"先精确、后兜底"的多级匹配,并以正反向测试锁死行为边界。#3600 正是这一工程模式的一次典型落地。
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 StartedRust0622
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