feat(ship): add stage audit trail and sortable ids
This commit is contained in:
@@ -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。
|
||||
---
|
||||
|
||||
|
||||
@@ -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`。
|
||||
@@ -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
|
||||
|
||||
同一套字段,厚度不同。
|
||||
|
||||
@@ -7,6 +7,7 @@ delivery-loop 的正文,不要让 worker 再开一层 ship 入口。
|
||||
你是 ship <ship-id> 的 worker,只做步骤 <step>。
|
||||
项目根:<project-root>
|
||||
Ship 目录:.pouch/ship/<ship-id>/
|
||||
当前阶段目录:.pouch/ship/<ship-id>/<stage>/
|
||||
任务 id:<taskId 或「无」>
|
||||
目标 delivery.state:<targetState 或「不改」>
|
||||
停止点 stopAt:<stopAt>
|
||||
@@ -37,6 +38,9 @@ pass;没有 HOST、设备或浏览器的测试根必须记录 environment-fail
|
||||
- summary: 三句以内,做了什么、证据在哪、还缺什么
|
||||
- evidence: 写入的指针
|
||||
- ask: 若 status=ask,要主会话问用户的那一个问题
|
||||
|
||||
同时把本次工作的命令、关键结果、阻塞和证据路径写入当前阶段目录的
|
||||
`activity.md`;不要把完整日志复制进 ship,只写可复核摘要和外部文件路径。
|
||||
```
|
||||
|
||||
`<skill>` 必须用 `next_step.py` JSON 里的 `skill` 字段,不要改成别的。`skill` 为
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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())
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -21,6 +21,11 @@ tasks:
|
||||
delivery:
|
||||
stopAt: null
|
||||
state: change_ready
|
||||
manager:
|
||||
lastStep: null
|
||||
lastResult: null
|
||||
lastSummary: null
|
||||
inFlight: null
|
||||
evidence:
|
||||
testRuns: []
|
||||
artifacts: []
|
||||
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user