#!/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 ## 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-.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/.yml` + docs 为准**,§8 由维护者 **周期性** 与已闭合项对齐(不必每篇同时改两处)。", "", "## 优先级(以 doc_id 为准)", "", "- **主键**:`doc_id`(`XX-YY`)→ 本目录 **`story-doc-.md`**;与 **`ansible/playbooks/verify/.yml`**、**`docs/-*.md`**、**`ansible/files//`** 一一对齐。", "- **补充 backlog**:若 `_bmad-output/implementation-artifacts/` 下另有 **同主题** 文件(如 **`*-baseline-verify-oc.md`**、Epic 编号命名的 `3-2-*.md`),视为**同一 `doc_id` 或跨多篇 `doc_id` 的深化说明**;实施与验收仍以 **`verify/.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())