【免费下载链接】gsd-core
Git. Ship. Done - Core
本篇技术指南基于 gsd-core 仓库的变更记录 .changeset/tidy-tigers-forage.md 展开。核心议题是:当多个 Git worktree(并行 workstream)各自独立运行
/gsd:capture --seed时,如何保证铸造出的种子 ID 天然唯一、不依赖共享计数器,并保持旧SEED-NNNID 的完整可解析性。读完本文,你将掌握 GSD 种子 ID 的旧缺陷根因、新 ID 语法(SEED-YYMMDD-xxx)的生成算法、残余碰撞上界的数学来源,以及list-seeds、audit、new-milestone等读取侧如何统一解析两种 ID。
一、问题背景:并行 Workstream 与「共享目录 + 本地可见性」的冲突
GSD(Git. Ship. Done)的种子(Seed)机制用于捕获前瞻性想法(forward-looking idea),并附带触发条件,待new-milestone扫描时自动浮现。它的工作目录是.planning/seeds/,由/gsd:capture --seed写入,流程定义在 gsd-core/workflows/plant-seed.md。
问题出在「共享目录」与「worktree 本地可见性」的组合上:.planning/seeds/目录本身被多个 worktree 共享,但每个 worktree 只能看到已经合并(merged)进当前分支的文件。旧版plant-seed.md用ls | wc -l统计本地 worktree 可见的种子文件数来推导下一个 ID(即SEED-NNN递增计数)。当两个 workstream 各自从相同基线出发、在任何一方合并之前同时播种时,两个 worktree 看到的文件集合完全一致,于是计算出相同的下一个序号——两个SEED-NNN文件被 Git 静默合并,产生重复 ID。
这一缺陷在测试文件 tests/plant-seed-id.test.cjs 的头部注释中被精确记录:
plant-seed.mdderived the next seed id fromls | wc -l— a count of files the local worktree happens to see. Two workstreams planting before either merges computed the same id and git merged both files silently.
计数型 ID 的本质问题是:ID 的唯一性依赖一个「全局共享的单调计数器」,但每个 worktree 没有可靠的全局视角。解决思路不是去同步计数器(在分布式并行场景下代价极高),而是让 ID 的生成完全不依赖其他 worktree 的存在——即每个 worktree 仅凭本地信息即可铸造出(大概率)唯一的 ID。
二、新 ID 语法:SEED-YYMMDD-xxx
修复后,generate-seed-id步骤(定义于 gsd-core/workflows/plant-seed.md)铸造的 ID 语法为:
SEED-YYMMDD-xxx各段含义:
| 段 | 取值 | 来源 | 说明 |
|---|---|---|---|
YYMMDD | 6 位本地日期(年-月-日各 2 位) | date +%y%m%d | 本地时钟,任何 worktree 独立可算 |
xxx | 3 个随机 base36 字符(a-z0-9) | /dev/urandom+tr+head -c 3 | 每次播种独立抽样,互不共享 |
| 完整示例 | SEED-260914-k3x | — | 日期 + 随机后缀 + 后续 slug |
对应在plant-seed.md的generate-seed-id步骤中,核心生成逻辑为:
# Seed id: date + 3 random base36 chars (the `.planning/quick/` shape). # NO shared counter: `.planning/seeds/` is shared, but each worktree only sees # what has merged — counting files collides across parallel workstreams (#4378). SEED_DATE=$(date +%y%m%d) SEED_ID="" for _SEED_ATTEMPT in 1 2; do # `|| true`: `tr` reads an infinite stream, so `head -c` closing the pipe # takes SIGPIPE — harmless (head already has its 3 bytes) but fatal under # pipefail. SEED_SUFFIX=$(LC_ALL=C tr -dc 'a-z0-9' </dev/urandom | head -c 3) || true if [ ${#SEED_SUFFIX} -ne 3 ]; then echo "ERROR: could not draw a random id suffix (is /dev/urandom available?)" >&2 exit 1 fi SEED_ID="SEED-${SEED_DATE}-${SEED_SUFFIX}" # Same-day regen guard, written as a find existence test (never `ls <glob>`: # under a stray nullglob that shape silently degenerates — #3409 drift guard). [ -z "$(find .planning/seeds -maxdepth 1 -name "${SEED_ID}-*.md" -print 2>/dev/null)" ] && break done if [ -n "$(find .planning/seeds -maxdepth 1 -name "${SEED_ID}-*.md" -print 2>/dev/null)" ]; then echo "ERROR: could not draw an unused seed id after 2 attempts" >&2 exit 1 fi这段脚本体现了多个刻意设计,逐一说明:
- 日期段用本地时钟:
date +%y%m%d在任意 worktree 上独立可执行,不需要读取共享目录状态——这正是消除跨 worktree 依赖的关键。 - 随机后缀用
[a-z0-9](base36):tr -dc 'a-z0-9' </dev/urandom从熵源过滤出 36 个字符的字母表,head -c 3精确截取 3 字节。LC_ALL=C保证字符集行为可移植。 - SIGPIPE 处理:
tr读取的是无限流,head -c 3拿到 3 字节后关闭管道会让tr收到 SIGPIPE——这是无害的(head已拿到所需字节),但若不加|| true,在pipefail下整个管道会被判定为失败。 - 空后缀必须大声失败:若抽不到 3 个字符(例如
/dev/urandom不可用),${#SEED_SUFFIX} -ne 3检查会触发exit 1。这是为了防止铸造出SEED-260914-(空后缀)——所有同日种子的 ID 都会塌缩成裸日期,等于把 #4378 的碰撞问题以另一种形式复活。 - 同日再生成保护(regen guard):抽到后缀后,用
find .planning/seeds -maxdepth 1 -name "${SEED_ID}-*.md"做存在性检查;若该 ID 已存在,最多重试 2 次。刻意写成find存在性测试而非ls <glob>——ls通配在nullglob开启时会静默退化成空匹配(#3409 漂移防护),而find的-print输出不受该 shell 选项影响。 - 双次重试仍碰撞则失败:2 次尝试后仍命中已有文件,就
exit 1,绝不静默写出重复 ID。
三、残余碰撞上界:为什么 1/46656 是「已接受的」风险
新方案并非零碰撞,而是把跨 worktree 的结构性碰撞(计数相同导致必然碰撞)替换成了概率性的同日随机碰撞。变更记录中给出的上界是每对约 1/46656:
36^3 = 46656即随机后缀是 3 个 base36 字符,总共有 46656 种组合。两个 workstream 在同一天、彼此都尚未合并的情况下各自播种,抽到相同后缀的概率为 1/46656。这一数量级正是.planning/quick/快速任务方案早已接受的碰撞上界——也就是说,种子 ID 现在复用了项目内既有的、经过验证的 ID 随机化模式(变更记录原文:“the same bound the.planning/quick/scheme already accepts”),而不是引入一种全新的、未经验证的唯一性策略。
对比旧方案:计数型 ID 的碰撞是确定性事件(双方看到相同文件数 → 必然同号);新方案的碰撞是极低概率的随机事件,且同一 worktree 内还有同日重生成保护兜底。从「必然碰撞」到「概率碰撞」,是一次质的改善。
四、向后兼容:旧 SEED-NNN 的完整解析链路
变更记录强调:现有的SEED-NNN种子继续在 list、enrich、new-milestone 扫描、audit 中可解析。这意味着读取侧必须同时理解两种语法:
旧语法:SEED-NNN (如 SEED-081,计数时代产物) 新语法:SEED-YYMMDD-xxx (如 SEED-260914-k3x,日期 + 随机后缀)读取侧的统一解析函数是deriveSeedIdentity,实现在 src/commands.cts(cmdListSeeds与 audit 共用)。其核心是三条正则(见 src/commands.cts):
const CANONICAL_SEED_ID_RE = /^SEED-(?:\d{6}-[a-z0-9]{3}|\d+)$/i; const SEED_ID_PREFIX_RE = /^(SEED-(?:\d{6}-[a-z0-9]{3}|\d+))/i; const SEED_SLUG_RE = /^SEED-(?:\d{6}-[a-z0-9]{3}|\d+)-(.+)$/i;deriveSeedIdentity(src/commands.cts)的判定顺序为:
- frontmatter
id:优先:若文件 frontmatter 的id:字段命中CANONICAL_SEED_ID_RE(SEED-YYYYMMDD-xxx或SEED-NNN),则以它为 canonicalseed_id; - 文件名前缀回退:frontmatter 缺失或不合语法时,从文件名 stem 中提取
SEED_ID_PREFIX_RE匹配的前缀作为 ID; - slug 提取:用
SEED_SLUG_RE从SEED-…-<slug>中切出 slug 描述段。
这里有一个关键的细节(也是 #4378 的回归风险点):文件名前缀回退必须保留完整的SEED-YYMMDD-xxx,绝不能截断到日期前缀。若截断到SEED-260914,那么同一天播种的所有新格式种子在 frontmatter 缺失时都会被解析成同一个 ID——这正是 #4378 想消灭的碰撞以另一种形式回归。测试 tests/list-seeds.test.cjs 与 tests/list-seeds.property.test.cjs 用「5 位日期解析为 legacy、7 位日期解析为 legacy、2 字符后缀不解析为新格式、新格式 ID 端到端 canonical」等边界样例把这一行为钉死。
4.1 大小写宽容:文档展示与运行时 ID 的差异
文档与工作流输出中,新格式 ID 常展示为大写占位符SEED-YYMMDD-XXX,而运行时铸造的是小写 base36(tr -dc 'a-z0-9'只产出小写)。因此--enrich的目标提取正则必须大小写宽容。plant-seed.md的parse-idea步骤使用:
ENRICH_MATCH=$(echo "$ARGUMENTS" | grep -oE '\-\-enrich[[:space:]]+SEED-[0-9]+(-[a-zA-Z0-9]{3})?' | head -1)该模式的关键设计:
- 锚定
--enrich标志:不能取$ARGUMENTS中最靠左的SEED-[0-9]+——那会截断大写/畸形后缀到日期段,并可能错误地 enrich 一个无关的同日种子(测试用例"see SEED-5 first" --enrich SEED-7验证了提取器必须跟随标志而非最左 ID)。 - 捕获完整 ID:
SEED-[0-9]+(-[a-zA-Z0-9]{3})?同时匹配旧格式SEED-081(无后缀)和新格式SEED-260914-K3X(带 3 位后缀)。 - 歧义即失败:若一个目标匹配到多个种子文件(
grep -c .大于 1),直接报错退出,绝不用head -1随机挑一个;旧格式重复 ID 无法靠更长 ID 消歧,需人工重命名重复文件,新格式则要求用完整 ID 重跑。
4.2 读取侧与写入侧的语法一致性由测试强制
写入侧(plant-seed.md的generate-seed-id)与读取侧(commands.cts的deriveSeedIdentity)是「一份语法、两个表面」。为防止两侧漂移,tests/plant-seed-id.test.cjs 底部提供了一个property parity 测试:从铸造现场的date +%y%m%d指令和head -c 3宽度中解析出日期宽度与后缀宽度,再用 fast-check 随机生成 200 组SEED-{date}-{suffix}-{slug}组合,逐一断言deriveSeedIdentity能原样还原seed_id与slug。任何一侧的宽度漂移都会在此失败。
五、audit 扫描与 list-seeds 的 ID 一致性收敛
变更记录还包含一个读取侧的收敛细节:audit 的种子扫描现在发布与list-seeds相同的 canonical ID,其 acknowledge 用任一 ID(旧或新)都能解析到同一个文件。
在 src/audit.cts 中,scanSeeds(#4378 roll-in)通过调用commandsModule.deriveSeedIdentity复用同一解析函数,而不是自己另写一套 ID 推导:
- 扫描时(src/audit.cts),先判断文件名是否为良构种子名(
seedIdMatchRawName),再对每个SEED-*.md文件调用deriveSeedIdentity——frontmatterid:命中种子语法(旧SEED-NNN或新SEED-YYMMDD-xxx)时取 canonical ID,否则回退文件名前缀。此前 audit 与 list-seeds 各自推导 ID、彼此不一致,会导致 deferral 误归档; - acknowledge 时(src/audit.cts),
--seed-id参数可能以任一形态到达(audit 列表里看到的是新 ID,用户可能凭记忆输入旧 ID)。代码对.planning/seeds/下每个SEED-*.md推导其 derived ID,只要derivedAckId === seedId || stem === seedId即视为命中,最终用requireSafePath约束目标必须在规划目录内(PathAcceptance.AbsoluteInsideRoot)后写 acknowledge。
这保证了「list 里显示什么,audit 里就能解析什么」,且新旧 ID 都能定位到同一文件。
六、端到端使用链路
6.1 播种:/gsd:capture --seed
命令 commands/gsd/capture.md 将--seed标志路由到plant-seed工作流,落盘路径为:
.planning/seeds/SEED-YYMMDD-xxx-slug.md写出的种子文件骨架(frontmatter 默认值):
--- id: {SEED_ID} status: dormant planted: {ISO date} planted_during: {current milestone/phase from STATE.md, or "unknown" if not in a GSD project} trigger_when: when relevant scope: unknown --- # {SEED_ID}: {$IDEA}plant-seed是一次性(one-shot)捕获:文件立即写入,不因任何提问而阻塞。trigger_when默认"when relevant"(种子会在任意 new-milestone 扫描中浮现,用户可稍后用--enrich收窄),scope默认"unknown"。随后自动收集面包屑(从想法文本抽取关键词、grep 代码库、检查 STATE.md/ROADMAP.md/todos/)并提交 git。
6.2 增补:/gsd:capture --seed --enrich SEED-…
--enrich走enrich-seed步骤,以AskUserQuestion(或--text文本模式)收集 Trigger、Why、Scope 三项,回填 frontmatter 与正文段落,并再次提交。enrich 不会重铸 ID——目标种子文件已存在,ID 保持不变。
6.3 列出与审计:/gsd:capture --list-seeds
只读工作流 gsd-core/workflows/list-seeds.md 调用gsd_run list-seeds "$STATUS_FILTER"(支持可选状态过滤,如dormant、active、triggered),按seed_id排序渲染表格:
ID Status Scope Trigger Title SEED-001 dormant large when websockets land Real-time collaboration SEED-006 triggered medium MILE-04 planning Remove legacy auth cratescount为 0 时给出引导文案;状态过滤无匹配时明确提示。该流程全程只读、绝不改动种子文件。
6.4 自动浮现:new-milestone的种子扫描
gsd-core/workflows/new-milestone.md 的 Step 2.5「Scan Planted Seeds」用ls .planning/seeds/SEED-*.md扫描种子,提取标题、trigger_when与planted_during,与里程碑目标比对。匹配的种子经用户(或--auto模式全选)确认后,进入 Step 9 的需求定义作为额外输入;未选中的种子原样保留,绝不删除或修改。种子 ID 无论新旧格式,在此扫描中均可正常解析。
七、回归防线:围绕 #4378 的测试矩阵
本修复的回归测试分布在三个文件中,构成「写入契约 → 读取契约 → 属性一致性」三层防线:
| 测试文件 | 验证内容 |
|---|---|
| tests/plant-seed-id.test.cjs | 写入侧契约:date +%y%m%d与tr -dc 'a-z0-9'+head -c 3必须存在;空后缀必须 abort;同日 regen guard 必须是find存在性测试而非ls <glob>;旧计数时代的wc -l、NEXT=$((EXISTING + 1))、printf "%03d"、SEED-{PADDED}占位符全部不得残留;--enrich提取必须锚定标志、捕获完整 ID、大小写宽容、歧义即失败;parity 属性测试保证写出的每种 ID 都能被读取侧原样还原 |
| tests/list-seeds.test.cjs | 读取侧行为:新格式 ID 是 canonical 的、不被截断到日期前缀;legacy 计数 ID 与新格式 ID 可共存;文件名回退保留完整新格式 ID;大写新格式 ID 端到端 canonical |
| tests/list-seeds.property.test.cjs | 属性测试:新格式 ID 完整 round-trip;缺失 frontmatter 时回退到完整新格式前缀;5 位/7 位日期、2 字符后缀等边界解析为 legacy,防止正则宽度漂移 |
值得强调的是「写入侧与读取侧各自独立测试、再以 parity 互锁」的做法:写入语法与解析语法各有一份行为契约,任何一侧修改而另一侧未同步都会在测试矩阵中暴露——这正是变更记录中“writer and reader grammars must not diverge”的实现方式。
八、小结
#4378 的修复把种子 ID 的唯一性从「共享目录中的单调计数」(跨 worktree 结构性碰撞)改为「本地日期 + 随机 base36 后缀」(概率性碰撞,上界 1/46656,与.planning/quick/既有方案一致),同时:
- 写入侧:
plant-seed.md的generate-seed-id只用本地信息铸造 ID,附带 SIGPIPE 容错、空后缀大声失败、同日find重生成保护; - 读取侧:
commands.cts的deriveSeedIdentity+ 三条正则统一解析新旧两种语法,文件名前缀回退保留完整新格式 ID; - 一致性:audit 扫描与
list-seeds共享同一 canonical ID 推导,acknowledge 对任一形态 ID 解析到同一文件; - 回归防线:三层测试矩阵 + 写入/读取语法 parity 属性测试,杜绝两侧漂移与旧计数时代的占位符残留。
对于在多个 worktree 中并行推进 workstream 的团队,这套方案意味着:播种不再需要「先合并再看数」,每个 worktree 可以随时独立安全地铸造种子 ID,并把同日碰撞的概率控制在可接受的数量级内。相关实现与测试可直接在仓库中继续研读:gsd-core/workflows/plant-seed.md、gsd-core/workflows/list-seeds.md、src/commands.cts、src/audit.cts、tests/plant-seed-id.test.cjs。
【免费下载链接】gsd-core
Git. Ship. Done - Core
相关推荐
gsd-core 种子捕获契约修复:/gsd-explore 委托 plant-seed 工作流生成真实 SEED 记录
gsd core 种子捕获契约修复:/gsd explore 委托 plant seed 工作流生成真实 SEED 记录 导读:本文围绕 gsd core 仓库
Hugo collections.D 函数详解:基于种子生成有序不重复随机整数
Hugo collections.D 函数详解:基于种子生成有序不重复随机整数 collections.D 是 Hugo 0.149.0 引入的集合类模板函数,
开发工具前端CLIgsd-core 的 Changeset Fragments 机制:基于每 PR 变更片段的 CHANGELOG 自动生成方案
gsd core 的 Changeset Fragments 机制:基于每 PR 变更片段的 CHANGELOG 自动生成方案 核心导读 :本篇文章围绕 gsd
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考