248 lines
12 KiB
Python
Executable File
248 lines
12 KiB
Python
Executable File
#!/usr/bin/env python3
|
||
"""按执行域 doc_id 生成 BMad [CS] story-doc-XX-YY.md(与 verify playbook 一一对应)。
|
||
|
||
输出目录:_bmad-output/implementation-artifacts/stories-by-doc/
|
||
索引:同目录 README.md
|
||
|
||
用法:
|
||
python3 ansible/tools/gen_stories_by_doc.py # 全量
|
||
python3 ansible/tools/gen_stories_by_doc.py --doc-id 02-05 # 单篇
|
||
python3 ansible/tools/gen_stories_by_doc.py --readme-only # 仅刷新 README(含队列表)
|
||
python3 ansible/tools/gen_stories_by_doc.py --dry-run
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import argparse
|
||
import re
|
||
import sys
|
||
from pathlib import Path
|
||
|
||
ROOT = Path(__file__).resolve().parent.parent.parent
|
||
VERIFY_DIR = ROOT / "ansible" / "playbooks" / "verify"
|
||
DOCS_DIR = ROOT / "docs"
|
||
OUT_DIR = ROOT / "_bmad-output" / "implementation-artifacts" / "stories-by-doc"
|
||
|
||
EXEC_ID_RE = re.compile(r"^(0[1-9]|[1-9][0-9])-(0[1-9]|[1-9][0-9])\.yml$")
|
||
|
||
|
||
def playbook_is_noop(doc_id: str) -> bool:
|
||
yml = VERIFY_DIR / f"{doc_id}.yml"
|
||
if not yml.is_file():
|
||
return False
|
||
content = yml.read_text(encoding="utf-8", errors="ignore")
|
||
return "noop verify" in content.lower() or "noop-doc-verify" in content
|
||
|
||
|
||
def doc_title(doc_path: Path | None) -> str:
|
||
if doc_path is None or not doc_path.exists():
|
||
return "(文档待补)"
|
||
text = doc_path.read_text(encoding="utf-8", errors="ignore")
|
||
first = text.splitlines()[0].strip() if text else ""
|
||
if first.startswith("#"):
|
||
return first.lstrip("#").strip()
|
||
return doc_path.stem
|
||
|
||
|
||
def story_body(doc_id: str, rel_doc: str, title: str, noop: bool) -> str:
|
||
pb_rel = f"ansible/playbooks/verify/{doc_id}.yml"
|
||
story_rel = f"_bmad-output/implementation-artifacts/stories-by-doc/story-doc-{doc_id}.md"
|
||
noop_line = (
|
||
"**noop + verify_common 基线**(文档步骤以手工/Helm 为主)"
|
||
if noop
|
||
else "**自定义断言**(非纯 noop)"
|
||
)
|
||
return f"""# Story [CS] doc_id {doc_id}:{title}
|
||
|
||
Status: ready-for-dev
|
||
|
||
<!-- BMad [CS]:与执行域 doc_id 一一对应;由 ansible/tools/gen_stories_by_doc.py 生成。实施入口:Cursor `/bmad-dev-story`,并指定本 Story 文件路径 `{story_rel}`;工作流见 `.cursor/skills/bmad-dev-story/workflow.md`。 -->
|
||
|
||
## Meta
|
||
|
||
| 字段 | 值 |
|
||
|------|-----|
|
||
| **doc_id** | `{doc_id}` |
|
||
| **文档** | `{rel_doc}` |
|
||
| **verify playbook** | `{pb_rel}` |
|
||
| **清单/示例目录** | `ansible/files/{doc_id}/` |
|
||
| **playbook 形态** | {noop_line} |
|
||
|
||
## bmad-dev-story 入口
|
||
|
||
- **实施**:在 Cursor 使用 **`/bmad-dev-story`**,并在对话中提供本文件路径:`{story_rel}`(仓库根相对路径)。
|
||
- **工作流**:技能 **`bmad-dev-story`** → `.cursor/skills/bmad-dev-story/workflow.md`;Agent 仅可改本 Story 内 Tasks 勾选、Dev Agent Record、File List、Change Log、Status(与 workflow 一致)。
|
||
|
||
## Story
|
||
|
||
作为一名 **实验室维护者**,
|
||
我希望 **`./ansible/bin/verify.sh run {doc_id}`**(或 `./scripts/cs {doc_id}`)的行为与 **`{rel_doc}`** 中的 TL;DR / 成功判据 / 自动化验收描述一致,并满足仓库 **doc_id 三元契约** 与 **Output Contract(`[OC]` / `[OC-ASSERT]`)** 约定,
|
||
以便 **状态板、offline-check 与文档** 不漂移;**实施与收尾一律按 `/bmad-dev-story` 工作流、以本 Story 为规格真源**。
|
||
|
||
## Acceptance Criteria
|
||
|
||
1. **契约**:存在 `docs/{doc_id}-*.md`、`ansible/playbooks/verify/{doc_id}.yml`、`ansible/files/{doc_id}/`;`python3 ansible/tools/validate_matrix_playbooks.py` 通过。
|
||
2. **离线门禁**:`./scripts/offline-check.sh` 通过(含 R3 链接、syntax-check)。
|
||
3. **运行验收**:在已连通集群的实验室环境执行 `./ansible/bin/verify.sh run {doc_id}`,结果符合该篇文档预期(`verified` / `gated` 均需可解释;失败则修 playbook 或文档)。
|
||
4. **输出语义**:playbook 日志中 **`[OC]`** 行可被状态板/脚本解析;含断言处优先带 **`[OC-ASSERT]`**(与 `project-context.md`、ADR-004 一致)。
|
||
5. **文档真源**:篇内「契约与真源」或等价段落指向 `ansible/files/{doc_id}/`(若含带 yaml 标注的 fenced 代码块,须出现 `ansible/files/{doc_id}/` 路径以满足弱门禁)。
|
||
|
||
## Tasks / Subtasks
|
||
|
||
- [ ] **T1** 对照 **`{rel_doc}`** 的 TL;DR / 验证命令 / 预期,列出与 playbook 任务映射表(缺项 = 未覆盖)。
|
||
- [ ] **T2** 若存在差距:改 **`{pb_rel}`** 或 **`verify_common` role**,或修订文档(禁止双真源)。
|
||
- [ ] **T3** 回归:`./scripts/offline-check.sh` + `./ansible/bin/verify.sh run {doc_id}`。
|
||
- [ ] **T4**(可选)将本轮结论记入 **`_bmad-output/implementation-artifacts/`** 下 OC 笔记(与本 Story 文件名并列索引)。
|
||
|
||
## Dev Notes
|
||
|
||
- **执行域**:`XX>0 && YY>0`;导航页 `YY=00` 无对应 story。
|
||
- **Helm / 手工步骤**:noop 篇以文档与 `ansible/files` 示例为准;自动化安装若扩展,需 **env 门控 + teardown**(见 `00-03` §2.1)。
|
||
- **禁止**:用 noop **冒充** 已覆盖文档中要求集群变更/探针的判据(见 `epics-and-stories` Epic 2 Story 2.3)。
|
||
- **主键**:以 `doc_id` 为准;见 `project-context.md`「doc_id 与验证框架」。
|
||
- **实施入口**:本目录下每篇 Story 均通过 **`/bmad-dev-story`** 执行;勿绕过 workflow 随意改规格外文件而不更新 Tasks / File List。
|
||
|
||
### References
|
||
|
||
- [Source: `{rel_doc}`]
|
||
- [Source: `{pb_rel}`]
|
||
- [Source: `ansible/files/{doc_id}/README.md`](若存在)
|
||
- [Source: `project-context.md` — 契约 / OC]
|
||
- [Source: `docs/00-03-测试与验证框架.md`]
|
||
|
||
## Dev Agent Record
|
||
|
||
### Agent Model Used
|
||
|
||
(dev-story 填写)
|
||
|
||
### Completion Notes List
|
||
|
||
### File List
|
||
|
||
---
|
||
|
||
## Story completion status
|
||
|
||
- **ready-for-dev**
|
||
"""
|
||
|
||
|
||
def write_readme(ids: list[str]) -> str:
|
||
lines = [
|
||
"# stories-by-doc 索引",
|
||
"",
|
||
"> BMad **[CS] Create Story**:每个执行域 `doc_id` 一篇。**实施入口统一为 Cursor `/bmad-dev-story`**:在对话中附上对应 `story-doc-<doc_id>.md` 的仓库根相对路径;工作流见 `.cursor/skills/bmad-dev-story/workflow.md`。",
|
||
"",
|
||
"**生成 / 更新**:在仓库根执行 `python3 ansible/tools/gen_stories_by_doc.py`(或 `--doc-id XX-YY` 只生成一篇)。仅刷新本 README、不覆盖各篇 Story 时:`python3 ansible/tools/gen_stories_by_doc.py --readme-only`。",
|
||
"",
|
||
"## 开发队列与工作方式(50 篇 ≠ 50 条 Cursor Todo)",
|
||
"",
|
||
"### 1. backlog 从哪来",
|
||
"",
|
||
"- **优先队列**:[`sprint-status.md`](../sprint-status.md) **§8 后续 Sprint Backlog** — 先做其中仍标 **ready-for-dev** 或缺口明显的 `doc_id`(与 §8 中某行叙事对上的 `doc_id` 共享同一工程真源:`verify` + docs)。",
|
||
"- **全量索引**:下表列出全部执行域 `doc_id`;需要按系列清扫时,可自 **01-xx → 07-xx** 顺序推进。",
|
||
"",
|
||
"### 2. WIP 与 IDE Todo",
|
||
"",
|
||
"- **WIP**:[`sprint-status.md`](../sprint-status.md) **§7** — 同一时间最多 **1** 篇 Story `in-progress`。",
|
||
"- **Cursor**:每次只跑 **`/bmad-dev-story`** + **一篇** `story-doc-XX-YY.md`;IDE Todo 建议只保留 **当前 `doc_id`**(及可选一条回归命令如 `offline-check` / `verify.sh run XX-YY`),**不要**把 50 篇拆成 50 条并行 Todo。",
|
||
"",
|
||
"### 3. 收尾与 §8 对齐",
|
||
"",
|
||
"- 完成条件以该篇 **AC + Tasks** 为准;按 `.cursor/skills/bmad-dev-story/workflow.md` 更新 Story 内 **Tasks 勾选、Dev Agent Record、File List、Change Log、Status**(如进入 `review`)。",
|
||
"- **`sprint-status.md` §8**:可与 Story 状态交叉备注;避免长期双真源 — **规格与闭合状态以 Story 文件 + `ansible/playbooks/verify/<doc_id>.yml` + docs 为准**,§8 由维护者 **周期性** 与已闭合项对齐(不必每篇同时改两处)。",
|
||
"",
|
||
"## 优先级(以 doc_id 为准)",
|
||
"",
|
||
"- **主键**:`doc_id`(`XX-YY`)→ 本目录 **`story-doc-<doc_id>.md`**;与 **`ansible/playbooks/verify/<doc_id>.yml`**、**`docs/<doc_id>-*.md`**、**`ansible/files/<doc_id>/`** 一一对齐。",
|
||
"- **补充 backlog**:若 `_bmad-output/implementation-artifacts/` 下另有 **同主题** 文件(如 **`*-baseline-verify-oc.md`**、Epic 编号命名的 `3-2-*.md`),视为**同一 `doc_id` 或跨多篇 `doc_id` 的深化说明**;实施与验收仍以 **`verify/<doc_id>.yml` + 对应 docs** 为真源,规划文件名仅作辅助索引。",
|
||
"",
|
||
"下表 **Playbook** 列由本脚本根据 `verify/*.yml` 内容自动标注(`noop+verify_common` ≈ 文档/手工为主;`自定义断言` ≈ 含非 noop 任务),便于拣选体量与回归方式。",
|
||
"",
|
||
f"共 **{len(ids)}** 篇。",
|
||
"",
|
||
"| doc_id | Playbook | Story 文件 |",
|
||
"|--------|----------|------------|",
|
||
]
|
||
for did in ids:
|
||
kind = "noop+verify_common" if playbook_is_noop(did) else "自定义断言"
|
||
lines.append(f"| {did} | {kind} | [story-doc-{did}.md](story-doc-{did}.md) |")
|
||
lines.append("")
|
||
return "\n".join(lines)
|
||
|
||
|
||
def collect_doc_ids(single: str | None) -> list[str]:
|
||
ids: list[str] = []
|
||
for p in sorted(VERIFY_DIR.glob("*.yml")):
|
||
if p.name.startswith("_"):
|
||
continue
|
||
if not EXEC_ID_RE.match(p.name):
|
||
continue
|
||
stem = p.stem
|
||
if single and stem != single:
|
||
continue
|
||
ids.append(stem)
|
||
if single and single not in ids:
|
||
print(f"ERR: doc_id 无对应 verify playbook:{single}", file=sys.stderr)
|
||
sys.exit(2)
|
||
return sorted(ids)
|
||
|
||
|
||
def main() -> int:
|
||
ap = argparse.ArgumentParser(description="按 doc_id 生成 BMad CS(story-doc-*.md)")
|
||
ap.add_argument("--doc-id", help="仅生成该 XX-YY(默认全量)")
|
||
ap.add_argument("--dry-run", action="store_true", help="只打印将写入的路径")
|
||
ap.add_argument(
|
||
"--readme-only",
|
||
action="store_true",
|
||
help="只重写 stories-by-doc/README.md(不重写各 story-doc-*.md)",
|
||
)
|
||
args = ap.parse_args()
|
||
|
||
if args.readme_only and args.doc_id:
|
||
print("ERR: --readme-only 与 --doc-id 不能同时使用", file=sys.stderr)
|
||
return 2
|
||
|
||
doc_ids = collect_doc_ids(args.doc_id)
|
||
if not doc_ids:
|
||
print("ERR: 未发现执行域 verify playbook", file=sys.stderr)
|
||
return 2
|
||
|
||
if not args.dry_run:
|
||
OUT_DIR.mkdir(parents=True, exist_ok=True)
|
||
|
||
if args.readme_only:
|
||
if args.dry_run:
|
||
print(f"would write {OUT_DIR.relative_to(ROOT)}/README.md")
|
||
return 0
|
||
readme = write_readme(doc_ids)
|
||
(OUT_DIR / "README.md").write_text(readme, encoding="utf-8")
|
||
print(f"[OK] {OUT_DIR.relative_to(ROOT)}/README.md ({len(doc_ids)} 条, --readme-only)")
|
||
return 0
|
||
|
||
for doc_id in doc_ids:
|
||
noop = playbook_is_noop(doc_id)
|
||
matches = sorted(DOCS_DIR.glob(f"{doc_id}-*.md"))
|
||
doc_path = matches[0] if matches else None
|
||
rel_doc = str(doc_path.relative_to(ROOT)) if doc_path else f"docs/{doc_id}-*.md(缺失)"
|
||
title = doc_title(doc_path)
|
||
body = story_body(doc_id, rel_doc, title, noop)
|
||
out_path = OUT_DIR / f"story-doc-{doc_id}.md"
|
||
if args.dry_run:
|
||
print(f"would write {out_path.relative_to(ROOT)}")
|
||
continue
|
||
out_path.write_text(body, encoding="utf-8")
|
||
print(f"[OK] {out_path.relative_to(ROOT)}")
|
||
|
||
if args.dry_run:
|
||
return 0
|
||
|
||
readme = write_readme(doc_ids)
|
||
(OUT_DIR / "README.md").write_text(readme, encoding="utf-8")
|
||
print(f"[OK] {OUT_DIR.relative_to(ROOT)}/README.md ({len(doc_ids)} 条)")
|
||
return 0
|
||
|
||
|
||
if __name__ == "__main__":
|
||
raise SystemExit(main())
|