【免费下载链接】gsd-core
Git. Ship. Done - Core
本文以.changeset/archived/3544-workstream-inventory-builder.md所记录的变更为主线,深入 gsd-core 中Workstream Inventory(工作流清单)模块的架构设计:它是如何以sdk/src/workstream-inventory/builder.ts(规范 TypeScript 源)为唯一真相来源,生成bin/lib/workstream-inventory-builder.generated.cjs供安装器与命令使用,并通过check-workstream-inventory-builder-fresh的新鲜度守卫(freshness guard)将 SDK 与 installer-facing 产物锁定为同步。文章同时展开 builder 本身的纯投影算法、类型契约、状态派生规则及其回归测试矩阵,帮助读者理解workstream list / status / progress等命令背后的数据管线。
变更背景:为什么要引入一个"生成的 Builder Seam"
该 changeset(type: Added,关联 PR 3548)描述了一次架构收敛动作:引入一个生成的 Workstream Inventory Builder seam,并配以新鲜度守卫。其要点包括:
- 将
sdk/src/workstream-inventory/builder.ts(含测试)确立为规范 builder 源码(canonical builder source); - 生成
get-shit-done/bin/lib/workstream-inventory-builder.generated.cjs供安装器侧使用; - 把
check-workstream-inventory-builder-fresh接入 hooks/CI; - 更新 inventory 文档与清单,使SDK 产物与 installer-facing 产物保持同步。
这与仓库中 ADR-457 的 build-at-publish 思路 一脉相承:在 workstream-inventory-builder.cts 的模块头注释中明确写道,"手写的bin/lib/workstream-inventory-builder.cjs收敛为 TypeScript 单一真相来源,行为逐字节保留,仅增加类型"。也就是说,过去同一份逻辑需要在 SDK 侧与 CLI 安装侧各维护一份,容易发生漂移;生成式 seam 的目标就是让"同一份规范代码"只写一次、多处生成,杜绝手写副本之间的行为分叉。
需要说明的是:当前仓库只读镜像中以
src/workstream-inventory-builder.cts承载这份规范源码,sdk/src/workstream-inventory/builder.ts与sdk/scripts/check-workstream-inventory-builder-fresh.mjs是 changeset / PRD 描述的产物路径;阅读本文时可将两者视为同一概念在镜像中的不同落点。
分层架构:Builder 管"纯投影",Reader 管"I/O 编排"
理解这个 seam 的第一步是认清模块边界。gsd-core将"生成清单"这件事拆成两个职责截然不同的模块:
| 模块 | 职责 | I/O |
|---|---|---|
| src/workstream-inventory-builder.cts | 纯投影:把预先收集好的文件系统数据投影为类型化的WorkstreamInventory | 无 I/O、无异步 |
| src/workstream-inventory.cts | Reader 适配器:发现目录、读取 STATE.md / ROADMAP.md / 阶段文件、统计计划与摘要 | 只做 I/O 编排,投影全部委托给 Builder |
Reader 模块头注释明确了这一点:"workstream-inventory.cts拥有对.planning/workstreams/*状态的发现与只读投影;命令处理器应从这个清单渲染输出,而不是直接重扫工作流目录。纯投影逻辑在workstream-inventory-builder.cts,本模块只做 I/O 编排。"
这样的分层带来几个可验证的好处:
- 可测试性:Builder 是纯函数,测试可以直接构造输入对象、断言输出对象,无需文件系统(tests/workstream-inventory.test.cjs 中大量
buildWorkstreamInventory({...})单测即属此类); - 可生成性:纯投影无副作用,天然适合作为"规范源 → 生成产物"的对象;
- 职责单一:I/O 相关的故障处理(文件缺失、权限、原子写、ledger 持久化)全部收敛在 Reader 侧,Builder 不会因磁盘状态而改变行为。
输入输出契约:从 PhaseFilesCount 到 WorkstreamInventory
Builder 的核心入口是buildWorkstreamInventory(inputs: BuildWorkstreamInventoryInputs): WorkstreamInventory。其输入完全由调用方(Reader)预先收集,主要包括:
name/projectDir/workstreamDir:工作流身份与路径;phaseDirNames:阶段目录名列表;phaseFilesCounts: PhaseFilesCount[]:每个阶段目录的计划数、摘要数、规范 phase key、目录 mtime、是否属于当前里程碑、验证结论;roadmapPhaseCount/currentMilestonePhaseCount:分母来源(整份 ROADMAP 的阶段数,或当前里程碑声明的阶段数);stateProjection:从 STATE.md 投影出的Status/Current Phase/Last Activity;filesExist:ROADMAP / STATE / REQUIREMENTS 三个文件是否存在;milestoneShipped与milestoneShippedSignal:权威发货信号及其来源强度。
PhaseFilesCount上几个字段值得展开(源码注释有明确语义):
phaseKey:目录的规范阶段键(由 src/phase-id.cts 的phaseKeyFromDir得出)。两个同号但不同写法的陈旧目录(Bug #2445 场景:05-x与5-x-old)共享同一个 key,在 rollup 中必须只计一次,否则分子会超过按不同阶段计数的分母;mtimeMs:目录修改时间,用于同 key 目录冲突时的决胜(取更新的);inMilestone:该阶段目录是否属于当前里程碑(仅当里程碑作用域生效时有意义);complete:阶段完成结论,由具备 I/O 能力的调用方通过isPhaseComplete(src/verification.cts 中的单一 owner)计算后传入。Builder 作为纯投影不能自行调用 owner,缺失/未定义一律按"未完成"(?? false)处理,绝不默认为完成。
输出WorkstreamInventory的关键字段:
| 字段 | 语义 |
|---|---|
status/status_source/status_conflict | 工作流状态;'field'(原样取自 STATE.md)或'derived'(派生);两者不一致即为冲突 |
milestone_shipped_unverified | 发货信号被当前里程碑自身工件否定、未被采信 |
phases: PhaseStatus[] | 每个阶段目录的status(complete/in_progress/pending)、plan_count、summary_count |
phase_count/completed_phases/roadmap_phase_count | 阶段总数、已完成阶段数、分母阶段数 |
total_plans/completed_plans/progress_percent | 计划总数、已完成计划数、完成百分比(经clampPercent钳制) |
核心算法一:pickRollupWinners 与"每 key 只取一个胜者"
Builder 中最关键的去重逻辑是导出的pickRollupWinners<T>(sortedItems, keyOf, mtimeOf, includeItem?)。它从预排序列表中为每个 key 挑选唯一获胜项:mtimeMs更新者胜;完全平局时按排序顺序的**在任者(incumbent)**胜——因为只有"严格更大"的 mtime 才会替换它。includeItem允许调用方在比较前排除某些条目(例如非当前里程碑的目录),过滤发生在 mtime 比较之前,因此被排除的条目永远不可能在平局或比较中胜出。
源码注释给出了这个函数被抽成唯一共享实现的来龙去脉:#2645 评审发现此前存在两份手写副本并已静默分叉——workstream-inventory.cts的 ledger 胜者选择直接比较裸 mtime、不带作用域过滤,而本模块内部的rollupDirByKey先过滤掉非里程碑目录。一次 checkout/rebase 重置 mtime 后,一个"mtime 更新但属于旧里程碑"的陈旧目录就可能赢得 ledger 侧的选择、却输掉 builder 侧的选择,从而对真正计入completed_phases的阶段重新打开 #2645 的漏洞。注释中的结论很有代表性:"两条手写副本'使用相同规则'的注释并不能保证它们真的相同;一个共享函数才能。"
在buildWorkstreamInventory内部,rollupDirByKey正是用这个共享函数构建的:
const rollupDirByKey = pickRollupWinners( [...phaseDirNames].sort(), (dir) => countsMap.get(dir)?.phaseKey ?? dir, (dir) => countsMap.get(dir)?.mtimeMs ?? 0, (dir) => !(scoped && countsMap.get(dir)?.inMilestone === false), );这保证了:陈旧同号目录不会让completed_phases超过分母;被作用域排除的目录绝不参与统计。对应的回归测试在 tests/workstream-inventory.test.cjs 的pickRollupWinners: an excluded (out-of-milestone) item can never win over an included one, even with a newer mtime用例中,分别构造了正向、逆序输入与 unscoped 对照组。
核心算法二:里程碑作用域与"分子永不超分母"不变式
#2562 修复的缺陷类别是:把项目全部阶段历史当作当前里程碑来统计,或把"已声明但从未搭建目录"的阶段从分母中悄悄丢掉,最终把完成百分比错误地推高到 100%。
Builder 的处理方式:
- 当里程碑作用域开启(
milestoneScoped或currentMilestonePhaseCount > 0)时,只有inMilestone === true的阶段目录进入完成 rollup,分母取currentMilestonePhaseCount(ROADMAP## Progress表为当前里程碑声明的阶段数,包含已声明但未搭建目录的阶段); - 作用域关闭时回退到旧行为,分母为整份 ROADMAP 的阶段数;
- 不变式硬校验:在作用域下若
completedPhases > effectivePhaseCount,直接抛出invariant violated错误,而不是用旧的Math.min(100, …)静默钳制到 100%。注释明确说明:分子超过分母意味着两侧在不同的 phase-key 空间中推导,旧钳制把这种不一致掩盖成了 100%,新实现让它大声失败。
测试矩阵为这个不变式提供了丰富的用例:a stale same-numbered directory does not double-count the numerator、builder: a numerator above the denominator throws instead of capping to 100%、a located-but-empty current milestone reports 0%, not its predecessors' 100%等。其中"已声明但为空"的里程碑判定尤为精巧:当currentVersion非空、当前里程碑没有任何已归属阶段、且 ROADMAP 存在versionSectionFound/missingExplicitVersion/ 全部行都被归属到其他里程碑这三类见证之一时,判定为"已声明但空",从而避免回退到"整个项目历史即当前里程碑"的错误统计。
状态派生:shipped signal 是"主张",不是"事实"
Builder 对status的派生遵循 #1913 的原则:不信任可变的 STATE.mdStatus字段,而优先采信权威发货信号。信号分为三种强度(MilestoneShippedSignal):
snapshot:milestones/<version>-ROADMAP.md存在——由milestone complete写入,是最强的工具产物信号;heading:活动 ROADMAP 中当前里程碑标题带发货标记——操作员手工输入的散文,最弱;legacy:仅在无法确定当前里程碑版本时启用的项目生命周期级回退(#1913 对畸形/遗留项目的保护);null:无任何发货信号。
#2562 评审进一步要求返回"哪个信号触发了"而非布尔值,因为两种信号的交叉校验强度不同,用一个检查同时服务两种形状会回归最常见的归档场景:
heading:ROADMAP 未被归档,里程碑声明的每个阶段都应已在磁盘且完成,因此以完整完成率为门槛(还能兜住"已声明但未搭建"的无目录阶段);snapshot:milestone complete会把阶段目录移入milestones/<version>-phases/,同时复制(绝不截断)活动 ROADMAP,因此干净归档按构造就应读 0/N。若直接以完成率作门槛,会把每个已归档里程碑的milestone complete全部剥掉。正确的门槛是合取:存在存活的当前里程碑阶段目录(即归档不干净——阶段被新增或重开),且完成率不足。注释特别强调:只要求"存活目录自身未完成"太窄——一个已完成的存活目录旁若有一个已声明但未搭建的阶段,同样会复现症状,而无目录的阶段无处可查。
当信号与工件矛盾(shippedContradicted)时,状态降级为in_progress,即使 STATE.md 字段自己也写着milestone complete(artifactOverride分支)——"被拒绝的主张不能从另一扇门重新进来"。这些语义由milestone_shipped_unverified显式暴露,而非静默吞掉。
完成结论的单一 Owner:ADR-3180 §7.4 与 disk-strict
自 ADR-3180 §7.4(#3186)起,阶段"是否完成"不再由 builder 从 plan/summary 计数 + 验证状态本地重算,而是统一路由到 src/verification.cts 的isPhaseComplete单一 owner。Builder 只消费调用方传来的PhaseFilesCount.complete。这消除了"在本地后处理规范 owner 的结果"这一被 §7.4 禁止的旁路——旧实现正是因此复现了 disk-strict 头条场景(#3168):零计划 + 通过的验证,被读成pending而非complete。
disk-strict 的后果很直接:isPhaseComplete无条件要求verification.status === 'passed'。一个没有*-VERIFICATION.md的阶段(missing,例如禁用 verifier 的项目)永远不算完成——旧"禁用 verifier 的项目凭摘要数达标即可完成"的容忍被有意移除,且 changeset 中明确披露了这一行为变更。对应测试a missing verdict is NOT complete (verifier-off tolerance retired, disk-strict)与parity: every verifier status other than passed blocks completeness将这一约定钉死。
证据防篡改:#2645 验证台账(ledger)与原子写
虽然 ledger 的读写属于 Reader 侧职责,但它与 Builder 的 rollup 语义强耦合,是理解整个清单可信度的关键一环。问题(#2645)是:验证结论每次调用都从*-VERIFICATION.md现读,于是"验证器跑过、发现缺口、报告后来被删除"与"验证器从未跑过"都坍缩成同一个'missing'哨兵——只删一个文件就足以静默抬高完成百分比(Goodhart 漏洞)。
修复方案在工作流目录级维护.verification-ledger.json(绝不放在会被删除的阶段目录内部),记录每个 phase key 最近一次真实(非'missing')结论;实时读取为'missing'时回退到台账。台账读取是三态而非两态:
'absent'——文件根本不存在(未采纳台账,行为与 #2645 之前完全一致,避免上线当天全项目被一次性降到in_progress);'corrupt'——文件存在但无法读取/解析(含断链符号链接,通过lstatSync与真缺失区分),失败关闭;'ok'——文件可解析;有记录用记录,无记录的阶段按'unrecorded'失败关闭,防止同一漏洞针对单个阶段重开。
写入侧采用原子写(同目录临时文件 + rename)并对 Windows 的瞬时占用做指数退避重试(EPERM/EBUSY/EACCES,最多 5 次,25ms 起步翻倍),任何失败都只丢本次观测、绝不留下半截 JSON 或孤儿临时文件。值得注意的是,disk-strict 上线后 ledger 的"删除后回放旧结论"记忆已被 #3186 退役——isPhaseComplete每次都无条件现读磁盘,报告被删即读'missing'→ 不完成,与roadmap analyze/init manager/phase complete对同一磁盘状态的判定完全一致。测试Row 9的属性测试(任意真实结论序列后删除报告,阶段永不完成)与Row 11/12/16/17的故障注入(写失败、读失败、目录不可建、rename 失败)覆盖了这些路径。
生成式 Seam 与新鲜度守卫:SDK 与安装产物的同步保障
回到 changeset 的主题——生成 seam 的同步机制:
- 规范源:
sdk/src/workstream-inventory/builder.ts(含测试)持有 builder 的唯一实现; - 生成脚本:
sdk/scripts/gen-workstream-inventory-builder.ts负责从规范源发出gsd-core/bin/lib/workstream-inventory-builder.generated.cjs(CLI/安装器侧)与sdk/src/query/workstream-inventory-builder.generated.ts(SDK 侧)两份产物; - 新鲜度守卫:
sdk/scripts/check-workstream-inventory-builder-fresh.mjs校验生成产物是否与规范源一致,并被接入 hooks/CI——任何只改了规范源而未重新生成产物的提交都会被拦住; - 文档与清单同步:inventory 文档与 manifest(如 CONTEXT.md、
docs/INVENTORY.md、docs/INVENTORY-MANIFEST.json)同步更新,使 SDK 与 installer-facing 工件始终处于同一版本。
这套机制的意义在于:bin/lib/workstream-inventory-builder.generated.cjs是命令侧实际加载的模块(测试文件中的require('../gsd-core/bin/lib/workstream-inventory-builder.cjs')可作证),而 SDK 侧又需要类型化版本;如果没有生成式 seam 和新鲜度守卫,两份产物迟早分叉,而pickRollupWinners的故事已经证明"手写两份相同逻辑"在实践中的不可靠性。
消费者与扩展:workstream list / status / progress 的数据管线
Builder 产出的WorkstreamInventory[]是多个命令的公共数据源。Reader 模块导出的listWorkstreamInventories(cwd)返回WorkstreamInventoryList(flat或workstream模式、活动工作流、有序列表),并按"活动工作流置顶、其余按名称排序"输出;getOtherActiveWorkstreamInventories则过滤出其他未完成的工作流,供需要"切换/感知其他工作流"的命令使用。inspectWorkstream(cwd, name, options)是单工作流入口,还支持注入writeDiagnostic侧通道:当验证陈旧性检查(#3057 B3)因 fs/时钟故障无法完成时,以结构化{ phaseDir, reason }输出 stderr 诊断,不影响 rollup 结果——路由保持逐字节不变。
在 src/smart-entry.cts 中也能看到对同一套完成语义的引用(镜像workstream-inventory-builder.cts的里程碑完成信号判断),说明这份纯投影逻辑是全项目共享的"单一事实来源",而非某个命令的局部工具。
回归保障:一张测试矩阵钉住四类缺陷
tests/workstream-inventory.test.cjs 是这份模块最完整的验收记录,测试按缺陷编号组织:
- #1913:
milestoneShipped覆盖过期的executing字段(derived+conflict);归档快照 / ROADMAP SHIPPED 标记都能把状态派生为milestone complete; - #2562:当前里程碑作用域的全部边界——已声明但未搭建的阶段计入分母、旧里程碑阶段不进分子、零填充/项目码前缀/强调/标签装饰下"表格单元格与其目录永远得出同一 phase key"(fast-check 属性测试,1000 轮)、版本边界(
v2.0不匹配v2.0.1)、发货主张被自身工件否定时状态不得断言完成(含 STATE 字段复读被拒的ws-field-echo用例); - #2645:删除
*-VERIFICATION.md不得抬高完成度、台账三态与失败关闭、原子写故障注入、pickRollupWinners的共享实现保证、陈旧目录绝不污染获胜目录; - #3057 B3:验证陈旧性检查不确定时的诊断输出与 rollup 不变。
此外,inspectWorkstream cannot trip the Builder invariant用例把多种对抗形状(前序里程碑、重复 key、无目录阶段、未归属行、项目码前缀、子阶段)一次性塞进一个工作流,断言分子不超过分母且百分比低于 100——因为listWorkstreamInventories遍历时不捕获异常,Builder 的不变式抛出会拖垮workstream list / status / progress的全部工作流。
小结
从sdk/src/workstream-inventory/builder.ts规范源,到生成的bin/lib/workstream-inventory-builder.generated.cjs,再到 hooks/CI 中的check-workstream-inventory-builder-fresh,gsd-core 用一条**"规范源 → 生成产物 → 新鲜度守卫 → 文档清单"**的链路把 Workstream Inventory 的纯投影逻辑锁成单点事实来源。其内部的pickRollupWinners去重、里程碑作用域不变式、shipped signal 交叉校验、单一 completion owner 与验证台账,共同保证了workstream list / status / progress输出的状态与百分比不会被陈旧目录、过期字段或证据删除所篡改。想深入底层,建议依次阅读 src/workstream-inventory-builder.cts(纯投影)、src/workstream-inventory.cts(I/O 编排与 ledger)、tests/workstream-inventory.test.cjs(回归矩阵),并对照 ADR-3524 理解生成式 seam 在整个 SDK/安装器体系中的位置。
【免费下载链接】gsd-core
Git. Ship. Done - Core
相关推荐
gsd-core 修复实战:`init.milestone-op` 与 `roadmap.analyze` 的 Workstream 作用域解析修复(PR 3196)
gsd core 修复实战: init.milestone op 与 roadmap.analyze 的 Workstream 作用域解析修复(PR 3196)
agent-skills中的微服务架构:构建灵活且可扩展的应用系统
agent skills中的微服务架构:构建灵活且可扩展的应用系统 agent skills是一个为AI编码代理提供生产级工程技能的项目,其内部实现了基于微服务
AI 技能开发工具AI 评测Benchmarkgsd-core 命令参数投影单次索引优化:parseNamedArgs 从 O(flags×argv) 到 O(argv+flags) 的演进实录
gsd core 命令参数投影单次索引优化:parseNamedArgs 从 O flags×argv 到 O argv+flags 的演进实录 导读 本文以
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考