get-shit-done (GSD) Skill Surface 剪枝机制修复:applySurface 在集群禁用时清除 ~/.claude/skills 下遗留的 gsd-*/ 目录
【免费下载链接】get-shit-doneA light-weight and powerful meta-prompting, context engineering and spec-driven development system for Claude Code by TÂCHES.项目地址: https://gitcode.com/GitHub_Trending/getshi/get-shit-done
本文以 GSD 仓库中一份Fixed类型的 changeset(PR 3766)为核心,完整还原一次“运行时技能面(skill surface)”剪枝逻辑的缺陷修复:applySurface在禁用技能集群后为何会在~/.claude/skills/下遗留gsd-STEM/旧目录、根因(runtimeConfigDir取错目录导致剪枝跑在不存在的路径上)是如何定位的,以及pruneSkillDirs()如何被抽取为技能目录删除的单一事实来源(single point of truth),并附带 manifest 成员资格校验以防止误删用户自建gsd-*目录的数据丢失风险。读完后你能掌握 GSD surface 状态文件(.gsd-surface.json)的生效集合计算公式、剪枝的所有权判定规则,以及一套覆盖五类边界场景的回归测试写法。
一、changeset 声明的三件事
原始变更记录位于 .changeset/wise-pumas-glide.md,frontmatter 标记为type: Fixed、关联pr: 3766,正文声明了三条修复承诺:
applySurface现在会在集群(cluster)被禁用时剪除~/.claude/skills/gsd-STEM/目录——与安装/卸载(install/uninstall)的行为保持一致;- 根因说明:此前禁用的集群之所以会在磁盘上留下陈旧的 skill 目录,是因为 surface.md 规格文档指示 AI 把 skills 子目录(
~/.claude/skills)当作runtimeConfigDir来用,而不是基础配置目录(~/.claude); - 重构说明:将
pruneSkillDirs()抽取为技能目录删除逻辑的“单一事实来源”(single point of truth)。
下面逐条给出仓库内的实现证据。
二、背景:GSD 的 surface 机制与生效集合公式
Surface 模块实现于 surface.cjs(ADR-0011 Phase 2,Option B)。它管理的是运行期启用/禁用状态,独立于安装期的 profile 标记(.gsd-profile):
- 状态文件为各运行时配置目录根下的
.gsd-surface.json(如~/.claude/.gsd-surface.json),常量定义为SURFACE_FILE_NAME(surface.cjs#L37); - 状态结构(
SurfaceState,见 surface.cjs#L43-L49)包含四个字段:
{ "baseProfile": "full", // 字符串:基础 profile,如 core / standard / full "disabledClusters": [...], // 数组:被禁用的集群名 "explicitAdds": [...], // 数组:显式追加的技能 stem "explicitRemoves": [...] // 数组:显式移除的技能 stem }readSurface()对文件做结构性校验(缺字段或类型不符一律返回null,surface.cjs#L57-L77),writeSurface()通过平台写入缝隙(tmp+rename)原子落盘(surface.cjs#L85-L87)。
生效技能集合的解析在resolveSurface()中完成(surface.cjs#L126-L192),公式为模块头注释所写:
Effective skill set = base profile ∪ explicitAdds − disabledClusters − explicitRemoves, 然后经 manifest 做传递闭包(transitively closed)。具体步骤(surface.cjs#L153-L181):
- 基础 profile 解析:surface 存在时取
surface.baseProfile,否则回退到安装期标记readActiveProfile(),再缺省为full;full会把 manifest 中所有 stem 物化进集合; - 移除
disabledClusters展开后的技能(集群名 → stem 的展开由clustersToSkills()完成); explicitAdds按 manifest 的依赖关系做传递闭包(队列迭代,把每个新增 stem 的依赖一并加入);explicitRemoves只删 stem 本身,不级联;- 最后由技能集合推导 agents 集合(manifest 中
_calls_agents_<stem>键,surface.cjs#L183-L188)。
集群定义在 clusters.cjs:core_loop(new-project、discuss-phase、plan-phase、execute-phase、help、update)、audit_review、milestone、research_ideate(sketch、spike、forensics、explore、graphify、ns-ideate)等,注释说明集群成员可以重叠(一个技能可属于两个集群)。面向用户的入口是/gsd:surface命令,规格见 surface.md,子命令为list · status · profile <name> · disable <cluster> · enable <cluster> · reset。
三、缺陷解剖:剪枝为何从未在正确目录上执行
applySurface()负责把解析后的 surface 重新落盘(surface.cjs#L207-L218):
function applySurface(runtimeConfigDir, layout, manifest, clusterMap) { if (path.resolve(runtimeConfigDir) !== path.resolve(layout.configDir)) { throw new TypeError('applySurface runtimeConfigDir must match layout.configDir'); } const resolved = resolveSurface(layout.configDir, manifest, clusterMap); for (const kind of layout.kinds) { const staged = kind.stage(resolved); const dest = path.join(layout.configDir, kind.destSubpath); _syncGsdDir(staged, dest, kind, manifest); } return resolved; }对每个 artifact kind,目标目录dest = path.join(layout.configDir, kind.destSubpath)。对 Claude 全局安装,skills这个 kind 的destSubpath是'skills'。这里就是缺陷的关键:
- 正确调用:
runtimeConfigDir = ~/.claude→dest = ~/.claude/skills,剪枝在真实技能目录上执行; - 错误调用(修复前 surface.md 规格所指示):
runtimeConfigDir = ~/.claude/skills→dest = path.join('~/.claude/skills', 'skills') = ~/.claude/skills/skills——一个不存在的嵌套目录。
回归测试头部的注释精确复现了这条错误链(bug-3659 测试文件#L1-L30):_syncGsdDir的剪枝逻辑本身是对的,但因为剪枝作用在错误(且不存在)的目录上,被禁用集群对应的gsd-*目录永远不会从~/.claude/skills/中被移除——pruneSkillDirs()的第一行就是if (!fs.existsSync(skillsDir)) return;,空目录静默返回,缺陷因此长期潜伏。install/uninstall 路径(_removeGsdEntries)本来就传~/.claude所以工作正常,只有 surface 路径受影响,这正是 changeset 强调“matches the install/uninstall behavior”的原因。
四、修复一:pruneSkillDirs() 成为单一事实来源
修复后,剪枝逻辑集中在pruneSkillDirs(skillsDir, retainedNames, prefix, manifest)(surface.cjs#L248-L301),_syncGsdDir()对 skills kind 的同步委托给它(surface.cjs#L321-L346):先把 staged 目录整体覆盖拷贝进目标目录保证内容最新,再调用剪枝:
// Prune GSD-owned dirs that are no longer in the staged set. // pruneSkillDirs() is the single point of truth for this logic. pruneSkillDirs(destDir, stagedDirs, kindPrefix, manifest);该函数同时导出,供测试与需要独立剪枝的调用方使用(surface.cjs#L421-L430 注释明确“Exported for testing and for callers that need stand-alone pruning”)。
其所有权判定(ownership criteria)是整个函数的安全核心:
- 非空前缀路径(如
gsd-):目录名以前缀开头是必要但不充分条件——目录名去掉前缀后的 stem 还必须存在于 manifest 的正规 stem 集合中(过滤掉_calls_agents_元键),才被视为 GSD 拥有。前缀匹配但不在 manifest 中的目录(即用户自建、恰好以gsd-命名的技能)被保留并向 stderr 输出警告:
process.stderr.write( `[gsd] Warning: ${entry} matches GSD prefix '${prefix}' but is not in the manifest — preserving (user-owned or unknown)\n` );这是针对数据丢失类缺陷(测试注释中称“Finding 1”)的关键闸门:若只靠前缀匹配就删除,用户的gsd-mything/会在一次“禁用全部集群”操作中被静默抹掉。
- 空 prefix 路径(Hermes 等无前缀运行时):目录名直接出现在正规 manifest 中才视为 GSD 拥有;
- 防御性保守降级:manifest 不是
Map实例(safeManifest判定,surface.cjs#L251-L259)或完全缺失时,无法确认所有权,一个目录都不删;单个目录删除失败只写 stderr 不中断遍历(fs.rmSync(..., { recursive: true, force: true })包裹在 try/catch 中)。
五、修复二:surface.md 规格纠正 runtimeConfigDir 语义
surface.md 的 “runtimeConfigDir resolution” 一节明确写入了修复后的约定:
runtimeConfigDirforapplySurfaceis thebase Claude config directory(~/.claude), NOT the skills sub-directory (~/.claude/skills). This matchesinstallRuntimeArtifactsanduninstallRuntimeArtifacts, which also receive~/.claudeasconfigDir. The skill dirs themselves live at~/.claude/skills/gsd-*/because theclaude globallayout hasdestSubpath = 'skills'— they are derived fromconfigDir, not the root for it.
并给出 shell 侧参考:
# Claude Code — global install RUNTIME_CONFIG_DIR="${CLAUDE_CONFIG_DIR:-$HOME/.claude}" SCOPE="global" # Artifact destinations are derived from runtime layout # via resolveRuntimeArtifactLayout(runtime, RUNTIME_CONFIG_DIR, SCOPE) # then applySurface(RUNTIME_CONFIG_DIR, layout, manifest, CLUSTERS)配套的两个约定也随之统一:surface 状态文件落在${RUNTIME_CONFIG_DIR}/.gsd-surface.json(即~/.claude/.gsd-surface.json,与 install/uninstall 的标记文件位置一致);所有子命令(profile/disable/enable)都以scope='global'调用resolveRuntimeArtifactLayout(),使 skills kind 生效(surface.md#L63-L103)。applySurface内部的runtimeConfigDir !== layout.configDir强校验(见第三节的TypeError)则从代码层面杜绝了再次传错目录的可能。
六、回归测试矩阵:五类场景锁定剪枝契约
回归测试 bug-3659-applysurface-prune-skill-dirs.test.cjs 用临时目录模拟 Claude 全局安装布局(configDir类比~/.claude,其下skills/预置gsd-explore/、gsd-help/和一个用户目录my-custom-skill/,见 createFixture#L55-L70),随后通过真实的loadSkillsManifest()+resolveRuntimeArtifactLayout('claude', configDir, 'global')+applySurface()驱动,断言五类行为:
| 用例 | 场景 | 断言 |
|---|---|---|
| (a) L86-L116 | 禁用research_ideate集群 | 属于该集群的gsd-explore/被删除;属于未禁用core_loop的gsd-help/保留 |
| (b) L118-L138 | 保留集群的成员不受波及 | gsd-help/存在 |
| (c) L140-L163 | 非gsd-前缀的用户目录 | my-custom-skill/及其SKILL.md原样保留 |
| (d) L165-L204 | 幂等性 | 连续两次applySurface后readdirSync(skillsDir)完全一致,被剪目录持续缺席、用户目录持续存活 |
| (e) L206-L256 | 禁用全部集群的反向用例 | GSD 拥有的gsd-explore/、gsd-help/全删;my-custom-skill/保留;用户自建的gsd-mything/(前缀匹配但不在 manifest)必须保留——这是 Finding 1 数据丢失修复的回归护栏 |
用例 (e) 尤其重要:它证明“禁用所有集群”这一最极端操作也不会把用户资产连根拔掉,manifest 成员资格闸门与前缀检查构成双重约束。
七、行为总结与适用边界
把 changeset 的声明落到可验证的仓库事实上,可以归纳出修复后的剪枝契约:
- 触发时机:任何改写 surface 状态后重放
applySurface的操作(/gsd:surface的profile、disable、enable子命令)都会重新同步 skills 目录;不在保留集中的 GSD 拥有目录即被fs.rmSync递归删除,行为与 install/uninstall 对齐; - 删除资格:目录必须同时满足“前缀匹配”(Claude 等运行时)与“stem 在 manifest 中”两条;空 prefix 运行时以 manifest 成员资格为唯一判据;manifest 不可用时一律保守不删;
- 状态文件位置:
~/.claude/.gsd-surface.json(与.gsd-profile同级),CLAUDE_CONFIG_DIR环境变量可整体覆盖路径; - 可观测性:
listSurface()(surface.cjs#L386-L415)返回启用/禁用清单与 token 成本估算(按各技能description长度 ÷ 4 向上取整求和,与审计脚本口径一致),/gsd:surface status即基于此。
从源码结构看,pruneSkillDirs()被导出并单独命名,意味着后续若新增“按 profile 收敛”之外的独立清理入口(例如升级迁移时回收旧版目录),可以直接复用同一函数而不复制判定逻辑——这正是 changeset 中“single point of truth”表述的工程含义:所有权判定、manifest 闸门与保守降级规则只在一处维护,安装、卸载、surface 三条路径共享同一行为语义。
适用前提与限制:以上分析基于当前仓库中 Claude 运行时的globalscope 布局(destSubpath='skills'、prefixgsd-);其他运行时(如 Hermes 空 prefix 场景)走同一函数的不同分支,且scope与destSubpath均由resolveRuntimeArtifactLayout()(runtime-artifact-layout.cjs)按 runtime 解析,而非硬编码。
【免费下载链接】get-shit-doneA light-weight and powerful meta-prompting, context engineering and spec-driven development system for Claude Code by TÂCHES.项目地址: https://gitcode.com/GitHub_Trending/getshi/get-shit-done
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考