ship-it
编排 OpenSpec 变更的实施阶段,在其 Git 工作树内运行。是 plan-proposal 的孪生技能(后者在 develop 分支上运行规划阶段)。组合现有技能——openspec-apply-change、Docker 测试框架和 ship-change——并弥补它们之间缺失的连接。可无头运行。
flowchart LR
P["plan-proposal (develop)"] -->|"边界:衍生工作树"| S
subgraph 工作树 ["实施阶段 — 工作树 (此技能)"]
S["ship-it"] --> A["apply"] --> M["合并 develop (2.5)"] --> T["docker-harness 测试"] --> E["执行器(4.4)"] --> R["本地审查(4.5)"] --> C["ship-change"]
end
S -.->|"反向:遇到设计问题时写入 SHIP_IT_BLOCKED.md"| P
纯决策逻辑位于此技能自己的 scripts/ 目录下,并经过单元测试(.pi/skills/ship-it 是一个 Vitest 项目):
scripts/manifest.ts(parseManifest、deferDecision、filesystemRealityCheck),
scripts/no-weakening.ts(assertNoWeakening),以及
scripts/review-gate.ts(reviewRoundDecision、resolveReviewer、classifyFindings、REVIEW_TIMEOUT_MS)。
前置条件
- 在变更的工作树内运行(
.worktrees/os-<change>,分支为os/<change>)。从工作树目录的基名称解析变更名称。 docker可用(用于端到端测试框架);openspecCLI 在工作树内时从父仓库根目录解析(根据AGENTS.md)。- 宣告:“正在发布变更:
<change>(工作树实施阶段)。”
流程
1. 通过 openspec status 定位,但以文件系统真实状态为门控(幂等)
读取 openspec status --change <change> --json 仅用于定位。不要依赖 tasks.md 的复选框作为自动化场景完成的证据——手动勾选或部分先前的 - [x] 可能不准确。
解析 test-plan.md(parseManifest)。对于每行 automated 行,解析其折叠的测试文件并运行 filesystemRealityCheck:仅当该场景的测试文件存在且能通过测试框架时才算满足(步骤 3)。任何 automated 行其测试文件缺失 → 未完成 → 编写它(步骤 2),无论复选框状态如何。这使得重新调用真正幂等:初始运行和重新运行都能达到相同的全绿灯最终状态。
2. 如果仍有非手动工作,则运行 apply——注入示例
即使作为执行者,也要坚持知识库优先。 你知道任务所指的文件,因此你的直觉应该是直接对它们使用 grep/cat/Read。文档优先的门控仍然适用:在对符号执行 grep/rg、读取文件以了解其用途或追踪导入之前,首先运行 kb_search / kb agents <path> / kb_neighbors。了解文件不豁免你——先知识库查询符号/文件,然后编辑。
如果仍有非 manual-only 任务(实际代码加上折叠任务所指定的测试文件),则为此变更运行 openspec-apply-change。apply 编写代码并标记任务。
apply 本身不具备“从示例复制”的能力。对于每个折叠的测试任务,解析测试框架示例指针(任务命名的最接近的现有该类规范),并将其路径注入到传递给 apply 的任务上下文中——或者在修复循环中自己编写规范(步骤 4)。禁止使用没有示例的“编写 X.spec.ts”。
2.5. 集成 develop——在测试框架之前合并(主要集成点)
测试框架(步骤 3)是 ship-it 路径上最强的门控,因此集成必须位于其上游:测试框架必须验证合并后的树 T1,而不是合并前的树 T0。合并 origin/develop——远程引用,永远不要使用本地的 develop(在父仓库中检出 → 工作树分支冲突陷阱):
git fetch origin develop
git merge --no-edit origin/develop
幂等:已是最新 → “Already up to date”,无需合并提交,始终安全运行。使用合并,而非变基——步骤 9 最终使用 squash-merge,因此变基的线性历史无关紧要,而其强制推送则是 ship-change 文档中已知的陷阱。
冲突 → 中止并停止。 出现未解决的冲突时,执行 git merge --abort,报告,且不进入测试框架——绝不要在部分合并的树上运行门控。使用 ship-change 现有的配方解决简单冲突(AGENTS.md 保留联合;pnpm-lock.yaml → git checkout --theirs + pnpm install --lockfile-only),然后重新运行合并。无法机械解决的冲突 → 逃生通道(步骤 5)。
3. 测试框架生命周期——委托端口,始终清理
从 docker/test-up.sh 获取测试框架及其端口(首次运行时分配,重新运行时复用——不要添加端口代码)。从 .pi-test-harness.json 读取派生端口(dashboardPort)——永远不要硬编码 :18000。
包裹整个运行过程,确保清理始终执行(无论测试红、中止还是 test-up.sh 部分启动):
cleanup() { docker/test-down.sh || true; }
trap cleanup EXIT
docker/test-up.sh -d --build # 分配并记录 .pi-test-harness.json
port=$(jq -r '.dashboardPort' .pi-test-harness.json) # jq 存在于测试框架环境中
# 针对 $port 运行相关测试套件:
PW_E2E_USE_RUNNING=1 npm run test:e2e # L3;或针对其他级别的 L1/L2 套件
test-down.sh 对于部分创建的 Compose 项目是安全的(compose down -v + 尽力而为的 rm)。每个工作树独立的 Compose 项目可防止一个工作树的泄露阻塞另一个工作树。
4. 红测修复循环——由 ship-it 拥有(apply 无法修复已检查的任务)
apply 将 - [ ] → - [x],并且从不重新访问已检查的任务,因此对红色但已检查的测试重新调用 apply 无效。测试变红时,ship-it 自行驱动修复——编辑代码或测试,重新运行测试框架。绝不对已检查的任务重新调用 apply。
通过产生进展的循环来约束循环,而非固定次数:
- 改变工作树并使测试框架结果前进的循环算作一次。
- 对工作树没有产生变化的循环 → 立即停止并升级(步骤 5)。不要空转。
- 此约束与怀疑审查的 3 轮约束无关(语义不同)。
禁止弱化防护栏(机械执行,每个循环执行): 在接受对测试文件的更改之前,对其差异运行 assertNoWeakening(git diff -- <test-file>)。如果报告 ok:false(添加了 .only/skip,删除了断言,或强→宽松匹配器替换),拒绝更改——你不能通过降低测试标准来达到绿灯。修复代码本身。
4.4. 确定性执行器——廉价、离线,在任何模型调用之前
从工作树根目录运行执行器。每个执行器已经是对应规则的唯一拥有者;此步骤仅调用它们:
node scripts/check-conventions.mjs --base origin/develop
node scripts/dox-byte-gate.mjs
node scripts/i18n-lint.mjs --strict # --strict,否则无论结果如何都退出 0
node scripts/i18n-parity.mjs
node scripts/knip-config.mjs # knip.json 仍然根植于每个清单条目
node scripts/knip-ratchet.mjs # 每类死代码的棘轮机制(约 8 秒)
node scripts/knip-ratchet.mjs --check-baseline-diff origin/develop # 检查基线是否被提高
knip 对是死代码预言机的预防性一半:夜间作业在合并后运行,只能报告。顺序很重要——knip-config.mjs 优先,因为未根植的图会将活跃文件报告为死代码,而基于该数字的棘轮机制会产生噪音(实测:未根植 723 个发现 / 90 个未使用文件 vs 根植后 437 / 10)。通过删除死代码修复棘轮失败;提高基线会被第三条命令拒绝——该命令必须保持连接,否则“永远不要提高上限”就成了一句空话:普通的棘轮机制在面对提高的数字时会顺利通过。它还会拒绝已删除的类,这是一种更便宜的绕过方式。
它们放在此处,在测试框架之后和审查之前,因为它们是确定性的、离线的且近乎即时:一棵机械性失败的树绝不应耗费模型调用。非零退出状态会路由到步骤 4 的修复循环;步骤 4.5 不会运行。
--base 对于门控是强制性的:没有它,受影响的集合未定义,Discipline-Skills 和 Mermaid 规则会报告但不会门控。受影响的集合合并了已提交的差异和工作树,因此修复循环中尚未提交的修复也会被检查。
这些不会移入 quality:changed。该脚本是开发循环的预言机,没有自动调用者;发布门控就在这里。
4.5. 本地审查检查点——语义部分
在每次调用时运行。没有琐碎情况可以跳过:没有差异大小、路径或更改文件计数条件可以跳过它。
- 解决审查者:使用
scripts/review-gate.ts中的resolveReviewer。@review是必需的。未配置 → 硬失败,并提示update_roles/ 仪表盘的 Roles 面板。故意没有回退到会话默认模型:该模型是作者,因此回退会使门控变为自我审查。交互式运行可能会提供引导提示;无头运行会失败。 - 将其生成为一个隔离的子代理——一个带有
model: "@review"的Agent调用,携带review-code的评分标准。绝不允许上下文内的自我审查,也绝不使用 CodeRabbit CLI(那是ship-change的远程门控,在后面且不同)。 - 向其提供差异 + 意图:
git diff origin/develop...HEAD(三点差异,因此步骤 2.5 的合并不会归于此变更)加上未提交的工作树编辑,再加上proposal.md和任务文本。 - 限制调用时间为
REVIEW_TIMEOUT_MS(300 秒)。超时既不是通过也不是阻塞性发现——它是检查点失败。 - 路由发现:使用
classifyFindings:只有issue(blocking)重新进入修复循环。其他所有内容都报告并发布。 - 限制循环轮次:使用
reviewRoundDecision:审查、修复、重新审查——绝不允许第三轮。这是一个硬性数字上限,不是步骤 4 的无进展规则,因为审查者每轮都可能发出新的发现,而每次修复都会改变工作树,因此无进展约束永远不会触发。 assertNoWeakening仍然控制审查修复所做的每个测试编辑。只有通过弱化测试才能满足的发现是不可满足的 → 逃生通道(步骤 5),同时命名发现和防护栏。防护栏绝不会为了达到绿灯而放松。
每个 escape 决策都带有 reason;将其写入 SHIP_IT_BLOCKED.md。
5. 边界反向逃生通道
工作树边界不是单向的。在以下任一情况下触发反向路径:
apply报告设计问题(自然语言说明——实施揭示设计有误),或者- 步骤 4 的修复循环耗尽(无进展的循环)。
此时,不要无头重写 proposal.md/design.md:
- 保持工作树不变(不还原)。
- 编写
openspec/changes/<change>/SHIP_IT_BLOCKED.md,命名失败场景/设计差距以及已尝试的方法。 - 以非零退出。
- 通过仪表盘呈现,以便人工在
develop上重新进入plan-proposal/doubt-driven-review。
6. 内联驱动 ship-change,附带清单感知的延迟决策和清理顺序
一旦每个自动化场景都变为绿灯(经测试框架验证),即可发布。内联执行 ship-change 的流程(不作为黑盒子子代理),以便为下面的清理顺序保持步骤级别的控制。
延迟规则(deferDecision,清单感知):
test-plan.md存在 → 剩余的- [ ]只有在映射到manual-only清单行时才可延迟(行内(test-plan: manual-only)或解析为清单的(test-plan #<id>)引用)。任何其他剩余的 = 实际工作 = 停止(返回步骤 2 或逃生通道)。test-plan.md不存在(遗留变更)→ship-change的当前关键词延迟规则照常适用。
在破坏性步骤之前的归档+同步门控(至关重要): 在提案未归档且规范未同步时,不要让 ship-change 合并 PR、删除分支或移除工作树。ship-change 步骤 8.5 是那个硬门控——内联驱动 ship-change,在步骤 8.5 处保持,直到变更归档(源目录移至 openspec/changes/archive/,移动已提交)且规范已同步。归档失败/跳过 → 停止(返回步骤 2 或逃生通道),绝不允许进入步骤 9/10。
移除前清理的顺序(至关重要): 测试框架必须在 ship-change 步骤 10 移除工作树之前拆除(test-down.sh,已通过步骤 3 的 trap 连接)。泄漏的容器会使工作树“忙碌”并阻塞移除。先运行测试框架清理,然后让 ship-change 进行归档 → 提交 → PR → CI → CodeRabbit → (归档+同步门控) → squash-merge → 移除工作树。
防护措施
- 基于文件系统真实状态的幂等性——复选框
- [x]绝不是证据;测试文件必须存在且能通过测试框架。 - ship-it 拥有修复循环——绝不对已检查的任务重新调用
apply。 - 绝不弱化测试以达到绿灯——
assertNoWeakening拒绝它。 - 执行器(4.4)在审查者(4.5)之前——绝不在机械性失败的树上耗费模型调用。
- 审查是无条件的——没有琐碎情况可以跳过,并且
@review是必需的;绝不回退到会话默认模型(那是自我审查)。 - 两轮审查,硬性上限——审查、修复、重新审查,然后逃生。不是无进展约束:模型总是会取得“进展”。
- 在测试框架之前合并
develop(步骤 2.5)——强门控验证集成树T1;合并origin/develop(远程引用),绝不使用变基。冲突 → 中止并停止,绝不在部分合并的树上进入测试框架。 - 在提案未归档和同步前,绝不合并/删除分支/移除工作树——在
ship-change步骤 8.5(归档+同步门控)处保持直到通过;归档失败 → 停止。 - 始终拆除测试框架(trap/finally),在工作树移除之前。
- 绝不硬编码
:18000——从.pi-test-harness.json读取dashboardPort。 - 绝不无头重写规划制品——使用逃生通道。
- 内联驱动 ship-change——不要将其作为子代理生成(你需要其步骤边界来进行清理顺序)。
组成
…
[因翻译而截断]