chore: sync pouch skill workflows

This commit is contained in:
2026-10-14 10:58:03 +08:00
parent 57879cffdd
commit 125bfe0c23
16 changed files with 277 additions and 57 deletions
+2 -2
View File
@@ -47,7 +47,7 @@ pouch add builder -g # 全局
并负责上层编排。 并负责上层编排。
1. `makefile.builder` 目标:`help/build/clean/version` + 条件 `deb/docker/push*`, 1. `makefile.builder` 目标:`help/build/clean/version` + 条件 `deb/docker/push*`,
变量 `ARCHES/ARCH/VERSION/DIST_DIR/PROJECT_NAME`。 以及 `obtain` / `venue`。变量 `ARCHES/ARCH/VERSION/DIST_DIR/PROJECT_NAME/BUILD_VENUE`。
`VERSION` 通过 `include <builder>/scripts/version.mk` 从 Git 推导。 `VERSION` 通过 `include <builder>/scripts/version.mk` 从 Git 推导。
多架构清单是 `ARCHES`,见下方「多架构」。 多架构清单是 `ARCHES`,见下方「多架构」。
2. 运行 `python3 -I -S <builder>/scripts/check.py . --ready` 直到构建项 PASS。 2. 运行 `python3 -I -S <builder>/scripts/check.py . --ready` 直到构建项 PASS。
@@ -227,5 +227,5 @@ Docker 只有你点名镜像时才走。token 不会写进输出、提交或回
| `templates/makefile.android.builder` | Android APK + copy 轨道骨架 | | `templates/makefile.android.builder` | Android APK + copy 轨道骨架 |
| `templates/makefile.ios.builder` | iOS IPA + copy 轨道骨架 | | `templates/makefile.ios.builder` | iOS IPA + copy 轨道骨架 |
环境变量契约、脚本解析顺序(`$BUILDER_SKILL_DIR` → `~/.pouch/skills/builder/scripts/`)、 环境变量契约、脚本解析顺序(`$BUILDER_SKILL_DIR` → `~/.pouch` → `~/.agents`)、
脏工作树策略等完整规则见 contract.md。 脏工作树策略等完整规则见 contract.md。
+1 -1
View File
@@ -49,7 +49,7 @@ make -f makefile.builder <target>
| `ios` | 有 iOS IPA 的项目 | 可与 `build` 相同。产出 `$(DIST_DIR)/<name>_<version>_<arch>.ipa`;只构建不上传 | | `ios` | 有 iOS IPA 的项目 | 可与 `build` 相同。产出 `$(DIST_DIR)/<name>_<version>_<arch>.ipa`;只构建不上传 |
| `arches` | 可选(新模板必备) | 打印一行空格分隔的 `ARCHES`;无此目标视为存量 makefile,隐式单架构 | | `arches` | 可选(新模板必备) | 打印一行空格分隔的 `ARCHES`;无此目标视为存量 makefile,隐式单架构 |
| `push-deb` | 同时有多种发布轨道的项目 | 仅调 builder 的 `upload_deb.sh`。未指定 `ARCH=` 时上传 `ARCHES` 中每一项对应的 `.deb`;指定了则只上传那一个 | | `push-deb` | 同时有多种发布轨道的项目 | 仅调 builder 的 `upload_deb.sh`。未指定 `ARCH=` 时上传 `ARCHES` 中每一项对应的 `.deb`;指定了则只上传那一个 |
| `push-docker` | 同时有多种发布轨道的项目 | 仅调 builder 的 `publish_docker.sh` | | `push-docker` | 同时有多种发布轨道的项目 | `BUILD_VENUE=local` 时仅调 `publish_docker.sh`;`ci` 时仅调 `obtain.sh` |
| `push-copy` | 同时有多种发布轨道的项目 | 仅调 builder 的 `copy_artifact.sh`;由它按配置选择内置拷贝或自定义脚本 | | `push-copy` | 同时有多种发布轨道的项目 | 仅调 builder 的 `copy_artifact.sh`;由它按配置选择内置拷贝或自定义脚本 |
| `push` | 单一产物类型时必备;多产物项目为聚合 | 依序调用对应 push-* 或直接调脚本;是发布的唯一 make 入口 | | `push` | 单一产物类型时必备;多产物项目为聚合 | 依序调用对应 push-* 或直接调脚本;是发布的唯一 make 入口 |
| `obtain` | 有产物的项目 | 调用 `obtain.sh`,对精确 commit SHA 拿到已存在的产物。`BUILD_VENUE=ci` 时等待该 SHA 的 forge 流水线再 inspect,不在本机 `docker push` | | `obtain` | 有产物的项目 | 调用 `obtain.sh`,对精确 commit SHA 拿到已存在的产物。`BUILD_VENUE=ci` 时等待该 SHA 的 forge 流水线再 inspect,不在本机 `docker push` |
+6 -3
View File
@@ -31,12 +31,15 @@ DIST_DIR ?= dist
ifneq ($(BUILDER_SKILL_DIR),) ifneq ($(BUILDER_SKILL_DIR),)
include $(BUILDER_SKILL_DIR)/scripts/version.mk include $(BUILDER_SKILL_DIR)/scripts/version.mk
else BUILDER_SCRIPTS := $(BUILDER_SKILL_DIR)/scripts
else ifneq ($(wildcard $(HOME)/.pouch/skills/builder/scripts/version.mk),)
include $(HOME)/.pouch/skills/builder/scripts/version.mk include $(HOME)/.pouch/skills/builder/scripts/version.mk
BUILDER_SCRIPTS := $(HOME)/.pouch/skills/builder/scripts
else
include $(HOME)/.agents/skills/builder/scripts/version.mk
BUILDER_SCRIPTS := $(HOME)/.agents/skills/builder/scripts
endif endif
BUILDER_SCRIPTS := $(or $(BUILDER_SKILL_DIR),$(HOME)/.pouch/skills/builder)/scripts
.PHONY: help version arches venue clean build deb docker obtain push push-deb push-docker .PHONY: help version arches venue clean build deb docker obtain push push-deb push-docker
help: help:
+2 -1
View File
@@ -4,7 +4,8 @@
## 什么时候使用 ## 什么时候使用
通常由 `$short-ship`、`$pr-ship` 或 `$long-ship` 内部读取,不需要单独调用。 通常由 `$short-ship`、`$pr-ship` 或 `$long-ship` 派发的 worker 读取,不需要单独调用。
入口主会话不要把本 skill 读进自己的上下文。
```text ```text
按 delivery-loop 把当前 ship 推进到 review_ready。 按 delivery-loop 把当前 ship 推进到 review_ready。
+4 -3
View File
@@ -11,9 +11,10 @@ description: >-
## 使用方式 ## 使用方式
只有在入口 skill 明确要求执行交付时才读取 由 `short-ship` / `long-ship` / `pr-ship` **派出来的 worker** 读取本 skill 和
`references/state-machine.md`。入口 skill 负责确定任务类型和目标;本 skill 负责阶段、 `references/state-machine.md`。入口主会话不要读取或执行本 skill。
停止点、证据和安全门。
入口决定任务类型、停止点和派哪一步;本 skill 负责该步的阶段要求、证据和安全门。
## 不可违反的规则 ## 不可违反的规则
+1 -1
View File
@@ -1,6 +1,6 @@
interface: interface:
display_name: "Long Ship" display_name: "Long Ship"
short_description: "Turn an approved PRD into shipped work" 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." default_prompt: "You are the long-ship manager: keep the ship record, spawn a fresh agent for each next_step, and wait. Do not write the PRD, code, or run delivery in this session."
policy: policy:
allow_implicit_invocation: true allow_implicit_invocation: true
+2 -1
View File
@@ -17,7 +17,8 @@
## Agent 会做什么 ## Agent 会做什么
先绑到一个 ship id,检查 PR 是否可合并;合并后重新以目标分支 commit 回归和构建,再按配置继续发布或部署。 主会话只绑 ship id,再按步骤开新 agent 并等待。检查、合并、回归、构建和部署都在新
会话里做;合并后的回归以目标分支 commit 为准。
## 如何判断完成 ## 如何判断完成
+20 -18
View File
@@ -7,7 +7,8 @@ description: >-
# PR Ship:合并后的完整交付 # PR Ship:合并后的完整交付
重点是合并后的真实状态:PR 分支通过不代表目标分支已回归,也不代表产物和生产已更新。 当前会话只做管理:绑 ship id、按 `next_step.py` 开新 agent 并等待。不要在本会话
审 diff、合并或跑回归。
## 何时使用 ## 何时使用
@@ -22,29 +23,30 @@ description: >-
## 工作规则 ## 工作规则
1. 读取已安装的 `ship` skill。从 PR 正文、分支名或用户给出的 id 绑定已有 ship;没有则 1. 读取已安装 ship 的 [manager.md](../ship/references/manager.md)。派发 worker 时再读
用 PR 标题生成 slug,创建 `kind: short` 的薄记录,`source.kind: pr`。之后证据都写进 [worker-prompt.md](../ship/references/worker-prompt.md)。从 PR 正文、分支名或
这个 ship id。 用户给出的 id 绑定已有 ship;没有则用 PR 标题生成 slug,创建 `kind: short` 的
2. 读取 PR 的目标/源分支、差异、关联任务、审核状态、CI、冲突和变更风险;使用 `check`。 薄记录,`source.kind: pr`。之后证据都写进这个 ship id。
3. 审核、CI 或必需检查缺失时停止。若发现可修复问题,只有当前请求明确包含修复时才在 PR 分支上处理, 2. 只跑:
然后重新验证;不能静默改变 PR 范围。
4. 具备合并条件后执行合并,并重新获取目标分支;记录合并后的 commit,后续所有回归和构建都以它为来源。 ```bash
5. 读取并执行 `delivery-loop` 的状态机,运行完整回归;失败时停在合并后的中间状态或回到修复流程,不部署失败版本。 python3 <ship-skill>/scripts/next_step.py \
每个完成阶段把指针写入该 ship 的 `current.yaml`。 --project-root <root> --ship-id <id> --entry pr
6. 只有项目 profile 和当前授权允许时才构建、发布和部署;生产部署完成后做节点状态和关键路径检查。 ```
检查、修复、合并、回归、obtain、发布、部署都是 worker。主会话只写 `source.ref`
和等待结果。需要字段含义时再读 [record.md](../ship/references/record.md)。
3. 合并后的回归和构建必须以合并 commit 为来源;worker 会写 `commits.role: merged`。
不要读取 `check`、`delivery-loop`、`tester`、`builder`、`deployer`、`releaser`。
## 安全门 ## 安全门
- 开不出新 agent 时停止;worker 失败时不要自己把该步做完。
- 不绕过审核、CI、分支保护或冲突;不强推、不重写已共享历史。 - 不绕过审核、CI、分支保护或冲突;不强推、不重写已共享历史。
- 不用 PR 源分支的旧 APK/镜像证明合并版本已发布。 - 不用 PR 源分支的旧 APK/镜像证明合并版本已发布。
- 目标分支变化、合并后代码漂移、回归失败、产物来源不明或部署健康检查失败时停止。 - 目标分支变化、合并后代码漂移、回归失败、产物来源不明或部署健康检查失败时停止。
## 必读资料
- 记录格式:已安装的 `ship` skill 与 `references/record.md`。
- 执行交付阶段时读取已安装的 `delivery-loop` skill 及其状态机 reference。
- 发布版本时遵循 `releaser`;构建/部署/回归分别遵循 `builder`、`deployer`、`tester`。
## 完成报告 ## 完成报告
报告 ship id、PR/MR、审核/CI 结果、合并 commit、完整回归证据、产物摘要、tag、部署目标、健康检查和回滚点。 报告 ship id、PR/MR、审核/CI 结果、合并 commit、完整回归证据、产物摘要、tag、部署目标、健康检查和回滚点。数字来自账本。
+1 -1
View File
@@ -1,6 +1,6 @@
interface: interface:
display_name: "PR Ship" display_name: "PR Ship"
short_description: "Merge, regress, build, and deploy a PR" short_description: "Merge, regress, build, and deploy a PR"
default_prompt: "Use $pr-ship to process this PR through merge, full regression, and the configured deployment stop." default_prompt: "You are the pr-ship manager: bind the ship id, spawn a fresh agent for each next_step, and wait. Do not review, merge, or regress in this session."
policy: policy:
allow_implicit_invocation: true allow_implicit_invocation: true
+2 -1
View File
@@ -17,7 +17,8 @@
## Agent 会做什么 ## Agent 会做什么
在 `.pouch/ship/<ship-id>/` 建立或更新记录,把测试、产物和部署写成指针,而不是另写一套缺陷任务。 在 `.pouch/ship/<ship-id>/` 建立或更新记录,把测试、产物和部署写成指针。三个入口的
主会话只编排,具体步骤开新 agent。
## 如何判断完成 ## 如何判断完成
+11
View File
@@ -141,6 +141,17 @@ def validate_ship_dir(ship_dir: Path) -> list[str]:
if state not in DELIVERY_STATE: if state not in DELIVERY_STATE:
_issue(issues, f"delivery.state 无效: {state!r}") _issue(issues, f"delivery.state 无效: {state!r}")
manager = data.get("manager")
if manager is not None:
mgr = _mapping(manager, "manager", issues)
if mgr is not None:
result = mgr.get("lastResult")
if result not in (None, "pass", "fail", "blocked", "ask"):
_issue(issues, f"manager.lastResult 无效: {result!r}")
in_flight = mgr.get("inFlight")
if in_flight not in (None, "") and not isinstance(in_flight, str):
_issue(issues, "manager.inFlight 必须是字符串或空")
evidence = _mapping(data.get("evidence"), "evidence", issues) evidence = _mapping(data.get("evidence"), "evidence", issues)
if evidence is not None: if evidence is not None:
issues.extend(_validate_evidence(evidence, _project_root(ship_dir))) issues.extend(_validate_evidence(evidence, _project_root(ship_dir)))
+2 -1
View File
@@ -23,7 +23,8 @@
## Agent 会做什么 ## Agent 会做什么
建立 ship 记录,写短需求说明,改代码并验证,再继续完成配置允许的交付阶段。一次交付一个 ship id。 主会话只建 ship 记录、写短说明、按步骤开新 agent 并等待结果。改代码、测试、开合 PR、
构建和部署都在新会话里做,不会堆进同一个上下文。一次交付一个 ship id。
## 如何判断完成 ## 如何判断完成
+23 -23
View File
@@ -8,7 +8,8 @@ description: >-
# Short Ship:单次变更到交付 # Short Ship:单次变更到交付
把一次范围清楚的改动推进到项目配置允许的停止点。 当前会话只做管理:建账本、问用户、按 `next_step.py` 开新 agent 并等待。不要把
hunt / 改代码 / 测试 / 交付读进本会话。
## 何时使用 ## 何时使用
@@ -24,36 +25,35 @@ description: >-
## 工作规则 ## 工作规则
1. 读取已安装的 `ship` skill。没有 `ship-id` 就从需求生成候选 slug,用 1. 读取已安装 ship 的 [manager.md](../ship/references/manager.md)。派发 worker 时再读
`scripts/create_ship.py --kind short` 建 `.pouch/ship/<id>/`;已有目录则校验后复用。 [worker-prompt.md](../ship/references/worker-prompt.md)。没有 `ship-id` 就生成
写 `spec.md` 四段:要做什么、不做什么、可观察验收、怎么验。有故障证据时加复现和根因。 slug,跑 `create_ship.py --kind short`。功能任务不要写入 `.pouch/ack/tasks.yaml`。
任务保持恰好 1 条。功能任务不要写入 `.pouch/ack/tasks.yaml`。 2. 主会话只写 `spec.md` 四段和那一条 task。有故障证据时 `next_step.py` 会给出
2. 有故障证据时使用 `hunt` 确认根因、复现和验收信号。纯小需求跳过 hunt。 `hunt` worker,不要在本会话读 hunt。
3. 修改代码并补或更新回归用例;行为、API、图片、UI 改动不得只跑编译。 3. 之后只跑:
4. 使用 `tester` 做黑盒、浏览器或真机验证;涉及资源加载时检查真实 URL、状态、类型、
内容和截图。跑完立刻用 ship `append_evidence.py --kind testRun` 追加指针。 ```bash
5. 读取并执行 `delivery-loop`。停止点由用户或项目 delivery profile 决定,canonical python3 <ship-skill>/scripts/next_step.py \
为 `verified`、`validation_ready`、`review_ready` 或 `released`。每个完成阶段都要把 --project-root <root> --ship-id <id> --entry short
对应指针写入该 ship 的 `current.yaml`;缺指针则阶段未完成。需要产物时只调用 ```
builder `obtain.sh --sha <merged-or-pushed-commit>`。
6. 范围涨到需要 PRD 或多条任务时,同一 `ship-id` 把 `kind` 改为 `long`,交给 `actor: worker` 就开新 agent 并等待;`ask_user` / `blocked` 就停下来问用户。
`long-ship`,不要另开 id。 需要字段含义时再读 [record.md](../ship/references/record.md)。
4. 范围涨到需要 PRD 或多条任务时,同一 `ship-id` 把 `kind` 改为 `long`,改用
`--entry long`。
不要读取 `delivery-loop`、`tester`、`builder`、`deployer`、`check`、`hunt`。
这些由对应 worker 按 JSON 的 `skill` 加载。
## 安全门 ## 安全门
- 没有 ship-id、spec.md 四段不齐、验收不可观察时停止。 - 没有 ship-id、spec.md 四段不齐、验收不可观察时停止。
- 开不出新 agent 时停止;worker 失败时不要自己把该步做完。
- 根因不清、测试缺失、真机不可达、CI/回归失败或工作树有无关改动时停止并说明。 - 根因不清、测试缺失、真机不可达、CI/回归失败或工作树有无关改动时停止并说明。
- 不强推、不覆盖他人分支、不绕过 PR 审核,不把测试环境成功当生产成功。 - 不强推、不覆盖他人分支、不绕过 PR 审核,不把测试环境成功当生产成功。
- 数据迁移、破坏性 API、权限/密钥、安全修复和不可逆生产操作需要额外确认。 - 数据迁移、破坏性 API、权限/密钥、安全修复和不可逆生产操作需要额外确认。
## 必读资料
- 记录格式:已安装的 `ship` skill 与 `references/record.md`。
- 交付阶段:已安装的 `delivery-loop` 及其状态机 reference。
- 测试、构建、部署、提交前审查:项目的 `tester`、`builder`、`deployer`、`check`、
`releaser`。
## 完成报告 ## 完成报告
报告 ship id、spec 路径、根因(若有)、变更、测试/真机证据指针、停止点、commit、 报告 ship id、spec 路径、根因(若有)、变更、测试/真机证据指针、停止点、commit、
PR/tag、产物摘要、部署状态和未完成项。 PR/tag、产物摘要、部署状态和未完成项。数字来自账本,不要复述 worker 技能正文。
+1 -1
View File
@@ -1,6 +1,6 @@
interface: interface:
display_name: "Short Ship" display_name: "Short Ship"
short_description: "Ship a small change or bug fix through verification and delivery" 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." default_prompt: "You are the short-ship manager: keep the ship record, spawn a fresh agent for each next_step, and wait. Do not hunt, code, test, or deliver in this session."
policy: policy:
allow_implicit_invocation: true allow_implicit_invocation: true
+2
View File
@@ -66,6 +66,8 @@ description: >-
- **批准**:只在用户明确批准指定版本后,把 `spec.specStatus` 改为 `approved`,冻结需求基线。 - **批准**:只在用户明确批准指定版本后,把 `spec.specStatus` 改为 `approved`,冻结需求基线。
- **交接**:仅针对 `approved` 版本生成 `spec/handoff.yaml`;用户要求继续开发时把它交给 `long-ship`。 - **交接**:仅针对 `approved` 版本生成 `spec/handoff.yaml`;用户要求继续开发时把它交给 `long-ship`。
- **状态**:只读报告当前版本、待决策、风险和是否可以交接。 - **状态**:只读报告当前版本、待决策、风险和是否可以交接。
- 作为 `long-ship` 派来的 worker:只做 prompt 里的那一个模式,写完 `spec/` 就返回;
不要再调用 `long-ship`,不要改业务代码。
详细评审状态机和确认规则见 [review-loop.md](references/review-loop.md)。HTML 页面结构和 详细评审状态机和确认规则见 [review-loop.md](references/review-loop.md)。HTML 页面结构和
渲染要求见 [html-prd.md](references/html-prd.md)。只有需要对应模式时才读取它们。 渲染要求见 [html-prd.md](references/html-prd.md)。只有需要对应模式时才读取它们。
+197
View File
@@ -1,15 +1,44 @@
from __future__ import annotations from __future__ import annotations
import json
import subprocess import subprocess
import sys import sys
import tempfile import tempfile
import unittest import unittest
from pathlib import Path from pathlib import Path
from pouch.yaml_io import safe_dump, safe_load
REPO_ROOT = Path(__file__).resolve().parents[1] REPO_ROOT = Path(__file__).resolve().parents[1]
CREATE = REPO_ROOT / "skills" / "ship" / "scripts" / "create_ship.py" CREATE = REPO_ROOT / "skills" / "ship" / "scripts" / "create_ship.py"
VALIDATE = REPO_ROOT / "skills" / "ship" / "scripts" / "validate_ship.py" VALIDATE = REPO_ROOT / "skills" / "ship" / "scripts" / "validate_ship.py"
APPEND = REPO_ROOT / "skills" / "ship" / "scripts" / "append_evidence.py" APPEND = REPO_ROOT / "skills" / "ship" / "scripts" / "append_evidence.py"
NEXT = REPO_ROOT / "skills" / "ship" / "scripts" / "next_step.py"
FILLED_SPEC = """# 导出按钮
## 要做什么
列表页给登录用户一个导出按钮。
## 不做什么
不改权限模型。
## 可观察验收
- 登录后列表页可见导出按钮
## 怎么验
- 浏览器打开 /list 看到按钮
"""
FAULT_SPEC = FILLED_SPEC + """
## 故障证据
线上列表页没有导出按钮。复现:登录后打开 /list。
"""
def _run(script: Path, *args: str, cwd: Path | None = None) -> subprocess.CompletedProcess[str]: def _run(script: Path, *args: str, cwd: Path | None = None) -> subprocess.CompletedProcess[str]:
@@ -155,5 +184,173 @@ class CreateAndValidateTests(unittest.TestCase):
self.assertIn("不存在", appended.stderr) self.assertIn("不存在", appended.stderr)
def _create(root: Path, ship_id: str, kind: str, *, stop_at: str | None = None) -> Path:
args = [
"--project-root",
str(root),
"--ship-id",
ship_id,
"--kind",
kind,
"--title",
"列表页增加导出按钮",
]
if stop_at:
args.extend(["--stop-at", stop_at])
result = _run(CREATE, *args)
if result.returncode != 0:
raise AssertionError(result.stderr)
return root / ".pouch" / "ship" / ship_id
def _next(root: Path, ship_id: str, entry: str) -> dict:
result = _run(
NEXT,
"--project-root",
str(root),
"--ship-id",
ship_id,
"--entry",
entry,
)
if result.returncode != 0:
raise AssertionError(result.stderr or result.stdout)
return json.loads(result.stdout)
def _patch_current(ship_dir: Path, **fields) -> None:
path = ship_dir / "current.yaml"
data = safe_load(path.read_text(encoding="utf-8"))
for key, value in fields.items():
if key == "delivery_state":
data.setdefault("delivery", {})["state"] = value
elif key == "manager":
data["manager"] = value
elif key == "source":
data["source"] = value
elif key == "spec":
data["spec"].update(value)
else:
data[key] = value
path.write_text(safe_dump(data), encoding="utf-8")
def _fill_task(ship_dir: Path) -> None:
path = ship_dir / "tasks.yaml"
data = safe_load(path.read_text(encoding="utf-8"))
data["tasks"][0]["expected"] = ["登录用户看到导出"]
data["tasks"][0]["verification"] = ["打开 /list"]
path.write_text(safe_dump(data), encoding="utf-8")
class NextStepTests(unittest.TestCase):
def test_new_short_ship_writes_spec(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
_create(root, "add-export-button", "short")
doc = _next(root, "add-export-button", "short")
self.assertEqual(doc["step"], "write_spec")
self.assertEqual(doc["actor"], "manager")
def test_filled_short_without_stop_asks_user(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
ship_dir = _create(root, "add-export-button", "short")
(ship_dir / "spec.md").write_text(FILLED_SPEC, encoding="utf-8")
_fill_task(ship_dir)
doc = _next(root, "add-export-button", "short")
self.assertEqual(doc["status"], "ask_user")
def test_short_implement_then_verify(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
ship_dir = _create(root, "add-export-button", "short", stop_at="verified")
(ship_dir / "spec.md").write_text(FILLED_SPEC, encoding="utf-8")
_fill_task(ship_dir)
doc = _next(root, "add-export-button", "short")
self.assertEqual(doc["step"], "implement")
self.assertEqual(doc["actor"], "worker")
self.assertIsNone(doc["skill"])
_patch_current(
ship_dir,
manager={"lastStep": "implement", "lastResult": "pass", "inFlight": None},
)
data = safe_load((ship_dir / "current.yaml").read_text(encoding="utf-8"))
data["evidence"]["commits"] = [{"sha": "abc1234deadbeef", "role": "head"}]
(ship_dir / "current.yaml").write_text(safe_dump(data), encoding="utf-8")
doc = _next(root, "add-export-button", "short")
self.assertEqual(doc["step"], "verify")
self.assertEqual(doc["skill"], "tester")
_patch_current(ship_dir, delivery_state="verified")
doc = _next(root, "add-export-button", "short")
self.assertEqual(doc["status"], "done")
def test_fault_spec_dispatches_hunt(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
ship_dir = _create(root, "add-export-button", "short", stop_at="verified")
(ship_dir / "spec.md").write_text(FAULT_SPEC, encoding="utf-8")
_fill_task(ship_dir)
doc = _next(root, "add-export-button", "short")
self.assertEqual(doc["step"], "hunt")
self.assertEqual(doc["skill"], "hunt")
def test_long_starts_with_specifier_worker(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
_create(root, "tenant-permissions", "long")
doc = _next(root, "tenant-permissions", "long")
self.assertEqual(doc["step"], "specifier_new")
self.assertEqual(doc["skill"], "specifier")
self.assertEqual(doc["actor"], "worker")
def test_pr_check_then_merge(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
ship_dir = _create(root, "pr-export", "short", stop_at="released")
_patch_current(
ship_dir,
source={"kind": "pr", "ref": "https://git.example.com/app/-/merge_requests/12"},
)
data = safe_load((ship_dir / "current.yaml").read_text(encoding="utf-8"))
data["evidence"]["prs"] = [
{"url": "https://git.example.com/app/-/merge_requests/12", "status": "opened"}
]
(ship_dir / "current.yaml").write_text(safe_dump(data), encoding="utf-8")
doc = _next(root, "pr-export", "pr")
self.assertEqual(doc["step"], "check_pr")
self.assertEqual(doc["skill"], "check")
_patch_current(
ship_dir,
manager={"lastStep": "check_pr", "lastResult": "pass", "inFlight": None},
)
doc = _next(root, "pr-export", "pr")
self.assertEqual(doc["step"], "merge_pr")
self.assertEqual(doc["skill"], "delivery-loop")
def test_failed_worker_blocks_manager(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
ship_dir = _create(root, "add-export-button", "short", stop_at="verified")
(ship_dir / "spec.md").write_text(FILLED_SPEC, encoding="utf-8")
_fill_task(ship_dir)
_patch_current(
ship_dir,
manager={"lastStep": "implement", "lastResult": "fail", "inFlight": None},
)
doc = _next(root, "add-export-button", "short")
self.assertEqual(doc["status"], "blocked")
self.assertIn("不要自己做", doc["summary"])
def test_entry_skills_are_managers(self) -> None:
for name in ("short-ship", "long-ship", "pr-ship"):
text = (REPO_ROOT / "skills" / name / "SKILL.md").read_text(encoding="utf-8")
self.assertIn("开新 agent", text)
self.assertNotIn("读取并执行", text)
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()