closeout 是做什么的?
处理一个已审查的 PR,将工作区恢复到一个干净的基础分支——合并、在移除工作树之前将 shell 从中切换出来、修剪已合并的分支、拉取基础分支并验证最终状态。这是管道的尾部:是最高频的重复动作("我完成了,合并它并让我回到一个干净的 main 分支"),通过此技能将这一知识从用户脑海中移出,变为自动化操作。
为什么存在
/execute 步骤 0 为每个切片创建一个工作树;在此技能存在之前,没有哪个动作移除它。工作树是一个有进无出、不断增长的库存(Meadows, Thinking in Systems —— 对于有进无出的库存,其结构性的解决方案是添加缺失的反馈环,而不是更努力地告诫)。合并 → 拆除 → 拉取这个顺序序列只存在于用户的记忆中,而"前瞻性记忆会无声地失效"(Norman, The Design of Everyday Things —— 将知识从头脑转移到世界中)。这个顺序确实容易出错:当 shell 的当前工作目录(cwd)仍然位于要移除的工作树内时将其删除,会使 shell 孤悬于一个已废弃的目录上,而一个依赖 node_modules 的 Stop 钩子从那里触发时,会发出一长串虚假的 MODULE_NOT_FOUND "lint/类型错误",看起来像是一个真正的回归。步骤 4 中先 cd 到基础分支再移除的顺序正是为了解决这个常见错误而设计的关键修复。
此技能编排现有的工具(gh pr merge、wt remove / git worktree remove、commit-commands:clean_gone)。它不会引入任何文件系统状态。它验证的是结果——干净的工作树、全新的基础分支、PR 已合并——而不仅仅是清理动作运行过了(Meadows 所说的"追求错误的目标":如果以"是否运行过"来衡量 closeout,就会优化这个代理指标,并产生脆弱的仪式性流程)。
调用位置
这是一个主要的管道技能,负责默认交付路径中的 合并 → 清理 尾部:
… → /pre-merge → /compound (PR内,当存在经验教训时) → /pre-merge (在合并了经验的提交上重新运行;重新标记) → [合并 + 工作树拆除 — 本技能] → [关闭问题 — 步骤 9 叙述]
在以下情况使用 /closeout:
/pre-merge已创建了一个 PR 且审查已完成(或者改动足够微小无需审查)- PR 已获得批准且可以合并,并且你希望合并它并将工作区恢复到干净的基础分支
- 用户要求“close out”、“merge and clean up”、“tear down the worktree”或“get back to a clean main/base”
不要在以下情况使用 /closeout:
- PR 尚未创建或仍在审查中——请先运行
/pre-merge - 你想要捕获一个持久的经验教训——该操作属于
/compound,默认情况下它会在/closeout合并之前就已经在该 PR 上运行过了(合并后的/compound是回退方案);/closeout不捕获经验教训 - 你想要关闭 PRD 和切片问题,或更新研究文物的前言信息——这些操作属于
SYSTEM-OVERVIEW.md步骤 9 中的清理叙述;/closeout执行 git 工作流卫生这一半,并将 关闭问题和更新研究文物 这一半推迟到步骤 9 处理,避免重复。它所关闭的唯一一项是已合并切片在sidecar后端上的工作项(步骤 3),因为在合并时没有Closes #N行来处理它 - 这个改动根本不在一个功能分支上(你已经在基础分支上,没有任何东西需要合并)——那么也就没有什么需要 closeout 的
它是经过 HITL 确认且非阻塞的:它只是将已是可选的操作尾部变得自动化。它从不自动合并——合并是面向外部的且难以撤销,所以它总是先确认。
流程
作为 do-confirm 检查清单(Gawande)来运行:执行每个步骤,然后在进入下一步之前验证其结果。破坏性步骤(4-6)是自保护的——在运行它们之前确认步骤 2 中的安全前提条件成立。
0. 宿主提供的隔离?先让渡拆除权
在合并/拆除序列之前,确定此仓库的隔离是宿主拥有的(Conductor、Codespaces、devcontainers)。如果是,/closeout 仍然执行 GitHub 原生的合并,但不拆除工作树或修剪本地分支——宿主拥有生命周期的这一半,与此对抗是 Meadows 所说的策略抵抗(两个参与者推同一个库存)。将工作树库存让渡给宿主。
流出检测比 /execute 的流入检查更严格,不能仅使用通用的“顶层目录 ≠ 主工作树”启发式方法:一个管道创建的工作树也满足该测试,因此通用信号无法区分“宿主拥有拆除权”和“管道创建了此工作树并必须拆除它”。从 .claude/settings.json 的 worktree.provisioning 配置(默认为 "auto")中解析,并以 /execute 在流入时完全相同的方式在此端遵守显式覆盖:
host—— 无条件让渡拆除权。pipeline—— 管道拥有拆除权。即使存在宿主环境变量,也执行下面的完整步骤 4-6 —— 显式的pipeline设置意味着/execute自己创建了工作树,因此/closeout必须拆除它(即宿主出现之前的行为)。宿主环境变量不会覆盖显式的pipeline选择。auto(默认)—— 仅当宿主环境变量描述此工作树时才让渡拆除权:下面的代码块打印host_owned=yes。永远不要仅凭通用的“顶层目录 ≠ 主工作树”就决定让渡,也绝不要仅仅因为存在CONDUCTOR_WORKSPACE_PATH就决定让渡——在 Conductor 下启动的 shell 会继承该变量,并且可能携带一个命名了不同仓库工作区的路径,基于此让渡会使管道创建的工作树无人拆除。
# host-signal: 宿主环境变量描述的是 THIS 树,而不仅仅是这个 shell?
host_owned=no
top=$(cd "$(git rev-parse --show-toplevel)" && pwd -P)
if [ -n "${CONDUCTOR_WORKSPACE_PATH:-}" ]; then
ws=$(cd "$CONDUCTOR_WORKSPACE_PATH" 2>/dev/null && pwd -P) || ws=
if [ -n "$ws" ]; then
case "$top/" in "$ws"/*) host_owned=yes ;; esac
fi
fi
if [ -n "${CODESPACES:-}" ] || [ -n "${REMOTE_CONTAINERS:-}" ]; then host_owned=yes; fi
echo "host_owned=$host_owned"
如果拆除权未被让渡(包括 worktree.provisioning: "auto" 且 host_owned=no),则管道拥有拆除权——按下面的完整步骤 4-6 执行。
当隔离是宿主拥有时,/closeout 运行一个缩减的序列:
- 步骤 3(合并)仍然完整运行 ——
gh pr merge --delete-branch。Conductor 和类似的宿主会创建 PR,但将合并留给人类操作;管道执行 GitHub 原生的合并是其增加的价值,宿主会反映该结果。让渡的范围仅限于文件系统拆除,绝不包括合并。 - 跳过步骤 4 的重新锚定、步骤 5 的
wt remove/git worktree remove/ExitWorktree { remove }以及步骤 6 的本地分支修剪 —— 宿主拥有工作树和分支的生命周期(Conductor 在工作区归档时回收它们)。永远不要对宿主管理的树使用--force。 - 步骤 8 的检查清单:"工作树已删除"和"分支已修剪"变为**"不适用——宿主管理。"** 合并、干净的工作树、PR 已合并以及远程分支的检查仍然适用。最后一项很容易被同样归类为不适用,但并非如此:宿主拥有的是文件系统工作树和本地分支,而远程分支是由此技能仍然完整运行的
gh pr merge --delete-branch删除的(见上一项)。让渡宿主拥有的一半,并不意味着让渡宿主不拥有的那一半。
1. 确认意图并收集状态
从不自动合并。说明你将要做什么,并获取明确的许可。
首先收集事实(这些是只读的):
git branch --show-current # 功能分支
git rev-parse --show-toplevel # 当前工作树(可能是一个工作树)
git worktree list --porcelain | awk '/^worktree /{print $(2); exit}' # BASE 检出(主工作树)
gh pr view --json number,title,url,state,mergeable,mergeStateStatus,reviewDecision 2>/dev/null
确定 PR 所针对的基础分支——仓库声明的默认分支,而不是假设:
SC="<此技能目录>/references/sidecar.sh"
bash "$SC" base-ref --name # 裸分支名称: prod, 而不是 origin/prod
这个站点需要基础分支的名称,而不是一个 ref 引用——它要在步骤 4 中提供给 git switch <base-branch>,而 git switch 会直接拒绝远程跟踪引用(fatal: a branch is expected, got remote branch 'origin/prod')——它想要你即将拉取的本地基础分支。所以它请求 base-ref --name,并且故意不使用 /pre-merge、/help、/walk-commits、/visual-recap、/compound 和 /execute 所采用的 ref 形式;它们需要 ref 仅仅是因为它们将基础分支用作范围端点,此技能永远不需要这样做。不要试图“修复”它以匹配它们。如果 base-ref --name 退出时返回非零值,则表示未找到基础分支:传递其 stderr 并停止,而不是猜测一个。
告诉用户,在一个短代码块中:哪个 PR 将被合并,哪个工作树(如果有)和分支将被拆除,以及你将返回并拉取哪个基础分支。等待确认。如果此分支上没有 PR,则停止并路由到 /pre-merge。
2. 在任何破坏性步骤之前验证安全前提条件
拆除是破坏性的;首先验证其安全性(自我保护)。所有这些必须成立:
- 功能分支已完全推送 ——
git status显示没有未推送的提交,且git log @{u}..为空。 - 此分支上存在一个 PR 且可合并(
mergeable为MERGEABLE;mergeStateStatus不是BLOCKED/DIRTY)。 - 必要的检查已通过且审查已批准(
reviewDecision为APPROVED,或者仓库不要求审查且用户已明确接受)。
如果任何前提条件失败,则停止并报告失败的条目——不要合并或移除任何东西。前提失败是返回审查的信号,而不是强行通过。当失败的是审查 —— reviewDecision 是 CHANGES_REQUESTED —— 返回的路径是 /respond-to-review:它针对每个评论检查代码,由人来决定修复什么以及反驳什么,修复通过 /fix-findings 和重新运行 /pre-merge 进行,之后才能再次运行 /closeout。
审查时效性——批准仍然覆盖此差异吗?(非阻塞)
上面的三个复选框询问的是是否发生了审查。它们都没有问审查是否覆盖了即将合并的差异。在设计好的线性流程中,它们是同一个问题,这恰恰是这个差距不可见的原因;在真实的 HITL 会话中,范围会在审查之后累积到一个分支上,并且 /pre-merge 读取的差异与 /closeout 合并的差异之间没有任何连接。这是一个有进无出的反馈环(Meadows —— 结构性的修复是添加循环,而不是告诫),并且在退化最易造成损害的时刻,它悄然将“PR 已审查”降级为“PR 的早期版本已审查”。持续交付的原则陈述得很清楚:你审查的工件必须是你交付的工件。
读取 /pre-merge 阶段 4 留下的标记并将其与当前头部比较。它位于 backend stamp 指示的位置,根据 references/state-backend.md:在 PR 正文中,或在 sidecar 中。
git fetch --quiet
SC="<此技能目录>/references/sidecar.sh"
case "$(bash "$SC" backend stamp)" in
sidecar)
REVIEWED_SHA=$(bash "$SC" stamp read "$(git branch --show-current)")
HEAD_SHA=$(git rev-parse '@{u}' 2>/dev/null || git rev-parse HEAD) ;;
github)
REVIEWED_SHA=$(gh pr view <number> --json body -q .body \
| sed -n 's/.*<!-- reviewed-at: \([0-9a-f]\{40\}\) -->.*/\1/p' | tail -1)
HEAD_SHA=$(gh pr view <number> --json headRefOid -q .headRefOid) ;;
*) echo "closeout aborted: sidecar.sh backend stamp failed; not guessing the github path" >&2; exit 1 ;;
esac
在 sidecar 后端上,头部是分支的上游,也就是仓库将要合并的内容:上面的第一个前提条件已经要求它匹配本地头部。当分支没有上游时,它会回退到 HEAD。stamp read 打印的完整 40 字符 OID 与 PR 正文标记携带的相同,或者什么也不打印,因此下面的所有内容无论使用哪个后端都能正常运行。
正好是 40 个十六进制字符,通过字符串等值比较。 该句子的两半都是关键性的,因为这个标记是一个跨越两个技能之间的契约。/pre-commit.sh 阶段 4 发出完整的 40 字符 OID,而 headRefOid 总是 40 个字符,因此 \{40\} 是唯一可以进行比较的宽度——接受更短的宽度会让一个格式错误的标记(手动编辑的正文、一个模板占位符被替换到错误的一半中)成功解析,然后永远与 $HEAD_SHA 不同。[[ "$REVIEWED_SHA" == "$HEAD_SHA" ]] 就是比较:两个完整 OID 的精确字符串等值比较,绝不是前缀测试。如果正则表达式拒绝了一个标记,会生成一个空的 $REVIEWED_SHA,并进入下面的“未找到标记”分支,这会优雅降级 —— 这远比“SHAs 不同”分支好得多,后者会因为一个头部从未移动过的 PR 而发出错误警报。在 Skill Kit 仓库中,scripts/test-review-currency-marker.sh 针对这两个技能固定了这个往返测试,这样编写者的格式和此解析器就不会无声地产生偏差。
三种结果,只有一种会中断:
- SHAs 匹配 —— 完整的 OID 字符串等值,因此审查过的差异就是合并候选。静默通过。不要叙述它:这是常见情况,每次运行都宣布一个非事件正是将一个关口变成壁纸的原因。
- 未找到标记(
$REVIEWED_SHA为空) —— 手动提交的 PR、外部贡献、早于标记的 PR,或者是上述正则表达式拒绝了其形状的标记。用一行提示它(“此 PR 上没有/pre-merge标记 —— 审查时效性未知”)并继续。不存在标记并不能证明差异未审查,而每个未标记的 PR 都停下来正是造成警报疲劳的原因,这种疲劳会导致一个关口被反射性地废止(Meadows, policy resistance)。 - SHAs 不同 —— 两个格式良好的 40 字符 OID 不相等,意味着审查后有提交。不要停止。
……
[截断以进行翻译]