Files
2026-03-29 09:08:01 +08:00

248 lines
12 KiB
Python
Executable File
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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 CSstory-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())