feat: rename bugfix-ship/requirement-flow to short-ship/long-ship with shared ship records
short-ship and long-ship share one ship-id record under .pouch/ship/<id>/. specifier writes PRDs into spec/; delivery-loop appends evidence pointers. Old names stay as deprecation stubs so existing installs keep working.
This commit is contained in:
@@ -0,0 +1,24 @@
|
||||
# ship
|
||||
|
||||
一次交付的记录格式:需求说明、任务、测试运行、产物和部署结果都挂在同一个 ship id 下。
|
||||
|
||||
通常由 `$short-ship`、`$long-ship` 或 `$pr-ship` 内部使用,不需要单独调用。
|
||||
|
||||
## 什么时候使用
|
||||
|
||||
```text
|
||||
把这次改动记到 ship add-export-button,后续测试和部署都挂到这个 id。
|
||||
```
|
||||
|
||||
## 使用前准备
|
||||
|
||||
- 项目是 Git 仓库。
|
||||
- 入口 skill 已经决定这是 short 还是 long,或正在打开已有 ship。
|
||||
|
||||
## Agent 会做什么
|
||||
|
||||
在 `.pouch/ship/<ship-id>/` 建立或更新记录,把测试、产物和部署写成指针,而不是另写一套缺陷任务。
|
||||
|
||||
## 如何判断完成
|
||||
|
||||
目录能通过 ship 自带校验;用同一个 ship id 能找到 spec、任务和证据指针。
|
||||
@@ -0,0 +1,39 @@
|
||||
---
|
||||
name: ship
|
||||
description: >-
|
||||
short-ship、long-ship、pr-ship、specifier 共用的交付记录格式。一次交付一个
|
||||
ship-id,目录在 .pouch/ship/<id>/。不单独作为用户入口;用户说 short-ship、
|
||||
long-ship、交付记录、ship id 时由对应入口读取本 skill。
|
||||
---
|
||||
|
||||
# Ship:一次交付的记录
|
||||
|
||||
这是内部记录契约,不替用户选择 short 或 long,也不执行构建或部署。
|
||||
|
||||
## 使用方式
|
||||
|
||||
入口 skill 在拿到或创建 `ship-id` 之后读取 [record.md](references/record.md)。
|
||||
创建目录用 `scripts/create_ship.py`,改证据指针用 `scripts/append_evidence.py`,
|
||||
写回后跑 `scripts/validate_ship.py`。
|
||||
|
||||
## 不可违反的规则
|
||||
|
||||
- 进入 short-ship / long-ship / pr-ship 的第一件事是拿到或创建 `ship-id`。没有 id
|
||||
不得改业务代码、不得跑 tester / obtain / deploy。
|
||||
- 记录只写 `.pouch/ship/<ship-id>/`。功能、小改动和本次修复的任务不要写入
|
||||
`.pouch/ack/tasks.yaml`。ACK 任务板只收缺陷;缺陷要交付时在 ship 的 `source` 里引用。
|
||||
- `ship-id` 用稳定短 slug:小写字母开头,小写字母、数字、连字符,最长 64。不要用日期、
|
||||
随机数或当前分支名冒充业务 id。
|
||||
- 短长同一套字段。`kind: short` 恰好 1 条 task;`kind: long` 是 DAG,子任务默认不是
|
||||
新的 ship。
|
||||
- tester / builder / deployer 的 SSOT 仍在各自目录。ship 只追加指针。本轮跑过的
|
||||
测试、obtain、部署若没写入 `current.yaml` 的 `evidence`,该阶段不算完成。
|
||||
- 项目构建/部署契约仍在 `.pouch/ack/delivery.yaml` 和 builder/deployer 配置里,不要
|
||||
复制进每个 ship。
|
||||
- 旧的 `.pouch/specifier/features/<id>/` 在继续该需求时迁到
|
||||
`.pouch/ship/<id>/spec/`,规则见 record.md。
|
||||
|
||||
## 结果
|
||||
|
||||
有效 ship 目录含 `current.yaml`、`spec.md`、`tasks.yaml`、`status.md`;long 另有
|
||||
`spec/` 下的 PRD 与 handoff。校验通过后入口 skill 才能继续交付。
|
||||
@@ -0,0 +1,6 @@
|
||||
interface:
|
||||
display_name: "Ship Record"
|
||||
short_description: "Shared delivery record keyed by ship id"
|
||||
default_prompt: "Use $ship only as the record format for an in-progress short-ship, long-ship, or pr-ship."
|
||||
policy:
|
||||
allow_implicit_invocation: false
|
||||
@@ -0,0 +1,171 @@
|
||||
# Ship 记录契约
|
||||
|
||||
## 目录
|
||||
|
||||
- [布局](#布局)
|
||||
- [current.yaml](#currentyaml)
|
||||
- [short 与 long](#short-与-long)
|
||||
- [任务](#任务)
|
||||
- [证据指针](#证据指针)
|
||||
- [旧 specifier 目录](#旧-specifier-目录)
|
||||
|
||||
## 布局
|
||||
|
||||
```text
|
||||
.pouch/ship/<ship-id>/
|
||||
current.yaml # 总账:kind、状态、stopAt、指针
|
||||
spec.md # 短需求说明(short 和 long 都有)
|
||||
tasks.yaml # 本 ship 的任务 DAG
|
||||
status.md # 给人看的一页摘要
|
||||
spec/ # 仅 long:PRD 版本、评审、handoff
|
||||
brief.md
|
||||
prd-v001.html
|
||||
review.yaml
|
||||
decisions.yaml
|
||||
risks.yaml
|
||||
handoff.yaml # 仅 approved 后
|
||||
```
|
||||
|
||||
`status.md` 给人看;Agent 以 `current.yaml` 为准。两份不一致时改 `status.md`。
|
||||
|
||||
## current.yaml
|
||||
|
||||
必填:`shipId`、`kind`、`status`、`title`、`spec`、`tasks`、`evidence`。
|
||||
`shipId` 必须等于目录名。
|
||||
|
||||
```yaml
|
||||
shipId: add-export-button
|
||||
kind: short
|
||||
title: 列表页增加导出按钮
|
||||
status: in_progress
|
||||
stopAt: review_ready
|
||||
source:
|
||||
kind: user
|
||||
ref: null
|
||||
parentShipId: null
|
||||
childShipIds: []
|
||||
spec:
|
||||
path: spec.md
|
||||
revision: null
|
||||
specStatus: null
|
||||
prd: null
|
||||
approvedRevision: null
|
||||
reviews: null
|
||||
handoff: null
|
||||
tasks:
|
||||
path: tasks.yaml
|
||||
delivery:
|
||||
stopAt: review_ready
|
||||
state: change_ready
|
||||
evidence:
|
||||
testRuns: []
|
||||
artifacts: []
|
||||
deploys: []
|
||||
prs: []
|
||||
commits: []
|
||||
```
|
||||
|
||||
`kind`:`short` | `long`。
|
||||
|
||||
`status`:`draft`、`spec_review`、`approved`、`in_progress`、`blocked`、`done`、`parked`、`superseded`。
|
||||
|
||||
`delivery.state` 使用 delivery-loop 内部里程碑:`change_ready`、`verified`、
|
||||
`pr_opened`、`merged`、`regression_passed`、`artifact_built`、`published`、`deployed`。
|
||||
|
||||
`source.kind`:`user` | `hunt` | `ack` | `pr` | `feishu`。修 ACK 缺陷时
|
||||
`source.kind: ack` 且 `ref` 为缺陷任务 id。
|
||||
|
||||
## short 与 long
|
||||
|
||||
同一套字段,厚度不同。
|
||||
|
||||
| 字段 | short | long |
|
||||
| --- | --- | --- |
|
||||
| `spec.md` | 四段:要做什么、不做什么、可观察验收、怎么验。有故障证据时加复现和根因 | 同一四段作摘要 |
|
||||
| `spec/` | 不要创建 | specifier 写入 PRD、评审、决策、风险、handoff |
|
||||
| `spec.revision` 等 | 保持 `null` | 与 PRD 版本同步 |
|
||||
| `tasks.yaml` | 恰好 1 条 | DAG;`dependsOn` 指向本文件内 id |
|
||||
| 子项 | 无 | 默认是 task。只有该切片有独立 `stopAt` 并单独发布时才晋升为子 ship,写入 `childShipIds` |
|
||||
|
||||
范围变大时保持同一 `shipId`,把 `kind` 改成 `long`,补 `spec/` 并拆任务。
|
||||
|
||||
`spec.md` 不要写成 HTML PRD。long 的评审稿是 `spec/prd-vNNN.html`。
|
||||
|
||||
## 任务
|
||||
|
||||
`tasks.yaml` 只描述本 ship 的工作项,不是 ACK 缺陷板。
|
||||
|
||||
```yaml
|
||||
shipId: add-export-button
|
||||
tasks:
|
||||
- id: T-01
|
||||
title: 在列表页增加导出按钮
|
||||
dependsOn: []
|
||||
expected:
|
||||
- 登录用户在列表页看到「导出」
|
||||
verification:
|
||||
- 浏览器打开列表页,按钮可见且可点
|
||||
status: open
|
||||
stopAt: review_ready
|
||||
```
|
||||
|
||||
允许的任务 `status`:`open`、`in_progress`、`blocked`、`verified`、`merged`、`done`。
|
||||
|
||||
long 的子任务默认 `stopAt` 为 `pr_opened` 或 `merged`,不要单独打生产 tag 或升级生产。
|
||||
父 ship 在依赖任务完成后做集成回归,再按父记录的 `delivery.stopAt` 继续。
|
||||
|
||||
从 `spec/handoff.yaml` 建任务时复制 `id`、`title`、`dependsOn`、`expected`、
|
||||
`verification`,不要写进 `.pouch/ack/tasks.yaml`。
|
||||
|
||||
## 证据指针
|
||||
|
||||
不要把 tester 快照、镜像层或 compose 日志复制进 ship 目录。追加指针:
|
||||
|
||||
```yaml
|
||||
evidence:
|
||||
testRuns:
|
||||
- path: .pouch/tester/runs/20261013T031000Z-smoke-ab12.yaml
|
||||
result: pass
|
||||
suite: smoke
|
||||
artifacts:
|
||||
- sha: 0123456789abcdef0123456789abcdef01234567
|
||||
digest: sha256:abc
|
||||
ref: harbor.example.com/app:1.2.3
|
||||
venue: ci
|
||||
deploys:
|
||||
- env: test
|
||||
revision: 0123456789abcdef0123456789abcdef01234567
|
||||
health: pass
|
||||
prs:
|
||||
- url: https://git.example.com/app/-/merge_requests/12
|
||||
status: opened
|
||||
commits:
|
||||
- sha: 0123456789abcdef0123456789abcdef01234567
|
||||
role: baseline
|
||||
```
|
||||
|
||||
delivery-loop 阶段与指针:
|
||||
|
||||
| 阶段 | 至少追加 |
|
||||
| --- | --- |
|
||||
| `change_ready` | `commits` `role: baseline` |
|
||||
| `verified` | `testRuns` |
|
||||
| `pr_opened` | `prs` |
|
||||
| `merged` | `commits` `role: merged` |
|
||||
| `regression_passed` | `testRuns`(回归) |
|
||||
| `artifact_built` / `published` | `artifacts` |
|
||||
| `deployed` | `deploys` |
|
||||
|
||||
`path` 相对项目根。文件不存在则校验失败。用 `scripts/append_evidence.py` 追加后立刻
|
||||
`validate_ship.py`。
|
||||
|
||||
## 旧 specifier 目录
|
||||
|
||||
打开某个 id 时按这个顺序:
|
||||
|
||||
1. `.pouch/ship/<id>/` 已存在:用它。若同时还留着
|
||||
`.pouch/specifier/features/<id>/`,报告旧路径为遗留,不要两处同时写。
|
||||
2. 只有 `.pouch/specifier/features/<id>/`:把它移到 `.pouch/ship/<id>/spec/`,按旧
|
||||
`current.yaml` 生成 ship `current.yaml`(`kind: long`),用 `brief.md` 补
|
||||
`spec.md` 摘要,再校验。
|
||||
3. 两处都没有:由入口 skill 调用 `create_ship.py` 新建。
|
||||
@@ -0,0 +1,106 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Append one evidence pointer to current.yaml and re-validate."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from ship_lib import dump_yaml, load_yaml
|
||||
from validate_ship import validate_ship_dir
|
||||
|
||||
KINDS = ("testRun", "artifact", "deploy", "pr", "commit")
|
||||
KEY = {
|
||||
"testRun": "testRuns",
|
||||
"artifact": "artifacts",
|
||||
"deploy": "deploys",
|
||||
"pr": "prs",
|
||||
"commit": "commits",
|
||||
}
|
||||
|
||||
|
||||
def _entry(kind: str, args: argparse.Namespace) -> dict[str, Any]:
|
||||
if kind == "testRun":
|
||||
if not args.path or not args.result:
|
||||
raise SystemExit("testRun 需要 --path 和 --result")
|
||||
row: dict[str, Any] = {"path": args.path, "result": args.result}
|
||||
if args.suite:
|
||||
row["suite"] = args.suite
|
||||
return row
|
||||
if kind == "artifact":
|
||||
if not args.sha:
|
||||
raise SystemExit("artifact 需要 --sha")
|
||||
row = {"sha": args.sha}
|
||||
if args.digest:
|
||||
row["digest"] = args.digest
|
||||
if args.ref:
|
||||
row["ref"] = args.ref
|
||||
if args.venue:
|
||||
row["venue"] = args.venue
|
||||
return row
|
||||
if kind == "deploy":
|
||||
if not args.env:
|
||||
raise SystemExit("deploy 需要 --env")
|
||||
row = {"env": args.env}
|
||||
if args.sha:
|
||||
row["revision"] = args.sha
|
||||
if args.health:
|
||||
row["health"] = args.health
|
||||
return row
|
||||
if kind == "pr":
|
||||
if not args.url:
|
||||
raise SystemExit("pr 需要 --url")
|
||||
row = {"url": args.url, "status": args.pr_status or "opened"}
|
||||
return row
|
||||
if kind == "commit":
|
||||
if not args.sha or not args.role:
|
||||
raise SystemExit("commit 需要 --sha 和 --role")
|
||||
return {"sha": args.sha, "role": args.role}
|
||||
raise SystemExit(f"未知 kind: {kind}")
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description="向 ship current.yaml 追加证据指针")
|
||||
parser.add_argument("ship_dir")
|
||||
parser.add_argument("--kind", required=True, choices=KINDS)
|
||||
parser.add_argument("--path")
|
||||
parser.add_argument("--result")
|
||||
parser.add_argument("--suite")
|
||||
parser.add_argument("--sha")
|
||||
parser.add_argument("--digest")
|
||||
parser.add_argument("--ref")
|
||||
parser.add_argument("--venue")
|
||||
parser.add_argument("--env")
|
||||
parser.add_argument("--health")
|
||||
parser.add_argument("--url")
|
||||
parser.add_argument("--pr-status")
|
||||
parser.add_argument("--role")
|
||||
args = parser.parse_args()
|
||||
|
||||
ship_dir = Path(args.ship_dir).resolve()
|
||||
current_path = ship_dir / "current.yaml"
|
||||
data = load_yaml(current_path)
|
||||
if not isinstance(data, dict):
|
||||
raise SystemExit("current.yaml 不是 mapping")
|
||||
evidence = data.setdefault("evidence", {})
|
||||
if not isinstance(evidence, dict):
|
||||
raise SystemExit("evidence 不是 mapping")
|
||||
key = KEY[args.kind]
|
||||
bucket = evidence.setdefault(key, [])
|
||||
if not isinstance(bucket, list):
|
||||
raise SystemExit(f"evidence.{key} 不是列表")
|
||||
bucket.append(_entry(args.kind, args))
|
||||
dump_yaml(current_path, data)
|
||||
issues = validate_ship_dir(ship_dir)
|
||||
if issues:
|
||||
for item in issues:
|
||||
print(f"✗ {item}", file=sys.stderr)
|
||||
return 1
|
||||
print(f"✓ 已追加 evidence.{key}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,130 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Create a .pouch/ship/<id>/ record from templates. Refuse to overwrite."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import shutil
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from ship_lib import check_ship_id, skill_dir
|
||||
from validate_ship import validate_ship_dir
|
||||
|
||||
|
||||
def _replace(text: str, mapping: dict[str, str]) -> str:
|
||||
for key, value in mapping.items():
|
||||
text = text.replace(f"<{key}>", value)
|
||||
text = text.replace(f"{{{{ {key} }}}}", value)
|
||||
return text
|
||||
|
||||
|
||||
def create_ship(
|
||||
project_root: Path,
|
||||
ship_id: str,
|
||||
kind: str,
|
||||
title: str,
|
||||
*,
|
||||
stop_at: str | None = None,
|
||||
source_kind: str = "user",
|
||||
source_ref: str | None = None,
|
||||
) -> Path:
|
||||
err = check_ship_id(ship_id)
|
||||
if err:
|
||||
raise SystemExit(err)
|
||||
if kind not in {"short", "long"}:
|
||||
raise SystemExit(f"kind 无效: {kind}")
|
||||
title = title.strip()
|
||||
if not title:
|
||||
raise SystemExit("title 不能为空")
|
||||
|
||||
dest = project_root / ".pouch" / "ship" / ship_id
|
||||
if dest.exists():
|
||||
raise SystemExit(f"拒绝覆盖已有路径: {dest}")
|
||||
|
||||
templates = skill_dir() / "templates"
|
||||
dest.mkdir(parents=True)
|
||||
mapping = {
|
||||
"ship-id": ship_id,
|
||||
"title": title,
|
||||
"kind": kind,
|
||||
"short title": title,
|
||||
}
|
||||
|
||||
current = _replace(
|
||||
(templates / "current.template.yaml").read_text(encoding="utf-8"),
|
||||
mapping,
|
||||
)
|
||||
current = current.replace("kind: short", f"kind: {kind}", 1)
|
||||
current = current.replace("status: draft", "status: draft", 1)
|
||||
if stop_at:
|
||||
current = current.replace("stopAt: null", f"stopAt: {stop_at}")
|
||||
if source_kind != "user":
|
||||
current = current.replace("kind: user", f"kind: {source_kind}", 1)
|
||||
if source_ref:
|
||||
current = current.replace("ref: null", f"ref: {source_ref}", 1)
|
||||
if kind == "long":
|
||||
current = current.replace("status: draft", "status: spec_review", 1)
|
||||
|
||||
(dest / "current.yaml").write_text(current, encoding="utf-8")
|
||||
(dest / "spec.md").write_text(
|
||||
_replace((templates / "spec.template.md").read_text(encoding="utf-8"), mapping),
|
||||
encoding="utf-8",
|
||||
)
|
||||
(dest / "tasks.yaml").write_text(
|
||||
_replace(
|
||||
(templates / "tasks.template.yaml").read_text(encoding="utf-8"), mapping
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
(dest / "status.md").write_text(
|
||||
_replace(
|
||||
(templates / "status.template.md").read_text(encoding="utf-8"),
|
||||
{**mapping, "status": "spec_review" if kind == "long" else "draft",
|
||||
"stopAt": stop_at or "null", "delivery.state": "change_ready"},
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
if kind == "long":
|
||||
spec_dir = dest / "spec"
|
||||
spec_dir.mkdir()
|
||||
(spec_dir / "brief.md").write_text(f"# {title}\n\n", encoding="utf-8")
|
||||
(spec_dir / "review.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")
|
||||
|
||||
issues = validate_ship_dir(dest)
|
||||
if issues:
|
||||
shutil.rmtree(dest)
|
||||
for item in issues:
|
||||
print(f"✗ {item}", file=sys.stderr)
|
||||
raise SystemExit("新建后校验失败,已删除不完整目录")
|
||||
return dest
|
||||
|
||||
|
||||
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("--kind", choices=("short", "long"), required=True)
|
||||
parser.add_argument("--title", required=True)
|
||||
parser.add_argument("--stop-at", default=None)
|
||||
parser.add_argument("--source-kind", default="user")
|
||||
parser.add_argument("--source-ref", default=None)
|
||||
args = parser.parse_args()
|
||||
dest = create_ship(
|
||||
Path(args.project_root).resolve(),
|
||||
args.ship_id,
|
||||
args.kind,
|
||||
args.title,
|
||||
stop_at=args.stop_at,
|
||||
source_kind=args.source_kind,
|
||||
source_ref=args.source_ref,
|
||||
)
|
||||
print(dest)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,74 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Shared ship-id and YAML helpers. Zero third-party deps."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
_REPO_ROOT = Path(__file__).resolve().parents[3]
|
||||
if str(_REPO_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(_REPO_ROOT))
|
||||
|
||||
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]+)*$")
|
||||
DATE_ID_RE = re.compile(r"^\d{4}-\d{2}-\d{2}$|^\d{8}$")
|
||||
KIND = frozenset({"short", "long"})
|
||||
SHIP_STATUS = frozenset(
|
||||
{
|
||||
"draft",
|
||||
"spec_review",
|
||||
"approved",
|
||||
"in_progress",
|
||||
"blocked",
|
||||
"done",
|
||||
"parked",
|
||||
"superseded",
|
||||
}
|
||||
)
|
||||
TASK_STATUS = frozenset(
|
||||
{"open", "in_progress", "blocked", "verified", "merged", "done"}
|
||||
)
|
||||
DELIVERY_STATE = frozenset(
|
||||
{
|
||||
"change_ready",
|
||||
"verified",
|
||||
"pr_opened",
|
||||
"merged",
|
||||
"regression_passed",
|
||||
"artifact_built",
|
||||
"published",
|
||||
"deployed",
|
||||
}
|
||||
)
|
||||
SOURCE_KIND = frozenset({"user", "hunt", "ack", "pr", "feishu"})
|
||||
COMMIT_ROLE = frozenset({"baseline", "merged", "head"})
|
||||
|
||||
|
||||
def skill_dir() -> Path:
|
||||
return Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
def load_yaml(path: Path) -> Any:
|
||||
return safe_load(path.read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
def dump_yaml(path: Path, data: Any) -> None:
|
||||
path.write_text(safe_dump(data), encoding="utf-8")
|
||||
|
||||
|
||||
def check_ship_id(ship_id: str) -> str | None:
|
||||
if not ship_id:
|
||||
return "shipId 为空"
|
||||
if len(ship_id) > 64:
|
||||
return f"shipId 超过 64 字符: {ship_id}"
|
||||
if DATE_ID_RE.fullmatch(ship_id):
|
||||
return f"shipId 不能是日期: {ship_id}"
|
||||
if not SHIP_ID_RE.fullmatch(ship_id):
|
||||
return (
|
||||
f"shipId 无效: {ship_id!r}(小写字母开头,仅小写字母、数字、连字符)"
|
||||
)
|
||||
return None
|
||||
@@ -0,0 +1,279 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Validate a .pouch/ship/<id>/ record. Exit 0 on success, 1 on issues."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from ship_lib import (
|
||||
COMMIT_ROLE,
|
||||
DELIVERY_STATE,
|
||||
KIND,
|
||||
SHIP_STATUS,
|
||||
SOURCE_KIND,
|
||||
TASK_STATUS,
|
||||
check_ship_id,
|
||||
load_yaml,
|
||||
)
|
||||
|
||||
|
||||
def _issue(issues: list[str], msg: str) -> None:
|
||||
issues.append(msg)
|
||||
|
||||
|
||||
def _mapping(value: Any, path: str, issues: list[str]) -> dict[str, Any] | None:
|
||||
if value is None:
|
||||
_issue(issues, f"{path} 缺失")
|
||||
return None
|
||||
if not isinstance(value, dict):
|
||||
_issue(issues, f"{path} 必须是 mapping")
|
||||
return None
|
||||
return value
|
||||
|
||||
|
||||
def _project_root(ship_dir: Path) -> Path:
|
||||
# .pouch/ship/<id>
|
||||
return ship_dir.parent.parent.parent
|
||||
|
||||
|
||||
def validate_ship_dir(ship_dir: Path) -> list[str]:
|
||||
issues: list[str] = []
|
||||
ship_dir = ship_dir.resolve()
|
||||
current_path = ship_dir / "current.yaml"
|
||||
if not current_path.is_file():
|
||||
return [f"缺少 {current_path}"]
|
||||
|
||||
try:
|
||||
current = load_yaml(current_path)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
return [f"无法解析 current.yaml: {exc}"]
|
||||
|
||||
data = _mapping(current, "current.yaml", issues)
|
||||
if data is None:
|
||||
return issues
|
||||
|
||||
ship_id = data.get("shipId")
|
||||
if not isinstance(ship_id, str):
|
||||
_issue(issues, "current.yaml.shipId 必须是字符串")
|
||||
else:
|
||||
err = check_ship_id(ship_id)
|
||||
if err:
|
||||
_issue(issues, err)
|
||||
if ship_id != ship_dir.name:
|
||||
_issue(
|
||||
issues,
|
||||
f"目录名与 shipId 不一致: {ship_dir.name} != {ship_id}",
|
||||
)
|
||||
|
||||
kind = data.get("kind")
|
||||
if kind not in KIND:
|
||||
_issue(issues, f"kind 无效: {kind!r}(short|long)")
|
||||
status = data.get("status")
|
||||
if status not in SHIP_STATUS:
|
||||
_issue(issues, f"status 无效: {status!r}")
|
||||
if not isinstance(data.get("title"), str) or not str(data.get("title")).strip():
|
||||
_issue(issues, "title 不能为空")
|
||||
|
||||
source = data.get("source")
|
||||
if source is not None:
|
||||
src = _mapping(source, "source", issues)
|
||||
if src is not None and src.get("kind") not in SOURCE_KIND:
|
||||
_issue(issues, f"source.kind 无效: {src.get('kind')!r}")
|
||||
|
||||
spec = _mapping(data.get("spec"), "spec", issues)
|
||||
spec_path = ship_dir / "spec.md"
|
||||
if not spec_path.is_file():
|
||||
_issue(issues, "缺少 spec.md")
|
||||
if spec is not None:
|
||||
rel = spec.get("path") or "spec.md"
|
||||
if rel != "spec.md":
|
||||
_issue(issues, f"spec.path 必须是 spec.md,实际: {rel}")
|
||||
if kind == "long":
|
||||
spec_dir = ship_dir / "spec"
|
||||
if not spec_dir.is_dir():
|
||||
_issue(issues, "kind=long 缺少 spec/ 目录")
|
||||
revision = spec.get("revision")
|
||||
prd = spec.get("prd")
|
||||
if revision and not prd:
|
||||
_issue(issues, "long spec.revision 已设但 spec.prd 为空")
|
||||
if prd:
|
||||
prd_file = ship_dir / str(prd)
|
||||
if not prd_file.is_file():
|
||||
_issue(issues, f"缺少 PRD 文件: {prd}")
|
||||
handoff = spec.get("handoff")
|
||||
spec_status = spec.get("specStatus")
|
||||
if spec_status == "approved" and not handoff:
|
||||
_issue(issues, "specStatus=approved 但缺少 spec.handoff")
|
||||
if handoff:
|
||||
handoff_file = ship_dir / str(handoff)
|
||||
if not handoff_file.is_file():
|
||||
_issue(issues, f"缺少 handoff 文件: {handoff}")
|
||||
elif kind == "short":
|
||||
for field in (
|
||||
"revision",
|
||||
"specStatus",
|
||||
"prd",
|
||||
"approvedRevision",
|
||||
"reviews",
|
||||
"handoff",
|
||||
):
|
||||
if spec.get(field) not in (None, "", []):
|
||||
_issue(issues, f"kind=short 时 spec.{field} 必须为空")
|
||||
|
||||
tasks_meta = _mapping(data.get("tasks"), "tasks", issues)
|
||||
tasks_rel = "tasks.yaml"
|
||||
if tasks_meta is not None:
|
||||
tasks_rel = str(tasks_meta.get("path") or "tasks.yaml")
|
||||
tasks_path = ship_dir / tasks_rel
|
||||
if not tasks_path.is_file():
|
||||
_issue(issues, f"缺少 {tasks_rel}")
|
||||
else:
|
||||
issues.extend(_validate_tasks(tasks_path, ship_id, kind))
|
||||
|
||||
delivery = data.get("delivery")
|
||||
if delivery is not None:
|
||||
deliv = _mapping(delivery, "delivery", issues)
|
||||
if deliv is not None:
|
||||
state = deliv.get("state")
|
||||
if state not in DELIVERY_STATE:
|
||||
_issue(issues, f"delivery.state 无效: {state!r}")
|
||||
|
||||
evidence = _mapping(data.get("evidence"), "evidence", issues)
|
||||
if evidence is not None:
|
||||
issues.extend(_validate_evidence(evidence, _project_root(ship_dir)))
|
||||
|
||||
status_md = ship_dir / "status.md"
|
||||
if not status_md.is_file():
|
||||
_issue(issues, "缺少 status.md")
|
||||
|
||||
return issues
|
||||
|
||||
|
||||
def _validate_tasks(path: Path, ship_id: Any, kind: Any) -> list[str]:
|
||||
issues: list[str] = []
|
||||
try:
|
||||
data = load_yaml(path)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
return [f"无法解析 tasks.yaml: {exc}"]
|
||||
mapping = _mapping(data, "tasks.yaml", issues)
|
||||
if mapping is None:
|
||||
return issues
|
||||
if mapping.get("shipId") != ship_id:
|
||||
_issue(
|
||||
issues,
|
||||
f"tasks.yaml.shipId 与 current.yaml 不一致: "
|
||||
f"{mapping.get('shipId')!r} != {ship_id!r}",
|
||||
)
|
||||
tasks = mapping.get("tasks")
|
||||
if not isinstance(tasks, list) or not tasks:
|
||||
_issue(issues, "tasks.yaml.tasks 必须是非空列表")
|
||||
return issues
|
||||
if kind == "short" and len(tasks) != 1:
|
||||
_issue(issues, f"kind=short 必须恰好 1 条任务,实际 {len(tasks)}")
|
||||
ids: list[str] = []
|
||||
for i, item in enumerate(tasks):
|
||||
prefix = f"tasks[{i}]"
|
||||
row = _mapping(item, prefix, issues)
|
||||
if row is None:
|
||||
continue
|
||||
tid = row.get("id")
|
||||
if not isinstance(tid, str) or not tid.strip():
|
||||
_issue(issues, f"{prefix}.id 不能为空")
|
||||
else:
|
||||
if tid in ids:
|
||||
_issue(issues, f"重复任务 id: {tid}")
|
||||
ids.append(tid)
|
||||
if not isinstance(row.get("title"), str) or not str(row.get("title")).strip():
|
||||
_issue(issues, f"{prefix}.title 不能为空")
|
||||
depends = row.get("dependsOn") or []
|
||||
if not isinstance(depends, list):
|
||||
_issue(issues, f"{prefix}.dependsOn 必须是列表")
|
||||
status = row.get("status")
|
||||
if status not in TASK_STATUS:
|
||||
_issue(issues, f"{prefix}.status 无效: {status!r}")
|
||||
expected = row.get("expected")
|
||||
if not isinstance(expected, list) or not expected:
|
||||
_issue(issues, f"{prefix}.expected 必须是非空列表")
|
||||
verification = row.get("verification")
|
||||
if not isinstance(verification, list) or not verification:
|
||||
_issue(issues, f"{prefix}.verification 必须是非空列表")
|
||||
for i, item in enumerate(tasks):
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
for dep in item.get("dependsOn") or []:
|
||||
if dep not in ids:
|
||||
_issue(issues, f"tasks[{i}].dependsOn 引用未知 id: {dep}")
|
||||
if dep == item.get("id"):
|
||||
_issue(issues, f"tasks[{i}] 不能依赖自己")
|
||||
return issues
|
||||
|
||||
|
||||
def _validate_evidence(evidence: dict[str, Any], project_root: Path) -> list[str]:
|
||||
issues: list[str] = []
|
||||
for key in ("testRuns", "artifacts", "deploys", "prs", "commits"):
|
||||
value = evidence.get(key)
|
||||
if value is None:
|
||||
_issue(issues, f"evidence.{key} 缺失")
|
||||
continue
|
||||
if not isinstance(value, list):
|
||||
_issue(issues, f"evidence.{key} 必须是列表")
|
||||
continue
|
||||
for i, item in enumerate(value):
|
||||
prefix = f"evidence.{key}[{i}]"
|
||||
row = _mapping(item, prefix, issues)
|
||||
if row is None:
|
||||
continue
|
||||
if key == "testRuns":
|
||||
rel = row.get("path")
|
||||
if not isinstance(rel, str) or not rel.strip():
|
||||
_issue(issues, f"{prefix}.path 不能为空")
|
||||
else:
|
||||
target = project_root / rel
|
||||
if not target.is_file():
|
||||
_issue(issues, f"{prefix}.path 不存在: {rel}")
|
||||
if row.get("result") not in {
|
||||
"pass",
|
||||
"fail",
|
||||
"environment-fail",
|
||||
"skipped",
|
||||
"not-run",
|
||||
"partial",
|
||||
}:
|
||||
_issue(issues, f"{prefix}.result 无效: {row.get('result')!r}")
|
||||
elif key == "artifacts":
|
||||
if not row.get("sha"):
|
||||
_issue(issues, f"{prefix}.sha 不能为空")
|
||||
elif key == "deploys":
|
||||
if not row.get("env"):
|
||||
_issue(issues, f"{prefix}.env 不能为空")
|
||||
elif key == "prs":
|
||||
if not row.get("url"):
|
||||
_issue(issues, f"{prefix}.url 不能为空")
|
||||
elif key == "commits":
|
||||
if not row.get("sha"):
|
||||
_issue(issues, f"{prefix}.sha 不能为空")
|
||||
if row.get("role") not in COMMIT_ROLE:
|
||||
_issue(issues, f"{prefix}.role 无效: {row.get('role')!r}")
|
||||
return issues
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description="校验 ship 记录目录")
|
||||
parser.add_argument("path", help=".pouch/ship/<id> 或其中的 current.yaml")
|
||||
args = parser.parse_args()
|
||||
path = Path(args.path)
|
||||
ship_dir = path.parent if path.name == "current.yaml" else path
|
||||
issues = validate_ship_dir(ship_dir)
|
||||
if issues:
|
||||
for item in issues:
|
||||
print(f"✗ {item}", file=sys.stderr)
|
||||
return 1
|
||||
print(f"✓ ship 记录通过: {ship_dir}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,29 @@
|
||||
shipId: <ship-id>
|
||||
kind: short
|
||||
title: <short title>
|
||||
status: draft
|
||||
stopAt: null
|
||||
source:
|
||||
kind: user
|
||||
ref: null
|
||||
parentShipId: null
|
||||
childShipIds: []
|
||||
spec:
|
||||
path: spec.md
|
||||
revision: null
|
||||
specStatus: null
|
||||
prd: null
|
||||
approvedRevision: null
|
||||
reviews: null
|
||||
handoff: null
|
||||
tasks:
|
||||
path: tasks.yaml
|
||||
delivery:
|
||||
stopAt: null
|
||||
state: change_ready
|
||||
evidence:
|
||||
testRuns: []
|
||||
artifacts: []
|
||||
deploys: []
|
||||
prs: []
|
||||
commits: []
|
||||
@@ -0,0 +1,21 @@
|
||||
# <title>
|
||||
|
||||
## 要做什么
|
||||
|
||||
<用户可见的目标。>
|
||||
|
||||
## 不做什么
|
||||
|
||||
<明确排除的范围。>
|
||||
|
||||
## 可观察验收
|
||||
|
||||
- <能被浏览器、API 或真机看到的结果>
|
||||
|
||||
## 怎么验
|
||||
|
||||
- <命令、页面路径或手工步骤>
|
||||
|
||||
## 故障证据
|
||||
|
||||
<没有故障时删除本节。有则写复现步骤和根因。>
|
||||
@@ -0,0 +1,19 @@
|
||||
# <title>
|
||||
|
||||
- ship id: `<ship-id>`
|
||||
- kind: `<kind>`
|
||||
- status: `<status>`
|
||||
- stopAt: `<stopAt>`
|
||||
- delivery: `<delivery.state>`
|
||||
|
||||
## spec
|
||||
|
||||
见 `spec.md`。
|
||||
|
||||
## tasks
|
||||
|
||||
见 `tasks.yaml`。
|
||||
|
||||
## evidence
|
||||
|
||||
测试、产物、部署、PR 和 commit 指针写在 `current.yaml` 的 `evidence`。
|
||||
@@ -0,0 +1,11 @@
|
||||
shipId: <ship-id>
|
||||
tasks:
|
||||
- id: T-01
|
||||
title: <short title>
|
||||
dependsOn: []
|
||||
expected:
|
||||
- <observable acceptance signal>
|
||||
verification:
|
||||
- <how to verify>
|
||||
status: open
|
||||
stopAt: null
|
||||
Reference in New Issue
Block a user