feat(ship): add stage audit trail and sortable ids

This commit is contained in:
2026-10-14 10:57:16 +08:00
parent 4814bc1026
commit 57879cffdd
10 changed files with 371 additions and 4 deletions
+2 -1
View File
@@ -2,7 +2,8 @@
name: ship name: ship
description: >- description: >-
short-ship、long-ship、pr-ship、specifier 共用的交付记录格式。一次交付一个 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。 long-ship、交付记录、ship id 时由对应入口读取本 skill。
--- ---
+86
View File
@@ -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/<id>/` 账本:`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 <ship-skill>/scripts/next_step.py \
--project-root <root> --ship-id <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 的 `<stage>`,让 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`。
+22
View File
@@ -17,6 +17,15 @@
spec.md # 短需求说明(short 和 long 都有) spec.md # 短需求说明(short 和 long 都有)
tasks.yaml # 本 ship 的任务 DAG tasks.yaml # 本 ship 的任务 DAG
status.md # 给人看的一页摘要 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 spec/ # 仅 long:PRD 版本、评审、handoff
brief.md brief.md
prd-v001.html prd-v001.html
@@ -27,6 +36,11 @@
``` ```
`status.md` 给人看;Agent 以 `current.yaml` 为准。两份不一致时改 `status.md`。 `status.md` 给人看;Agent 以 `current.yaml` 为准。两份不一致时改 `status.md`。
阶段目录由 `create_ship.py` 初始化,由 `next_step.py` 在派发和收到结果时追加活动记录;
大日志留在原测试、构建或部署位置,阶段目录只保存摘要和路径。
新建 ship 使用可排序的 `YYYYMMDD-短语`,例如 `20261014-dynamic-editor`。旧的无日期
记录仍可读取和校验,不要为了改名移动既有 ship 目录。
## current.yaml ## current.yaml
@@ -57,6 +71,10 @@ tasks:
delivery: delivery:
stopAt: review_ready stopAt: review_ready
state: change_ready state: change_ready
manager:
lastStep: null
lastResult: null
inFlight: null
evidence: evidence:
testRuns: [] testRuns: []
artifacts: [] artifacts: []
@@ -75,6 +93,10 @@ evidence:
`source.kind`:`user` | `hunt` | `ack` | `pr` | `feishu`。修 ACK 缺陷时 `source.kind`:`user` | `hunt` | `ack` | `pr` | `feishu`。修 ACK 缺陷时
`source.kind: ack` 且 `ref` 为缺陷任务 id。 `source.kind: ack` 且 `ref` 为缺陷任务 id。
`manager` 由入口主会话维护,可选。`lastStep` / `lastResult` / `lastSummary` 记录刚结束的那一步;
`inFlight` 在 worker 未返回时写成步骤名,返回后清掉。worker 不要写这些字段。
`lastResult`:`pass` | `fail` | `blocked` | `ask`。
## short 与 long ## short 与 long
同一套字段,厚度不同。 同一套字段,厚度不同。
+4
View File
@@ -7,6 +7,7 @@ delivery-loop 的正文,不要让 worker 再开一层 ship 入口。
你是 ship <ship-id> 的 worker,只做步骤 <step>。 你是 ship <ship-id> 的 worker,只做步骤 <step>。
项目根:<project-root> 项目根:<project-root>
Ship 目录:.pouch/ship/<ship-id>/ Ship 目录:.pouch/ship/<ship-id>/
当前阶段目录:.pouch/ship/<ship-id>/<stage>/
任务 id:<taskId 或「无」> 任务 id:<taskId 或「无」>
目标 delivery.state:<targetState 或「不改」> 目标 delivery.state:<targetState 或「不改」>
停止点 stopAt:<stopAt> 停止点 stopAt:<stopAt>
@@ -37,6 +38,9 @@ pass;没有 HOST、设备或浏览器的测试根必须记录 environment-fail
- summary: 三句以内,做了什么、证据在哪、还缺什么 - summary: 三句以内,做了什么、证据在哪、还缺什么
- evidence: 写入的指针 - evidence: 写入的指针
- ask: 若 status=ask,要主会话问用户的那一个问题 - ask: 若 status=ask,要主会话问用户的那一个问题
同时把本次工作的命令、关键结果、阻塞和证据路径写入当前阶段目录的
`activity.md`;不要把完整日志复制进 ship,只写可复核摘要和外部文件路径。
``` ```
`<skill>` 必须用 `next_step.py` JSON 里的 `skill` 字段,不要改成别的。`skill` 为 `<skill>` 必须用 `next_step.py` JSON 里的 `skill` 字段,不要改成别的。`skill` 为
+3 -1
View File
@@ -8,7 +8,7 @@ import shutil
import sys import sys
from pathlib import Path 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 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 / "decisions.yaml").write_text("[]\n", encoding="utf-8")
(spec_dir / "risks.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) issues = validate_ship_dir(dest)
if issues: if issues:
shutil.rmtree(dest) shutil.rmtree(dest)
+25
View File
@@ -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())
+20 -1
View File
@@ -10,7 +10,7 @@ import sys
from pathlib import Path from pathlib import Path
from typing import Any 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 = ( SPEC_PLACEHOLDERS = (
"<用户可见的目标", "<用户可见的目标",
@@ -70,6 +70,7 @@ def _out(
"stopAt": stop_at, "stopAt": stop_at,
"readyTaskIds": ready_task_ids or [], "readyTaskIds": ready_task_ids or [],
"summary": summary, "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) json.dump(doc, sys.stdout, ensure_ascii=False, indent=2)
sys.stdout.write("\n") sys.stdout.write("\n")
return 0 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) 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) json.dump(doc, sys.stdout, ensure_ascii=False, indent=2)
sys.stdout.write("\n") sys.stdout.write("\n")
return 0 return 0
+135 -1
View File
@@ -5,6 +5,7 @@ from __future__ import annotations
import re import re
import sys import sys
from datetime import date, datetime, timezone
from pathlib import Path from pathlib import Path
from typing import Any 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 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}$") DATE_ID_RE = re.compile(r"^\d{4}-\d{2}-\d{2}$|^\d{8}$")
KIND = frozenset({"short", "long"}) KIND = frozenset({"short", "long"})
SHIP_STATUS = frozenset( SHIP_STATUS = frozenset(
@@ -47,6 +51,24 @@ DELIVERY_STATE = frozenset(
SOURCE_KIND = frozenset({"user", "hunt", "ack", "pr", "feishu"}) SOURCE_KIND = frozenset({"user", "hunt", "ack", "pr", "feishu"})
COMMIT_ROLE = frozenset({"baseline", "merged", "head"}) 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: def skill_dir() -> Path:
return Path(__file__).resolve().parents[1] return Path(__file__).resolve().parents[1]
@@ -72,3 +94,115 @@ def check_ship_id(ship_id: str) -> str | None:
f"shipId 无效: {ship_id!r}(小写字母开头,仅小写字母、数字、连字符)" f"shipId 无效: {ship_id!r}(小写字母开头,仅小写字母、数字、连字符)"
) )
return None 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")
@@ -21,6 +21,11 @@ tasks:
delivery: delivery:
stopAt: null stopAt: null
state: change_ready state: change_ready
manager:
lastStep: null
lastResult: null
lastSummary: null
inFlight: null
evidence: evidence:
testRuns: [] testRuns: []
artifacts: [] artifacts: []
+69
View File
@@ -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()