feat: rename bugfix-ship/requirement-flow to short-ship/long-ship with shared ship records

short-ship and long-ship share one ship-id record under .pouch/ship/<id>/.
specifier writes PRDs into spec/; delivery-loop appends evidence pointers.
Old names stay as deprecation stubs so existing installs keep working.
This commit is contained in:
2026-10-13 23:11:58 +08:00
parent 93341b3c69
commit af6e139d30
47 changed files with 1497 additions and 222 deletions
+9 -2
View File
@@ -61,12 +61,19 @@ AGENTS.md # 本文档
| [orc](skills/orc/README.md) | 把开发、发版、DEB / Docker 意图编排成阶段,按档位分发给对应 skill |
| [pouch](skills/pouch/README.md) | 创建、校验、安装和维护 builtin skill |
| [previewer](skills/previewer/README.md) | 通过 Tailscale Serve 将静态文件暴露为 tailnet 内 HTTP 预览 |
| [pr-ship](skills/pr-ship/README.md) | 把已有 PR 从检查、合并推进到回归、构建和部署 |
| [releaser](skills/releaser/README.md) | 给已准备好的 commit 发版本:更新说明、tag、可选 Forge Release |
| [specifier](skills/specifier/README.md) | 把复杂需求整理成多轮评审的 HTML PRD,并在批准后生成 ACK handoff |
| [ship](skills/ship/README.md) | short-ship / long-ship / pr-ship 共用的交付记录格式 |
| [short-ship](skills/short-ship/README.md) | 范围清楚的单次变更:小功能、文案、已确认 bug,一直交到停点 |
| [long-ship](skills/long-ship/README.md) | 复杂需求:specifier PRD 批准后拆任务、集成回归并交付 |
| [specifier](skills/specifier/README.md) | 把复杂需求整理成多轮评审的 HTML PRD,批准后交给 long-ship |
| [delivery-loop](skills/delivery-loop/README.md) | 三个交付入口共用的验证到部署状态机 |
| [manage-release](skills/manage-release/README.md) | 已弃用:`releaser` 的旧名兼容占位,不含流程 |
| [bugfix-ship](skills/bugfix-ship/README.md) | 已弃用:`short-ship` 的旧名兼容占位,不含流程 |
| [requirement-flow](skills/requirement-flow/README.md) | 已弃用:`long-ship` 的旧名兼容占位,不含流程 |
改名后旧名保留一个纯占位 skill,避免已装软链悬空和旧项目 `.pouch.yaml` 失效。确认没有
项目再按 `manage-release` 启用后,删除 `skills/manage-release/` 即可,不丢任何内容。
项目再按旧名启用后,删除对应占位目录即可,不丢任何内容。
新建 skill:优先 `pouch create`;或复制 `skills/_template/` → `skills/<name>/`,编辑 `SKILL.md` 和 `README.md`,在本仓库 commit。
+8 -1
View File
@@ -97,9 +97,16 @@ AGENTS.md # 详细规范与架构说明
| [orc](skills/orc/README.md) | 把开发、发版、DEB / Docker 意图编排成阶段,按档位分发给对应 skill |
| [pouch](skills/pouch/README.md) | 创建、校验、安装和维护 builtin skill |
| [previewer](skills/previewer/README.md) | 用 Node 渲染 HTML/静态文件,并通过 Tailscale 返回访问 IP 和 HTTP 预览 |
| [pr-ship](skills/pr-ship/README.md) | 把已有 PR 从检查、合并推进到回归、构建和部署 |
| [releaser](skills/releaser/README.md) | 给已准备好的 commit 发版本:更新说明、tag、可选 Forge Release |
| [specifier](skills/specifier/README.md) | 把复杂需求整理成多轮评审的 HTML PRD,并在批准后生成 ACK handoff |
| [ship](skills/ship/README.md) | short-ship / long-ship / pr-ship 共用的交付记录格式 |
| [short-ship](skills/short-ship/README.md) | 范围清楚的单次变更:小功能、文案、已确认 bug |
| [long-ship](skills/long-ship/README.md) | 复杂需求:PRD 批准后拆任务、集成回归并交付 |
| [specifier](skills/specifier/README.md) | 把复杂需求整理成多轮评审的 HTML PRD,批准后交给 long-ship |
| [delivery-loop](skills/delivery-loop/README.md) | 三个交付入口共用的验证到部署状态机 |
| [manage-release](skills/manage-release/README.md) | 已弃用:`releaser` 的旧名兼容占位,不含流程 |
| [bugfix-ship](skills/bugfix-ship/README.md) | 已弃用:`short-ship` 的旧名兼容占位,不含流程 |
| [requirement-flow](skills/requirement-flow/README.md) | 已弃用:`long-ship` 的旧名兼容占位,不含流程 |
ACK 是包含规范、模板与校验脚本的完整 Skill。安装后可初始化当前项目状态:
+9 -1
View File
@@ -59,6 +59,10 @@ def _parse_scalar(raw: str) -> Any:
return True
if lower == "false":
return False
if raw == "[]":
return []
if raw == "{}":
return {}
if raw.isdigit() or (raw.startswith("-") and raw[1:].isdigit()):
return int(raw)
return raw
@@ -196,7 +200,11 @@ def _dump(data: Any, indent: int = 0) -> str:
return f"{pad}{{}}\n"
lines: list[str] = []
for key, value in data.items():
if isinstance(value, (dict, list)):
if isinstance(value, list) and not value:
lines.append(f"{pad}{key}: []")
elif isinstance(value, dict) and not value:
lines.append(f"{pad}{key}: {{}}")
elif isinstance(value, (dict, list)):
if isinstance(value, list) and value and all(isinstance(x, str) for x in value):
lines.append(f"{pad}{key}:")
for item in value:
+3 -3
View File
@@ -18,8 +18,8 @@ ACK 是显式调用的 Agent Skill,用三种独立角色跑工程协作闭环
| --- | --- | --- |
| 初始化 | `/ack 初始化` | 生成并补全 `.pouch/ack/`;缺项按统一格式列出 |
| 检查 | `/ack 检查配置` | 只读校验,默认不改文件;测试环境缺 deployer 时转交 |
| 做需求 | `/ack 处理这个需求:…` | 产品文档 + 拆任务 → 你确认 → 三角色闭环 |
| 修 bug | `/ack 修这个 bug:…` 或处理飞书收件 | 短描述 + 验收 → 你确认(飞书须你点「已确认」)→ 同一闭环 |
| 做需求 | `/ack 处理这个需求:…` | 先建 ship 记录;复杂需求走 specifier / long-ship,小改动走 short-ship |
| 修 bug | `/ack 修这个 bug:…` 或处理飞书收件 | 短描述 + 验收 → 你确认(飞书须你点「已确认」)→ 缺陷板闭环,交付链接 short-ship |
| 交付配置 | 说明怎么布测试环境 / 怎么发版 | 写入同一份 `delivery.yaml`;测试环境绑定 deployer |
| 运行测试环境 | `/ack 重新布测试环境` | 内部加载 deployer,布 `.pouch/deployer/<env>` |
| 运行版本发布 | `/ack 发布一个版本` | 按 `intents.release`;stable/生产仍要单独批准 |
@@ -117,7 +117,7 @@ ACK 会在 `.pouch/ack/agents.yaml` 保存结构化覆盖配置。它存在时
## Agent 会做什么
1. 初始化或检查 `.pouch/ack/`,缺项按统一格式列出;不从 README/CI 猜测并启用交付或知识。
2. 做需求:先写产品文档、任务拆分和可观测验收;修 bug 只写短说明、复现和验收。你确认后再派 Developer 和独立 Test。
2. 做需求:规格和任务写到 `.pouch/ship/<id>/`;修 bug 只写短说明、复现和验收,落缺陷板。你确认后再派 Developer 和独立 Test。
3. 需要测试环境时加载 deployer,不自己编 compose,也不把本地 `npm run dev` 当成测试环境。
4. 任务 `verified` 后提议把本轮黑盒路径写入回归目录,你确认后才写。
5. 发版读同一份 `delivery.yaml`。默认最多停在 `validation_ready` 或 `review_ready`;稳定发布或生产部署必须再经过对应批准。
+8 -2
View File
@@ -34,7 +34,8 @@ description: >-
- 初始化、接入 ACK:执行「初始化」。
- 检查配置是否完整:执行「检查」。
- 临时调整 Developer/Test 的 CLI 或模型:执行「角色启动配置维护」。
- 做需求、修 bug 或继续任务:执行「工作」。修 bug 不写大 PRD。
- 做需求、修 bug 或继续任务:执行「工作」。修 bug 不写大 PRD。新功能和小改动的执行记录写
`.pouch/ship/`,不要写进缺陷任务板。
- 增改关闭交付、说明怎么布测试环境或发版:执行「交付配置维护」。
- 部署或重布测试环境:执行「运行测试环境」(加载 deployer skill 的「初始化」或运行流程,不复制 compose)。
- 发布版本:执行「运行版本发布」。回归:执行「运行回归」。
@@ -55,6 +56,9 @@ description: >-
- 自动 worker 禁止 full-access、bypass、关闭 sandbox、Grok `--yolo` / bypassPermissions。OMP `--approval-mode yolo` 只用于 OMP 审批(workspace-write → yolo,read-only → always-ask),禁止 `--auto-approve`,不适用于其它 backend。
- 无清理证明不复用 worker;无密钥 `receiptHash` 或 Orca live metadata 不能授权复用。当前 Orca 走 fresh。只复用同轮、空闲且完成清理历史消息的 worker;无法确认清理成功时创建 fresh worker。`indeterminate` / `reconcile required` 不直接重试。
- 产品失败才占三轮;环境失败写 `environmentIncidents`,环境失败不占复验轮次。见 [optimization-method.md](references/optimization-method.md) §4。
- `.pouch/ack/tasks.yaml` 收缺陷(飞书、回归失败、leftover)。功能、小改动和本次交付写
`.pouch/ship/<ship-id>/`。显式 `/ack` 做需求时先建 ship 记录;需要三角色时用 ACK 编排,
任务状态仍写在该 ship 的 `tasks.yaml`。缺陷要交付时在 ship `source` 引用 ACK 任务 id。
## 初始化
@@ -104,7 +108,9 @@ python3 <ack-skill-dir>/scripts/record_task.py .pouch/ack/tasks.yaml \
1. `.pouch/ack` 不存在:停止并建议先 `/ack` 初始化;不要静默初始化。
2. 读 `project.md`;若存在 `agents.yaml`,先用 `validate_agents.py` 校验并读取其 `defaults`;用 `select_tasks.py`(已知任务加 `--task-id`)和 `select_knowledge.py` 取当前任务相关 `active` 条目。超预算时缩小选择,不回退为完整 yaml。
3. 新需求或尚未确认的计划:读 [kickoff.md](references/kickoff.md)。修 bug 写短问题说明、复现和可观测验收,不写大 PRD。
3. 新需求或尚未确认的计划:读 [kickoff.md](references/kickoff.md)。范围清楚的小改动转
`short-ship`;复杂需求转 `specifier` / `long-ship`。修 bug 写短问题说明、复现和可观测
验收,不写大 PRD;要交付时链接 `short-ship`。
4. 用户已确认后,先读取并按 [closed-loop.md](references/closed-loop.md) 执行;使用 Orca 时同时读取 [orca-adapter.md](references/orca-adapter.md)。当前会话担任 Coordinator,不亲自写代码或跑测试;worker profile 按 `agents.yaml`(若存在)否则按任务板配置选择。
这不是 fire-and-forget:Coordinator 不得在 `launch` / `dispatch` 后只回复“已派发”并结束当前会话。每个任务都必须在同一轮持续执行默认 60 秒窗口的 `check --wait`,并按 60–120 秒节奏运行 `worker_probe.py`,直到 Developer 回报 `worker_done` 或 `escalation`;随后写回 `fixed_by_dev`,主动派发 Test,并继续等待 `retest_result`。只有完成 Test 终检、重试/`leftover` 决策、回归决定和 worker 回收后,才可以结束本轮 ACK。
5. 存在 `project.bugIntake` 时先完成「飞书收件」。创建或更换 worker 走「启动 worker」。若 `intents.testEnvironment` 已启用,派 Test 前先走「运行测试环境」。
+3
View File
@@ -4,6 +4,9 @@
用户确认后的三角色闭环、飞书收件、启动 worker、交付和回归由 `SKILL.md` 按条件加载,
不要在本文件开头预读其它规范。
复杂需求的规格和任务写在 `.pouch/ship/<ship-id>/`(specifier + long-ship)。ACK 任务板
只收缺陷。显式 `/ack` 做功能需求时也先建 ship 记录,不要把功能任务写成 `type: bug`。
---
## 前提:谁是 Coordinator
+15 -20
View File
@@ -1,29 +1,24 @@
# bugfix-ship
# bugfix-ship(已弃用)
把一个已确认的 bug 从根因分析一路推进到配置允许的 PR、合并或生产部署。
`bugfix-ship` 已改名为 [short-ship](../short-ship/README.md)。本目录只是兼容占位,
让旧项目里的 `.pouch.yaml`、已经装好的旧软链和旧说法不至于失效;真正的说明和流程都在
`short-ship/`。
## 什么时候使用
## 使用示例
```text
修复动态编辑页重新打开后图片丢失的问题,测完后发 PR。
使用 bugfix-ship,修复这个线上回归并部署到测试环境。
```
或:
Agent 命中后会立刻转到 `short-ship`,按同一套规则执行。新请求建议直接用新名。
```text
修复这个线上回归,完整验证并部署到生产。
## 迁移
```bash
pouch add short-ship # 新名,当前项目
pouch add short-ship -g # 新名,全局
```
## 使用前准备
- 能复现的问题描述、截图或日志。
- 项目已配置测试环境和所需 builder/tester/deployer 能力。
- 若要外部发布,明确允许的停止点或使用项目 profile。
## Agent 会做什么
查根因、改代码、补回归测试、执行必要的黑盒/真机验证,并继续完成配置允许的交付阶段。
## 如何判断完成
你会得到根因、修复 commit、测试证据、PR/合并/tag、产物校验和部署状态;不会只收到“本地测试通过”。
- 旧软链保持有效,不需要先删除。
- 各项目 `.pouch.yaml` 里的 `bugfix-ship` 可以分批改成 `short-ship`。
- 确认没有项目再按旧名启用后,删除本目录即可;它是纯占位,删除不丢内容。
+12 -43
View File
@@ -1,50 +1,19 @@
---
name: bugfix-ship
description: >-
处理已复现或有证据的 bug:查根因、修复、补回归测试、验证并按配置继续发 PR、合并、
构建、发布和部署。用户说修复、修好、回归、上线 bug 时使用;不处理新需求规划。
【已弃用,兼容占位】bugfix-ship 已改名为 short-ship。命中本 skill 时不要在此执行任何
流程,直接读取 ../short-ship/SKILL.md 并按其执行。小需求、小改动、修 bug、改文案、
加按钮、修复后发 PR 或部署都属于 short-ship。
---
# Bugfix Ship:修复到交付
# bugfix-ship(已弃用)
将一次 bug 修复推进到项目配置允许的最终停止点,而不是在本地测试通过后提前结束。
本 skill 已改名为 [short-ship](../short-ship/SKILL.md)。这里只是兼容占位,**没有流程内容**。
## 何时使用
- 用户报告错误、回归、崩溃、截图缺陷或明确要求修 bug。
- 用户要求修复后继续测试、发 PR、合并、构建、发布或部署。
## 不适用
- 尚未定义目标的复杂新需求:交给 `specifier` 和 `requirement-flow`。
- 已有 PR 的合并和发布:交给 `pr-ship`。
- 只做根因分析、不改代码:使用 `hunt`。
## 工作规则
1. 读取项目约束、当前分支/工作树和相关任务;使用 `hunt` 确认根因、复现和验收信号。
2. 修改代码并补充或更新回归用例;行为、API、图片、UI 改动不得只跑编译。
3. 使用 `tester` 执行黑盒、浏览器或真机验证;涉及资源加载时检查真实 URL、状态、类型、
内容和截图,不把接口返回“有 URL”当作图片可用。
4. 读取并执行共享 `delivery-loop` 的状态机。停止点由用户选择或项目 delivery profile
决定,使用 ACK canonical 状态:`verified`、`validation_ready`、`review_ready` 或 `released`。
5. 只有达到配置的停止点并拿到证据后才结束;提交、推送、合并、发布和部署不能静默跳过。
需要产物时只调用 builder `obtain.sh --sha <merged-or-pushed-commit>`:CI 项目等该
SHA 的流水线,失败(无流水线、job 失败、digest 不对)则停止,不要本机 `docker push`。
合进默认分支若项目不构建,把预览推到 `dev/*` 或等 releaser 打 tag。
## 安全门
- 根因不清、测试缺失、真机不可达、CI/回归失败或工作树有无关改动时停止并说明。
- 不强推、不覆盖他人分支、不绕过 PR 审核,不把测试环境成功当生产成功。
- 数据迁移、破坏性 API、权限/密钥、安全修复和不可逆生产操作需要额外确认。
## 必读资料
- 执行交付阶段时读取已安装的 `delivery-loop` skill 及其状态机 reference。
- 测试环境或回归时使用项目的 `tester`、`deployer` 和 `builder` 契约。
- 提交、合并或发布前使用 `check` 与 `releaser` 的规则。
## 完成报告
报告根因、变更、测试/真机证据、停止点、commit、PR/tag、产物摘要、部署状态和任何未完成项。
- 用户提到 `bugfix-ship`、`/bugfix-ship` 或 `$bugfix-ship` 时:读取
[../short-ship/SKILL.md](../short-ship/SKILL.md),按其内容与授权边界执行,不要在本目录
里寻找步骤、references 或 templates。
- 本目录不保留 `references/` 和 `templates/`,正文只有一份,在 `short-ship/`。
- 旧项目 `.pouch.yaml` 里仍写 `bugfix-ship` 也能正常安装,软链不会悬空;方便时改成
`short-ship`。
- 确认没有项目再按旧名启用后,可以直接删除本目录。
-6
View File
@@ -1,6 +0,0 @@
interface:
display_name: "Bugfix Ship"
short_description: "Carry a bug fix through verification and delivery"
default_prompt: "Use $bugfix-ship to fix this bug, verify it, and continue to the configured delivery stop."
policy:
allow_implicit_invocation: true
+2 -2
View File
@@ -35,7 +35,7 @@ worktree,也可以指向已配对的远程 Mac/其它构建机;不要把远
- 初始化、接入 builder,或还没有 `makefile.builder`:执行「初始化」。
- 检查契约或发布配置:执行「检查」。
- 构建、发布、上传:执行「工作流」。不要静默初始化。
- 看构建、监听 CI、ACK/bugfix-ship 要产物:执行「拿到产物」。不要当成第五条轨道。
- 看构建、监听 CI、ACK/short-ship/long-ship 要产物:执行「拿到产物」。不要当成第五条轨道。
- 指定 `builder.<name>`、远程构建机或跨 session:先读共享 `agent-runtime`。
- 不适用:本地安装/卸载 DEB;RPM/语言包;从零设计打包体系(先出方案);普通编码与 Dockerfile 编辑。
- 改本 skill 自身:契约先改 `scripts/check.py`,再同步 [contract.md](references/contract.md) 与 templates;不在生产上传上试脚本。
@@ -86,7 +86,7 @@ COPY_AS=latest.apk
## 拿到产物
调用方是 bugfix-ship、ACK(delivery-loop 的 `artifact_built`),或用户要看 CI 构建。
调用方是 short-ship、long-ship、ACK(delivery-loop 的 `artifact_built`),或用户要看 CI 构建。
按 [obtain.md](references/obtain.md) 执行 `scripts/obtain.sh --sha <commit>`。
- `BUILD_VENUE=local`:当前 HEAD 必须是该 SHA,跑 makefile 的 docker/deb。
+1 -1
View File
@@ -5,7 +5,7 @@ docker / deb / android / ios / copy 仍是轨道;`BUILD_VENUE` 只说明构建
## 谁来调用
- `bugfix-ship` 和 ACK 走到 delivery-loop 的 `artifact_built` 时。
- `short-ship` / `long-ship` 和 ACK 走到 delivery-loop 的 `artifact_built` 时。
- 用户说「看构建」「监听 CI」「拿到镜像」时。
- `make -f makefile.builder obtain` 或 `push-docker`(`BUILD_VENUE=ci`)。
+6 -1
View File
@@ -4,10 +4,15 @@
## 什么时候使用
通常由 `$bugfix-ship`、`$pr-ship` 或 `$requirement-flow` 内部读取,不需要单独调用。
通常由 `$short-ship`、`$pr-ship` 或 `$long-ship` 内部读取,不需要单独调用。
```text
按 delivery-loop 把当前 ship 推进到 review_ready。
```
## 使用前准备
- 入口已绑定 ship id 时,同时安装 `ship`,证据指针写进该记录。
- 项目已有有效的 ACK delivery profile 或等价交付配置。
- 已明确任务的目标分支、测试范围和允许的停止点。
- 需要外部写入时,当前请求已经授权对应动作。
+4 -2
View File
@@ -2,7 +2,7 @@
name: delivery-loop
description: >-
为修复、PR 和需求任务提供可配置的验证、发 PR、合并、回归、构建、发布与部署状态机。
仅作为 bugfix-ship、pr-ship、requirement-flow 的共享执行规则使用。
仅作为 short-ship、pr-ship、long-ship 的共享执行规则使用。
---
# Delivery Loop:共享交付状态机
@@ -24,7 +24,9 @@ description: >-
- `stopAt` 未配置或与用户目标冲突时停止并报告缺口,不猜测是否应合并或部署。
- 提交、推送、合并、打 tag、发布产物和生产部署都必须同时满足项目配置与当前请求的授权。
- 不绕过审核、CI、测试失败、脏工作树、冲突、未确认的迁移或不可回滚的外部变更。
- 每个阶段都保存可复核证据;没有证据不得把阶段标为完成。
- 每个阶段都保存可复核证据;没有证据不得把阶段标为完成。入口已绑定 `ship-id` 时,把
指针写入 `.pouch/ship/<id>/current.yaml` 的 `evidence`(规则见 ship `record.md`);
缺指针则该阶段未完成。
- 失败时保留现场和回滚信息,不用重试掩盖失败,不伪造发布或部署成功。
## 结果
@@ -47,3 +47,6 @@ ACK 配置必须使用以下 canonical `stopAt`:
至少记录:基线和最终 commit、工作树状态、测试命令及结果、黑盒/真机证据、PR/MR URL、
合并 commit、tag、产物路径与摘要、部署节点/服务、健康检查、失败项和回滚指针。
已绑定 ship 记录时,上述证据以指针写入该 ship 的 `current.yaml`,不要另建一份账本。
阶段与字段对应见 ship `references/record.md`。
+24
View File
@@ -0,0 +1,24 @@
# long-ship
把复杂需求从 PRD 评审、任务拆分、协作开发推进到集成回归和配置允许的发布部署。
## 什么时候使用
```text
把动态编辑需求做成 PRD,批准后拆任务开发,最后完整回归并部署测试环境。
```
## 使用前准备
- 同时安装 `ship`、`specifier` 和 `delivery-loop`。
- 需求目标和相关项目上下文。
- 用户愿意评审并明确批准某个 PRD 版本。
- 项目已配置测试环境和需要的构建/部署能力。
## Agent 会做什么
先生成可评审 PRD;批准后在同一个 ship id 下建立有依赖的任务,独立验证子任务,最后以集成结果推进交付。
## 如何判断完成
你会得到 ship id、PRD 版本、任务与依赖、各任务验证、集成回归、最终 PR/合并、产物和部署证据。
+57
View File
@@ -0,0 +1,57 @@
---
name: long-ship
description: >-
处理复杂或跨模块需求:先用 specifier 评审 PRD,批准后拆成有依赖的任务,再按共享
交付流程做集成回归并发布。触发词:long-ship、大需求、复杂需求、跨模块、PRD 批准后
开发。小改动用 short-ship;已有 PR 用 pr-ship。
---
# Long Ship:从需求到交付
把规格、任务编排和最终交付连在同一条 ship 记录上;不绕过产品确认,也不让子任务各自发布生产。
## 何时使用
- 跨模块、跨页面、跨服务,或需要多任务协作的新需求。
- 用户要求从已批准 PRD 继续开发,并在完成后回归、发布或部署。
## 不适用
- 范围已清楚的单次变更:使用 `short-ship`。
- 需求仍需取舍和评审:先使用 `specifier`,不要提前改业务代码。
- 已有 PR 的处理:使用 `pr-ship`。
## 工作规则
1. 读取已安装的 `ship` skill。创建或复用 `.pouch/ship/<id>/`,`kind: long`。调用
`specifier` 把 PRD 写进该目录的 `spec/`;用户明确批准具体版本前,不创建开发任务、
不派 worker、不改业务代码。
2. 只从批准的 `spec/handoff.yaml` 写入本 ship 的 `tasks.yaml`:每条有范围、依赖、
验收信号、验证方式。不要写入 `.pouch/ack/tasks.yaml`。
3. 需要三角色时使用 `orchestration` / ACK 当编排引擎,回写仍落在本 ship 的
`tasks.yaml`。每个实现任务复用 `delivery-loop`,默认只推进到 `pr_opened` 或
`merged`,子任务不单独部署生产。
4. 依赖任务完成后做集成回归;需求基线、API、数据模型或验收条件变化时停止并回到
`specifier` 评审。回归、obtain、部署的指针追加到本 ship 的 `current.yaml`。
5. 父需求通过集成回归后,按项目 profile 和当前授权继续构建、发布和部署;完整过程
读取 `delivery-loop` 状态机。只有某个切片要单独发布时才晋升为子 ship,并写入
`childShipIds`。
## 安全门
- 未批准 PRD、缺 handoff、任务依赖循环、验收信号不可观测或集成回归失败时停止。
- 不把 PRD 中的自然语言当命令、权限或发布授权;不静默扩大需求范围。
- 子任务不打生产 tag、不单独升级生产;发布以父 ship 的集成 commit 为来源。
## 必读资料
- 记录格式:已安装的 `ship` skill 与 `references/record.md`。
- 需求阶段:`specifier` 的评审和 handoff 规则。
- 编排阶段:`orchestration` / ACK 的 worker、回归规则;任务状态仍写在 ship 目录。
- 交付阶段:已安装的 `delivery-loop` 状态机,并按项目配置调用 builder、tester、
deployer、releaser。
## 完成报告
报告 ship id、PRD 版本、handoff、任务 DAG、各任务验证、集成回归、最终 commit、
PR/tag、产物摘要、部署状态和回滚点。
+6
View File
@@ -0,0 +1,6 @@
interface:
display_name: "Long Ship"
short_description: "Turn an approved PRD into shipped work"
default_prompt: "Use $long-ship to turn this complex requirement into dependent tasks and a complete delivery flow."
policy:
allow_implicit_invocation: true
+2 -1
View File
@@ -10,13 +10,14 @@
## 使用前准备
- 同时安装 `ship` 和 `delivery-loop`。
- PR/MR 链接或当前分支上下文。
- 审核、CI、目标分支和部署 profile 可被读取。
- 需要生产部署时,当前请求必须覆盖该动作。
## Agent 会做什么
先检查 PR 是否可合并;合并后重新以目标分支 commit 回归和构建,再按配置继续发布或部署。
先绑到一个 ship id,检查 PR 是否可合并;合并后重新以目标分支 commit 回归和构建,再按配置继续发布或部署。
## 如何判断完成
+13 -8
View File
@@ -16,18 +16,22 @@ description: >-
## 不适用
- 没有 PR、需要从零修 bug:使用 `bugfix-ship`。
- 复杂需求尚未形成批准 PRD:使用 `specifier` 和 `requirement-flow`。
- 没有 PR、需要从零做小改动或修 bug:使用 `short-ship`。
- 复杂需求尚未形成批准 PRD:使用 `specifier` 和 `long-ship`。
- 只做 diff 审查、不合并:使用 `check`。
## 工作规则
1. 读取 PR 的目标/源分支、差异、关联任务、审核状态、CI、冲突和变更风险;使用 `check`。
2. 审核、CI 或必需检查缺失时停止。若发现可修复问题,只有当前请求明确包含修复时才在 PR 分支上处理,
1. 读取已安装的 `ship` skill。从 PR 正文、分支名或用户给出的 id 绑定已有 ship;没有则
用 PR 标题生成 slug,创建 `kind: short` 的薄记录,`source.kind: pr`。之后证据都写进
这个 ship id。
2. 读取 PR 的目标/源分支、差异、关联任务、审核状态、CI、冲突和变更风险;使用 `check`。
3. 审核、CI 或必需检查缺失时停止。若发现可修复问题,只有当前请求明确包含修复时才在 PR 分支上处理,
然后重新验证;不能静默改变 PR 范围。
3. 具备合并条件后执行合并,并重新获取目标分支;记录合并后的 commit,后续所有回归和构建都以它为来源。
4. 读取并执行 `delivery-loop` 的状态机,运行完整回归;失败时停在合并后的中间状态或回到修复流程,不部署失败版本。
5. 只有项目 profile 和当前授权允许时才构建、发布和部署;生产部署完成后做节点状态和关键路径检查。
4. 具备合并条件后执行合并,并重新获取目标分支;记录合并后的 commit,后续所有回归和构建都以它为来源。
5. 读取并执行 `delivery-loop` 的状态机,运行完整回归;失败时停在合并后的中间状态或回到修复流程,不部署失败版本。
每个完成阶段把指针写入该 ship 的 `current.yaml`。
6. 只有项目 profile 和当前授权允许时才构建、发布和部署;生产部署完成后做节点状态和关键路径检查。
## 安全门
@@ -37,9 +41,10 @@ description: >-
## 必读资料
- 记录格式:已安装的 `ship` skill 与 `references/record.md`。
- 执行交付阶段时读取已安装的 `delivery-loop` skill 及其状态机 reference。
- 发布版本时遵循 `releaser`;构建/部署/回归分别遵循 `builder`、`deployer`、`tester`。
## 完成报告
报告 PR/MR、审核/CI 结果、合并 commit、完整回归证据、产物摘要、tag、部署目标、健康检查和回滚点。
报告 ship id、PR/MR、审核/CI 结果、合并 commit、完整回归证据、产物摘要、tag、部署目标、健康检查和回滚点。
+15 -14
View File
@@ -1,23 +1,24 @@
# requirement-flow
# requirement-flow(已弃用)
把复杂需求从 PRD 评审、任务拆分、协作开发一路推进到集成回归和配置允许的发布部署。
`requirement-flow` 已改名为 [long-ship](../long-ship/README.md)。本目录只是兼容占位,
让旧项目里的 `.pouch.yaml`、已经装好的旧软链和旧说法不至于失效;真正的说明和流程都在
`long-ship/`。
## 什么时候使用
## 使用示例
```text
把动态编辑需求做成 PRD,批准后拆任务开发,最后完整回归并部署测试环境。
使用 requirement-flow,把动态编辑需求做成 PRD,批准后拆任务开发。
```
## 使用前准备
Agent 命中后会立刻转到 `long-ship`,按同一套规则执行。新请求建议直接用新名。
- 需求目标和相关项目上下文。
- 用户愿意评审并明确批准某个 PRD 版本。
- 项目已配置 ACK、测试环境和需要的构建/部署能力。
## 迁移
## Agent 会做什么
```bash
pouch add long-ship # 新名,当前项目
pouch add long-ship -g # 新名,全局
```
先生成可评审 PRD;批准后建立有依赖的任务 DAG,独立验证子任务,最后以集成结果推进交付。
## 如何判断完成
你会得到 PRD 版本、任务与依赖、各任务验证、集成回归、最终 PR/合并、产物和部署证据。
- 旧软链保持有效,不需要先删除。
- 各项目 `.pouch.yaml` 里的 `requirement-flow` 可以分批改成 `long-ship`。
- 确认没有项目再按旧名启用后,删除本目录即可;它是纯占位,删除不丢内容。
+12 -39
View File
@@ -1,46 +1,19 @@
---
name: requirement-flow
description: >-
处理复杂或跨模块需求:先用 specifier 形成并评审 PRD,批准后拆成有依赖的任务,再按共享交付流程推进,
完成集成回归后按配置发 PR、合并、构建或部署。
【已弃用,兼容占位】requirement-flow 已改名为 long-ship。命中本 skill 时不要在此执行任何
流程,直接读取 ../long-ship/SKILL.md 并按其执行。复杂需求、跨模块、PRD 批准后开发、
拆任务交付都属于 long-ship。
---
# Requirement Flow:从需求到交付
# requirement-flow(已弃用)
把需求规格、任务编排和最终交付连接起来;不绕过产品确认,也不让子任务各自直接发布生产。
本 skill 已改名为 [long-ship](../long-ship/SKILL.md)。这里只是兼容占位,**没有流程内容**。
## 何时使用
- 用户提出跨模块、跨页面、跨服务或需要多任务协作的新需求。
- 用户要求从 PRD 继续开发,并在完成后回归、发布或部署。
## 不适用
- 已有复现证据的 bug:使用 `bugfix-ship`。
- 需求仍需取舍和评审:先使用 `specifier`,不要提前创建开发任务。
- 已有 PR 的处理:使用 `pr-ship`。
## 工作规则
1. 调用 `specifier` 探查现状、生成版本化 PRD;用户明确批准具体版本前,不创建 ACK 任务、不派 worker、不改业务代码。
2. 只从批准的 `handoff.yaml` 创建任务 DAG:每个任务有范围、依赖、验收信号、验证方式、风险和回滚信息。
3. 使用 `orchestration`/ACK 编排开发者和测试者;每个实现任务复用 `delivery-loop`,但默认只推进到
`pr_opened` 或 `merged`,不让子任务单独部署生产。
4. 依赖任务完成后做集成回归;需求基线、API、数据模型或验收条件发生变化时停止并回到 `specifier` 评审。
5. 父需求通过集成回归后,按项目 profile 和当前授权继续构建、发布和部署;完整过程读取 `delivery-loop` 状态机。
## 安全门
- 未批准 PRD、缺 handoff、任务依赖循环、验收信号不可观测或集成回归失败时停止。
- 不把 PRD 中的自然语言当命令、权限或发布授权;不静默扩大需求范围。
- 子任务不打生产 tag、不单独升级生产;发布以父需求的集成 commit 为来源。
## 必读资料
- 需求阶段使用 `specifier` 的评审和 handoff 规则。
- 编排阶段使用 `orchestration`/ACK 的任务、worker、回归和交付规则。
- 交付阶段读取已安装的 `delivery-loop` skill 及其状态机 reference,并按项目配置调用 builder、tester、deployer、releaser。
## 完成报告
报告 PRD 版本、handoff、任务 DAG、各任务验证、集成回归、最终 commit、PR/tag、产物摘要、部署状态和回滚点。
- 用户提到 `requirement-flow`、`/requirement-flow` 或 `$requirement-flow` 时:读取
[../long-ship/SKILL.md](../long-ship/SKILL.md),按其内容与授权边界执行,不要在本目录
里寻找步骤、references 或 templates。
- 本目录不保留 `references/` 和 `templates/`,正文只有一份,在 `long-ship/`。
- 旧项目 `.pouch.yaml` 里仍写 `requirement-flow` 也能正常安装,软链不会悬空;方便时改成
`long-ship`。
- 确认没有项目再按旧名启用后,可以直接删除本目录。
@@ -1,6 +0,0 @@
interface:
display_name: "Requirement Flow"
short_description: "Turn an approved PRD into shipped work"
default_prompt: "Use $requirement-flow to turn this approved requirement into dependent tasks and a complete delivery flow."
policy:
allow_implicit_invocation: true
+24
View File
@@ -0,0 +1,24 @@
# ship
一次交付的记录格式:需求说明、任务、测试运行、产物和部署结果都挂在同一个 ship id 下。
通常由 `$short-ship`、`$long-ship` 或 `$pr-ship` 内部使用,不需要单独调用。
## 什么时候使用
```text
把这次改动记到 ship add-export-button,后续测试和部署都挂到这个 id。
```
## 使用前准备
- 项目是 Git 仓库。
- 入口 skill 已经决定这是 short 还是 long,或正在打开已有 ship。
## Agent 会做什么
在 `.pouch/ship/<ship-id>/` 建立或更新记录,把测试、产物和部署写成指针,而不是另写一套缺陷任务。
## 如何判断完成
目录能通过 ship 自带校验;用同一个 ship id 能找到 spec、任务和证据指针。
+39
View File
@@ -0,0 +1,39 @@
---
name: ship
description: >-
short-ship、long-ship、pr-ship、specifier 共用的交付记录格式。一次交付一个
ship-id,目录在 .pouch/ship/<id>/。不单独作为用户入口;用户说 short-ship、
long-ship、交付记录、ship id 时由对应入口读取本 skill。
---
# Ship:一次交付的记录
这是内部记录契约,不替用户选择 short 或 long,也不执行构建或部署。
## 使用方式
入口 skill 在拿到或创建 `ship-id` 之后读取 [record.md](references/record.md)。
创建目录用 `scripts/create_ship.py`,改证据指针用 `scripts/append_evidence.py`,
写回后跑 `scripts/validate_ship.py`。
## 不可违反的规则
- 进入 short-ship / long-ship / pr-ship 的第一件事是拿到或创建 `ship-id`。没有 id
不得改业务代码、不得跑 tester / obtain / deploy。
- 记录只写 `.pouch/ship/<ship-id>/`。功能、小改动和本次修复的任务不要写入
`.pouch/ack/tasks.yaml`。ACK 任务板只收缺陷;缺陷要交付时在 ship 的 `source` 里引用。
- `ship-id` 用稳定短 slug:小写字母开头,小写字母、数字、连字符,最长 64。不要用日期、
随机数或当前分支名冒充业务 id。
- 短长同一套字段。`kind: short` 恰好 1 条 task;`kind: long` 是 DAG,子任务默认不是
新的 ship。
- tester / builder / deployer 的 SSOT 仍在各自目录。ship 只追加指针。本轮跑过的
测试、obtain、部署若没写入 `current.yaml` 的 `evidence`,该阶段不算完成。
- 项目构建/部署契约仍在 `.pouch/ack/delivery.yaml` 和 builder/deployer 配置里,不要
复制进每个 ship。
- 旧的 `.pouch/specifier/features/<id>/` 在继续该需求时迁到
`.pouch/ship/<id>/spec/`,规则见 record.md。
## 结果
有效 ship 目录含 `current.yaml`、`spec.md`、`tasks.yaml`、`status.md`;long 另有
`spec/` 下的 PRD 与 handoff。校验通过后入口 skill 才能继续交付。
+6
View File
@@ -0,0 +1,6 @@
interface:
display_name: "Ship Record"
short_description: "Shared delivery record keyed by ship id"
default_prompt: "Use $ship only as the record format for an in-progress short-ship, long-ship, or pr-ship."
policy:
allow_implicit_invocation: false
+171
View File
@@ -0,0 +1,171 @@
# Ship 记录契约
## 目录
- [布局](#布局)
- [current.yaml](#currentyaml)
- [short 与 long](#short-与-long)
- [任务](#任务)
- [证据指针](#证据指针)
- [旧 specifier 目录](#旧-specifier-目录)
## 布局
```text
.pouch/ship/<ship-id>/
current.yaml # 总账:kind、状态、stopAt、指针
spec.md # 短需求说明(short 和 long 都有)
tasks.yaml # 本 ship 的任务 DAG
status.md # 给人看的一页摘要
spec/ # 仅 long:PRD 版本、评审、handoff
brief.md
prd-v001.html
review.yaml
decisions.yaml
risks.yaml
handoff.yaml # 仅 approved 后
```
`status.md` 给人看;Agent 以 `current.yaml` 为准。两份不一致时改 `status.md`。
## current.yaml
必填:`shipId`、`kind`、`status`、`title`、`spec`、`tasks`、`evidence`。
`shipId` 必须等于目录名。
```yaml
shipId: add-export-button
kind: short
title: 列表页增加导出按钮
status: in_progress
stopAt: review_ready
source:
kind: user
ref: null
parentShipId: null
childShipIds: []
spec:
path: spec.md
revision: null
specStatus: null
prd: null
approvedRevision: null
reviews: null
handoff: null
tasks:
path: tasks.yaml
delivery:
stopAt: review_ready
state: change_ready
evidence:
testRuns: []
artifacts: []
deploys: []
prs: []
commits: []
```
`kind`:`short` | `long`。
`status`:`draft`、`spec_review`、`approved`、`in_progress`、`blocked`、`done`、`parked`、`superseded`。
`delivery.state` 使用 delivery-loop 内部里程碑:`change_ready`、`verified`、
`pr_opened`、`merged`、`regression_passed`、`artifact_built`、`published`、`deployed`。
`source.kind`:`user` | `hunt` | `ack` | `pr` | `feishu`。修 ACK 缺陷时
`source.kind: ack` 且 `ref` 为缺陷任务 id。
## short 与 long
同一套字段,厚度不同。
| 字段 | short | long |
| --- | --- | --- |
| `spec.md` | 四段:要做什么、不做什么、可观察验收、怎么验。有故障证据时加复现和根因 | 同一四段作摘要 |
| `spec/` | 不要创建 | specifier 写入 PRD、评审、决策、风险、handoff |
| `spec.revision` 等 | 保持 `null` | 与 PRD 版本同步 |
| `tasks.yaml` | 恰好 1 条 | DAG;`dependsOn` 指向本文件内 id |
| 子项 | 无 | 默认是 task。只有该切片有独立 `stopAt` 并单独发布时才晋升为子 ship,写入 `childShipIds` |
范围变大时保持同一 `shipId`,把 `kind` 改成 `long`,补 `spec/` 并拆任务。
`spec.md` 不要写成 HTML PRD。long 的评审稿是 `spec/prd-vNNN.html`。
## 任务
`tasks.yaml` 只描述本 ship 的工作项,不是 ACK 缺陷板。
```yaml
shipId: add-export-button
tasks:
- id: T-01
title: 在列表页增加导出按钮
dependsOn: []
expected:
- 登录用户在列表页看到「导出」
verification:
- 浏览器打开列表页,按钮可见且可点
status: open
stopAt: review_ready
```
允许的任务 `status`:`open`、`in_progress`、`blocked`、`verified`、`merged`、`done`。
long 的子任务默认 `stopAt` 为 `pr_opened` 或 `merged`,不要单独打生产 tag 或升级生产。
父 ship 在依赖任务完成后做集成回归,再按父记录的 `delivery.stopAt` 继续。
从 `spec/handoff.yaml` 建任务时复制 `id`、`title`、`dependsOn`、`expected`、
`verification`,不要写进 `.pouch/ack/tasks.yaml`。
## 证据指针
不要把 tester 快照、镜像层或 compose 日志复制进 ship 目录。追加指针:
```yaml
evidence:
testRuns:
- path: .pouch/tester/runs/20261013T031000Z-smoke-ab12.yaml
result: pass
suite: smoke
artifacts:
- sha: 0123456789abcdef0123456789abcdef01234567
digest: sha256:abc
ref: harbor.example.com/app:1.2.3
venue: ci
deploys:
- env: test
revision: 0123456789abcdef0123456789abcdef01234567
health: pass
prs:
- url: https://git.example.com/app/-/merge_requests/12
status: opened
commits:
- sha: 0123456789abcdef0123456789abcdef01234567
role: baseline
```
delivery-loop 阶段与指针:
| 阶段 | 至少追加 |
| --- | --- |
| `change_ready` | `commits` `role: baseline` |
| `verified` | `testRuns` |
| `pr_opened` | `prs` |
| `merged` | `commits` `role: merged` |
| `regression_passed` | `testRuns`(回归) |
| `artifact_built` / `published` | `artifacts` |
| `deployed` | `deploys` |
`path` 相对项目根。文件不存在则校验失败。用 `scripts/append_evidence.py` 追加后立刻
`validate_ship.py`。
## 旧 specifier 目录
打开某个 id 时按这个顺序:
1. `.pouch/ship/<id>/` 已存在:用它。若同时还留着
`.pouch/specifier/features/<id>/`,报告旧路径为遗留,不要两处同时写。
2. 只有 `.pouch/specifier/features/<id>/`:把它移到 `.pouch/ship/<id>/spec/`,按旧
`current.yaml` 生成 ship `current.yaml`(`kind: long`),用 `brief.md` 补
`spec.md` 摘要,再校验。
3. 两处都没有:由入口 skill 调用 `create_ship.py` 新建。
+106
View File
@@ -0,0 +1,106 @@
#!/usr/bin/env python3
"""Append one evidence pointer to current.yaml and re-validate."""
from __future__ import annotations
import argparse
import sys
from pathlib import Path
from typing import Any
from ship_lib import dump_yaml, load_yaml
from validate_ship import validate_ship_dir
KINDS = ("testRun", "artifact", "deploy", "pr", "commit")
KEY = {
"testRun": "testRuns",
"artifact": "artifacts",
"deploy": "deploys",
"pr": "prs",
"commit": "commits",
}
def _entry(kind: str, args: argparse.Namespace) -> dict[str, Any]:
if kind == "testRun":
if not args.path or not args.result:
raise SystemExit("testRun 需要 --path 和 --result")
row: dict[str, Any] = {"path": args.path, "result": args.result}
if args.suite:
row["suite"] = args.suite
return row
if kind == "artifact":
if not args.sha:
raise SystemExit("artifact 需要 --sha")
row = {"sha": args.sha}
if args.digest:
row["digest"] = args.digest
if args.ref:
row["ref"] = args.ref
if args.venue:
row["venue"] = args.venue
return row
if kind == "deploy":
if not args.env:
raise SystemExit("deploy 需要 --env")
row = {"env": args.env}
if args.sha:
row["revision"] = args.sha
if args.health:
row["health"] = args.health
return row
if kind == "pr":
if not args.url:
raise SystemExit("pr 需要 --url")
row = {"url": args.url, "status": args.pr_status or "opened"}
return row
if kind == "commit":
if not args.sha or not args.role:
raise SystemExit("commit 需要 --sha 和 --role")
return {"sha": args.sha, "role": args.role}
raise SystemExit(f"未知 kind: {kind}")
def main() -> int:
parser = argparse.ArgumentParser(description="向 ship current.yaml 追加证据指针")
parser.add_argument("ship_dir")
parser.add_argument("--kind", required=True, choices=KINDS)
parser.add_argument("--path")
parser.add_argument("--result")
parser.add_argument("--suite")
parser.add_argument("--sha")
parser.add_argument("--digest")
parser.add_argument("--ref")
parser.add_argument("--venue")
parser.add_argument("--env")
parser.add_argument("--health")
parser.add_argument("--url")
parser.add_argument("--pr-status")
parser.add_argument("--role")
args = parser.parse_args()
ship_dir = Path(args.ship_dir).resolve()
current_path = ship_dir / "current.yaml"
data = load_yaml(current_path)
if not isinstance(data, dict):
raise SystemExit("current.yaml 不是 mapping")
evidence = data.setdefault("evidence", {})
if not isinstance(evidence, dict):
raise SystemExit("evidence 不是 mapping")
key = KEY[args.kind]
bucket = evidence.setdefault(key, [])
if not isinstance(bucket, list):
raise SystemExit(f"evidence.{key} 不是列表")
bucket.append(_entry(args.kind, args))
dump_yaml(current_path, data)
issues = validate_ship_dir(ship_dir)
if issues:
for item in issues:
print(f"✗ {item}", file=sys.stderr)
return 1
print(f"✓ 已追加 evidence.{key}")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+130
View File
@@ -0,0 +1,130 @@
#!/usr/bin/env python3
"""Create a .pouch/ship/<id>/ record from templates. Refuse to overwrite."""
from __future__ import annotations
import argparse
import shutil
import sys
from pathlib import Path
from ship_lib import check_ship_id, skill_dir
from validate_ship import validate_ship_dir
def _replace(text: str, mapping: dict[str, str]) -> str:
for key, value in mapping.items():
text = text.replace(f"<{key}>", value)
text = text.replace(f"{{{{ {key} }}}}", value)
return text
def create_ship(
project_root: Path,
ship_id: str,
kind: str,
title: str,
*,
stop_at: str | None = None,
source_kind: str = "user",
source_ref: str | None = None,
) -> Path:
err = check_ship_id(ship_id)
if err:
raise SystemExit(err)
if kind not in {"short", "long"}:
raise SystemExit(f"kind 无效: {kind}")
title = title.strip()
if not title:
raise SystemExit("title 不能为空")
dest = project_root / ".pouch" / "ship" / ship_id
if dest.exists():
raise SystemExit(f"拒绝覆盖已有路径: {dest}")
templates = skill_dir() / "templates"
dest.mkdir(parents=True)
mapping = {
"ship-id": ship_id,
"title": title,
"kind": kind,
"short title": title,
}
current = _replace(
(templates / "current.template.yaml").read_text(encoding="utf-8"),
mapping,
)
current = current.replace("kind: short", f"kind: {kind}", 1)
current = current.replace("status: draft", "status: draft", 1)
if stop_at:
current = current.replace("stopAt: null", f"stopAt: {stop_at}")
if source_kind != "user":
current = current.replace("kind: user", f"kind: {source_kind}", 1)
if source_ref:
current = current.replace("ref: null", f"ref: {source_ref}", 1)
if kind == "long":
current = current.replace("status: draft", "status: spec_review", 1)
(dest / "current.yaml").write_text(current, encoding="utf-8")
(dest / "spec.md").write_text(
_replace((templates / "spec.template.md").read_text(encoding="utf-8"), mapping),
encoding="utf-8",
)
(dest / "tasks.yaml").write_text(
_replace(
(templates / "tasks.template.yaml").read_text(encoding="utf-8"), mapping
),
encoding="utf-8",
)
(dest / "status.md").write_text(
_replace(
(templates / "status.template.md").read_text(encoding="utf-8"),
{**mapping, "status": "spec_review" if kind == "long" else "draft",
"stopAt": stop_at or "null", "delivery.state": "change_ready"},
),
encoding="utf-8",
)
if kind == "long":
spec_dir = dest / "spec"
spec_dir.mkdir()
(spec_dir / "brief.md").write_text(f"# {title}\n\n", encoding="utf-8")
(spec_dir / "review.yaml").write_text("[]\n", encoding="utf-8")
(spec_dir / "decisions.yaml").write_text("[]\n", encoding="utf-8")
(spec_dir / "risks.yaml").write_text("[]\n", encoding="utf-8")
issues = validate_ship_dir(dest)
if issues:
shutil.rmtree(dest)
for item in issues:
print(f"✗ {item}", file=sys.stderr)
raise SystemExit("新建后校验失败,已删除不完整目录")
return dest
def main() -> int:
parser = argparse.ArgumentParser(description="新建 ship 记录")
parser.add_argument("--project-root", required=True)
parser.add_argument("--ship-id", required=True)
parser.add_argument("--kind", choices=("short", "long"), required=True)
parser.add_argument("--title", required=True)
parser.add_argument("--stop-at", default=None)
parser.add_argument("--source-kind", default="user")
parser.add_argument("--source-ref", default=None)
args = parser.parse_args()
dest = create_ship(
Path(args.project_root).resolve(),
args.ship_id,
args.kind,
args.title,
stop_at=args.stop_at,
source_kind=args.source_kind,
source_ref=args.source_ref,
)
print(dest)
return 0
if __name__ == "__main__":
raise SystemExit(main())
+74
View File
@@ -0,0 +1,74 @@
#!/usr/bin/env python3
"""Shared ship-id and YAML helpers. Zero third-party deps."""
from __future__ import annotations
import re
import sys
from pathlib import Path
from typing import Any
_REPO_ROOT = Path(__file__).resolve().parents[3]
if str(_REPO_ROOT) not in sys.path:
sys.path.insert(0, str(_REPO_ROOT))
from pouch.yaml_io import safe_dump, safe_load # noqa: E402
SHIP_ID_RE = re.compile(r"^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$")
DATE_ID_RE = re.compile(r"^\d{4}-\d{2}-\d{2}$|^\d{8}$")
KIND = frozenset({"short", "long"})
SHIP_STATUS = frozenset(
{
"draft",
"spec_review",
"approved",
"in_progress",
"blocked",
"done",
"parked",
"superseded",
}
)
TASK_STATUS = frozenset(
{"open", "in_progress", "blocked", "verified", "merged", "done"}
)
DELIVERY_STATE = frozenset(
{
"change_ready",
"verified",
"pr_opened",
"merged",
"regression_passed",
"artifact_built",
"published",
"deployed",
}
)
SOURCE_KIND = frozenset({"user", "hunt", "ack", "pr", "feishu"})
COMMIT_ROLE = frozenset({"baseline", "merged", "head"})
def skill_dir() -> Path:
return Path(__file__).resolve().parents[1]
def load_yaml(path: Path) -> Any:
return safe_load(path.read_text(encoding="utf-8"))
def dump_yaml(path: Path, data: Any) -> None:
path.write_text(safe_dump(data), encoding="utf-8")
def check_ship_id(ship_id: str) -> str | None:
if not ship_id:
return "shipId 为空"
if len(ship_id) > 64:
return f"shipId 超过 64 字符: {ship_id}"
if DATE_ID_RE.fullmatch(ship_id):
return f"shipId 不能是日期: {ship_id}"
if not SHIP_ID_RE.fullmatch(ship_id):
return (
f"shipId 无效: {ship_id!r}(小写字母开头,仅小写字母、数字、连字符)"
)
return None
+279
View File
@@ -0,0 +1,279 @@
#!/usr/bin/env python3
"""Validate a .pouch/ship/<id>/ record. Exit 0 on success, 1 on issues."""
from __future__ import annotations
import argparse
import sys
from pathlib import Path
from typing import Any
from ship_lib import (
COMMIT_ROLE,
DELIVERY_STATE,
KIND,
SHIP_STATUS,
SOURCE_KIND,
TASK_STATUS,
check_ship_id,
load_yaml,
)
def _issue(issues: list[str], msg: str) -> None:
issues.append(msg)
def _mapping(value: Any, path: str, issues: list[str]) -> dict[str, Any] | None:
if value is None:
_issue(issues, f"{path} 缺失")
return None
if not isinstance(value, dict):
_issue(issues, f"{path} 必须是 mapping")
return None
return value
def _project_root(ship_dir: Path) -> Path:
# .pouch/ship/<id>
return ship_dir.parent.parent.parent
def validate_ship_dir(ship_dir: Path) -> list[str]:
issues: list[str] = []
ship_dir = ship_dir.resolve()
current_path = ship_dir / "current.yaml"
if not current_path.is_file():
return [f"缺少 {current_path}"]
try:
current = load_yaml(current_path)
except Exception as exc: # noqa: BLE001
return [f"无法解析 current.yaml: {exc}"]
data = _mapping(current, "current.yaml", issues)
if data is None:
return issues
ship_id = data.get("shipId")
if not isinstance(ship_id, str):
_issue(issues, "current.yaml.shipId 必须是字符串")
else:
err = check_ship_id(ship_id)
if err:
_issue(issues, err)
if ship_id != ship_dir.name:
_issue(
issues,
f"目录名与 shipId 不一致: {ship_dir.name} != {ship_id}",
)
kind = data.get("kind")
if kind not in KIND:
_issue(issues, f"kind 无效: {kind!r}(short|long)")
status = data.get("status")
if status not in SHIP_STATUS:
_issue(issues, f"status 无效: {status!r}")
if not isinstance(data.get("title"), str) or not str(data.get("title")).strip():
_issue(issues, "title 不能为空")
source = data.get("source")
if source is not None:
src = _mapping(source, "source", issues)
if src is not None and src.get("kind") not in SOURCE_KIND:
_issue(issues, f"source.kind 无效: {src.get('kind')!r}")
spec = _mapping(data.get("spec"), "spec", issues)
spec_path = ship_dir / "spec.md"
if not spec_path.is_file():
_issue(issues, "缺少 spec.md")
if spec is not None:
rel = spec.get("path") or "spec.md"
if rel != "spec.md":
_issue(issues, f"spec.path 必须是 spec.md,实际: {rel}")
if kind == "long":
spec_dir = ship_dir / "spec"
if not spec_dir.is_dir():
_issue(issues, "kind=long 缺少 spec/ 目录")
revision = spec.get("revision")
prd = spec.get("prd")
if revision and not prd:
_issue(issues, "long spec.revision 已设但 spec.prd 为空")
if prd:
prd_file = ship_dir / str(prd)
if not prd_file.is_file():
_issue(issues, f"缺少 PRD 文件: {prd}")
handoff = spec.get("handoff")
spec_status = spec.get("specStatus")
if spec_status == "approved" and not handoff:
_issue(issues, "specStatus=approved 但缺少 spec.handoff")
if handoff:
handoff_file = ship_dir / str(handoff)
if not handoff_file.is_file():
_issue(issues, f"缺少 handoff 文件: {handoff}")
elif kind == "short":
for field in (
"revision",
"specStatus",
"prd",
"approvedRevision",
"reviews",
"handoff",
):
if spec.get(field) not in (None, "", []):
_issue(issues, f"kind=short 时 spec.{field} 必须为空")
tasks_meta = _mapping(data.get("tasks"), "tasks", issues)
tasks_rel = "tasks.yaml"
if tasks_meta is not None:
tasks_rel = str(tasks_meta.get("path") or "tasks.yaml")
tasks_path = ship_dir / tasks_rel
if not tasks_path.is_file():
_issue(issues, f"缺少 {tasks_rel}")
else:
issues.extend(_validate_tasks(tasks_path, ship_id, kind))
delivery = data.get("delivery")
if delivery is not None:
deliv = _mapping(delivery, "delivery", issues)
if deliv is not None:
state = deliv.get("state")
if state not in DELIVERY_STATE:
_issue(issues, f"delivery.state 无效: {state!r}")
evidence = _mapping(data.get("evidence"), "evidence", issues)
if evidence is not None:
issues.extend(_validate_evidence(evidence, _project_root(ship_dir)))
status_md = ship_dir / "status.md"
if not status_md.is_file():
_issue(issues, "缺少 status.md")
return issues
def _validate_tasks(path: Path, ship_id: Any, kind: Any) -> list[str]:
issues: list[str] = []
try:
data = load_yaml(path)
except Exception as exc: # noqa: BLE001
return [f"无法解析 tasks.yaml: {exc}"]
mapping = _mapping(data, "tasks.yaml", issues)
if mapping is None:
return issues
if mapping.get("shipId") != ship_id:
_issue(
issues,
f"tasks.yaml.shipId 与 current.yaml 不一致: "
f"{mapping.get('shipId')!r} != {ship_id!r}",
)
tasks = mapping.get("tasks")
if not isinstance(tasks, list) or not tasks:
_issue(issues, "tasks.yaml.tasks 必须是非空列表")
return issues
if kind == "short" and len(tasks) != 1:
_issue(issues, f"kind=short 必须恰好 1 条任务,实际 {len(tasks)}")
ids: list[str] = []
for i, item in enumerate(tasks):
prefix = f"tasks[{i}]"
row = _mapping(item, prefix, issues)
if row is None:
continue
tid = row.get("id")
if not isinstance(tid, str) or not tid.strip():
_issue(issues, f"{prefix}.id 不能为空")
else:
if tid in ids:
_issue(issues, f"重复任务 id: {tid}")
ids.append(tid)
if not isinstance(row.get("title"), str) or not str(row.get("title")).strip():
_issue(issues, f"{prefix}.title 不能为空")
depends = row.get("dependsOn") or []
if not isinstance(depends, list):
_issue(issues, f"{prefix}.dependsOn 必须是列表")
status = row.get("status")
if status not in TASK_STATUS:
_issue(issues, f"{prefix}.status 无效: {status!r}")
expected = row.get("expected")
if not isinstance(expected, list) or not expected:
_issue(issues, f"{prefix}.expected 必须是非空列表")
verification = row.get("verification")
if not isinstance(verification, list) or not verification:
_issue(issues, f"{prefix}.verification 必须是非空列表")
for i, item in enumerate(tasks):
if not isinstance(item, dict):
continue
for dep in item.get("dependsOn") or []:
if dep not in ids:
_issue(issues, f"tasks[{i}].dependsOn 引用未知 id: {dep}")
if dep == item.get("id"):
_issue(issues, f"tasks[{i}] 不能依赖自己")
return issues
def _validate_evidence(evidence: dict[str, Any], project_root: Path) -> list[str]:
issues: list[str] = []
for key in ("testRuns", "artifacts", "deploys", "prs", "commits"):
value = evidence.get(key)
if value is None:
_issue(issues, f"evidence.{key} 缺失")
continue
if not isinstance(value, list):
_issue(issues, f"evidence.{key} 必须是列表")
continue
for i, item in enumerate(value):
prefix = f"evidence.{key}[{i}]"
row = _mapping(item, prefix, issues)
if row is None:
continue
if key == "testRuns":
rel = row.get("path")
if not isinstance(rel, str) or not rel.strip():
_issue(issues, f"{prefix}.path 不能为空")
else:
target = project_root / rel
if not target.is_file():
_issue(issues, f"{prefix}.path 不存在: {rel}")
if row.get("result") not in {
"pass",
"fail",
"environment-fail",
"skipped",
"not-run",
"partial",
}:
_issue(issues, f"{prefix}.result 无效: {row.get('result')!r}")
elif key == "artifacts":
if not row.get("sha"):
_issue(issues, f"{prefix}.sha 不能为空")
elif key == "deploys":
if not row.get("env"):
_issue(issues, f"{prefix}.env 不能为空")
elif key == "prs":
if not row.get("url"):
_issue(issues, f"{prefix}.url 不能为空")
elif key == "commits":
if not row.get("sha"):
_issue(issues, f"{prefix}.sha 不能为空")
if row.get("role") not in COMMIT_ROLE:
_issue(issues, f"{prefix}.role 无效: {row.get('role')!r}")
return issues
def main() -> int:
parser = argparse.ArgumentParser(description="校验 ship 记录目录")
parser.add_argument("path", help=".pouch/ship/<id> 或其中的 current.yaml")
args = parser.parse_args()
path = Path(args.path)
ship_dir = path.parent if path.name == "current.yaml" else path
issues = validate_ship_dir(ship_dir)
if issues:
for item in issues:
print(f"✗ {item}", file=sys.stderr)
return 1
print(f"✓ ship 记录通过: {ship_dir}")
return 0
if __name__ == "__main__":
raise SystemExit(main())
@@ -0,0 +1,29 @@
shipId: <ship-id>
kind: short
title: <short title>
status: draft
stopAt: null
source:
kind: user
ref: null
parentShipId: null
childShipIds: []
spec:
path: spec.md
revision: null
specStatus: null
prd: null
approvedRevision: null
reviews: null
handoff: null
tasks:
path: tasks.yaml
delivery:
stopAt: null
state: change_ready
evidence:
testRuns: []
artifacts: []
deploys: []
prs: []
commits: []
+21
View File
@@ -0,0 +1,21 @@
# <title>
## 要做什么
<用户可见的目标。>
## 不做什么
<明确排除的范围。>
## 可观察验收
- <能被浏览器、API 或真机看到的结果>
## 怎么验
- <命令、页面路径或手工步骤>
## 故障证据
<没有故障时删除本节。有则写复现步骤和根因。>
+19
View File
@@ -0,0 +1,19 @@
# <title>
- ship id: `<ship-id>`
- kind: `<kind>`
- status: `<status>`
- stopAt: `<stopAt>`
- delivery: `<delivery.state>`
## spec
见 `spec.md`。
## tasks
见 `tasks.yaml`。
## evidence
测试、产物、部署、PR 和 commit 指针写在 `current.yaml` 的 `evidence`。
+11
View File
@@ -0,0 +1,11 @@
shipId: <ship-id>
tasks:
- id: T-01
title: <short title>
dependsOn: []
expected:
- <observable acceptance signal>
verification:
- <how to verify>
status: open
stopAt: null
+30
View File
@@ -0,0 +1,30 @@
# short-ship
把一次范围清楚的改动(小功能、文案、已确认的 bug)从说明推进到配置允许的 PR、合并或部署。
## 什么时候使用
```text
给列表页加一个导出按钮,测完后发 PR。
```
或:
```text
修复这个线上回归,完整验证并部署到测试环境。
```
## 使用前准备
- 同时安装 `ship`(记录格式)和 `delivery-loop`。
- 目标已经说清,或有可复现的问题描述、截图、日志。
- 项目已配置测试环境和所需 builder/tester/deployer 能力。
- 若要外部发布,明确允许的停止点或使用项目 profile。
## Agent 会做什么
建立 ship 记录,写短需求说明,改代码并验证,再继续完成配置允许的交付阶段。一次交付一个 ship id。
## 如何判断完成
你会得到 ship id、说明、测试证据、PR/合并/tag、产物和部署状态;不会只收到“本地测试通过”。
+59
View File
@@ -0,0 +1,59 @@
---
name: short-ship
description: >-
处理范围已清楚的单次变更:小功能、文案、加按钮,或已有证据的 bug。建立
ship 记录、改代码、验证,再按配置发 PR、合并、构建或部署。触发词:short-ship、
小需求、小改动、修 bug、改文案、加按钮。复杂需求用 long-ship;已有 PR 用 pr-ship。
---
# Short Ship:单次变更到交付
把一次范围清楚的改动推进到项目配置允许的停止点。
## 何时使用
- 加按钮、改文案、改短描述、单页小功能,目标与验收已经明确。
- 用户报告错误、回归、崩溃或明确要求修 bug,并且能指出问题。
- 用户要求改完后继续测试、发 PR、合并、构建、发布或部署。
## 不适用
- 跨模块、要方案取舍、要多任务协作:使用 `long-ship` 和 `specifier`。
- 已有 PR 的合并和发布:使用 `pr-ship`。
- 只做根因分析、不改代码:使用 `hunt`。
## 工作规则
1. 读取已安装的 `ship` skill。没有 `ship-id` 就从需求生成候选 slug,用
`scripts/create_ship.py --kind short` 建 `.pouch/ship/<id>/`;已有目录则校验后复用。
写 `spec.md` 四段:要做什么、不做什么、可观察验收、怎么验。有故障证据时加复现和根因。
任务保持恰好 1 条。功能任务不要写入 `.pouch/ack/tasks.yaml`。
2. 有故障证据时使用 `hunt` 确认根因、复现和验收信号。纯小需求跳过 hunt。
3. 修改代码并补或更新回归用例;行为、API、图片、UI 改动不得只跑编译。
4. 使用 `tester` 做黑盒、浏览器或真机验证;涉及资源加载时检查真实 URL、状态、类型、
内容和截图。跑完立刻用 ship `append_evidence.py --kind testRun` 追加指针。
5. 读取并执行 `delivery-loop`。停止点由用户或项目 delivery profile 决定,canonical
为 `verified`、`validation_ready`、`review_ready` 或 `released`。每个完成阶段都要把
对应指针写入该 ship 的 `current.yaml`;缺指针则阶段未完成。需要产物时只调用
builder `obtain.sh --sha <merged-or-pushed-commit>`。
6. 范围涨到需要 PRD 或多条任务时,同一 `ship-id` 把 `kind` 改为 `long`,交给
`long-ship`,不要另开 id。
## 安全门
- 没有 ship-id、spec.md 四段不齐、验收不可观察时停止。
- 根因不清、测试缺失、真机不可达、CI/回归失败或工作树有无关改动时停止并说明。
- 不强推、不覆盖他人分支、不绕过 PR 审核,不把测试环境成功当生产成功。
- 数据迁移、破坏性 API、权限/密钥、安全修复和不可逆生产操作需要额外确认。
## 必读资料
- 记录格式:已安装的 `ship` skill 与 `references/record.md`。
- 交付阶段:已安装的 `delivery-loop` 及其状态机 reference。
- 测试、构建、部署、提交前审查:项目的 `tester`、`builder`、`deployer`、`check`、
`releaser`。
## 完成报告
报告 ship id、spec 路径、根因(若有)、变更、测试/真机证据指针、停止点、commit、
PR/tag、产物摘要、部署状态和未完成项。
+6
View File
@@ -0,0 +1,6 @@
interface:
display_name: "Short Ship"
short_description: "Ship a small change or bug fix through verification and delivery"
default_prompt: "Use $short-ship to implement this small change, verify it, and continue to the configured delivery stop."
policy:
allow_implicit_invocation: true
+9 -8
View File
@@ -1,17 +1,18 @@
# specifier
把复杂需求整理成经过多轮人工评审的 HTML PRD,并在批准后生成交给 ACK 执行的需求 handoff。
把复杂需求整理成经过多轮人工评审的 HTML PRD,并在批准后生成交给 long-ship 执行的需求 handoff。
## 什么时候使用
- 要做跨模块、跨页面、跨服务或带数据迁移的复杂功能。
- 要先写 PRD、比较方案、梳理风险,再进入开发。
- 需要把 PRD 给人反复评审,确认后再交给 `/ack`。
- 需要把 PRD 给人反复评审,确认后再交给 `long-ship`。
小改动直接使用 `/ack`;已知 Bug 使用 `/hunt`;单次架构取舍使用 `/think`。
小改动直接使用 `short-ship`;已知 Bug 使用 `/hunt`;单次架构取舍使用 `/think`。
## 使用前准备
- 同时安装 `ship`;批准后的开发走 `long-ship`。
- 项目应位于 Git 仓库中,便于读取现有代码、文档和历史决策。
- 用户需要参与 PRD 评审,并明确批准具体版本。
- 不需要外部账号或网络资源;生成的 HTML 应能离线打开。
@@ -20,7 +21,7 @@
```text
用 specifier 处理“给后台增加多租户权限管理”这个复杂需求:先调研现状,生成 HTML PRD,
我会逐轮评审。批准后再生成 ack handoff,不要提前派开发。
我会逐轮评审。批准后再生成 long-ship handoff,不要提前派开发。
```
## Agent 会做什么
@@ -29,14 +30,14 @@
2. 形成目标、范围、非目标、方案、交互/架构、风险、依赖和可观测验收。
3. 生成可离线打开的版本化 HTML PRD;保留每个历史版本并记录本轮变更。
4. 根据用户意见反复修改,直到用户明确批准指定版本。
5. 仅在批准后生成 `handoff.yaml`,供 `/ack` 创建和执行任务。
5. 仅在批准后生成 `spec/handoff.yaml`,供 `long-ship` 创建和执行任务。
6. 用户需要远程查看,或说“把 HTML 渲染出来”时,可调用独立的 `previewer` skill;它用 Node
渲染并通过 Tailscale 暴露为 HTTP URL,同时返回应该使用的 Tailscale IP。
Specifier 只负责需求规格,不修改业务代码,也不替代 ACK 的开发和独立测试。
Specifier 只负责需求规格,不修改业务代码,也不替代 long-ship 的开发和独立测试。
## 如何判断完成
- 评审阶段:得到一个带版本号的 HTML PRD、变更记录、风险和未决问题。
- 交接阶段:用户明确批准某个版本,且 `handoff.yaml` 绑定了该版本和完整验收信息。
- 开发阶段:改用 `/ack`,由 ACK 负责 Developer、Test、回归和交付闭环。
- 交接阶段:用户明确批准某个版本,且 `spec/handoff.yaml` 绑定了该版本和完整验收信息。
- 开发阶段:改用 `long-ship`,任务和证据写在同一个 ship id 下。
+48 -38
View File
@@ -2,14 +2,15 @@
name: specifier
description: >-
把复杂或跨模块需求整理成可评审、可版本化的 HTML PRD,并在用户明确批准某个版本后
生成交给 ack 的结构化 handoff。用户说做复杂功能、写 PRD、梳理需求、设计方案、需求
评审、反复改需求或交给 ack 开发时使用;小改动、Bug 根因排查和单纯架构讨论不使用。
生成交给 long-ship 的结构化 handoff。用户说做复杂功能、写 PRD、梳理需求、设计方案、
需求评审、反复改需求或交给 long-ship 开发时使用;小改动、Bug 根因排查和单纯架构讨论
不使用。
---
# Specifier:复杂需求规格化
把模糊需求变成经过人确认的需求基线,再交给 `ack` 执行。HTML 是给人评审的正式交付物,
结构化状态和 handoff 是给 Agent/ACK 消费的机器接口。
把模糊需求变成经过人确认的需求基线,再交给 `long-ship` 执行。HTML 是给人评审的正式交付物,
结构化状态和 handoff 是给 ship 记录消费的机器接口。
---
@@ -17,46 +18,53 @@ description: >-
- 用户要做跨模块、跨页面、跨服务或有数据迁移的复杂功能。
- 用户要求写 PRD、产品方案、需求规格、架构/交互方案,并需要多轮确认。
- 用户要求把确认后的需求交给 `/ack` 开发。
- 用户要求把确认后的需求交给 `long-ship` 开发。
## 不适用
- 单文件或范围明确的小改动:直接交给 `/ack`。
- 已有复现证据的 Bug:使用 `/hunt` 找根因,再按需要交给 `/ack`。
- 单文件或范围明确的小改动:使用 `short-ship`。
- 已有复现证据的 Bug:使用 `/hunt` 找根因,再按需要交给 `short-ship`。
- 只问一个技术选型或一次性方案判断:使用 `/think`。
- 用户尚未批准的需求:不得创建 ACK 任务、派 worker、改业务代码或开始交付。
- 用户尚未批准的需求:不得创建开发任务、派 worker、改业务代码或开始交付。
---
## 工作目录和事实源
每个需求使用独立目录 `.pouch/specifier/features/<feature-id>/`。开始前检查目录是否已存在,
不要覆盖既有版本;已有需求要进入对应版本的 `review` 或 `revise` 模式。
每个需求使用独立 ship 目录 `.pouch/ship/<ship-id>/`。规格文件写在 `spec/`。开始前读取
已安装的 `ship` skill;已有需求要进入对应版本的 `review` 或 `revise` 模式。
```text
.pouch/specifier/features/<feature-id>/
├── current.yaml # 当前版本、状态和批准信息
├── brief.md # 需求输入和现状摘要
├── prd-v001.html # 人类评审版本,不覆盖旧版本
├── review.yaml # 每轮意见、修改请求和确认
├── decisions.yaml # 关键方案决策
├── risks.yaml # 风险、依赖和缓解措施
└── handoff.yaml # 仅 approved 后生成,交给 ack
.pouch/ship/<ship-id>/
├── current.yaml # ship 总账;spec.revision / specStatus 在这里
├── spec.md # 短摘要
├── tasks.yaml # 批准后由 long-ship 写入,本 skill 不写
└── spec/
├── brief.md
├── prd-v001.html
├── review.yaml
├── decisions.yaml
├── risks.yaml
└── handoff.yaml # 仅 approved 后生成,交给 long-ship
```
目录名使用稳定、短、小写连字符的 feature ID。若用户未提供,先从需求生成候选并说明;
目录名使用稳定、短、小写连字符的 ship id。若用户未提供,先从需求生成候选并说明;
不要用日期、随机数或当前分支名冒充业务 ID。
`.pouch/specifier/` 只保存需求规格状态,不复制 skill 内容,不修改项目的 `AGENTS.md`、
`CLAUDE.md` 或已有 ACK 状态。HTML 可引用仓库中的文档和代码路径,但不能依赖外部网络资源。
若只有旧路径 `.pouch/specifier/features/<id>/`,按 ship `record.md` 迁到
`.pouch/ship/<id>/spec/` 后再继续。不要两处同时写。
`.pouch/ship/<id>/spec/` 只保存需求规格,不复制 skill 内容,不修改项目的 `AGENTS.md`、
`CLAUDE.md` 或 ACK 缺陷板。HTML 可引用仓库中的文档和代码路径,但不能依赖外部网络资源。
## 模式路由
- **新建**:探查现状,建立 brief,生成 `prd-v001.html`,状态为 `review`。
- **评审 / 修改**:读取当前版本和 `review.yaml`,把用户意见转成修改项,生成新版本,
- **新建**:探查现状,建立 `spec/brief.md`,生成 `spec/prd-v001.html`,把
`spec.specStatus` 设为 `review`。
- **评审 / 修改**:读取当前版本和 `spec/review.yaml`,把用户意见转成修改项,生成新版本,
展示变更摘要和仍未决的问题。
- **批准**:只在用户明确批准指定版本后,把状态改为 `approved`,冻结需求基线。
- **交接**:仅针对 `approved` 版本生成 `handoff.yaml`;用户要求继续开发时把它交给 `/ack`。
- **批准**:只在用户明确批准指定版本后,把 `spec.specStatus` 改为 `approved`,冻结需求基线。
- **交接**:仅针对 `approved` 版本生成 `spec/handoff.yaml`;用户要求继续开发时把它交给 `long-ship`。
- **状态**:只读报告当前版本、待决策、风险和是否可以交接。
详细评审状态机和确认规则见 [review-loop.md](references/review-loop.md)。HTML 页面结构和
@@ -64,14 +72,14 @@ description: >-
## 新建和调研
1. 先判断需求是否真的复杂;不复杂时给出转 `/ack` 的建议,不强行生成 PRD。
1. 先判断需求是否真的复杂;不复杂时给出转 `short-ship` 的建议,不强行生成 PRD。
2. 读取项目覆盖层、相关文档、历史决策和代码;优先确认现有能力、模块边界、数据模型、
API、权限、运行约束和回归用例。不要从 README 或猜测中虚构项目事实。
3. 形成现状与目标差异:背景、用户、场景、目标、非目标、范围、约束、成功标准。
4. 对关键方案给出取舍,记录推荐方案、被否决方案、最脆弱假设、风险、依赖和回滚方向。
5. 描述必要的产品流程、交互状态、领域模型、状态流、API/数据变化和非功能要求。
6. 按可独立验证的垂直切片拆任务,写出依赖、关键路径、`expected` 和 `verification`,
但不要写入 `.pouch/ack/tasks.yaml`。
但不要写入 `.pouch/ack/tasks.yaml` 或本 ship 的 `tasks.yaml`(任务由 long-ship 在批准后创建)。
7. 按模板生成自包含 HTML PRD,并在回复中说明文件路径、版本、待确认问题和下一步。
8. 用户需要在其它设备查看 HTML,或说“把这个 HTML 渲染出来”时,调用已安装的
`previewer` 能力传入该文件;不要在 `specifier` 内复制 Node/Tailscale Serve 逻辑。
@@ -98,20 +106,21 @@ description: >-
需求评审状态,HTML 被展示不等于用户批准。回复中要同时给出 Tailscale IP 和 URL,不能只
说“已经渲染”或只返回本机 `127.0.0.1`。
## 批准和 ACK 交接
## 批准和 long-ship 交接
批准前只允许写 specifier 自己的需求目录。批准后仍不自动启动 worker;先生成 `handoff.yaml`,
其中必须绑定:feature ID、PRD 版本和路径、需求范围、非目标、决策、任务依赖、验收信号、
风险、回滚/发布注意事项和回归候选。
批准前只允许写该 ship 的 spec 目录和 `current.yaml` 的 spec 字段。批准后仍不自动启动
worker;先生成 `spec/handoff.yaml`,其中必须绑定:ship id、PRD 版本和路径、需求范围、
非目标、决策、任务依赖、验收信号、风险、回滚/发布注意事项和回归候选。同步
`current.yaml` 的 `spec.specStatus`、`spec.approvedRevision`、`spec.handoff`。
`handoff.yaml` 是 ACK 的输入,不是 ACK 的执行记录。用户明确要求继续开发时,提示使用:
`handoff.yaml` 是 `long-ship` 的输入,不是执行记录。用户明确要求继续开发时,提示使用:
```text
/ack 使用 specifier handoff:.pouch/specifier/features/<feature-id>/handoff.yaml
/long-ship 使用 handoff:.pouch/ship/<ship-id>/spec/handoff.yaml
```
ACK 应引用批准的 HTML 版本,不复制另一份可漂移的 PRD。若 ACK 不存在、handoff 缺字段或
需求状态不是 `approved`,停止交接并报告缺口。
long-ship 应引用批准的 HTML 版本,不复制另一份可漂移的 PRD。handoff 缺字段或需求状态
不是 `approved` 时,停止交接并报告缺口。
## 安全边界
@@ -123,6 +132,7 @@ ACK 应引用批准的 HTML 版本,不复制另一份可漂移的 PRD。若 AC
## 验证
完成前检查:HTML 可离线打开、版本号与 `current.yaml` 一致、旧版本未被覆盖、每个未决问题
有明确状态、每个任务有可观测验收、handoff 只在 `approved` 后存在且绑定正确的 PRD 版本。
需要交给 ACK 时,先确认 handoff schema 与用户批准版本一致,再停止等待 ACK。
完成前检查:HTML 可离线打开、版本号与 ship `current.yaml` 的 `spec.revision` 一致、旧版本
未被覆盖、每个未决问题有明确状态、每个任务有可观测验收、handoff 只在 `approved` 后存在
且绑定正确的 PRD 版本。需要交给 long-ship 时,先确认 handoff schema 与用户批准版本一致,
再停止等待 long-ship。
+1 -1
View File
@@ -1,6 +1,6 @@
interface:
display_name: "Specifier"
short_description: "Draft and review complex requirements as HTML PRDs"
default_prompt: "Use $specifier to turn this complex feature request into a reviewable HTML PRD."
default_prompt: "Use $specifier to turn this complex feature request into a reviewable HTML PRD in the ship record."
policy:
allow_implicit_invocation: true
+2 -2
View File
@@ -28,7 +28,7 @@ HTML 是给人离线评审的交付物,不是运行中的产品页面。它必
- 颜色和状态不能只依靠颜色表达;为状态提供文字标签。
- 长表格允许横向滚动,代码或 API 示例使用 `pre`,正文保持可复制。
- `h1` 只有一个;标题层级连续;链接和表格有可读的焦点/边框状态。
- 页首显示 feature ID、PRD 版本、状态、生成时间和基于的版本。
- 页首显示 ship id、PRD 版本、状态、生成时间和基于的版本。
- 变更内容用文字摘要或 `ins` / `del` 标记表达,不要只用颜色标记。
## 内容质量
@@ -36,4 +36,4 @@ HTML 是给人离线评审的交付物,不是运行中的产品页面。它必
- 把“体验好”“性能足够”“支持权限”等不可验证表述改成可观察信号或明确待决策项。
- 不把实现方案伪装成用户需求;需求、决策和任务分开写。
- 未知事实写“待确认”,并加入未决问题,不填看似合理的数字、接口或页面。
- 每条验收都能被 ACK 的 Test 按步骤观察;测试工具不可用时仍能区分产品失败和环境失败。
- 每条验收都能被 long-ship / Test 按步骤观察;测试工具不可用时仍能区分产品失败和环境失败。
+14 -11
View File
@@ -9,15 +9,17 @@
## 状态
`current.yaml` 至少记录:
ship `current.yaml` 的 spec 字段至少记录:
```yaml
featureId: tenant-permissions
currentRevision: v002
status: review
prd: prd-v002.html
shipId: tenant-permissions
kind: long
spec:
revision: v002
specStatus: review
prd: spec/prd-v002.html
approvedRevision: null
reviews: review.yaml
reviews: spec/review.yaml
```
允许的状态:
@@ -55,11 +57,11 @@ reviews: review.yaml
## 每轮评审
1. 读取 `current.yaml`、当前 HTML 和所有未解决的 review 项;不要只读用户最新一句话。
1. 读取 ship `current.yaml`、当前 HTML 和所有未解决的 review 项;不要只读用户最新一句话。
2. 将新反馈归类:`change`、`decision`、`question`、`rejected`、`parked`。
3. 说明每个反馈对范围、架构、任务和验收的影响。
4. 生成新版本 HTML,保留旧文件。
5. 更新 `current.yaml` 和 `review.yaml`,并在回复中给出:新版本路径、变更摘要、未决问题、
5. 更新 ship `current.yaml` 的 spec 字段和 `spec/review.yaml`,并在回复中给出:新版本路径、变更摘要、未决问题、
是否仍需批准。
如果反馈只影响措辞,也生成新版本;评审记录需要保持可追溯。若用户明确说不采纳,记录理由,
@@ -82,7 +84,8 @@ reviews: review.yaml
- HTML 版本、状态文件和 review 记录一致。
如果用户说“继续”“可以”“看起来不错”,但没有绑定版本,继续保持 `review`,请用户确认
具体版本。批准后把 `approvedRevision` 写入 `current.yaml`,再生成 handoff;不要同时启动 ACK。
具体版本。批准后把 `spec.approvedRevision` 写入 ship `current.yaml`,再生成
`spec/handoff.yaml`;不要同时启动 long-ship 或 ACK。
## 批准后的变更
@@ -94,5 +97,5 @@ reviews: review.yaml
4. 标记受影响的任务、验收、风险和 ACK handoff 字段;
5. 重新等待用户批准。
已经进入 ACK 的任务不会因 HTML 更新而自动改变。需要执行变更时,明确告诉用户应创建新的
ACK 任务或由 ACK 重新规划,不能静默改写执行记录。
已经进入 long-ship 的任务不会因 HTML 更新而自动改变。需要执行变更时,明确告诉用户应
修订本 ship 的 `tasks.yaml` 或重新规划,不能静默改写执行记录。
@@ -1,6 +1,16 @@
featureId: <feature-id>
currentRevision: v001
status: review
prd: prd-v001.html
# 已并入 ship current.yaml 的 spec 字段。新建需求请用 ship 模板,不要单独写这份文件。
# 迁移旧 .pouch/specifier/features/<id>/current.yaml 时按此对照:
# featureId -> shipId
# currentRevision -> spec.revision
# status -> spec.specStatus
# prd -> spec.prd(路径加上 spec/ 前缀)
# approvedRevision -> spec.approvedRevision
# reviews -> spec.reviews
shipId: <ship-id>
spec:
revision: v001
specStatus: review
prd: spec/prd-v001.html
approvedRevision: null
reviews: review.yaml
reviews: spec/review.yaml
handoff: null
@@ -1,8 +1,8 @@
featureId: <feature-id>
shipId: <ship-id>
specifierRevision: v001
status: approved
prd:
path: prd-v001.html
path: spec/prd-v001.html
revision: v001
scope:
goals: []
+1 -1
View File
@@ -30,7 +30,7 @@
<main>
<article>
<header>
<p class="meta"><span class="badge">Feature: {{feature_id}}</span><span class="badge">PRD: {{revision}}</span><span class="badge status">状态:{{status}}</span></p>
<p class="meta"><span class="badge">Ship: {{ship_id}}</span><span class="badge">PRD: {{revision}}</span><span class="badge status">状态:{{status}}</span></p>
<h1>{{feature_title}}</h1>
<p>{{one_sentence_summary}}</p>
<p class="muted">生成时间:{{generated_at}} · 基于版本:{{changed_from}}</p>
+28
View File
@@ -32,8 +32,14 @@ class CatalogTests(unittest.TestCase):
"releaser",
"note-style",
"pouch",
"short-ship",
"long-ship",
"ship",
"pr-ship",
# 旧名兼容占位,仍须可列出(见 test_manage_release_is_deprecation_stub)
"manage-release",
"bugfix-ship",
"requirement-flow",
):
self.assertIn(f"| {name} |", result.stdout)
self.assertIn("/ack 初始化", result.stdout)
@@ -51,6 +57,28 @@ class CatalogTests(unittest.TestCase):
self.assertFalse((stub / "references").exists())
self.assertFalse((stub / "templates").exists())
def test_bugfix_ship_is_deprecation_stub(self) -> None:
stub = REPO_ROOT / "skills" / "bugfix-ship"
skill = (stub / "SKILL.md").read_text(encoding="utf-8")
self.assertIn("name: bugfix-ship", skill)
self.assertIn("已弃用", skill)
self.assertIn("../short-ship/SKILL.md", skill)
self.assertTrue((REPO_ROOT / "skills" / "short-ship" / "SKILL.md").is_file())
self.assertFalse((stub / "references").exists())
self.assertFalse((stub / "templates").exists())
self.assertFalse((stub / "agents").exists())
def test_requirement_flow_is_deprecation_stub(self) -> None:
stub = REPO_ROOT / "skills" / "requirement-flow"
skill = (stub / "SKILL.md").read_text(encoding="utf-8")
self.assertIn("name: requirement-flow", skill)
self.assertIn("已弃用", skill)
self.assertIn("../long-ship/SKILL.md", skill)
self.assertTrue((REPO_ROOT / "skills" / "long-ship" / "SKILL.md").is_file())
self.assertFalse((stub / "references").exists())
self.assertFalse((stub / "templates").exists())
self.assertFalse((stub / "agents").exists())
def test_missing_readme_fails(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
skill = Path(tmp) / "demo"
+159
View File
@@ -0,0 +1,159 @@
from __future__ import annotations
import subprocess
import sys
import tempfile
import unittest
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parents[1]
CREATE = REPO_ROOT / "skills" / "ship" / "scripts" / "create_ship.py"
VALIDATE = REPO_ROOT / "skills" / "ship" / "scripts" / "validate_ship.py"
APPEND = REPO_ROOT / "skills" / "ship" / "scripts" / "append_evidence.py"
def _run(script: Path, *args: str, cwd: Path | None = None) -> subprocess.CompletedProcess[str]:
return subprocess.run(
[sys.executable, str(script), *args],
check=False,
capture_output=True,
text=True,
cwd=cwd,
)
class YamlEmptyCollectionsTests(unittest.TestCase):
def test_roundtrip_empty_list_and_dict(self) -> None:
from pouch.yaml_io import safe_dump, safe_load
data = {"items": [], "meta": {}}
loaded = safe_load(safe_dump(data))
self.assertEqual(loaded, data)
class CreateAndValidateTests(unittest.TestCase):
def test_create_short_ship_validates(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
result = _run(
CREATE,
"--project-root",
str(root),
"--ship-id",
"add-export-button",
"--kind",
"short",
"--title",
"列表页增加导出按钮",
)
self.assertEqual(result.returncode, 0, result.stderr)
ship_dir = root / ".pouch" / "ship" / "add-export-button"
self.assertTrue((ship_dir / "current.yaml").is_file())
self.assertTrue((ship_dir / "spec.md").is_file())
self.assertFalse((ship_dir / "spec").is_dir())
check = _run(VALIDATE, str(ship_dir))
self.assertEqual(check.returncode, 0, check.stderr)
def test_create_long_ship_has_spec_dir(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
result = _run(
CREATE,
"--project-root",
str(root),
"--ship-id",
"tenant-permissions",
"--kind",
"long",
"--title",
"多租户权限",
)
self.assertEqual(result.returncode, 0, result.stderr)
ship_dir = root / ".pouch" / "ship" / "tenant-permissions"
self.assertTrue((ship_dir / "spec").is_dir())
check = _run(VALIDATE, str(ship_dir))
self.assertEqual(check.returncode, 0, check.stderr)
def test_rejects_date_id(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
result = _run(
CREATE,
"--project-root",
tmp,
"--ship-id",
"20261013",
"--kind",
"short",
"--title",
"nope",
)
self.assertNotEqual(result.returncode, 0)
self.assertIn("日期", result.stderr)
def test_append_test_run_pointer(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
created = _run(
CREATE,
"--project-root",
str(root),
"--ship-id",
"add-export-button",
"--kind",
"short",
"--title",
"列表页增加导出按钮",
)
self.assertEqual(created.returncode, 0, created.stderr)
run_dir = root / ".pouch" / "tester" / "runs"
run_dir.mkdir(parents=True)
run_path = run_dir / "20261013T010000Z-smoke-ab12.yaml"
run_path.write_text("runId: demo\n", encoding="utf-8")
ship_dir = root / ".pouch" / "ship" / "add-export-button"
appended = _run(
APPEND,
str(ship_dir),
"--kind",
"testRun",
"--path",
".pouch/tester/runs/20261013T010000Z-smoke-ab12.yaml",
"--result",
"pass",
"--suite",
"smoke",
)
self.assertEqual(appended.returncode, 0, appended.stderr)
text = (ship_dir / "current.yaml").read_text(encoding="utf-8")
self.assertIn("20261013T010000Z-smoke-ab12.yaml", text)
def test_missing_pointer_file_fails(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
_run(
CREATE,
"--project-root",
str(root),
"--ship-id",
"add-export-button",
"--kind",
"short",
"--title",
"列表页增加导出按钮",
)
ship_dir = root / ".pouch" / "ship" / "add-export-button"
appended = _run(
APPEND,
str(ship_dir),
"--kind",
"testRun",
"--path",
".pouch/tester/runs/missing.yaml",
"--result",
"pass",
)
self.assertNotEqual(appended.returncode, 0)
self.assertIn("不存在", appended.stderr)
if __name__ == "__main__":
unittest.main()