From 4814bc10262f7f92c2cd4713c2c83863d1ebd4f4 Mon Sep 17 00:00:00 2001 From: laily Date: Wed, 14 Oct 2026 09:57:08 +0800 Subject: [PATCH] feat(ship): run every project test surface in regression --- .../delivery-loop/references/state-machine.md | 6 + skills/long-ship/README.md | 4 +- skills/long-ship/SKILL.md | 54 +- skills/ship/SKILL.md | 11 +- skills/ship/references/worker-prompt.md | 43 ++ skills/ship/scripts/next_step.py | 645 ++++++++++++++++++ tests/test_ship_multiroot.py | 71 ++ 7 files changed, 807 insertions(+), 27 deletions(-) create mode 100644 skills/ship/references/worker-prompt.md create mode 100644 skills/ship/scripts/next_step.py create mode 100644 tests/test_ship_multiroot.py diff --git a/skills/delivery-loop/references/state-machine.md b/skills/delivery-loop/references/state-machine.md index a43f62c..08fa40b 100644 --- a/skills/delivery-loop/references/state-machine.md +++ b/skills/delivery-loop/references/state-machine.md @@ -31,6 +31,12 @@ ACK 配置必须使用以下 canonical `stopAt`: - `pr_opened`:提交说明、测试证据、风险和回滚信息已写入 PR;不能把草稿 PR 当 `review_ready`。 - `merged`:目标分支的合并 commit 已确认,不能只确认源分支 commit。 - `regression_passed`:以合并 commit 执行项目规定的完整回归;失败则回到修复或停止。 + 回归 worker 必须先发现项目及其子项目的 tester catalog(使用 + `catalog.py roots --root `),逐个测试根执行并记录独立快照;父仓库 + 没有 catalog 不能遮蔽有 catalog 的 `app` 子项目。任一发现的测试根未执行或无证据, + 不得把阶段标为 `regression_passed`。还必须执行项目 profile 或 + `.pouch/ack/project.md` 声明的项目原生测试套件,并为每个套件记录命令和结果;任一 + 测试面缺少结果时都不能通过回归。 - `artifact_built`:对**合并后的精确 commit SHA** 调用 builder `obtain.sh`。`BUILD_VENUE=ci` 时监听该 SHA 的流水线并 inspect 产物,不在本机重新编译。无流水线、job 失败或 digest 对不上则本阶段失败,不得进入 `published`。项目若声明默认分支不构建,合进 `main` 会 `no_pipeline`:预览推 `dev/*`,正式打 tag。 - `published`:镜像/APK 等外部分发位置可复核,记录 digest 或文件校验和。`venue=ci` 且 CI 已经 push 到 registry 时,obtain 成功即同时满足本阶段,不要再本机 `publish_docker.sh`。 - `deployed`:目标服务运行在预期版本,健康检查和关键路径验证通过;生产部署还必须进入 `released`。 diff --git a/skills/long-ship/README.md b/skills/long-ship/README.md index c4acc42..7d90e8f 100644 --- a/skills/long-ship/README.md +++ b/skills/long-ship/README.md @@ -17,7 +17,9 @@ ## Agent 会做什么 -先生成可评审 PRD;批准后在同一个 ship id 下建立有依赖的任务,独立验证子任务,最后以集成结果推进交付。 +主会话只建 ship 记录、让你批 PRD、按步骤开新 agent 并等待。写 PRD、改代码、验证和 +交付都在新会话里做。同一 ship id 串起任务和证据。 +集成回归会先发现父仓库及子项目的 tester catalog,逐个测试根执行并分别记录快照,不会因父仓库没有 catalog 而跳过 `app` 等子项目;项目 profile 或 `.pouch/ack/project.md` 声明的原生测试套件也必须逐项执行并记录证据。 ## 如何判断完成 diff --git a/skills/long-ship/SKILL.md b/skills/long-ship/SKILL.md index 14903ff..3a68686 100644 --- a/skills/long-ship/SKILL.md +++ b/skills/long-ship/SKILL.md @@ -8,7 +8,8 @@ description: >- # Long Ship:从需求到交付 -把规格、任务编排和最终交付连在同一条 ship 记录上;不绕过产品确认,也不让子任务各自发布生产。 +当前会话只做管理:建账本、让用户批 PRD、按 `next_step.py` 开新 agent 并等待。 +不要在本会话写 PRD、改业务代码或跑集成回归。 ## 何时使用 @@ -23,35 +24,42 @@ description: >- ## 工作规则 -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`。 +1. 读取已安装 ship 的 [manager.md](../ship/references/manager.md)。派发 worker 时再读 + [worker-prompt.md](../ship/references/worker-prompt.md)。创建或复用 + `.pouch/ship//`,`kind: long`。用户明确批准具体版本前,不创建开发任务、 + 不改业务代码。 +2. 只跑: + + ```bash + python3 /scripts/next_step.py \ + --project-root --ship-id --entry long + ``` + + specifier / 实现 / 验证 / 开合 PR / 集成回归 / obtain / 部署都是 worker。 + 主会话从 `handoff.yaml` 写入 `tasks.yaml`(`materialize_tasks`),不要写入 + `.pouch/ack/tasks.yaml`。 +3. 无依赖冲突的 `implement_task` 可一轮并行,必须全部等完。子任务默认只到 + `pr_opened` 或 `merged`,不单独部署生产。 +4. 需求基线变化时停下来,让 `next_step.py` 回到 specifier;不要自己改 PRD。 + 需要字段含义时再读 [record.md](../ship/references/record.md)。 + +集成回归阶段由 tester worker 负责。worker 必须先发现项目根及子项目的所有 tester +catalog,按测试根分别执行并把每个根的运行快照写入 ship evidence;父仓库没有 catalog +时不能跳过拥有 catalog 的 `app` 等子项目。任一发现的测试根没有可复核结果,回归阶段 +不能通过。还必须执行项目 profile 或 `.pouch/ack/project.md` 声明的项目原生测试套件, +为每个套件记录独立命令、结果和证据;任一测试面缺少结果时不能通过。 + +不要读取 `specifier`、`delivery-loop`、`tester`、`builder`、`deployer`、 +`orchestration`(除非当前就在 Orca 里派发)。这些由 worker 或 manager.md 的条件加载。 ## 安全门 - 未批准 PRD、缺 handoff、任务依赖循环、验收信号不可观测或集成回归失败时停止。 +- 开不出新 agent 时停止;worker 失败时不要自己把该步做完。 - 不把 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、产物摘要、部署状态和回滚点。 +PR/tag、产物摘要、部署状态和回滚点。数字来自账本。 diff --git a/skills/ship/SKILL.md b/skills/ship/SKILL.md index 6259d0c..bcb9324 100644 --- a/skills/ship/SKILL.md +++ b/skills/ship/SKILL.md @@ -2,7 +2,7 @@ name: ship description: >- short-ship、long-ship、pr-ship、specifier 共用的交付记录格式。一次交付一个 - ship-id,目录在 .pouch/ship//。不单独作为用户入口;用户说 short-ship、 + ship-id,目录在 .pouch/ship/{id}/。不单独作为用户入口;用户说 short-ship、 long-ship、交付记录、ship id 时由对应入口读取本 skill。 --- @@ -12,7 +12,10 @@ description: >- ## 使用方式 -入口 skill 在拿到或创建 `ship-id` 之后读取 [record.md](references/record.md)。 +`short-ship` / `long-ship` / `pr-ship` 的主会话只编排:读 +[manager.md](references/manager.md),按 `scripts/next_step.py` 开新 agent;派发时 +用 [worker-prompt.md](references/worker-prompt.md)。 + 创建目录用 `scripts/create_ship.py`,改证据指针用 `scripts/append_evidence.py`, 写回后跑 `scripts/validate_ship.py`。 @@ -20,6 +23,8 @@ description: >- - 进入 short-ship / long-ship / pr-ship 的第一件事是拿到或创建 `ship-id`。没有 id 不得改业务代码、不得跑 tester / obtain / deploy。 +- 入口主会话不得加载 hunt / tester / builder / deployer / releaser / check / + specifier / delivery-loop,也不得亲自执行这些步骤;开不出新 agent 时停止。 - 记录只写 `.pouch/ship//`。功能、小改动和本次修复的任务不要写入 `.pouch/ack/tasks.yaml`。ACK 任务板只收缺陷;缺陷要交付时在 ship 的 `source` 里引用。 - `ship-id` 用稳定短 slug:小写字母开头,小写字母、数字、连字符,最长 64。不要用日期、 @@ -31,7 +36,7 @@ description: >- - 项目构建/部署契约仍在 `.pouch/ack/delivery.yaml` 和 builder/deployer 配置里,不要 复制进每个 ship。 - 旧的 `.pouch/specifier/features//` 在继续该需求时迁到 - `.pouch/ship//spec/`,规则见 record.md。 + `.pouch/ship//spec/`,规则见 [record.md](references/record.md)。 ## 结果 diff --git a/skills/ship/references/worker-prompt.md b/skills/ship/references/worker-prompt.md new file mode 100644 index 0000000..202d264 --- /dev/null +++ b/skills/ship/references/worker-prompt.md @@ -0,0 +1,43 @@ +# Worker prompt 模板 + +主会话把下面整段交给**新的** agent,替换尖括号。不要追加 hunt / tester / +delivery-loop 的正文,不要让 worker 再开一层 ship 入口。 + +```text +你是 ship 的 worker,只做步骤 。 +项目根: +Ship 目录:.pouch/ship// +任务 id: +目标 delivery.state: +停止点 stopAt: + +先读已安装的 skill:。 +需要写证据指针时读 ship 的 references/record.md,用 +scripts/append_evidence.py 追加后跑 scripts/validate_ship.py。 +不要读取 short-ship、long-ship、pr-ship,不要再开 ship 入口,不要派下一个步骤。 + +范围: +验收:见 spec.md 与 tasks.yaml 里该任务的 expected / verification。 +授权:<本步允许的 git/PR/部署动作;未写的不要做> + +若步骤是 `verify_task` 或 `regress` 且 skill 是 `tester`:先执行 +`python3 /scripts/catalog.py roots --root `。`verify_task` 按 +任务范围选择相关测试根;`regress` 必须对返回的每个独立测试根分别执行 `validate`、 +完整范围选择和测试。父仓库没有 catalog 时继续进入有 catalog 的子项目(例如 `app`), +不要把多个项目的 cases 合并。`regress` 只有在所有发现的测试根都完成、每个根都有自己 +的 `.pouch/tester/runs/*.yaml`/`.csv` 快照并把所有路径写入 ship evidence 后,才可返回 +pass;没有 HOST、设备或浏览器的测试根必须记录 environment-fail/blocked,不能把未执行 +的根省略掉。同时执行项目 profile 或 `.pouch/ack/project.md` 声明的项目原生测试套件 +(例如 `go test ./...`、构建检查或前端检查),每个套件都要有独立的命令、结果和证据; +不能只因为 tester catalog 已完成就跳过这些项目测试。 + +完成后只返回: +- step: +- status: pass | fail | blocked | ask +- summary: 三句以内,做了什么、证据在哪、还缺什么 +- evidence: 写入的指针 +- ask: 若 status=ask,要主会话问用户的那一个问题 +``` + +`` 必须用 `next_step.py` JSON 里的 `skill` 字段,不要改成别的。`skill` 为 +`null` 时不要加载执行类 skill。 diff --git a/skills/ship/scripts/next_step.py b/skills/ship/scripts/next_step.py new file mode 100644 index 0000000..0e69e28 --- /dev/null +++ b/skills/ship/scripts/next_step.py @@ -0,0 +1,645 @@ +#!/usr/bin/env python3 +"""Print the next manager/worker step for a ship as JSON.""" + +from __future__ import annotations + +import argparse +import json +import re +import sys +from pathlib import Path +from typing import Any + +from ship_lib import load_yaml + +SPEC_PLACEHOLDERS = ( + "<用户可见的目标", + "<明确排除的范围", + "<能被浏览器", + "<命令、页面路径", +) +TASK_PLACEHOLDERS = ( + "", +) +FAULT_PLACEHOLDER = "<没有故障时删除" +DONE_TASK = frozenset({"verified", "merged", "done"}) +READY_DEP = frozenset({"verified", "merged", "done"}) +STOP_AT_STATE = { + "verified": "verified", + "review_ready": "pr_opened", + "validation_ready": "deployed", + "released": "deployed", +} +DELIVERY_STEPS: tuple[tuple[str, str, str], ...] = ( + ("verified", "verify", "tester"), + ("pr_opened", "open_pr", "delivery-loop"), + ("merged", "merge_pr", "delivery-loop"), + ("regression_passed", "regress", "tester"), + ("artifact_built", "obtain", "builder"), + ("published", "publish", "releaser"), + ("deployed", "deploy", "deployer"), +) +DELIVERY_ORDER = ("change_ready",) + tuple(s[0] for s in DELIVERY_STEPS) + + +def _out( + *, + ship_id: str, + entry: str, + actor: str, + step: str, + status: str = "ready", + skill: str | None = None, + task_id: str | None = None, + target_state: str | None = None, + stop_at: str | None = None, + ready_task_ids: list[str] | None = None, + summary: str, +) -> dict[str, Any]: + return { + "shipId": ship_id, + "entry": entry, + "actor": actor, + "step": step, + "status": status, + "skill": skill, + "taskId": task_id, + "targetState": target_state, + "stopAt": stop_at, + "readyTaskIds": ready_task_ids or [], + "summary": summary, + } + + +def _text(path: Path) -> str: + return path.read_text(encoding="utf-8") if path.is_file() else "" + + +def _has_any(text: str, markers: tuple[str, ...]) -> bool: + return any(m in text for m in markers) + + +def _fault_section(spec_md: str) -> str: + match = re.search(r"^## 故障证据\s*$", spec_md, re.MULTILINE) + if not match: + return "" + rest = spec_md[match.end() :] + nxt = re.search(r"^## ", rest, re.MULTILINE) + body = rest[: nxt.start()] if nxt else rest + return body.strip() + + +def _needs_hunt(spec_md: str) -> bool: + body = _fault_section(spec_md) + if not body or FAULT_PLACEHOLDER in body: + return False + return True + + +def _tasks(ship_dir: Path) -> list[dict[str, Any]]: + data = load_yaml(ship_dir / "tasks.yaml") + if not isinstance(data, dict): + return [] + tasks = data.get("tasks") + return [t for t in tasks if isinstance(t, dict)] if isinstance(tasks, list) else [] + + +def _task_placeholders(tasks: list[dict[str, Any]]) -> bool: + blob = json.dumps(tasks, ensure_ascii=False) + return _has_any(blob, TASK_PLACEHOLDERS) + + +def _commits(current: dict[str, Any]) -> list[dict[str, Any]]: + evidence = current.get("evidence") or {} + if not isinstance(evidence, dict): + return [] + rows = evidence.get("commits") or [] + return [r for r in rows if isinstance(r, dict)] + + +def _has_change_commit(current: dict[str, Any]) -> bool: + return any(r.get("role") in {"head", "merged"} for r in _commits(current)) + + +def _has_merged_commit(current: dict[str, Any]) -> bool: + return any(r.get("role") == "merged" for r in _commits(current)) + + +def _manager(current: dict[str, Any]) -> dict[str, Any]: + value = current.get("manager") + return value if isinstance(value, dict) else {} + + +def _stop_state(stop_at: str | None) -> str | None: + if not stop_at: + return None + return STOP_AT_STATE.get(stop_at) + + +def _state_index(state: str | None) -> int: + if state in DELIVERY_ORDER: + return DELIVERY_ORDER.index(state) + return 0 + + +def _next_delivery( + *, + ship_id: str, + entry: str, + current_state: str, + stop_at: str | None, + skip_until: str | None = None, +) -> dict[str, Any] | None: + target_stop = _stop_state(stop_at) + if not stop_at or not target_stop: + return _out( + ship_id=ship_id, + entry=entry, + actor="manager", + step="ask_user", + status="ask_user", + stop_at=stop_at, + summary="stopAt 未配置或无法映射,主会话向用户确认停止点后再继续", + ) + start = _state_index(current_state) + floor = _state_index(skip_until) if skip_until else start + start = max(start, floor) + stop_i = _state_index(target_stop) + if start >= stop_i: + return None + nxt = DELIVERY_STEPS[start] + state, step, skill = nxt + summary = f"派 worker 把 delivery.state 推进到 {state},加载 {skill}" + if step == "regress": + summary = ( + "派 tester worker 做合并后的完整回归:先发现父仓库和子项目的所有 tester " + "catalog,逐根执行并为每根写独立快照;再执行项目声明的原生测试套件;" + "任一测试面缺证据不得通过" + ) + return _out( + ship_id=ship_id, + entry=entry, + actor="worker", + step=step, + skill=skill, + target_state=state, + stop_at=stop_at, + summary=summary, + ) + + +def _failed(ship_id: str, entry: str, mgr: dict[str, Any], stop_at: str | None) -> dict[str, Any] | None: + if mgr.get("lastResult") not in {"fail", "blocked"}: + return None + last = str(mgr.get("lastStep") or "unknown") + return _out( + ship_id=ship_id, + entry=entry, + actor="manager", + step=last, + status="blocked", + stop_at=stop_at, + summary=f"上一步 {last} 失败或阻塞,主会话不要自己做;问用户是否开新 agent 重试", + ) + + +def _ready_tasks(tasks: list[dict[str, Any]]) -> list[dict[str, Any]]: + by_id = {str(t.get("id")): t for t in tasks if t.get("id")} + ready: list[dict[str, Any]] = [] + for task in tasks: + status = task.get("status") + if status not in {"open", "in_progress"}: + continue + deps = task.get("dependsOn") or [] + if not isinstance(deps, list): + continue + ok = True + for dep in deps: + other = by_id.get(str(dep)) + if other is None or other.get("status") not in READY_DEP: + ok = False + break + if ok: + ready.append(task) + return ready + + +def decide_next(ship_dir: Path, entry: str) -> dict[str, Any]: + current_path = ship_dir / "current.yaml" + if not current_path.is_file(): + return _out( + ship_id=ship_dir.name, + entry=entry, + actor="manager", + step="ensure_record", + status="blocked", + summary="没有 current.yaml,主会话先跑 create_ship.py", + ) + current = load_yaml(current_path) + if not isinstance(current, dict): + return _out( + ship_id=ship_dir.name, + entry=entry, + actor="manager", + step="ensure_record", + status="blocked", + summary="current.yaml 无法解析", + ) + + ship_id = str(current.get("shipId") or ship_dir.name) + kind = current.get("kind") + stop_at = current.get("stopAt") or (current.get("delivery") or {}).get("stopAt") + if isinstance(stop_at, str) and stop_at in {"", "null"}: + stop_at = None + state = (current.get("delivery") or {}).get("state") or "change_ready" + source = current.get("source") if isinstance(current.get("source"), dict) else {} + spec = current.get("spec") if isinstance(current.get("spec"), dict) else {} + mgr = _manager(current) + spec_md = _text(ship_dir / "spec.md") + tasks = _tasks(ship_dir) + + stop = stop_at if isinstance(stop_at, str) else None + failed = _failed(ship_id, entry, mgr, stop) + if failed: + return failed + if mgr.get("lastResult") == "ask": + last = str(mgr.get("lastStep") or "unknown") + return _out( + ship_id=ship_id, + entry=entry, + actor="manager", + step="ask_user", + status="ask_user", + stop_at=stop, + summary=f"上一步 {last} 需要用户决定,主会话先问用户,不要自己执行下一步", + ) + + in_flight = mgr.get("inFlight") + if isinstance(in_flight, str) and in_flight.strip(): + return _out( + ship_id=ship_id, + entry=entry, + actor="manager", + step=in_flight, + status="blocked", + stop_at=stop, + summary=f"manager.inFlight={in_flight},先等待该 worker,不要重派或自己做", + ) + + use_pr = entry == "pr" or source.get("kind") == "pr" + use_long = (not use_pr) and (entry == "long" or kind == "long") + + if use_pr: + return _decide_pr(ship_id, current, source, mgr, state, stop) + if use_long: + return _decide_long(ship_dir, ship_id, current, spec, spec_md, tasks, state, stop) + return _decide_short(ship_id, spec_md, tasks, current, state, stop) + + +def _decide_short( + ship_id: str, + spec_md: str, + tasks: list[dict[str, Any]], + current: dict[str, Any], + state: str, + stop_at: str | None, +) -> dict[str, Any]: + if not spec_md or _has_any(spec_md, SPEC_PLACEHOLDERS) or _task_placeholders(tasks): + return _out( + ship_id=ship_id, + entry="short", + actor="manager", + step="write_spec", + stop_at=stop_at, + summary="主会话补齐 spec.md 四段和唯一任务的 expected/verification,不要加载执行类 skill", + ) + if not stop_at: + return _out( + ship_id=ship_id, + entry="short", + actor="manager", + step="ask_user", + status="ask_user", + summary="stopAt 未配置,主会话向用户确认 verified / review_ready / validation_ready / released", + ) + if state == "change_ready" and _needs_hunt(spec_md) and not _has_change_commit(current): + last = _manager(current).get("lastStep") + if last != "hunt": + return _out( + ship_id=ship_id, + entry="short", + actor="worker", + step="hunt", + skill="hunt", + stop_at=stop_at, + summary="派 hunt worker 确认根因、复现和验收信号", + ) + if state == "change_ready" and not _has_change_commit(current): + return _out( + ship_id=ship_id, + entry="short", + actor="worker", + step="implement", + skill=None, + task_id=str(tasks[0]["id"]) if tasks and tasks[0].get("id") else "T-01", + stop_at=stop_at, + summary="派 worker 按 spec.md 改代码,不要加载 delivery-loop", + ) + nxt = _next_delivery( + ship_id=ship_id, entry="short", current_state=state, stop_at=stop_at + ) + if nxt: + return nxt + return _out( + ship_id=ship_id, + entry="short", + actor="manager", + step="report", + status="done", + stop_at=stop_at, + summary="已到停止点,主会话按账本写完成报告", + ) + + +def _decide_long( + ship_dir: Path, + ship_id: str, + current: dict[str, Any], + spec: dict[str, Any], + spec_md: str, + tasks: list[dict[str, Any]], + state: str, + stop_at: str | None, +) -> dict[str, Any]: + spec_status = spec.get("specStatus") + handoff = spec.get("handoff") + prd = spec.get("prd") + handoff_path = ship_dir / str(handoff) if handoff else ship_dir / "spec" / "handoff.yaml" + + if spec_status not in {"approved"}: + if spec_status in {None, "", "draft"} and not prd: + return _out( + ship_id=ship_id, + entry="long", + actor="worker", + step="specifier_new", + skill="specifier", + stop_at=stop_at, + summary="派 specifier worker 写 spec/ 下的 PRD,主会话不要读 specifier", + ) + if spec_status in {"changes_requested"}: + return _out( + ship_id=ship_id, + entry="long", + actor="worker", + step="specifier_review", + skill="specifier", + stop_at=stop_at, + summary="派 specifier worker 按用户意见出新 PRD 版本", + ) + return _out( + ship_id=ship_id, + entry="long", + actor="manager", + step="ask_user", + status="ask_user", + stop_at=stop_at, + summary="PRD 待用户批准或提出修改;主会话问用户,不要自己改 PRD", + ) + + if not handoff_path.is_file(): + return _out( + ship_id=ship_id, + entry="long", + actor="worker", + step="specifier_handoff", + skill="specifier", + stop_at=stop_at, + summary="已批准但缺少 handoff,派 specifier worker 生成 spec/handoff.yaml", + ) + + if _task_placeholders(tasks): + return _out( + ship_id=ship_id, + entry="long", + actor="manager", + step="materialize_tasks", + stop_at=stop_at, + summary="主会话从 handoff.yaml 写入 tasks.yaml,不要加载 specifier", + ) + + unfinished = [t for t in tasks if t.get("status") not in DONE_TASK] + if unfinished: + ready = _ready_tasks(tasks) + if not ready: + return _out( + ship_id=ship_id, + entry="long", + actor="manager", + step="ask_user", + status="blocked", + stop_at=stop_at, + summary="有未完成任务但依赖未就绪,主会话报告阻塞,不要自己开发", + ) + open_ready = [t for t in ready if t.get("status") == "open"] + if open_ready: + ids = [str(t["id"]) for t in open_ready if t.get("id")] + first = ids[0] + return _out( + ship_id=ship_id, + entry="long", + actor="worker", + step="implement_task", + skill=None, + task_id=first, + ready_task_ids=ids, + stop_at=stop_at, + summary="派 worker 实现无依赖冲突的任务;多个 id 可并行,仍全部等完", + ) + inprog = [t for t in ready if t.get("status") == "in_progress"] + task = inprog[0] + tid = str(task.get("id") or "") + last = _manager(current).get("lastStep") + if last == "implement_task": + return _out( + ship_id=ship_id, + entry="long", + actor="worker", + step="verify_task", + skill="tester", + task_id=tid, + target_state="verified", + stop_at=stop_at, + summary="派 tester worker 验证该任务,主会话不要自己点浏览器", + ) + task_stop = task.get("stopAt") or "merged" + if last == "verify_task" and task_stop in {"pr_opened", "merged", "review_ready"}: + return _out( + ship_id=ship_id, + entry="long", + actor="worker", + step="open_pr", + skill="delivery-loop", + task_id=tid, + target_state="pr_opened", + stop_at=stop_at, + summary="派 worker 为该子任务开 PR,默认不部署生产", + ) + if last == "open_pr" and task_stop in {"merged"}: + return _out( + ship_id=ship_id, + entry="long", + actor="worker", + step="merge_pr", + skill="delivery-loop", + task_id=tid, + target_state="merged", + stop_at=stop_at, + summary="派 worker 合并该子任务 PR,不要升级生产", + ) + return _out( + ship_id=ship_id, + entry="long", + actor="manager", + step="ask_user", + status="blocked", + task_id=tid, + stop_at=stop_at, + summary=f"任务 {tid} 处于 in_progress,但 lastStep={last!r},主会话先核对账本", + ) + + parent_state = state + if _state_index(state) < _state_index("merged"): + parent_state = "merged" + nxt = _next_delivery( + ship_id=ship_id, + entry="long", + current_state=parent_state, + stop_at=stop_at, + skip_until="merged", + ) + if nxt: + return nxt + return _out( + ship_id=ship_id, + entry="long", + actor="manager", + step="report", + status="done", + stop_at=stop_at, + summary="已到停止点,主会话按账本写完成报告", + ) + + +def _decide_pr( + ship_id: str, + current: dict[str, Any], + source: dict[str, Any], + mgr: dict[str, Any], + state: str, + stop_at: str | None, +) -> dict[str, Any]: + if not source.get("ref"): + return _out( + ship_id=ship_id, + entry="pr", + actor="manager", + step="bind_pr", + stop_at=stop_at, + summary="主会话把 PR/MR 写入 source.ref 和 evidence.prs,不要加载 check", + ) + if not stop_at: + return _out( + ship_id=ship_id, + entry="pr", + actor="manager", + step="ask_user", + status="ask_user", + summary="stopAt 未配置,主会话向用户确认合并后停在哪一档", + ) + if not _has_merged_commit(current) and state not in { + "merged", + "regression_passed", + "artifact_built", + "published", + "deployed", + }: + if mgr.get("lastStep") == "check_pr" and mgr.get("lastResult") == "pass": + return _out( + ship_id=ship_id, + entry="pr", + actor="worker", + step="merge_pr", + skill="delivery-loop", + target_state="merged", + stop_at=stop_at, + summary="派 worker 合并 PR,并以合并 commit 作为后续来源", + ) + if mgr.get("lastStep") == "check_pr" and mgr.get("lastResult") == "ask": + return _out( + ship_id=ship_id, + entry="pr", + actor="worker", + step="fix_pr", + skill=None, + stop_at=stop_at, + summary="检查要求修复且当前请求含修复时,派 worker 在 PR 分支上改;不要扩范围", + ) + return _out( + ship_id=ship_id, + entry="pr", + actor="worker", + step="check_pr", + skill="check", + stop_at=stop_at, + summary="派 check worker 看差异、审核和 CI,主会话不要自己审代码", + ) + nxt = _next_delivery( + ship_id=ship_id, + entry="pr", + current_state=state if state != "change_ready" else "merged", + stop_at=stop_at, + skip_until="merged", + ) + if nxt: + return nxt + return _out( + ship_id=ship_id, + entry="pr", + actor="manager", + step="report", + status="done", + stop_at=stop_at, + summary="已到停止点,主会话按账本写完成报告", + ) + + +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("--entry", choices=("short", "long", "pr"), required=True) + args = parser.parse_args() + ship_dir = Path(args.project_root).resolve() / ".pouch" / "ship" / args.ship_id + if not ship_dir.is_dir(): + doc = _out( + ship_id=args.ship_id, + entry=args.entry, + actor="manager", + step="ensure_record", + status="blocked", + summary="没有 ship 目录,主会话先跑 create_ship.py", + ) + json.dump(doc, sys.stdout, ensure_ascii=False, indent=2) + sys.stdout.write("\n") + return 0 + doc = decide_next(ship_dir, args.entry) + json.dump(doc, sys.stdout, ensure_ascii=False, indent=2) + sys.stdout.write("\n") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/test_ship_multiroot.py b/tests/test_ship_multiroot.py new file mode 100644 index 0000000..e76ed70 --- /dev/null +++ b/tests/test_ship_multiroot.py @@ -0,0 +1,71 @@ +from __future__ import annotations + +import json +import subprocess +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" +NEXT = REPO_ROOT / "skills" / "ship" / "scripts" / "next_step.py" + + +def _run(script: Path, *args: str) -> subprocess.CompletedProcess[str]: + return subprocess.run( + ["python3", str(script), *args], + cwd=REPO_ROOT, + text=True, + capture_output=True, + check=False, + ) + + +class MultiRootRegressionDispatchTests(unittest.TestCase): + def test_regression_dispatch_requires_all_tester_roots(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + ship_id = "multi-project-regression" + created = _run( + CREATE, + "--project-root", str(root), + "--ship-id", ship_id, + "--kind", "short", + "--title", "列表页增加导出按钮", + "--stop-at", "released", + ) + self.assertEqual(created.returncode, 0, created.stderr) + ship_dir = root / ".pouch" / "ship" / ship_id + (ship_dir / "spec.md").write_text( + """# 导出按钮\n\n## 要做什么\n\n列表页给登录用户一个导出按钮。\n\n## 不做什么\n\n不改权限模型。\n\n## 可观察验收\n\n- 登录后列表页可见导出按钮\n\n## 怎么验\n\n- 浏览器打开 /list 看到按钮\n""", + encoding="utf-8", + ) + tasks = safe_load((ship_dir / "tasks.yaml").read_text(encoding="utf-8")) + tasks["tasks"][0].update( + status="verified", + expected=["登录用户看到导出"], + verification=["打开 /list"], + ) + (ship_dir / "tasks.yaml").write_text(safe_dump(tasks), encoding="utf-8") + current = safe_load((ship_dir / "current.yaml").read_text(encoding="utf-8")) + current["delivery"]["state"] = "merged" + current["evidence"]["commits"] = [{"sha": "abc1234deadbeef", "role": "head"}] + (ship_dir / "current.yaml").write_text(safe_dump(current), encoding="utf-8") + + result = _run( + NEXT, + "--project-root", str(root), + "--ship-id", ship_id, + "--entry", "short", + ) + self.assertEqual(result.returncode, 0, result.stderr) + doc = json.loads(result.stdout) + self.assertEqual(doc["step"], "regress") + self.assertEqual(doc["skill"], "tester") + self.assertIn("所有 tester catalog", doc["summary"]) + + +if __name__ == "__main__": + unittest.main()