首页
/ GSD 的 SDK-first 架构缝重构:规划路径投影、工作流清单、STATE.md 变换与 CJS 命令路由的收敛实战

GSD 的 SDK-first 架构缝重构:规划路径投影、工作流清单、STATE.md 变换与 CJS 命令路由的收敛实战

2026-09-05 11:03:27作者:卓艾滢Kingsley

本文基于 get-shit-done(GSD,面向 Claude Code / OpenCode / Gemini CLI / Codex CLI 的元提示词、上下文工程与规格驱动开发系统)仓库中 changeset 3312-sdk-first-architecture-seams.md(对应 PR #3316,“Refactor SDK-first architecture seams”)展开,逐条剖析该重构覆盖的四条“架构缝(seam)”:规划路径投影、工作流清单、STATE.md 进度投影以及 CJS 命令路由。读完后你将理解 GSD 如何用“单一来源纯投影 + 生成式 CJS 桥接 + 薄适配器”的模式消除 CJS 与 SDK 双实现之间的行为漂移(drift),并能在本仓库中定位每一处实现与对应的测试证据。

1. changeset 原文解读:一次“收口”式重构

changeset 文件本身极简,但信息密度很高:

type: Changed
pr: 3316

正文一句话概括了全部改动范围:

Tighten SDK-first architecture seams across planning path projection, workstream inventory, STATE.md transforms, and CJS command routing. Shared CJS/SDK helpers now reduce drift, and STATE.md progress projection preserves curated wider aggregates without hiding real disk-derived progress.

拆开看,它声明了三类事实:

  1. 重构覆盖四条缝:规划路径投影(planning path projection)、工作流清单(workstream inventory)、STATE.md 变换(transforms)、CJS 命令路由(command routing);
  2. 手段:引入 CJS 与 SDK 共享的 helper,压低两侧实现漂移;
  3. 关键行为语义:STATE.md 的进度投影要“保住人工策展的更宽聚合值(curated wider aggregates),同时又不掩盖磁盘扫描得到的真实进度”。

这条改动是 GSD “SDK-first”架构策略的具体落地。该策略的总体边界定义在 ADR 0005-sdk-architecture-seam-map.md 中:SDK 被显式建模为“带缝的模块组合 + 薄调用点适配器”,任何行为变化都应收敛到所有权的 seam 模块内,否则即视为设计违规。本次 changeset 涉及的两条缝另有专门 ADR:0004-worktree-workstream-seam-module.md(规划/工作树/工作流路径状态策略)与 0006-planning-path-projection-module.md(规划路径投影策略)。

2. 规划路径投影缝:planning-workspace.cjs 集中持有路径推导

changeset 说的“planning path projection”,在当前仓库中由 planning-workspace.cjs 独家持有。模块头注释直接声明了所有权边界:

/**
 * Planning Workspace — .planning path resolution + active workstream routing.
 *
 * This module owns the planning workspace seam:
 * - planningDir/planningRoot/planningPaths
 * - active workstream pointer policy (session-scoped > shared)
 * - pointer storage adapters (session/shared/memory)
 */

2.1 三个路径函数:唯一的 .planning 布局真相源

planningDir(cwd, ws, project)L60-L77)负责把“工作目录 + 工作流 + 项目”三个维度投影成磁盘路径,并内置了两条防御规则:

  • 未显式传参时回退读取环境变量 GSD_PROJECT / GSD_WORKSTREAM
  • BAD_SEGMENT = /[/\\]|\.\./ 拒绝项目/工作流名中的路径分隔符与 .. 穿越分量,命中即抛 GSD_PROJECT contains invalid path characters 之类的错误。

planningPaths(cwd, ws)L83-L94)则把目录一次性展开为固定键集合,供所有命令共用:

相对 .planning 的路径 说明
state STATE.md 状态文档(frontmatter + 正文)
roadmap ROADMAP.md 阶段路线图
project PROJECT.md 项目文档
config config.json 工作流配置
phases phases/ 阶段目录
requirements REQUIREMENTS.md 需求文档

“路径投影”之所以重要,是因为 GSD 的 CJS 命令与 SDK 查询处理器过去都各自拼路径;收敛到这一处后,workstreams/<ws> 的布局(path.join(base, 'workstreams', ws))变成单点事实,双端不可能再算出不一致的目录。

2.2 active workstream 指针:session 优先于 shared 的适配器策略

工作流“当前激活”指针的存储被抽象成三类适配器(L169-L242):

  • sharedcreateSharedPointerAdapter):写入 .planning/active-workstream,全终端共享;
  • sessioncreateSessionScopedPointerAdapter):依据 11 个会话环境变量(GSD_SESSION_KEYCLAUDE_SESSION_IDOPENCODE_SESSION_IDCODEX_THREAD_IDTMUX_PANE 等)派生 sessionKey,落到 os.tmpdir()/gsd-workstream-sessions/<sha1(planningRoot)>/<sessionKey>,并回退到控制 TTY token——这让并行的多个 AI 会话各自持有独立激活工作流,互不踩踏;
  • memorycreateMemoryPointerAdapter):纯内存值,用于测试注入。

选择逻辑在 pickActiveWorkstreamAdapter:只要探测到会话标识就走 session 适配器,否则回落 shared。读取侧还有两层自清洁:activeWorkstream.get() 会校验指针名字符合命名策略(workstream-name-policy.cjs)且 .planning/workstreams/<name> 目录真实存在,任一不满足即清空指针并返回 null

同模块的 withPlanningLockL248-L307)展示了“缝内细节”的典型深度:用 wx 标志原子创建 .lock,对 PLANNING_LOCK_RETRY_ERRNOSEPERM/EBUSY/EAGAIN/EINTR/EINVAL/EIO/ENOENT/ESTALE——覆盖 Docker overlay-fs、NFS、Windows/macOS 杀软场景)做重试而非传播,超过 30 秒的陈旧锁会被直接摘除,整个超时窗口 10 秒。这些跨平台容错被封闭在缝内,调用方无感知。

值得一提的是模块尾部的 findContextMdInL391-L401):它把“CONTEXT.mdNN-CONTEXT.md 前缀形式”这一判据从 init.cjs、roadmap.cjs、core.cjs、gap-checker.cjs 等 5 个调用点的重复实现中提取为规范谓词(注释明确引用了 issue #3739)。这是 changeset 所述“Shared CJS/SDK helpers now reduce drift”的典型例证——去重本身就是收敛漂移的手段。

3. 工作流清单缝:纯投影 builder + 生成式 CJS,双端共享一份逻辑

这是四条缝中“共享 helper 降漂移”体现得最彻底的一条,分为三层。

3.1 CJS 侧:I/O 编排与投影彻底分离

workstream-inventory.cjs 的文件头注释写明了分工:

Pure projection logic lives in workstream-inventory-builder.generated.cjs. This module handles I/O orchestration only.

inspectWorkstreamL58-L87)只做三件事:读 phases/ 子目录、经 plan-scan.cjs 统计每个阶段的 plan/summary 文件数、从 STATE.md 抽取 Status / Current Phase / Last Activity 字段投影(readStateProjection),然后把这份“预收集的输入”交给纯函数 buildWorkstreamInventorylistWorkstreamInventoriesL89-L116)则定义了 flat/workstream 两种运行模式:当 .planning/workstreams/ 不存在时返回 mode: 'flat' 与提示 No workstreams — operating in flat mode。命令处理器只消费这份 inventory,不再各自重扫目录——这正是 0004 ADR 要求的路径-状态策略集中化。

3.2 SDK 侧:唯一的逻辑真相源

纯投影逻辑的源头在 sdk/src/workstream-inventory/builder.ts。文件头注释强调 “No I/O. No async”——调用方负责收集 BuilderInputs,模块只做无状态变换。其输出类型是完整可校验的清单结构:

export interface WorkstreamInventory {
  name: string;
  path: string;
  active: boolean;
  files: { roadmap: boolean; state: boolean; requirements: boolean };
  status: string;
  current_phase: string | null;
  last_activity: string | null;
  phases: WorkstreamPhaseInventory[];
  phase_count: number;
  completed_phases: number;
  roadmap_phase_count: number;
  total_plans: number;
  completed_plans: number;
  progress_percent: number;
}

其中每个 WorkstreamPhaseInventory 携带 directorystatus: 'complete' | 'in_progress' | 'pending'plan_countsummary_count,即清单投影中阶段状态判定与进度百分比的唯一算法。

3.3 生成 + 新鲜度门禁:漂移在 CI 阶段即被拦截

SDK 是 TypeScript/ESM(sdk/package.jsontype: module、构建产物 dist/index.js),而安装到各 AI 运行时下的 GSD 命令是 CJS。两侧共享同一逻辑的方式是:以 SDK 的 builder.ts 为源,生成 CJS 版本 workstream-inventory-builder.generated.cjs

sdk/package.json 的 scripts 里成对出现:

"gen:workstream-inventory-builder": "npm run build && node scripts/gen-workstream-inventory-builder.mjs",
"check:workstream-inventory-builder-fresh": "npm run build && node scripts/check-workstream-inventory-builder-fresh.mjs"

gen:* 负责重新生成,check:* 负责断言生成物与源一致——一旦有人在 CJS 侧手改生成文件或忘记重新生成,门禁即失败。仓库根 package.json 通过 check:workstream-inventory-builder-fresh 把该检查挂到了主仓流程中,测试 workstream-inventory-builder-generator.test.cjs 进一步验证生成链路,planning-workspace.test.cjs 覆盖路径/指针/锁行为。这种“单一源 + 投影生成 + 新鲜度检查”的组合,就是 changeset 中 “Shared CJS/SDK helpers now reduce drift” 的机制性答案:漂移不再依赖人工同步,而是被编译期/CI 检查强制为零。

4. STATE.md 变换缝:保住策展聚合,又不吞掉磁盘真实进度

changeset 最后一句——“preserves curated wider aggregates without hiding real disk-derived progress”——对应 state.cjs(约 1940 行)中针对 issue #3242 Bug A 的两处关键变换。

4.1 readModifyWriteStateMdresync 开关

STATE.md 的 frontmatter 含 progress.* 计数字段,由 syncStateFrontmatter 基于磁盘扫描重建。但“全量重建”会与“人工跨里程碑策展的聚合值”冲突(比如一个跨 3 个里程碑的总计划数,局部磁盘扫描只能看到更窄的子集)。解法在 readModifyWriteStateMdL1034-L1058)的 resync 选项:

  • 默认 resync: true:transform 后从磁盘整体重建 frontmatter;
  • 传入 resync: false(如 state update 单字段正文更新):变换前先对 frontmatter 做快照,syncStateFrontmatter 运行后把快照中的 progress 块原样写回。注释明确写道“only restore keys that were present in the snapshot”——只恢复快照里已存在的键,因此 syncStateFrontmatter 合法派生的新字段(如 status、current_phase)不受影响。

写路径整体在 acquireStateLock / releaseStateLock 锁内完成,避免读改写交错。

4.2 state json 的读侧对称保护

cmdStateJsonL1081-L1087 附近)在输出 JSON 时始终“从正文 + 磁盘重建”,以防 SUMMARY 文件新增后 percent/completed_plans 陈旧(#1589);随后调用 shouldPreserveExistingProgress(existingFm.progress, built.progress) 做判定:只有当既有 frontmatter 的聚合值宽于(wider than)磁盘扫描结果时才保留策展值,否则让磁盘真实进度覆盖。这就是 changeset 语义的完整实现:“保住更宽的策展聚合”与“不掩盖磁盘真实进度”是一对互补规则,而非单向保留。

state update 侧同样区分了字段:仅当更新 ProgressTotal Plans in PhaseTotal Phases 这类会直接投影进 progress.* 的字段时才置 shouldResync = true 触发重建(L195-L212),其余正文字段更新走 resync: false 路径,不碰策展计数器。相关回归由 bug-3242-state-update-progress-trample.test.cjs 与 bug-2642-state-update-progress-trample.test.cjs 守护。

5. CJS 命令路由缝:共享 family 路由与后续演进

changeset 的第四条缝是“CJS command routing”。其共享 helper 是 cjs-command-router-adapter.cjs(全文件 39 行),导出单一函数 routeCjsCommandFamily

function routeCjsCommandFamily({
  args, subcommands, handlers, defaultSubcommand,
  unsupported = {}, unknownMessage, error,
}) {
  const subcommand = args[1] || defaultSubcommand;
  if (subcommand && unsupported[subcommand]) { error(unsupported[subcommand]); return; }
  const handler = subcommand ? handlers[subcommand] : null;
  if (handler) { handler(); return; }
  const available = subcommands.filter(s => !unsupported[s]);
  error(unknownMessage(subcommand, available));
}

它把“取子命令 → 拒绝 unsupported → 分发 handler → 兜底 unknown 错误信息(附可用列表)”这一每个命令家族都要写一遍的样板,收敛为 CJS 各路由(phase/roadmap/state/validate/verify 等 *-command-router.cjs)共享的一个实现——错误信息的构造权仍交给调用方的 unknownMessage 回调,保持输出文案可控,只收敛控制流。

从源码结构看,该缝的演进并未止步于此:当前仓库已出现 command-routing-hub.cjs,以纯结果({ ok: true, data } | { ok: false, errorKind, message })方式进一步集中“SDK vs CJS 模式决策、错误分类法、no-throw 契约”,且定义了一个封闭的 ERROR_KINDS 枚举(UnknownCommand / InvalidArgs / HandlerRefusal / HandlerFailure / SdkLoadFailed / SdkDispatchFailedL31-L42)。其头部注释列出的不变量值得注意:Hub 绝不向 stdout/stderr 打印、绝不 process.exit、绝不抛出、且 SDK 模式下不做 CJS 静默回退(SDK 崩溃即返回 SdkDispatchFailed)。这延续了 #3316 确立的方向:路由层的决策逻辑持续从各家族向中心收敛,同时保持结果可判定、错误分类可枚举。测试侧由 command-routing-hub.test.cjscommand-contract.test.cjs 以及 cjs-sdk-bridge-seam-contracts.test.cjs 等覆盖。

6. 如何在仓库中验证这套重构

结合 changeset 与源码,验证路径如下(均为只读查看,无需修改仓库):

  1. 路径投影单点化:查看 planning-workspace.cjsplanningDir/planningPathsfindContextMdIn,对照测试 planning-workspace.test.cjs
  2. 共享清单逻辑:从 SDK 源 sdk/src/workstream-inventory/builder.ts 出发,对照 CJS 侧 workstream-inventory.cjs 与生成物 workstream-inventory-builder.generated.cjs,运行 npm run check:workstream-inventory-builder-fresh(根 package.json)确认新鲜度;
  3. STATE.md 投影语义:定位 state.cjsreadModifyWriteStateMdresync 分支与 cmdStateJsonshouldPreserveExistingProgress 调用,回归测试见 tests/bug-3242-state-update-progress-trample.test.cjs
  4. 路由收敛:通读 cjs-command-router-adapter.cjs 全文件,再比较 command-routing-hub.cjs 的不变量清单与 tests/command-routing-hub.test.cjs

7. 适用前提与小结

需要说明的边界:本文以仓库当前 HEAD(1.50.0-canary.0)的实现为准;changeset #3316 本身只承诺“收紧四条缝、引入共享 helper、改进 STATE.md 进度投影语义”,而 command-routing-hub.cjs 等中心路由是后续提交引入的进一步演进,阅读时应区分“changeset 直接内容”与“仓库现状”。SDK 侧要求 Node >=22.0.0(见 sdk/package.json),CJS 命令在受支持运行时的安装布局下生效。

小结:这次“SDK-first 架构缝”重构的方法论可以概括为三步——路径与清单的推导集中到 seam 模块,纯投影逻辑以 SDK 为唯一源并经生成物下沉到 CJS 且以新鲜度检查锁死漂移,STATE.md 的写/读两侧用对称的保留规则调和“策展值”与“磁盘值”。对维护多实现(CJS + SDK)的系统而言,这套“单一源 + 投影生成 + 行为回归测试”的组合是比口头约定更有效的一致性工程。

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