get-shit-done 阶段删除深度解析:修复 `phase remove --force` 旗标位置敏感问题(Bug 3409)
本文围绕 changeset 记录 3409-phase-remove-force-position.md 展开,讲解 get-shit-done(GSD)规划系统中 phase remove 子命令的 --force 旗标修复细节:当旗标出现在位置参数之前时,旧版参数解析会把 --force 误当作阶段 ID,导致"假成功"响应与 STATE.md 中阶段计数(phase-count)的非预期漂移。读完本文,你将掌握该命令在 CLI 与 SDK 两条查询路径下的完整行为语义、--force 保护机制的底层实现,以及删除阶段后目录重编号、ROADMAP.md 更新与 STATE.md 计数同步的调用链。
一、changeset 记录本身说了什么
GSD 仓库采用按 PR 拆分的 CHANGELOG 片段机制:所有带用户可见变更的 PR 都会在 .changeset 目录投下一个独立 fragment 文件,发布时由脚本统一合并进顶层 CHANGELOG.md,从而避免多个 PR 同时编辑 CHANGELOG.md 产生合并冲突(机制说明见 .changeset/README.md)。
本次修复的 fragment 内容非常凝练,完整原文如下:
---
type: Fixed
pr: 3416
---
**`phase remove --force <phase>` now works correctly across both CLI and
SDK query paths (#3409)** — flag parsing no longer treats `--force` as the
phase id when the flag appears before the positional argument, preventing
false success responses and unintended `STATE.md` phase-count drift.
翻译成工程语言,这条修复包含三个关键信息点:
- 触发条件:用户把
--force写在阶段号之前,例如phase remove --force 6(而不是phase remove 6 --force); - 旧行为:参数解析把
--force这个旗标字符串当成了位置参数(阶段 ID),后续删除流程基于一个不存在的"阶段"执行,返回了假成功响应,同时却把STATE.md里的阶段总数减一,造成规划状态的计数漂移(drift); - 修复范围:CLI 路径与 SDK query 路径两条链路同时修复,而非只修其中一条。
二、phase remove 命令的正确用法与行为语义
phase remove 是 GSD 规划体系(.planning/ 目录)中的阶段管理命令,官方文档 CLI-TOOLS.md 中给出的规范用法为:
node gsd-tools.cjs phase remove <phase> [--force]
该命令的完整行为语义(从源码可确认):
| 步骤 | 行为 | 源码依据 |
|---|---|---|
| 1. 参数校验 | 必须且只能有一个阶段号位置参数;出现其他 --xxx 旗标直接抛 Validation 错误 |
phase-lifecycle.ts |
| 2. 定位阶段目录 | 在 .planning/phases/ 下按阶段 token 匹配目录(支持 06-dashboard 这类带 slug 的目录名) |
phase-lifecycle.ts |
| 3. 已执行工作保护 | 非 --force 时,若阶段目录内存在 *-SUMMARY.md 或 SUMMARY.md,拒绝删除 |
phase-lifecycle.ts |
| 4. 删除目录 | 递归删除目标阶段目录 | phase-lifecycle.ts |
| 5. 重编号 | 对后续阶段目录与内部文件执行重编号(整数阶段 07-api→06-api;小数/插入阶段 06.2→06.1) |
phase-lifecycle.ts |
| 6. 更新 ROADMAP.md | 移除对应章节、重编号后续章节,并修正 Depends on: Phase N 依赖引用 |
phase-lifecycle.ts |
| 7. 更新 STATE.md | 在文件锁保护下将 total_phases 减一,同步正文 "Plan: X of N" 与 "Total Phases" 字段 |
phase-lifecycle.ts |
成功时返回结构化结果:{ removed, directory_deleted, renamed_directories, renamed_files, roadmap_updated, state_updated }(见 phase-lifecycle.ts),CLI 路径的 cmdPhaseRemove 输出同构的 JSON 结果,方便脚本消费。
三、Bug #3409 的根源:旗标与位置参数的顺序耦合
--force 旗标的本意是跳过"已执行工作保护"——当阶段目录下已存在执行产物(SUMMARY 文件)时,普通 phase remove 会拒绝并提示:
Phase 6 has 1 executed plan(s). Use --force to remove anyway.
这段保护逻辑在 CLI 路径 phase.cjs 与 SDK 路径 phase-lifecycle.ts 中是一致的:
// get-shit-done/bin/lib/phase.cjs (L1026-L1033)
// Guard against removing executed work
if (targetDir && !force) {
const files = fs.readdirSync(path.join(phasesDir, targetDir));
const summaries = files.filter(f => f.endsWith('-SUMMARY.md') || f === 'SUMMARY.md');
if (summaries.length > 0) {
error(`Phase ${targetPhase} has ${summaries.length} executed plan(s). Use --force to remove anyway.`);
}
}
问题在于旧版参数解析对旗标出现位置敏感:当用户写出 phase remove --force 6 时,解析器没有识别 --force 是旗标,而是把它当作了阶段 ID 的候选值。其后果链条是:
- 目标目录查找失败(不存在叫
--force的阶段); - 但由于查找失败后流程未正确中断,
ROADMAP.md更新与STATE.md计数减一等下游副作用仍然执行; - 命令最终返回"成功",
STATE.md中total_phases却少了一个不存在的阶段——这正是 changeset 中所说的 false success responses and unintendedSTATE.mdphase-count drift。
四、修复后的实现:位置无关的旗标解析
修复后的 SDK query 处理器 phaseRemove 采用逐 token 扫描的方式分离旗标与位置参数,旗标出现在任何位置都等价(phase-lifecycle.ts):
export const phaseRemove: QueryHandler = async (args, projectDir, workstream) => {
let force = false;
const positional: string[] = [];
for (const token of args) {
if (token === '--force') {
force = true;
continue;
}
if (token.startsWith('--')) {
throw new GSDError(`phase remove does not support ${token}`, ErrorClassification.Validation);
}
positional.push(token);
}
if (positional.length > 1) {
throw new GSDError('phase remove accepts exactly one phase number', ErrorClassification.Validation);
}
const targetPhase = positional[0];
if (!targetPhase) {
throw new GSDError('phase number required for phase remove', ErrorClassification.Validation);
}
...
这个解析循环有三层防御,逐层对应一类非法输入:
--force是唯一的白名单旗标:无论出现在args数组的哪个下标,一律置位force并跳过,永远不会进入positional;- 未知
--前缀旗标 fail-fast:如--dry-run这类不支持的旗标直接抛Validation分类错误,而不是被静默当作阶段号; - 位置参数数量强约束:超过一个位置参数即报错,且阶段号缺失时报
phase number required——即使解析阶段被绕过,后续"目录不存在"检查(phase-lifecycle.ts 抛Phase ${targetPhase} not found)也会在改动任何文件之前中止流程。
CLI 路径则由 cmdPhaseRemove 承担,其中 const force = options.force || false 表明旗标已由上层路由解析为结构化 options 对象,天然与位置参数解耦——两条路径经过 #3416 的修复后在语义上对齐,这正是 changeset 强调 "across both CLI and SDK query paths" 的含义。
五、回归测试:三种旗标排列组合的验证
修复随附的回归测试位于 phase-lifecycle.test.ts,针对同一个含 SUMMARY 文件的阶段目录构造了三种调用,恰好覆盖旗标的三种语义状态:
// 1. 不带 --force:必须被保护机制拦截
await expect(phaseRemove(['6'], tmpDir)).rejects.toThrow('--force');
// 2. --force 在阶段号之后(传统用法)
const result = await phaseRemove(['6', '--force'], tmpDir);
expect(data.removed).toBe('6');
expect(data.directory_deleted).toBeTruthy();
// 3. bug-3409: --force 在阶段号之前
const result = await phaseRemove(['--force', '6'], tmpDir);
expect(data.removed).toBe('6');
expect(data.directory_deleted).toBeTruthy();
第三个用例(['--force', '6'])就是 bug 复现用例:修复前该调用会因旗标被误认为阶段 ID 而无法删除目标阶段;修复后断言 removed === '6' 且目录确实被删除。此外测试套件还覆盖了相邻的失败路径,例如 ROADMAP.md 缺失时报 ROADMAP.md not found(phase-lifecycle.test.ts)、目标阶段不存在时不得改动 STATE.md(phase-lifecycle.test.ts)——后者直接锁死了"假成功导致计数漂移"这一回归方向。
六、实践建议与适用边界
基于本次修复,使用 phase remove 时建议遵循以下实践:
- 旗标位置自由,但阶段号必须且只能有一个:
phase remove 6 --force与phase remove --force 6现在完全等价;phase remove 5 6这类多阶段调用会被Validation错误拒绝; - 把
--force当破坏性开关使用:它唯一的作用是放行"目录内已有 SUMMARY 执行产物"的阶段。SUMMARY 文件是 GSD 执行器完成 plan 后写入的产物,删除不可恢复,生产项目中建议先归档再强制删除; - 关注删除后的连锁重编号:删除阶段 6 会使
07-api变06-api、ROADMAP 章节与Depends on引用一并下移,STATE.md的total_phases与 "of N" 计数同步减一(见 phase-lifecycle.ts)。因此该命令最好在提交边界(commit 边界)附近使用,便于用版本控制回滚整个重编号结果; - 小数阶段同样适用:
phase remove 6.2 --force会触发小数阶段重编号(06.3→06.2),重编号为 best-effort(失败时静默跳过),但目录删除与 ROADMAP/STATE 更新不受影响(phase-lifecycle.ts)。
从源码结构看,本修复的价值不止于单一 bug:phaseRemove 处理器被标注为 Port of cmdPhaseRemove from phase.cjs lines 597-661(phase-lifecycle.ts),即 CLI 与 SDK 是同一命令的两份实现投影。任何只修一条路径的改动都会造成两条链路行为分叉——#3409 的 changeset 明确要求"both CLI and SDK query paths"同时生效,配合两侧各自的测试,构成 GSD 命令投影一致性(shell-command-projection)体系的典型范例。
七、相关文件索引
| 文件 | 说明 |
|---|---|
| .changeset/3409-phase-remove-force-position.md | 本 bug 的 changeset 记录(type: Fixed, PR #3416) |
| .changeset/README.md | changeset fragment 机制说明 |
| docs/CLI-TOOLS.md | phase remove <phase> [--force] 官方命令格式 |
| sdk/src/query/phase-lifecycle.ts | SDK 路径 phaseRemove 处理器(修复后实现) |
| get-shit-done/bin/lib/phase.cjs | CLI 路径 cmdPhaseRemove 实现 |
| sdk/src/query/phase-lifecycle.test.ts | --force 三种排列的回归测试 |
| CHANGELOG.md | phase remove <N> [--force] 能力条目 |
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 StartedRust0623
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