diff --git a/skills/builder/README.md b/skills/builder/README.md index 8583e93..aecb3e3 100644 --- a/skills/builder/README.md +++ b/skills/builder/README.md @@ -47,7 +47,7 @@ pouch add builder -g # 全局 并负责上层编排。 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 /scripts/version.mk` 从 Git 推导。 多架构清单是 `ARCHES`,见下方「多架构」。 2. 运行 `python3 -I -S /scripts/check.py . --ready` 直到构建项 PASS。 @@ -227,5 +227,5 @@ Docker 只有你点名镜像时才走。token 不会写进输出、提交或回 | `templates/makefile.android.builder` | Android APK + copy 轨道骨架 | | `templates/makefile.ios.builder` | iOS IPA + copy 轨道骨架 | -环境变量契约、脚本解析顺序(`$BUILDER_SKILL_DIR` → `~/.pouch/skills/builder/scripts/`)、 +环境变量契约、脚本解析顺序(`$BUILDER_SKILL_DIR` → `~/.pouch` → `~/.agents`)、 脏工作树策略等完整规则见 contract.md。 diff --git a/skills/builder/references/contract.md b/skills/builder/references/contract.md index 7f590ad..aed6c21 100644 --- a/skills/builder/references/contract.md +++ b/skills/builder/references/contract.md @@ -49,7 +49,7 @@ make -f makefile.builder | `ios` | 有 iOS IPA 的项目 | 可与 `build` 相同。产出 `$(DIST_DIR)/__.ipa`;只构建不上传 | | `arches` | 可选(新模板必备) | 打印一行空格分隔的 `ARCHES`;无此目标视为存量 makefile,隐式单架构 | | `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` | 单一产物类型时必备;多产物项目为聚合 | 依序调用对应 push-* 或直接调脚本;是发布的唯一 make 入口 | | `obtain` | 有产物的项目 | 调用 `obtain.sh`,对精确 commit SHA 拿到已存在的产物。`BUILD_VENUE=ci` 时等待该 SHA 的 forge 流水线再 inspect,不在本机 `docker push` | diff --git a/skills/builder/templates/makefile.builder b/skills/builder/templates/makefile.builder index f639f64..b37d51a 100644 --- a/skills/builder/templates/makefile.builder +++ b/skills/builder/templates/makefile.builder @@ -31,12 +31,15 @@ DIST_DIR ?= dist ifneq ($(BUILDER_SKILL_DIR),) 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 + BUILDER_SCRIPTS := $(HOME)/.pouch/skills/builder/scripts +else + include $(HOME)/.agents/skills/builder/scripts/version.mk + BUILDER_SCRIPTS := $(HOME)/.agents/skills/builder/scripts 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 help: diff --git a/skills/delivery-loop/README.md b/skills/delivery-loop/README.md index a1b43dc..bdc8638 100644 --- a/skills/delivery-loop/README.md +++ b/skills/delivery-loop/README.md @@ -4,7 +4,8 @@ ## 什么时候使用 -通常由 `$short-ship`、`$pr-ship` 或 `$long-ship` 内部读取,不需要单独调用。 +通常由 `$short-ship`、`$pr-ship` 或 `$long-ship` 派发的 worker 读取,不需要单独调用。 +入口主会话不要把本 skill 读进自己的上下文。 ```text 按 delivery-loop 把当前 ship 推进到 review_ready。 diff --git a/skills/delivery-loop/SKILL.md b/skills/delivery-loop/SKILL.md index 81f5be1..3764412 100644 --- a/skills/delivery-loop/SKILL.md +++ b/skills/delivery-loop/SKILL.md @@ -11,9 +11,10 @@ description: >- ## 使用方式 -只有在入口 skill 明确要求执行交付时才读取 -`references/state-machine.md`。入口 skill 负责确定任务类型和目标;本 skill 负责阶段、 -停止点、证据和安全门。 +由 `short-ship` / `long-ship` / `pr-ship` **派出来的 worker** 读取本 skill 和 +`references/state-machine.md`。入口主会话不要读取或执行本 skill。 + +入口决定任务类型、停止点和派哪一步;本 skill 负责该步的阶段要求、证据和安全门。 ## 不可违反的规则 diff --git a/skills/long-ship/agents/openai.yaml b/skills/long-ship/agents/openai.yaml index 7073901..edb6237 100644 --- a/skills/long-ship/agents/openai.yaml +++ b/skills/long-ship/agents/openai.yaml @@ -1,6 +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." + 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: allow_implicit_invocation: true diff --git a/skills/pr-ship/README.md b/skills/pr-ship/README.md index c556d44..4c30f0f 100644 --- a/skills/pr-ship/README.md +++ b/skills/pr-ship/README.md @@ -17,7 +17,8 @@ ## Agent 会做什么 -先绑到一个 ship id,检查 PR 是否可合并;合并后重新以目标分支 commit 回归和构建,再按配置继续发布或部署。 +主会话只绑 ship id,再按步骤开新 agent 并等待。检查、合并、回归、构建和部署都在新 +会话里做;合并后的回归以目标分支 commit 为准。 ## 如何判断完成 diff --git a/skills/pr-ship/SKILL.md b/skills/pr-ship/SKILL.md index 84d10c7..983712d 100644 --- a/skills/pr-ship/SKILL.md +++ b/skills/pr-ship/SKILL.md @@ -7,7 +7,8 @@ description: >- # PR Ship:合并后的完整交付 -重点是合并后的真实状态:PR 分支通过不代表目标分支已回归,也不代表产物和生产已更新。 +当前会话只做管理:绑 ship id、按 `next_step.py` 开新 agent 并等待。不要在本会话 +审 diff、合并或跑回归。 ## 何时使用 @@ -22,29 +23,30 @@ description: >- ## 工作规则 -1. 读取已安装的 `ship` skill。从 PR 正文、分支名或用户给出的 id 绑定已有 ship;没有则 - 用 PR 标题生成 slug,创建 `kind: short` 的薄记录,`source.kind: pr`。之后证据都写进 - 这个 ship id。 -2. 读取 PR 的目标/源分支、差异、关联任务、审核状态、CI、冲突和变更风险;使用 `check`。 -3. 审核、CI 或必需检查缺失时停止。若发现可修复问题,只有当前请求明确包含修复时才在 PR 分支上处理, - 然后重新验证;不能静默改变 PR 范围。 -4. 具备合并条件后执行合并,并重新获取目标分支;记录合并后的 commit,后续所有回归和构建都以它为来源。 -5. 读取并执行 `delivery-loop` 的状态机,运行完整回归;失败时停在合并后的中间状态或回到修复流程,不部署失败版本。 - 每个完成阶段把指针写入该 ship 的 `current.yaml`。 -6. 只有项目 profile 和当前授权允许时才构建、发布和部署;生产部署完成后做节点状态和关键路径检查。 +1. 读取已安装 ship 的 [manager.md](../ship/references/manager.md)。派发 worker 时再读 + [worker-prompt.md](../ship/references/worker-prompt.md)。从 PR 正文、分支名或 + 用户给出的 id 绑定已有 ship;没有则用 PR 标题生成 slug,创建 `kind: short` 的 + 薄记录,`source.kind: pr`。之后证据都写进这个 ship id。 +2. 只跑: + + ```bash + python3 /scripts/next_step.py \ + --project-root --ship-id --entry pr + ``` + + 检查、修复、合并、回归、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、分支保护或冲突;不强推、不重写已共享历史。 - 不用 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、部署目标、健康检查和回滚点。数字来自账本。 diff --git a/skills/pr-ship/agents/openai.yaml b/skills/pr-ship/agents/openai.yaml index 006c9f7..1fd50d5 100644 --- a/skills/pr-ship/agents/openai.yaml +++ b/skills/pr-ship/agents/openai.yaml @@ -1,6 +1,6 @@ interface: display_name: "PR Ship" 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: allow_implicit_invocation: true diff --git a/skills/ship/README.md b/skills/ship/README.md index c9fa930..c1443bb 100644 --- a/skills/ship/README.md +++ b/skills/ship/README.md @@ -17,7 +17,8 @@ ## Agent 会做什么 -在 `.pouch/ship//` 建立或更新记录,把测试、产物和部署写成指针,而不是另写一套缺陷任务。 +在 `.pouch/ship//` 建立或更新记录,把测试、产物和部署写成指针。三个入口的 +主会话只编排,具体步骤开新 agent。 ## 如何判断完成 diff --git a/skills/ship/scripts/validate_ship.py b/skills/ship/scripts/validate_ship.py index 2f01d99..e09de94 100644 --- a/skills/ship/scripts/validate_ship.py +++ b/skills/ship/scripts/validate_ship.py @@ -141,6 +141,17 @@ def validate_ship_dir(ship_dir: Path) -> list[str]: if state not in DELIVERY_STATE: _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) if evidence is not None: issues.extend(_validate_evidence(evidence, _project_root(ship_dir))) diff --git a/skills/short-ship/README.md b/skills/short-ship/README.md index ab52629..80db6dc 100644 --- a/skills/short-ship/README.md +++ b/skills/short-ship/README.md @@ -23,7 +23,8 @@ ## Agent 会做什么 -建立 ship 记录,写短需求说明,改代码并验证,再继续完成配置允许的交付阶段。一次交付一个 ship id。 +主会话只建 ship 记录、写短说明、按步骤开新 agent 并等待结果。改代码、测试、开合 PR、 +构建和部署都在新会话里做,不会堆进同一个上下文。一次交付一个 ship id。 ## 如何判断完成 diff --git a/skills/short-ship/SKILL.md b/skills/short-ship/SKILL.md index 688a66a..c8ec477 100644 --- a/skills/short-ship/SKILL.md +++ b/skills/short-ship/SKILL.md @@ -8,7 +8,8 @@ description: >- # Short Ship:单次变更到交付 -把一次范围清楚的改动推进到项目配置允许的停止点。 +当前会话只做管理:建账本、问用户、按 `next_step.py` 开新 agent 并等待。不要把 +hunt / 改代码 / 测试 / 交付读进本会话。 ## 何时使用 @@ -24,36 +25,35 @@ description: >- ## 工作规则 -1. 读取已安装的 `ship` skill。没有 `ship-id` 就从需求生成候选 slug,用 - `scripts/create_ship.py --kind short` 建 `.pouch/ship//`;已有目录则校验后复用。 - 写 `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 `。 -6. 范围涨到需要 PRD 或多条任务时,同一 `ship-id` 把 `kind` 改为 `long`,交给 - `long-ship`,不要另开 id。 +1. 读取已安装 ship 的 [manager.md](../ship/references/manager.md)。派发 worker 时再读 + [worker-prompt.md](../ship/references/worker-prompt.md)。没有 `ship-id` 就生成 + slug,跑 `create_ship.py --kind short`。功能任务不要写入 `.pouch/ack/tasks.yaml`。 +2. 主会话只写 `spec.md` 四段和那一条 task。有故障证据时 `next_step.py` 会给出 + `hunt` worker,不要在本会话读 hunt。 +3. 之后只跑: + + ```bash + python3 /scripts/next_step.py \ + --project-root --ship-id --entry short + ``` + + `actor: worker` 就开新 agent 并等待;`ask_user` / `blocked` 就停下来问用户。 + 需要字段含义时再读 [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 四段不齐、验收不可观察时停止。 +- 开不出新 agent 时停止;worker 失败时不要自己把该步做完。 - 根因不清、测试缺失、真机不可达、CI/回归失败或工作树有无关改动时停止并说明。 - 不强推、不覆盖他人分支、不绕过 PR 审核,不把测试环境成功当生产成功。 - 数据迁移、破坏性 API、权限/密钥、安全修复和不可逆生产操作需要额外确认。 -## 必读资料 - -- 记录格式:已安装的 `ship` skill 与 `references/record.md`。 -- 交付阶段:已安装的 `delivery-loop` 及其状态机 reference。 -- 测试、构建、部署、提交前审查:项目的 `tester`、`builder`、`deployer`、`check`、 - `releaser`。 - ## 完成报告 报告 ship id、spec 路径、根因(若有)、变更、测试/真机证据指针、停止点、commit、 -PR/tag、产物摘要、部署状态和未完成项。 +PR/tag、产物摘要、部署状态和未完成项。数字来自账本,不要复述 worker 技能正文。 diff --git a/skills/short-ship/agents/openai.yaml b/skills/short-ship/agents/openai.yaml index 4ad28bf..4d4ab19 100644 --- a/skills/short-ship/agents/openai.yaml +++ b/skills/short-ship/agents/openai.yaml @@ -1,6 +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." + 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: allow_implicit_invocation: true diff --git a/skills/specifier/SKILL.md b/skills/specifier/SKILL.md index b97fe45..5298797 100644 --- a/skills/specifier/SKILL.md +++ b/skills/specifier/SKILL.md @@ -66,6 +66,8 @@ description: >- - **批准**:只在用户明确批准指定版本后,把 `spec.specStatus` 改为 `approved`,冻结需求基线。 - **交接**:仅针对 `approved` 版本生成 `spec/handoff.yaml`;用户要求继续开发时把它交给 `long-ship`。 - **状态**:只读报告当前版本、待决策、风险和是否可以交接。 +- 作为 `long-ship` 派来的 worker:只做 prompt 里的那一个模式,写完 `spec/` 就返回; + 不要再调用 `long-ship`,不要改业务代码。 详细评审状态机和确认规则见 [review-loop.md](references/review-loop.md)。HTML 页面结构和 渲染要求见 [html-prd.md](references/html-prd.md)。只有需要对应模式时才读取它们。 diff --git a/tests/test_ship_record.py b/tests/test_ship_record.py index 0d424ff..d8869e8 100644 --- a/tests/test_ship_record.py +++ b/tests/test_ship_record.py @@ -1,15 +1,44 @@ from __future__ import annotations +import json import subprocess import sys import tempfile import unittest from pathlib import Path +from pouch.yaml_io import safe_dump, safe_load + 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" +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]: @@ -155,5 +184,173 @@ class CreateAndValidateTests(unittest.TestCase): 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__": unittest.main()