diff --git a/skills/ship/SKILL.md b/skills/ship/SKILL.md index bcb9324..a21e24d 100644 --- a/skills/ship/SKILL.md +++ b/skills/ship/SKILL.md @@ -2,7 +2,8 @@ name: ship description: >- short-ship、long-ship、pr-ship、specifier 共用的交付记录格式。一次交付一个 - ship-id,目录在 .pouch/ship/{id}/。不单独作为用户入口;用户说 short-ship、 + ship-id,目录在 .pouch/ship/{id}/。新建 id 使用 YYYYMMDD-短语,例如 + 20261014-dynamic-editor。不单独作为用户入口;用户说 short-ship、 long-ship、交付记录、ship id 时由对应入口读取本 skill。 --- diff --git a/skills/ship/references/manager.md b/skills/ship/references/manager.md new file mode 100644 index 0000000..99b7981 --- /dev/null +++ b/skills/ship/references/manager.md @@ -0,0 +1,86 @@ +# Ship 主会话:只编排 + +入口 `short-ship` / `long-ship` / `pr-ship` 的**当前会话是管理员**。它跟用户说话、 +维护账本、按 `next_step.py` 开新 agent,然后等待结果。它不把具体步骤的 skill +读进自己的上下文,也不亲自做那些步骤。 + +## 目录 + +- [主会话可以做](#主会话可以做) +- [主会话不能做](#主会话不能做) +- [循环](#循环) +- [怎么开新 agent](#怎么开新-agent) +- [失败](#失败) + +## 主会话可以做 + +- 跟用户确认范围、停止点、批准 PRD、授权生产部署,并写完成报告。 +- 跑 ship 脚本:`new_ship_id.py`、`create_ship.py`、`next_step.py`、`append_evidence.py`(仅当 worker + 已给出指针而漏写时)、`validate_ship.py`。 +- 读写 `.pouch/ship//` 账本:`spec.md`、`tasks.yaml`、`current.yaml` 的 + `status` / `delivery.state` / `spec.*` / `manager.*` / 任务 `status`。 +- 按 `next_step.py` 的 JSON 开一个(或 long 的一轮并行 `implement_task`)新 agent, + 把 [worker-prompt.md](worker-prompt.md) 填好交给它,**等到它返回**再继续。 + +需要看字段含义时读 [record.md](record.md)。不要预读其它 skill。 + +## 主会话不能做 + +- 不要读取或执行:`hunt`、`tester`、`builder`、`deployer`、`releaser`、`check`、 + `specifier`、`delivery-loop`,以及它们的 `references/`。 +- 不要改业务代码、跑测试、开/合 PR、obtain、发版、部署、写 HTML PRD。 +- 不要在开不出新 agent 时改由自己做该步。 +- 不要 fire-and-forget:派出去就回复“已派发”并结束本轮。 +- 不要让 worker 再去调 `short-ship` / `long-ship` / `pr-ship`。 + +`next_step.py` 的 `actor` 为 `manager` 时,这一步就是跟用户说话或改账本,不是去加载 +执行类 skill。 + +## 循环 + +1. 没有 `ship-id`:先用 `new_ship_id.py --phrase <短语>` 生成 + `YYYYMMDD-短语`,再跑 `create_ship.py`,写 `spec.md` 四段(short) + 或进入 long 的 specifier 步。功能任务不要写入 `.pouch/ack/tasks.yaml`。 +2. 跑: + + ```bash + python3 /scripts/next_step.py \ + --project-root --ship-id --entry short|long|pr + ``` + +3. 按 JSON 行动: + - `status: done`:写完成报告,结束。 + - `status: blocked` / `ask_user`:告诉用户缺口或问题,等回复。生产部署(`stopAt: + released` 且下一步是 `deploy`)必须再问一次,除非当前这句话已经授权。 + - `actor: manager`:补账本或问用户,再跑 `next_step.py`。 + - `actor: worker`:开新 agent,等待,把结果写入 `manager.lastStep` / + `manager.lastResult` / `manager.lastSummary`,按 `targetState` / `taskId` 更新账本,立刻 + `validate_ship.py`。失败不要自己补做。 +4. 重复直到 `done`、`blocked` 且用户不再推进,或用户叫停。 + +不要凭记忆跳过 `next_step.py`。long 的无依赖 `implement_task` 可以一轮并行多个, +仍要全部等完再进入下一步。 + +## 怎么开新 agent + +必须是 **fresh** 会话,prompt 只用 [worker-prompt.md](worker-prompt.md) 填好的那一份。 + +- 当前运行时有子 agent / Task / spawn:用它,不要把执行类 skill 先读进本会话。 +- 当前就在 Orca 里:再读已安装的 `orchestration`,做 supervised dispatch(inject 后 + `check --wait` 直到 `worker_done` 或 `escalation`)。不要在本文件复制 orca 命令。 +- 没有开新 agent 的能力:`blocked`,说明缺什么。不要自己执行。 + +把 `next_step.py` 返回的 `stage` 字段填入 worker prompt 的 ``,让 worker 把摘要 +写到对应的 `1-spec`、`2-develop` 等目录。worker 返回后把 summary/evidence 一并写入 +`manager.lastSummary` 和阶段 `activity.md`。 + +一次只派 JSON 里的这一步(long 的并行 implement 除外)。worker 需要用户决定时,它 +返回 `ask`;主会话问用户,再派或改账本。 + +## 失败 + +- worker `fail` / `blocked`:停在现场,报告摘要和证据。主会话不要接手做该步。 + 用户明确说重试时,再开一个**新** agent,不要复用刚失败的会话。 +- 校验失败:先修账本再继续,不要假装阶段已完成。 +- 范围涨到需要 PRD 或多条任务:同一 `ship-id` 把 `kind` 改为 `long`,之后 `--entry + long`。 diff --git a/skills/ship/references/record.md b/skills/ship/references/record.md index f08bad7..69f3f80 100644 --- a/skills/ship/references/record.md +++ b/skills/ship/references/record.md @@ -17,6 +17,15 @@ spec.md # 短需求说明(short 和 long 都有) tasks.yaml # 本 ship 的任务 DAG status.md # 给人看的一页摘要 + 1-spec/ # 规格、评审、根因和 handoff + 2-develop/ # 开发任务和变更 + 3-test/ # 任务验证与集成回归 + 4-review/ # PR、审核与合并 + 5-build/ # 合并后的构建/obtain + 6-publish/ # 发布产物 + 7-deploy/ # 部署与健康检查 + stage.yaml # 阶段状态和包含的步骤 + activity.md # 该阶段的调度、结果和证据摘要 spec/ # 仅 long:PRD 版本、评审、handoff brief.md prd-v001.html @@ -27,6 +36,11 @@ ``` `status.md` 给人看;Agent 以 `current.yaml` 为准。两份不一致时改 `status.md`。 +阶段目录由 `create_ship.py` 初始化,由 `next_step.py` 在派发和收到结果时追加活动记录; +大日志留在原测试、构建或部署位置,阶段目录只保存摘要和路径。 + +新建 ship 使用可排序的 `YYYYMMDD-短语`,例如 `20261014-dynamic-editor`。旧的无日期 +记录仍可读取和校验,不要为了改名移动既有 ship 目录。 ## current.yaml @@ -57,6 +71,10 @@ tasks: delivery: stopAt: review_ready state: change_ready +manager: + lastStep: null + lastResult: null + inFlight: null evidence: testRuns: [] artifacts: [] @@ -75,6 +93,10 @@ evidence: `source.kind`:`user` | `hunt` | `ack` | `pr` | `feishu`。修 ACK 缺陷时 `source.kind: ack` 且 `ref` 为缺陷任务 id。 +`manager` 由入口主会话维护,可选。`lastStep` / `lastResult` / `lastSummary` 记录刚结束的那一步; +`inFlight` 在 worker 未返回时写成步骤名,返回后清掉。worker 不要写这些字段。 +`lastResult`:`pass` | `fail` | `blocked` | `ask`。 + ## short 与 long 同一套字段,厚度不同。 diff --git a/skills/ship/references/worker-prompt.md b/skills/ship/references/worker-prompt.md index 202d264..ac81ee1 100644 --- a/skills/ship/references/worker-prompt.md +++ b/skills/ship/references/worker-prompt.md @@ -7,6 +7,7 @@ delivery-loop 的正文,不要让 worker 再开一层 ship 入口。 你是 ship 的 worker,只做步骤 。 项目根: Ship 目录:.pouch/ship// +当前阶段目录:.pouch/ship/// 任务 id: 目标 delivery.state: 停止点 stopAt: @@ -37,6 +38,9 @@ pass;没有 HOST、设备或浏览器的测试根必须记录 environment-fail - summary: 三句以内,做了什么、证据在哪、还缺什么 - evidence: 写入的指针 - ask: 若 status=ask,要主会话问用户的那一个问题 + +同时把本次工作的命令、关键结果、阻塞和证据路径写入当前阶段目录的 +`activity.md`;不要把完整日志复制进 ship,只写可复核摘要和外部文件路径。 ``` `` 必须用 `next_step.py` JSON 里的 `skill` 字段,不要改成别的。`skill` 为 diff --git a/skills/ship/scripts/create_ship.py b/skills/ship/scripts/create_ship.py index b88fe3b..b8d6187 100644 --- a/skills/ship/scripts/create_ship.py +++ b/skills/ship/scripts/create_ship.py @@ -8,7 +8,7 @@ import shutil import sys from pathlib import Path -from ship_lib import check_ship_id, skill_dir +from ship_lib import check_ship_id, ensure_stage_layout, skill_dir from validate_ship import validate_ship_dir @@ -94,6 +94,8 @@ def create_ship( (spec_dir / "decisions.yaml").write_text("[]\n", encoding="utf-8") (spec_dir / "risks.yaml").write_text("[]\n", encoding="utf-8") + ensure_stage_layout(dest) + issues = validate_ship_dir(dest) if issues: shutil.rmtree(dest) diff --git a/skills/ship/scripts/new_ship_id.py b/skills/ship/scripts/new_ship_id.py new file mode 100644 index 0000000..4ac7094 --- /dev/null +++ b/skills/ship/scripts/new_ship_id.py @@ -0,0 +1,25 @@ +#!/usr/bin/env python3 +"""Print a sortable YYYYMMDD-short-phrase ship id.""" + +from __future__ import annotations + +import argparse +from datetime import date, datetime + +from ship_lib import dated_ship_id + + +def main() -> int: + parser = argparse.ArgumentParser(description="生成可排序的 ship-id") + parser.add_argument("--phrase", required=True, help="小写英文短语,例如 dynamic-editor") + parser.add_argument("--date", default=None, help="可选日期,格式 YYYYMMDD") + args = parser.parse_args() + selected = date.today() + if args.date: + selected = datetime.strptime(args.date, "%Y%m%d").date() + print(dated_ship_id(args.phrase, today=selected)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/skills/ship/scripts/next_step.py b/skills/ship/scripts/next_step.py index 0e69e28..36288aa 100644 --- a/skills/ship/scripts/next_step.py +++ b/skills/ship/scripts/next_step.py @@ -10,7 +10,7 @@ import sys from pathlib import Path from typing import Any -from ship_lib import load_yaml +from ship_lib import ensure_stage_layout, load_yaml, record_stage_event, stage_for_step SPEC_PLACEHOLDERS = ( "<用户可见的目标", @@ -70,6 +70,7 @@ def _out( "stopAt": stop_at, "readyTaskIds": ready_task_ids or [], "summary": summary, + "stage": (stage_for_step(step) or (None, None))[0], } @@ -635,7 +636,25 @@ def main() -> int: json.dump(doc, sys.stdout, ensure_ascii=False, indent=2) sys.stdout.write("\n") return 0 + current = load_yaml(ship_dir / "current.yaml") + if isinstance(current, dict): + manager = current.get("manager") + if isinstance(manager, dict) and manager.get("lastStep") and manager.get("lastResult"): + record_stage_event( + ship_dir, + step=str(manager.get("lastStep")), + status=str(manager.get("lastResult")), + summary=str(manager.get("lastSummary") or ""), + ) doc = decide_next(ship_dir, args.entry) + ensure_stage_layout(ship_dir) + record_stage_event( + ship_dir, + step=str(doc.get("step") or ""), + status=str(doc.get("status") or "ready"), + summary=str(doc.get("summary") or ""), + task_id=doc.get("taskId"), + ) json.dump(doc, sys.stdout, ensure_ascii=False, indent=2) sys.stdout.write("\n") return 0 diff --git a/skills/ship/scripts/ship_lib.py b/skills/ship/scripts/ship_lib.py index 659b460..ebdbc53 100644 --- a/skills/ship/scripts/ship_lib.py +++ b/skills/ship/scripts/ship_lib.py @@ -5,6 +5,7 @@ from __future__ import annotations import re import sys +from datetime import date, datetime, timezone from pathlib import Path from typing import Any @@ -14,7 +15,10 @@ if str(_REPO_ROOT) not in sys.path: 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]+)*$") +SHIP_ID_RE = re.compile( + r"^(?:[a-z][a-z0-9]*(?:-[a-z0-9]+)*|\d{8}-[a-z][a-z0-9]*(?:-[a-z0-9]+)*)$" +) +DATED_SHIP_ID_RE = re.compile(r"^\d{8}-[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( @@ -47,6 +51,24 @@ DELIVERY_STATE = frozenset( SOURCE_KIND = frozenset({"user", "hunt", "ack", "pr", "feishu"}) COMMIT_ROLE = frozenset({"baseline", "merged", "head"}) +# These directories are the human-readable audit trail for the orchestration +# phases. The step list is intentionally kept here so create_ship and +# next_step cannot drift apart. +STAGE_DEFINITIONS = ( + ("1-spec", "规格与审批", ("write_spec", "specifier_new", "specifier_review", "specifier_handoff", "materialize_tasks", "hunt")), + ("2-develop", "开发", ("implement", "implement_task",)), + ("3-test", "验证与回归", ("verify", "verify_task", "regress")), + ("4-review", "PR 与合并", ("open_pr", "check_pr", "fix_pr", "merge_pr")), + ("5-build", "构建", ("obtain",)), + ("6-publish", "发布", ("publish",)), + ("7-deploy", "部署", ("deploy",)), +) +STAGE_BY_STEP = { + step: (directory, title) + for directory, title, steps in STAGE_DEFINITIONS + for step in steps +} + def skill_dir() -> Path: return Path(__file__).resolve().parents[1] @@ -72,3 +94,115 @@ def check_ship_id(ship_id: str) -> str | None: f"shipId 无效: {ship_id!r}(小写字母开头,仅小写字母、数字、连字符)" ) return None + + +def check_dated_ship_id(ship_id: str) -> str | None: + """Validate the convention for newly-created records. + + check_ship_id remains backward compatible so existing ship records can be + inspected. New entry points should use this stricter check. + """ + err = check_ship_id(ship_id) + if err: + return err + if not DATED_SHIP_ID_RE.fullmatch(ship_id): + return ( + f"新 ship-id 必须是 YYYYMMDD-短语格式: {ship_id!r}" + ) + return None + + +def dated_ship_id(phrase: str, *, today: date | None = None) -> str: + """Build a sortable ship id from a short lowercase phrase.""" + phrase = re.sub(r"[^a-z0-9]+", "-", phrase.strip().lower()).strip("-") + if not phrase: + raise ValueError("ship-id 短语不能为空") + value = f"{(today or date.today()):%Y%m%d}-{phrase}" + err = check_dated_ship_id(value) + if err: + raise ValueError(err) + return value + + +def stage_for_step(step: str | None) -> tuple[str, str] | None: + return STAGE_BY_STEP.get(str(step)) if step else None + + +def ensure_stage_layout(ship_dir: Path) -> None: + """Create the visible phase folders and their small status manifests.""" + for order, (directory, title, steps) in enumerate(STAGE_DEFINITIONS, start=1): + stage_dir = ship_dir / directory + stage_dir.mkdir(parents=True, exist_ok=True) + manifest = stage_dir / "stage.yaml" + if not manifest.exists(): + manifest.write_text( + safe_dump({ + "order": order, + "stage": directory, + "title": title, + "status": "pending", + "steps": list(steps), + "startedAt": None, + "finishedAt": None, + }), + encoding="utf-8", + ) + activity = stage_dir / "activity.md" + if not activity.exists(): + activity.write_text( + f"# {directory} {title}\n\n状态:pending\n\n" + "## 活动记录\n\n", + encoding="utf-8", + ) + + +def _stage_manifest(path: Path) -> dict[str, Any]: + try: + value = safe_load(path.read_text(encoding="utf-8")) + except Exception: # noqa: BLE001 + value = None + return value if isinstance(value, dict) else {} + + +def record_stage_event( + ship_dir: Path, + *, + step: str | None, + status: str, + summary: str = "", + task_id: str | None = None, +) -> None: + """Append one dispatch/result event to the visible stage audit trail.""" + resolved = stage_for_step(step) + if resolved is None: + return + ensure_stage_layout(ship_dir) + directory, title = resolved + stage_dir = ship_dir / directory + manifest_path = stage_dir / "stage.yaml" + manifest = _stage_manifest(manifest_path) + now = datetime.now(timezone.utc).isoformat(timespec="seconds") + event_key = f"{step}|{status}|{task_id or ''}|{summary.strip()}" + if manifest.get("lastEventKey") == event_key: + return + manifest["lastEventKey"] = event_key + if status in {"ready", "in_progress"}: + if manifest.get("status") == "pending": + manifest["startedAt"] = now + manifest["status"] = "in_progress" + elif status in {"pass", "fail", "blocked", "ask"}: + manifest["lastResult"] = status + manifest["lastStep"] = step + if status == "pass": + manifest["status"] = "completed" + manifest["finishedAt"] = now + elif status in {"fail", "blocked"}: + manifest["status"] = "blocked" + manifest_path.write_text(safe_dump(manifest), encoding="utf-8") + line = f"- `{now}` `{step}` status=`{status}`" + if task_id: + line += f" task=`{task_id}`" + if summary: + line += f":{summary.strip()}" + with (stage_dir / "activity.md").open("a", encoding="utf-8") as handle: + handle.write(line + "\n") diff --git a/skills/ship/templates/current.template.yaml b/skills/ship/templates/current.template.yaml index 9d8ffe6..782a73f 100644 --- a/skills/ship/templates/current.template.yaml +++ b/skills/ship/templates/current.template.yaml @@ -21,6 +21,11 @@ tasks: delivery: stopAt: null state: change_ready +manager: + lastStep: null + lastResult: null + lastSummary: null + inFlight: null evidence: testRuns: [] artifacts: [] diff --git a/tests/test_ship_stages.py b/tests/test_ship_stages.py new file mode 100644 index 0000000..3d6567d --- /dev/null +++ b/tests/test_ship_stages.py @@ -0,0 +1,69 @@ +from __future__ import annotations + +import json +import subprocess +import tempfile +import unittest +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +CREATE = ROOT / "skills/ship/scripts/create_ship.py" +NEXT = ROOT / "skills/ship/scripts/next_step.py" +NEW_ID = ROOT / "skills/ship/scripts/new_ship_id.py" + + +def run(script: Path, *args: str) -> subprocess.CompletedProcess[str]: + return subprocess.run( + ["python3", str(script), *args], + cwd=ROOT, + text=True, + capture_output=True, + check=False, + ) + + +class ShipStageTests(unittest.TestCase): + def test_new_id_is_sortable(self) -> None: + result = run(NEW_ID, "--phrase", "dynamic-editor", "--date", "20261014") + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(result.stdout.strip(), "20261014-dynamic-editor") + + def test_create_initializes_visible_stage_directories(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + result = run( + CREATE, + "--project-root", str(root), + "--ship-id", "20261014-dynamic-editor", + "--kind", "short", + "--title", "动态编辑器", + ) + self.assertEqual(result.returncode, 0, result.stderr) + ship = root / ".pouch/ship/20261014-dynamic-editor" + for name in ("1-spec", "2-develop", "3-test", "4-review", "5-build", "6-publish", "7-deploy"): + self.assertTrue((ship / name / "stage.yaml").is_file()) + self.assertTrue((ship / name / "activity.md").is_file()) + + def test_next_step_records_stage_dispatch(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + ship_id = "20261014-dynamic-editor" + created = run( + CREATE, + "--project-root", str(root), + "--ship-id", ship_id, + "--kind", "short", + "--title", "动态编辑器", + "--stop-at", "verified", + ) + self.assertEqual(created.returncode, 0, created.stderr) + 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["stage"], "1-spec") + activity = (root / ".pouch/ship" / ship_id / "1-spec/activity.md").read_text() + self.assertIn("write_spec", activity) + + +if __name__ == "__main__": + unittest.main()