首页
/ gstack /landing-report:只读版本队列仪表盘,看清多工作区并行 ship 时的 VERSION 槽位之争

gstack /landing-report:只读版本队列仪表盘,看清多工作区并行 ship 时的 VERSION 槽位之争

2026-09-06 13:29:51作者:魏侃纯Zoe

在多 Conductor 工作区并行开发同一个仓库时,最容易发生的问题是两个分支 bump 到同一个 VERSION,后合并的一方悄悄覆盖前者的 CHANGELOG 条目。gstack 的 /landing-report 技能就是为这类场景设计的只读仪表盘:它展示当前哪些 VERSION 槽位已被 open PR 占用、哪些兄弟工作区有即将合入的 WIP、以及你下次运行 /ship 会拿到哪个槽位。本文以 landing-report/SKILL.md 为主体,结合底层工具 bin/gstack-next-version、版本原语库 lib/version-source.ts 与测试 test/gstack-next-version.test.ts,完整讲解该技能的调用契约、五步工作流、输出格式与冲突分配算法。

一、这个技能解决什么问题

/landing-report 的定位在 AGENTS.md 中只有一行:"Read-only dashboard for the workspace-aware ship queue.";在 docs/skills.md 的技能清单里,它被归类为 Ship Queue Dashboard——workspace-aware ship 队列的只读快照。

SKILL.md 的 "Why this skill exists" 一段给出了最直接的解释:当你同时运行 5-10 个 Conductor 工作区时,需要一眼看清"哪个版本号被谁占了、你下次 /ship 会落在哪个槽位"。实现上,它只是对 /ship 所使用的同一个底层工具 bin/gstack-next-version 做了一次只读调用,全程无任何变更——原文的比喻是:"Think of it as gh pr list for VERSION numbers."

这个技能随 gstack v1.11.0.0 的 "Workspace-aware ship" 发布而诞生。CHANGELOG.md 对该发布背景的描述是:在多个 Conductor 窗口并行操作时,"两个分支 bump 到同一版本,谁后合并谁就把对方的 CHANGELOG 条目覆盖掉或带重复标题落地,直到你 grep '^## \[' 才发现"。该版本让 /ship 查询 open PR 队列、看到已声称的版本、在你所选 bump 级别上挑下一个空槽位;而 /landing-report 则是"在打开当天第 6 个 PR 之前查一下我在队里排第几"的检查入口——运行它不会 ship 任何东西,只是观察。

触发方式与工具白名单

技能 frontmatter 声明的触发词(triggers)为:landing reportversion queueship queuewhat version comes nextshow open PR versionsallowed-tools 只包含 BashRead——没有 Edit、没有 Write,从工具层就保证了只读语义。frontmatter 中 preamble-tier: 2version: 0.1.0;文件头注释表明该文档由 landing-report/SKILL.md.tmpl 自动生成(bun run gen:skill-docs 重新生成),不要直接编辑。

二、调用契约:Preamble 与 Plan Mode 安全

与所有 gstack 技能一致,/landing-report 启动前先执行一段 Preamble bash 收集运行环境。这段脚本在 gstack 各技能间共享,其作用可归纳为输出以下几组关键环境变量,供后续步骤分支判断:

变量 含义
BRANCH 当前 git 分支(git branch --show-current
PROACTIVE / PROACTIVE_PROMPTED 是否允许主动建议技能(gstack-config get proactive
SKILL_PREFIX 若为 true,建议/调用时使用 /gstack-* 命名
REPO_MODE / SESSION_KIND 仓库模式;会话类型 spawned | headless | interactive
CONDUCTOR_SESSION 在 Conductor 宿主内时置 true;此时 AskUserQuestion 不可靠,所有决策改为 prose 渲染
TELEMETRY / TEL_PROMPTED 遥测开关(community / anonymous / off)与首次询问标记
EXPLAIN_LEVEL 输出解释风格,defaultterse(其余取值归一为 default
UPDATE_CHECK 是否执行升级检查(false 时跳过升级提示)
ACTIVATED / FIRST_LOOP_SHOWN / FIRST_TASK 首次运行标记与项目形态探测(greenfieldcode_*branch_aheaddirty_defaultclean_default
HAS_ROUTING / ROUTING_DECLINED CLAUDE.md 是否已有 ## Skill routing
VENDORED_GSTACK 检测项目内是否 vendored 了 gstack(已废弃,建议迁移 team mode)
MODEL_OVERLAY 本技能固定为 claude,即附加 Claude 模型族的微调补丁
CHECKPOINT_MODE / CHECKPOINT_PUSH 连续检查点提交模式(continuous 时自动以 WIP: 前缀提交逻辑单元)
GSTACK_PLAN_MODE plan-mode 探测(CLAUDE_PLAN_FILEGSTACK_PLAN_MODE_FORCEactive,否则 inactive
SPAWNED_SESSION 在 OpenClaw 等 AI 编排器派生的会话中运行时置 true

Preamble 还会把本次运行写入 ~/.gstack/analytics/skill-usage.jsonl(受 telemetry 开关门控)与项目级 timeline.jsonl,并从 ~/.gstack/projects/<slug>/learnings.jsonl 加载历史学习条目(超过 5 条时调用 gstack-learnings-search --limit 3)。

Plan Mode 下允许的操作

SKILL.md 明确列出本技能在 plan mode 下允许的操作(因为它们只用于"为计划提供信息"):$B$Dcodex exec/codex review、写入 ~/.gstack/、写入 plan 文件、以及对生成产物的 open。文末 "Plan Mode" 一节的结论是:PLAN MODE EXCEPTION — ALWAYS RUN——本技能完全只读:不写文件、不改 git 状态、不改网络状态,因此在 plan mode 中运行是安全的。

另外两条运行期规则也值得注意:若 PROACTIVEfalse,不得自动调用或主动建议技能,只能询问;若技能在 plan mode 中被用户显式调用,技能文件被视为可执行指令而非参考资料,从 Step 0 开始逐步执行。

三、五步工作流

Step 1:探测平台与 base 分支

与 gstack 其他技能使用同一套探测逻辑:优先从当前 PR 读 base,其次从仓库默认分支读,最后回退到字面量 main

BASE_BRANCH=$(gh pr view --json baseRefName -q .baseRefName 2>/dev/null || \
              gh repo view --json defaultBranchRef -q .defaultBranchRef.name 2>/dev/null || \
              echo main)
echo "Base branch: $BASE_BRANCH"

Step 2:读取当前版本状态

分别取本地 VERSION 与远端 base 分支上的 VERSION,作为后续槽位计算的基准:

CURRENT_VERSION=$(cat VERSION 2>/dev/null | tr -d '[:space:]' || echo "0.0.0.0")
git fetch origin "$BASE_BRANCH" --quiet 2>/dev/null || true
BASE_VERSION=$(git show "origin/$BASE_BRANCH:VERSION" 2>/dev/null | tr -d '[:space:]' || echo "$CURRENT_VERSION")
echo "origin/$BASE_BRANCH VERSION: $BASE_VERSION"
echo "branch HEAD VERSION: $CURRENT_VERSION"

Step 3:查询版本队列

对四个 bump 级别各调用一次底层工具,把每个级别的候选结果落到临时文件(文档正文写 "three times",但给出的循环实际遍历 micro patch minor major 四个级别);任一次失败则写入 {"offline":true} 占位,保证 Step 4 渲染不会因缺文件中断:

for LEVEL in micro patch minor major; do
  bun run ~/.claude/skills/gstack/bin/gstack-next-version \
    --base "$BASE_BRANCH" \
    --bump "$LEVEL" \
    --current-version "$BASE_VERSION" \
    > "/tmp/landing-$LEVEL.json" 2>/dev/null || echo '{"offline":true}' > "/tmp/landing-$LEVEL.json"
done

SKILL.md 特别说明这次调用"很便宜(same gh call cached by bun)",因为同一 PR 列表查询在 bun 侧有缓存。

Step 4:渲染仪表盘

jq 从 JSON 中提取字段,其中 patch 级别的 JSON 是队列与兄弟工作区数据的规范来源——这些字段在各 bump 级别间完全相同,只有 .version 不同。需要提取的字段:

  • .hostgithub | gitlab | unknown
  • .offline — 查询是否失败
  • .claimed{pr, branch, version, url} 数组
  • .siblings — 发现的所有兄弟 worktree
  • .active_siblings — 其中"很可能即将合入"的子集

在线时的渲染格式(SKILL.md 要求按此格式精确输出):

╔══════════════════════════════════════════════════════════════════╗
║                     GSTACK LANDING REPORT                        ║
╠══════════════════════════════════════════════════════════════════╣
║ Repo:    <owner/repo>                                            ║
║ Base:    <base> @ v<base-version>                                ║
║ Host:    <github|gitlab|unknown>                                 ║
║ Status:  <ONLINE|OFFLINE: queue-awareness unavailable>           ║
╚══════════════════════════════════════════════════════════════════╝

Open PRs claiming versions on <base>:
  #1152  alpha-branch         → v1.7.0.0
  #1153  beta-branch          → v1.7.0.0  ⚠ collision with #1152
  #1151  gamma-branch         → v1.6.5.0

Sibling Conductor worktrees (<workspace_root>):
  path                        branch                 VERSION      last commit   PR
  ──────────────────────────────────────────────────────────────────────────────────
  ../tokyo-v2                 feat/dashboard         v1.7.1.0    3h ago         none  ★ active
  ../melbourne                feat/review            v1.6.0.0    12d ago        none
  ../osaka                    feat/payments          v1.8.0.0    5h ago         #1155

★ active = has VERSION ahead of base AND last commit < 24h AND no open PR.
  These are the ones likely to ship soon.

If you ran /ship right now, you'd claim:
  micro bump:  v1.6.3.1   (queue-advance: none)
  patch bump:  v1.7.1.0   (bumped past claimed 1.7.0.0)
  minor bump:  v1.8.0.0   (bumped past claimed 1.7.0.0)
  major bump:  v2.0.0.0   (no major collisions)

离线或宿主未知时,输出缩短为:

╔══════════════════════════════════════════════════════════════════╗
║                     GSTACK LANDING REPORT                        ║
╠══════════════════════════════════════════════════════════════════╣
║ Status:  OFFLINE — queue-awareness unavailable                   ║
║ Reason:  <offline reason from warnings>                          ║
╚══════════════════════════════════════════════════════════════════╝

Fallback: local VERSION bumps still work, but collisions cannot be detected.

Step 5:给出下一步建议

渲染表格后,按以下优先级只建议一项

  1. 队列存在冲突(两个 open PR 声称同一版本):"⚠ Two open PRs collide on v<X>. Whoever merges second will either overwrite the first's CHANGELOG entry or land a duplicate. Consider asking one author to rerun /ship to pick up the next free slot."
  2. 某个 active 兄弟工作区的版本高于当前分支:"Sibling worktree <path> has v<X> committed <N>h ago and hasn't PR'd yet. If that work ships first, your branch will need to rebump at land time."
  3. 一切干净:"Queue is clean. Next /ship will claim a slot without conflict."

四、底层实现:bin/gstack-next-version 如何给出这个快照

SKILL.md 的五个 Step 只是"消费方",真正的队列感知逻辑全部在 bin/gstack-next-version 这个 Bun/TS 工具里。文件头部注释把它与 /ship 的职责切分写得很清楚:"Contract: util NEVER writes files or mutates state. Pure reader + reporter. /ship consumes the JSON and decides what to do."——这正好解释了为什么 /landing-report 可以宣称绝对只读。

命令行接口与退出码

Usage:
  gstack-next-version --base <branch> --bump <major|minor|patch|micro> \
    --current-version <X.Y.Z.W> [--workspace-root <path>|null] \
    [--version-path <path>] [--json]

--bump 是必填项(缺失或取值不在四档之内时以退出码 2 报错);--base 缺省时按 origin/HEAD 符号引用 → origin/main 探测 → origin/master 探测 → 字面量 main 的链路自动判定(与 test/gstack-next-version.test.ts 中 "default-base detection" 一组的 fixture 仓库测试逐条对应)。退出码约定:0 = 成功输出 JSON(即使含 "offline":true"host":"unknown");2 = 参数非法;3 = 工具自身 bug(未预期异常)。

JSON 输出契约

main() 最终写出的 JSON 字段如下(对应 SKILL.md 中 jq 提取的对象):

字段 说明
version 最终选中的槽位(已越过队列声称与 active 兄弟)
current_version / base_version 调用方传入(或从 origin/<base> 读取)的基准版本
version_path 实际解析出的 VERSION 文件路径
bump 请求的 bump 级别
host github / gitlab / unknown(由 origin URL 嗅探 + gh/glab 认证探测得出)
offline 宿主队列查询是否失败
fallback 是否回退到 git 通道("git"null)——/ship Step 12 依赖此字段判断选取结果是否可信
claimed 真实声称列表(版本高于 base 的 PR 才被计入)
siblings / active_siblings 兄弟 worktree 全量 / 其中判定为 active 的子集
reason 选取理由(如 bumped past claimed 1.7.0.0
warnings 全部降级/告警信息

VERSION 路径解析(monorepo 支持)

版本文件的定位优先级为:--version-path CLI 标志 > 仓库根的 .gstack/version-path 文件(单行相对路径,提交进仓库让协作者共享)> 默认 VERSIONresolveVersionPath() 是一个纯函数,测试用临时目录直接驱动而不 mock git。若钉住的路径以 .json 结尾(如 frontend/package.json),读取时按 JSON 解析取 .version 字段——这一行为由 lib/version-source.ts 中的 isJsonVersionPath() / extractVersion() 统一实现,注释说明这是为修复 #2501:此前 JSON 文件被当纯文本做空白剥离,每个版本读取都落回 0.0.0.0,竞争对手 PR 的声称因此被当作 "malformed" 丢弃,队列冲突检查静默失效。

冲突分配算法:pickNextSlot

槽位选择的核心是 pickNextSlot(base, claimed, level, width),语义是"在同一级别上越过队列中的最高声称":先对 base 做一次 bump 得到候选;若队列中存在高于 base 的声称,则改为对最高声称再做一次同级别 bump,取较大者。例如 base 为 1.6.3.0、队列已声称 1.7.0.0、请求 minor 时,结果是 1.8.0.0(对 main 仍是 minor 语义,保留 ship 时的意图)。跨级别也成立:队列有 1.7.0.0(minor)而你要 patch,则落在 1.7.1.0

版本宽度(width)是 #2501 的另一半:parseVersion() 同时接受 3 段与 4 段(3 段在比较前 pad 成 [a,b,c,0]),versionWidth() 记录字符串实际段数,fmtVersion() 输出时收窄回仓库自己的宽度;在 3 段仓库中 micro 没有可动的第四段,bumpVersion() 会把它执行为 patch 并通过 bumpWasCoerced() 告知调用方打 warning——因为 /ship 默认自动选 micro,报错会让该工具在所有 3 段仓库里不可用,而静默 no-op 更糟(会把起始版本写回去、声称一个已被占用的槽位)。

声称收集:GitHub / GitLab / 纯 git 三级通道

  • GitHub 通道gh pr list --state open --base <base> --limit 200 拉取 open PR,用 gh repo view 拿到本仓库 owner 以过滤 fork PR(fork PR 通过 Contents API 会解析到本仓库 main 的 VERSION,形成幽灵声称),再以 GH_API_CONCURRENCY = 10 的有界并发逐个 gh api repos/{owner}/{repo}/contents/<versionPath>?ref=<head> 读取各 PR 头部的 VERSION 文件(base64 解码后过 extractVersion)。
  • GitLab 通道glab mr list --opened --target-branch <base> + Files API,逐 MR 读取。
  • 纯 git 回退(#2545):当宿主查询离线或 host 未知时,fetchGitClaimed() 接管分配。源码注释记录了一次真实事故:2026-08-12 某个下游仓库 gh pr list 失败、工具返回 offline:true 且声称集合为空,/ship 回退到本地算术,把 0.1.57.0 分给了第二个 PR,而一个 open PR 已占用它——两者合并后 main 上出现两个写着 v0.1.57.0 的提交,审计发现该回退已持续误分配三周。修复原则是"git 知道 API 本来要问什么":git ls-remote --heads origin 拿到远端实时分支清单(零本地变更,GIT_TERMINAL_PROMPT=0 + 5s 超时防止凭据提示挂死);每个候选分支的 VERSION 优先从实时 tip sha 读取,本地缺对象的分支用一次批量 shallow fetch--depth=1 --no-tags,不逐分支爬取)补齐,仍不可读的以 UNKNOWN claim warning 显式上报而非静默跳过;此外还扫描 base 最近 400 条提交主题中形如 v<semver>已发布版本(VERSION 文件只能反映最新值,看不到"已合并又被重挑"的号),达到 400 条上限时会主动声明扫描范围。

这个回退的设计目标在注释里一句话概括:"offline degrades the QUEUE VIEW (no PR numbers, no draft status) without degrading the ALLOCATION"——队列视图降级,但版本分配不降级。

兄弟 worktree 扫描与 "active" 判定

scanSiblings()<workspace_root>/<repoName>/<workspace>/ 的 Conductor 布局下遍历同级目录(跳过自身、要求存在 .git 与版本文件),读取每个兄弟的分支、版本、最后提交时间戳,并用 PR 声称列表的分支名匹配出 has_open_pr。workspace_root 的解析优先级是:--workspace-root CLI > gstack-config get workspace_root > 默认 $HOME/conductor/workspaces;显式传 null 可关闭扫描(非 Conductor 用户)。

markActiveSiblings() 的判定规则与仪表盘脚注一致,且由常量 ACTIVE_SIBLING_MAX_AGE_S = 24h 固化:版本高于 base最后提交在 24 小时内没有 open PR 三条同时满足才标为 active。若存在版本不低于最终候选的 active 兄弟,main() 还会把最终槽位再 bump 过去(reason 记为 bumped past active sibling <version>)。

五、配置项:workspace_root

/landing-report 依赖的唯一 gstack 配置键是 workspace_root(由 bin/gstack-config 管理,v1.11.0.0 引入):默认 $HOME/conductor/workspaces,设为 null 则禁用兄弟扫描。对不使用 Conductor 多窗口的用户,建议显式置 null,可以让兄弟表为空、报告只聚焦 PR 队列。

六、如何用测试验证这些行为

test/gstack-next-version.test.ts 是这个技能可信度的直接依据,覆盖与 SKILL.md 各步骤一一对应:

  • pickNextSlot(队列感知分配的核心):无声称的干净 bump、单/多重碰撞、跨级别碰撞(queued MINOR 越过我的 PATCH 落在 1.7.1.0)、低于 base 的声称被忽略、未排序声称的正确处理;
  • markActiveSiblings:四条判定逐一验证——高版本且 24h 内且无 PR 才 active;有 open PR 的不算(已在队列里);25h 前的提交算 stale;等于或低于 base 的不算;
  • 离线输出契约(#2545):用失败的 gh 桩 + 本地 bare fixture 仓库断言 offline:truefallback:'git'仍然返回可用版本号("degraded queue view, NOT degraded allocation");在线路径则断言 fallbacknull
  • fetchGitClaimed 系列:从 remote-tracking ref 发现兄弟声称并推动选取(0.1.67.0 → 选 0.1.68.0);识别已发布版本;在远端 ref 上读取 JSON 版本路径;非 git 目录降级为 warning 而非抛错;ls-remote 优先的"实时分支"语义(已删除分支的 stale 本地 ref 与 upstream 远端的 ref 都不计入声称、零本地 ref 变更);未 fetch 的实时声称通过单次批量 fetch 解析(测试用 PATH shim 数 git fetch 调用次数锁定为 1);单个悬空 ref 不毒化整批,只以 UNKNOWN claim 告警;
  • 3 段仓库宽度:origin base 不可读时零基准也按仓库自身宽度塑形(base_version0.0.0 而非 0.0.0.0,分配结果 0.0.1 而非 0.0.1.0);
  • 默认 base 探测origin/HEAD 符号引用优先于 origin/main 存在性;无 HEAD 时 origin/master 探测生效;全无时落到字面量 main 并在 warnings 中声明。

此外,/ship 侧的版本状态分类与双写(VERSION + package.json 的 npm 三段合法形式翻译,见 lib/version-source.tsnpmVersion())由 bin/gstack-version-bump 承担,与 gstack-next-version 构成"读取器/写入器"分工——这解释了 /landing-report/ship 之间"同一个工具、只读与写"的关系。

七、延伸阅读

适用前提/landing-report 需要在已安装 gstack(~/.claude/skills/gstack/)的项目内运行;GitHub 通道要求 gh 已认证,GitLab 通道要求 glab 已认证;两者都不可用时自动降级为 git 通道或离线提示。由于该技能只读,在 plan mode、CI 或无网络环境中运行均安全,离线时唯一的损失是"冲突无法被检测到"这一提示本身。

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