diff --git a/AGENTS.md b/AGENTS.md index fc054c0..04748d6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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//`,编辑 `SKILL.md` 和 `README.md`,在本仓库 commit。 diff --git a/README.md b/README.md index 5c9569d..b321d36 100644 --- a/README.md +++ b/README.md @@ -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。安装后可初始化当前项目状态: diff --git a/pouch/yaml_io.py b/pouch/yaml_io.py index 84dd12c..4551e46 100644 --- a/pouch/yaml_io.py +++ b/pouch/yaml_io.py @@ -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: diff --git a/skills/ack/README.md b/skills/ack/README.md index c60b29e..65c912e 100644 --- a/skills/ack/README.md +++ b/skills/ack/README.md @@ -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/` | | 运行版本发布 | `/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//`;修 bug 只写短说明、复现和验收,落缺陷板。你确认后再派 Developer 和独立 Test。 3. 需要测试环境时加载 deployer,不自己编 compose,也不把本地 `npm run dev` 当成测试环境。 4. 任务 `verified` 后提议把本轮黑盒路径写入回归目录,你确认后才写。 5. 发版读同一份 `delivery.yaml`。默认最多停在 `validation_ready` 或 `review_ready`;稳定发布或生产部署必须再经过对应批准。 diff --git a/skills/ack/SKILL.md b/skills/ack/SKILL.md index c76f143..5297d95 100644 --- a/skills/ack/SKILL.md +++ b/skills/ack/SKILL.md @@ -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//`。显式 `/ack` 做需求时先建 ship 记录;需要三角色时用 ACK 编排, + 任务状态仍写在该 ship 的 `tasks.yaml`。缺陷要交付时在 ship `source` 引用 ACK 任务 id。 ## 初始化 @@ -104,7 +108,9 @@ python3 /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 前先走「运行测试环境」。 diff --git a/skills/ack/references/kickoff.md b/skills/ack/references/kickoff.md index e34df59..d0c63df 100644 --- a/skills/ack/references/kickoff.md +++ b/skills/ack/references/kickoff.md @@ -4,6 +4,9 @@ 用户确认后的三角色闭环、飞书收件、启动 worker、交付和回归由 `SKILL.md` 按条件加载, 不要在本文件开头预读其它规范。 +复杂需求的规格和任务写在 `.pouch/ship//`(specifier + long-ship)。ACK 任务板 +只收缺陷。显式 `/ack` 做功能需求时也先建 ship 记录,不要把功能任务写成 `type: bug`。 + --- ## 前提:谁是 Coordinator diff --git a/skills/bugfix-ship/README.md b/skills/bugfix-ship/README.md index 4812f3c..a1f2045 100644 --- a/skills/bugfix-ship/README.md +++ b/skills/bugfix-ship/README.md @@ -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`。 +- 确认没有项目再按旧名启用后,删除本目录即可;它是纯占位,删除不丢内容。 diff --git a/skills/bugfix-ship/SKILL.md b/skills/bugfix-ship/SKILL.md index 72b8acd..04e0fbf 100644 --- a/skills/bugfix-ship/SKILL.md +++ b/skills/bugfix-ship/SKILL.md @@ -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 `: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`。 +- 确认没有项目再按旧名启用后,可以直接删除本目录。 diff --git a/skills/bugfix-ship/agents/openai.yaml b/skills/bugfix-ship/agents/openai.yaml deleted file mode 100644 index b5829df..0000000 --- a/skills/bugfix-ship/agents/openai.yaml +++ /dev/null @@ -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 diff --git a/skills/builder/SKILL.md b/skills/builder/SKILL.md index 5ac442b..e53520c 100644 --- a/skills/builder/SKILL.md +++ b/skills/builder/SKILL.md @@ -35,7 +35,7 @@ worktree,也可以指向已配对的远程 Mac/其它构建机;不要把远 - 初始化、接入 builder,或还没有 `makefile.builder`:执行「初始化」。 - 检查契约或发布配置:执行「检查」。 - 构建、发布、上传:执行「工作流」。不要静默初始化。 -- 看构建、监听 CI、ACK/bugfix-ship 要产物:执行「拿到产物」。不要当成第五条轨道。 +- 看构建、监听 CI、ACK/short-ship/long-ship 要产物:执行「拿到产物」。不要当成第五条轨道。 - 指定 `builder.`、远程构建机或跨 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 `。 - `BUILD_VENUE=local`:当前 HEAD 必须是该 SHA,跑 makefile 的 docker/deb。 diff --git a/skills/builder/references/obtain.md b/skills/builder/references/obtain.md index ce061a8..183d991 100644 --- a/skills/builder/references/obtain.md +++ b/skills/builder/references/obtain.md @@ -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`)。 diff --git a/skills/delivery-loop/README.md b/skills/delivery-loop/README.md index 3824662..a1b43dc 100644 --- a/skills/delivery-loop/README.md +++ b/skills/delivery-loop/README.md @@ -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 或等价交付配置。 - 已明确任务的目标分支、测试范围和允许的停止点。 - 需要外部写入时,当前请求已经授权对应动作。 diff --git a/skills/delivery-loop/SKILL.md b/skills/delivery-loop/SKILL.md index 357db17..81f5be1 100644 --- a/skills/delivery-loop/SKILL.md +++ b/skills/delivery-loop/SKILL.md @@ -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//current.yaml` 的 `evidence`(规则见 ship `record.md`); + 缺指针则该阶段未完成。 - 失败时保留现场和回滚信息,不用重试掩盖失败,不伪造发布或部署成功。 ## 结果 diff --git a/skills/delivery-loop/references/state-machine.md b/skills/delivery-loop/references/state-machine.md index c404ab4..a43f62c 100644 --- a/skills/delivery-loop/references/state-machine.md +++ b/skills/delivery-loop/references/state-machine.md @@ -47,3 +47,6 @@ ACK 配置必须使用以下 canonical `stopAt`: 至少记录:基线和最终 commit、工作树状态、测试命令及结果、黑盒/真机证据、PR/MR URL、 合并 commit、tag、产物路径与摘要、部署节点/服务、健康检查、失败项和回滚指针。 + +已绑定 ship 记录时,上述证据以指针写入该 ship 的 `current.yaml`,不要另建一份账本。 +阶段与字段对应见 ship `references/record.md`。 diff --git a/skills/long-ship/README.md b/skills/long-ship/README.md new file mode 100644 index 0000000..c4acc42 --- /dev/null +++ b/skills/long-ship/README.md @@ -0,0 +1,24 @@ +# long-ship + +把复杂需求从 PRD 评审、任务拆分、协作开发推进到集成回归和配置允许的发布部署。 + +## 什么时候使用 + +```text +把动态编辑需求做成 PRD,批准后拆任务开发,最后完整回归并部署测试环境。 +``` + +## 使用前准备 + +- 同时安装 `ship`、`specifier` 和 `delivery-loop`。 +- 需求目标和相关项目上下文。 +- 用户愿意评审并明确批准某个 PRD 版本。 +- 项目已配置测试环境和需要的构建/部署能力。 + +## Agent 会做什么 + +先生成可评审 PRD;批准后在同一个 ship id 下建立有依赖的任务,独立验证子任务,最后以集成结果推进交付。 + +## 如何判断完成 + +你会得到 ship id、PRD 版本、任务与依赖、各任务验证、集成回归、最终 PR/合并、产物和部署证据。 diff --git a/skills/long-ship/SKILL.md b/skills/long-ship/SKILL.md new file mode 100644 index 0000000..14903ff --- /dev/null +++ b/skills/long-ship/SKILL.md @@ -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//`,`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、产物摘要、部署状态和回滚点。 diff --git a/skills/long-ship/agents/openai.yaml b/skills/long-ship/agents/openai.yaml new file mode 100644 index 0000000..7073901 --- /dev/null +++ b/skills/long-ship/agents/openai.yaml @@ -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 diff --git a/skills/pr-ship/README.md b/skills/pr-ship/README.md index be49cea..c556d44 100644 --- a/skills/pr-ship/README.md +++ b/skills/pr-ship/README.md @@ -10,13 +10,14 @@ ## 使用前准备 +- 同时安装 `ship` 和 `delivery-loop`。 - PR/MR 链接或当前分支上下文。 - 审核、CI、目标分支和部署 profile 可被读取。 - 需要生产部署时,当前请求必须覆盖该动作。 ## Agent 会做什么 -先检查 PR 是否可合并;合并后重新以目标分支 commit 回归和构建,再按配置继续发布或部署。 +先绑到一个 ship id,检查 PR 是否可合并;合并后重新以目标分支 commit 回归和构建,再按配置继续发布或部署。 ## 如何判断完成 diff --git a/skills/pr-ship/SKILL.md b/skills/pr-ship/SKILL.md index 29d2504..84d10c7 100644 --- a/skills/pr-ship/SKILL.md +++ b/skills/pr-ship/SKILL.md @@ -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、部署目标、健康检查和回滚点。 diff --git a/skills/requirement-flow/README.md b/skills/requirement-flow/README.md index fe768c5..8e47160 100644 --- a/skills/requirement-flow/README.md +++ b/skills/requirement-flow/README.md @@ -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`。 +- 确认没有项目再按旧名启用后,删除本目录即可;它是纯占位,删除不丢内容。 diff --git a/skills/requirement-flow/SKILL.md b/skills/requirement-flow/SKILL.md index e7cf0a5..d6de45b 100644 --- a/skills/requirement-flow/SKILL.md +++ b/skills/requirement-flow/SKILL.md @@ -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`。 +- 确认没有项目再按旧名启用后,可以直接删除本目录。 diff --git a/skills/requirement-flow/agents/openai.yaml b/skills/requirement-flow/agents/openai.yaml deleted file mode 100644 index 39f8052..0000000 --- a/skills/requirement-flow/agents/openai.yaml +++ /dev/null @@ -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 diff --git a/skills/ship/README.md b/skills/ship/README.md new file mode 100644 index 0000000..c9fa930 --- /dev/null +++ b/skills/ship/README.md @@ -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 自带校验;用同一个 ship id 能找到 spec、任务和证据指针。 diff --git a/skills/ship/SKILL.md b/skills/ship/SKILL.md new file mode 100644 index 0000000..6259d0c --- /dev/null +++ b/skills/ship/SKILL.md @@ -0,0 +1,39 @@ +--- +name: ship +description: >- + short-ship、long-ship、pr-ship、specifier 共用的交付记录格式。一次交付一个 + ship-id,目录在 .pouch/ship//。不单独作为用户入口;用户说 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//`。功能、小改动和本次修复的任务不要写入 + `.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//` 在继续该需求时迁到 + `.pouch/ship//spec/`,规则见 record.md。 + +## 结果 + +有效 ship 目录含 `current.yaml`、`spec.md`、`tasks.yaml`、`status.md`;long 另有 +`spec/` 下的 PRD 与 handoff。校验通过后入口 skill 才能继续交付。 diff --git a/skills/ship/agents/openai.yaml b/skills/ship/agents/openai.yaml new file mode 100644 index 0000000..aedf156 --- /dev/null +++ b/skills/ship/agents/openai.yaml @@ -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 diff --git a/skills/ship/references/record.md b/skills/ship/references/record.md new file mode 100644 index 0000000..f08bad7 --- /dev/null +++ b/skills/ship/references/record.md @@ -0,0 +1,171 @@ +# Ship 记录契约 + +## 目录 + +- [布局](#布局) +- [current.yaml](#currentyaml) +- [short 与 long](#short-与-long) +- [任务](#任务) +- [证据指针](#证据指针) +- [旧 specifier 目录](#旧-specifier-目录) + +## 布局 + +```text +.pouch/ship// + 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//` 已存在:用它。若同时还留着 + `.pouch/specifier/features//`,报告旧路径为遗留,不要两处同时写。 +2. 只有 `.pouch/specifier/features//`:把它移到 `.pouch/ship//spec/`,按旧 + `current.yaml` 生成 ship `current.yaml`(`kind: long`),用 `brief.md` 补 + `spec.md` 摘要,再校验。 +3. 两处都没有:由入口 skill 调用 `create_ship.py` 新建。 diff --git a/skills/ship/scripts/append_evidence.py b/skills/ship/scripts/append_evidence.py new file mode 100644 index 0000000..bcd28fd --- /dev/null +++ b/skills/ship/scripts/append_evidence.py @@ -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()) diff --git a/skills/ship/scripts/create_ship.py b/skills/ship/scripts/create_ship.py new file mode 100644 index 0000000..b88fe3b --- /dev/null +++ b/skills/ship/scripts/create_ship.py @@ -0,0 +1,130 @@ +#!/usr/bin/env python3 +"""Create a .pouch/ship// 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()) diff --git a/skills/ship/scripts/ship_lib.py b/skills/ship/scripts/ship_lib.py new file mode 100644 index 0000000..659b460 --- /dev/null +++ b/skills/ship/scripts/ship_lib.py @@ -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 diff --git a/skills/ship/scripts/validate_ship.py b/skills/ship/scripts/validate_ship.py new file mode 100644 index 0000000..2f01d99 --- /dev/null +++ b/skills/ship/scripts/validate_ship.py @@ -0,0 +1,279 @@ +#!/usr/bin/env python3 +"""Validate a .pouch/ship// 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/ + 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/ 或其中的 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()) diff --git a/skills/ship/templates/current.template.yaml b/skills/ship/templates/current.template.yaml new file mode 100644 index 0000000..9d8ffe6 --- /dev/null +++ b/skills/ship/templates/current.template.yaml @@ -0,0 +1,29 @@ +shipId: +kind: 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: [] diff --git a/skills/ship/templates/spec.template.md b/skills/ship/templates/spec.template.md new file mode 100644 index 0000000..691713e --- /dev/null +++ b/skills/ship/templates/spec.template.md @@ -0,0 +1,21 @@ +# + +## 要做什么 + +<用户可见的目标。> + +## 不做什么 + +<明确排除的范围。> + +## 可观察验收 + +- <能被浏览器、API 或真机看到的结果> + +## 怎么验 + +- <命令、页面路径或手工步骤> + +## 故障证据 + +<没有故障时删除本节。有则写复现步骤和根因。> diff --git a/skills/ship/templates/status.template.md b/skills/ship/templates/status.template.md new file mode 100644 index 0000000..2780647 --- /dev/null +++ b/skills/ship/templates/status.template.md @@ -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`。 diff --git a/skills/ship/templates/tasks.template.yaml b/skills/ship/templates/tasks.template.yaml new file mode 100644 index 0000000..b136e7b --- /dev/null +++ b/skills/ship/templates/tasks.template.yaml @@ -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 diff --git a/skills/short-ship/README.md b/skills/short-ship/README.md new file mode 100644 index 0000000..ab52629 --- /dev/null +++ b/skills/short-ship/README.md @@ -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、产物和部署状态;不会只收到“本地测试通过”。 diff --git a/skills/short-ship/SKILL.md b/skills/short-ship/SKILL.md new file mode 100644 index 0000000..688a66a --- /dev/null +++ b/skills/short-ship/SKILL.md @@ -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、产物摘要、部署状态和未完成项。 diff --git a/skills/short-ship/agents/openai.yaml b/skills/short-ship/agents/openai.yaml new file mode 100644 index 0000000..4ad28bf --- /dev/null +++ b/skills/short-ship/agents/openai.yaml @@ -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 diff --git a/skills/specifier/README.md b/skills/specifier/README.md index 745a408..910d71f 100644 --- a/skills/specifier/README.md +++ b/skills/specifier/README.md @@ -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 下。 diff --git a/skills/specifier/SKILL.md b/skills/specifier/SKILL.md index cda9f29..b97fe45 100644 --- a/skills/specifier/SKILL.md +++ b/skills/specifier/SKILL.md @@ -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。 diff --git a/skills/specifier/agents/openai.yaml b/skills/specifier/agents/openai.yaml index 6fec2f8..bfab565 100644 --- a/skills/specifier/agents/openai.yaml +++ b/skills/specifier/agents/openai.yaml @@ -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 diff --git a/skills/specifier/references/html-prd.md b/skills/specifier/references/html-prd.md index 7a56c8e..8fe5f0e 100644 --- a/skills/specifier/references/html-prd.md +++ b/skills/specifier/references/html-prd.md @@ -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 按步骤观察;测试工具不可用时仍能区分产品失败和环境失败。 diff --git a/skills/specifier/references/review-loop.md b/skills/specifier/references/review-loop.md index d8f8e29..48b4ba7 100644 --- a/skills/specifier/references/review-loop.md +++ b/skills/specifier/references/review-loop.md @@ -9,15 +9,17 @@ ## 状态 -`current.yaml` 至少记录: +ship `current.yaml` 的 spec 字段至少记录: ```yaml -featureId: tenant-permissions -currentRevision: v002 -status: review -prd: prd-v002.html -approvedRevision: null -reviews: review.yaml +shipId: tenant-permissions +kind: long +spec: + revision: v002 + specStatus: review + prd: spec/prd-v002.html + approvedRevision: null + 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` 或重新规划,不能静默改写执行记录。 diff --git a/skills/specifier/templates/current.template.yaml b/skills/specifier/templates/current.template.yaml index a0eb9fd..204ef58 100644 --- a/skills/specifier/templates/current.template.yaml +++ b/skills/specifier/templates/current.template.yaml @@ -1,6 +1,16 @@ -featureId: <feature-id> -currentRevision: v001 -status: review -prd: prd-v001.html -approvedRevision: null -reviews: review.yaml +# 已并入 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: spec/review.yaml + handoff: null diff --git a/skills/specifier/templates/handoff.template.yaml b/skills/specifier/templates/handoff.template.yaml index 438bb1d..2e6d4ae 100644 --- a/skills/specifier/templates/handoff.template.yaml +++ b/skills/specifier/templates/handoff.template.yaml @@ -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: [] diff --git a/skills/specifier/templates/prd.template.html b/skills/specifier/templates/prd.template.html index 39d7908..bf58e51 100644 --- a/skills/specifier/templates/prd.template.html +++ b/skills/specifier/templates/prd.template.html @@ -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> diff --git a/tests/test_pouch_catalog.py b/tests/test_pouch_catalog.py index aa0f153..041687d 100644 --- a/tests/test_pouch_catalog.py +++ b/tests/test_pouch_catalog.py @@ -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" diff --git a/tests/test_ship_record.py b/tests/test_ship_record.py new file mode 100644 index 0000000..0d424ff --- /dev/null +++ b/tests/test_ship_record.py @@ -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()