diff --git a/.gitea/workflows/test.yml b/.gitea/workflows/test.yml index b62e35d..9656eeb 100644 --- a/.gitea/workflows/test.yml +++ b/.gitea/workflows/test.yml @@ -107,6 +107,12 @@ jobs: cd "$REPO_DIR" python3 -m unittest discover -s tests/cli -v + echo "========================================" + echo "🧪 Python 扩展测试" + echo "========================================" + + python3 -m unittest discover -s tests -p "test_*.py" -v + echo "========================================" echo "📄 模板验证测试" echo "========================================" diff --git a/AGENTS.md b/AGENTS.md deleted file mode 100644 index 9c4c30e..0000000 --- a/AGENTS.md +++ /dev/null @@ -1,143 +0,0 @@ -# 代理指引(playbook) - -> 关于 playbook 仓库的特殊性: -> -> - 在 playbook 仓库中:规则集模板位于 `rulesets/` -> - 在目标项目中:同步后规则集位于 `.agents/` -> - AI 代理读取目标项目根目录的 `.agents/`(由 playbook.py 的 `[sync_standards]` 生成) -> -> 本文适用于目标项目。playbook 仓库自身没有源代码,不需要 AI 代理规则。 - -以 `.agents/` 下规则为准: - -- 入口:`.agents/index.md` -- 语言规则:`.agents/tsl/index.md`、`.agents/cpp/index.md`、 - `.agents/python/index.md`、`.agents/markdown/index.md` - ---- - -## 三层架构(分层知识库) - -本仓库将代理规则与知识分为三层: - -### Layer 1: `.agents/`(最小硬规则,每种语言 ≤ 50 行) - -- 加载:自动,始终在上下文中 -- 内容:硬约束与安全红线 -- 作用:快速判断能做/不能做 -- 规模控制:TSL 44 行 | Python 45 行 | C++ 47 行 | Markdown 31 行 - -### Layer 2: `codex/skills/`(按需加载,每个 skill 100-1000 行) - -- 加载:由 `$` 触发或由代理判定 -- 内容:操作指南、最佳实践、工作流 -- 作用:指导具体怎么做 - -关键技能: - -- `$tsl-guide` - TSL 渐进式语法训练(基础/高级/函数/最佳实践) -- `$commit-message` - 提交信息规范 -- `$style-cleanup` - 格式与风格整理 -- `$bulk-refactor-workflow` - 安全批量重构流程 - -### Layer 3: `docs/`(权威静态文档) - -- 加载:按需读取特定章节 -- 内容:完整语言手册、代码风格、工具链配置 -- 作用:最终权威 -- 冲突处理:规则冲突时以 `docs/` 为准 - -注:函数库拆分在 `docs/tsl/syntax_book/function/` 下。 -不要加载整个目录,只加载需要的片段。 - ---- - -## 使用场景 - -### 场景 1:编写简单的 TSL 函数 - -```text -1. 自动读取 .agents/tsl/index.md(44 行) -2. 触发 $tsl-guide,加载 SKILL.md(192 行) -3. 生成代码 - -Token 消耗:~6,000 tokens -``` - -### 场景 2:编写 TSL 类 - -```text -1. 自动读取 .agents/tsl/index.md(44 行) -2. 触发 $tsl-guide,加载 SKILL.md + references/advanced.md -3. 生成代码 - -Token 消耗:~10,000 tokens -``` - -### 场景 3:查询 TSL 函数库条目 - -```text -1. 自动读取 .agents/tsl/index.md(44 行) -2. 触发 $tsl-guide,加载 references/functions_index.md -3. 使用 rg 定位函数片段 -4. 返回答案 - -Token 消耗:~8,000 tokens -``` - ---- - -## 性能指标 - -| 指标 | 之前 | 现在 | 改善 | -| --------------- | ------- | ------- | ---- | -| .agents 规模 | ~500 行 | 168 行 | -66% | -| 持久化 tokens | ~12,500 | ~4,200 | -66% | -| 场景平均 tokens | ~12,500 | ~10,500 | -16% | - ---- - -## 维护原则 - -### .agents/ 修改规则 - -可做: - -- 增加新的安全漏洞类型 -- 更新核心约定(文件名、格式规则) -- 添加不可妥协的硬性约束 - -不可做: - -- 添加推荐型最佳实践(放到 skill) -- 添加详细语法解释(放到 skill 或 docs) -- 超过 50 行限制(拆分为 skill) - -### Skills 创建规则 - -可做: - -- 增加新流程(如 code-review) -- 从零教授新语言(如 tsl-guide) -- 添加跨语言通用知识(如 style-cleanup) - ---- - -## FAQ - -Q:为什么 .agents 这么小? -A:因为它会在每次对话加载。控制在 50 行内可减少约 71% 的持久 token 消耗。 - -Q:为什么 TSL 需要专门的 tsl-guide? -A:TSL 非预训练语言,需要从零教学。 - -Q:如果项目有自定义约定怎么办? -A:将 playbook 以 git subtree 方式引入项目,并修改项目的 `.agents/`。 - ---- - -## 相关文档 - -- Skills 使用指南:`SKILLS.md` -- 开发规范:`docs/index.md` -- 项目 README:`README.md` diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 5e4b261..8852d06 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,7 +1,34 @@ -# Contributing +# Contributing + +Thanks for improving the Playbook templates and tooling. This repo is a template +source for downstream projects, so changes should stay small, predictable, and +backwards compatible when possible. + +## What to change + +- Templates: `templates/`, `rulesets/`, `docs/` +- Tooling: `scripts/` +- Tests: `tests/` ## Commit messages -Follow the repository commit message standard: +Follow `docs/common/commit_message.md` and use the required emoji/type mapping. -- `docs/common/commit_message.md` +## Tests + +Run the relevant checks before pushing: + +```bash +python -m unittest discover -s tests/cli -v +python -m unittest discover -s tests -p "test_*.py" -v +sh tests/templates/validate_python_templates.sh +sh tests/templates/validate_cpp_templates.sh +sh tests/templates/validate_ci_templates.sh +sh tests/templates/validate_project_templates.sh +sh tests/integration/check_doc_links.sh +``` + +## Templates and docs + +- Keep placeholder definitions documented in `templates/README.md`. +- Update template last-updated dates when changing template content. diff --git a/README.md b/README.md index f0b209b..75030bf 100644 --- a/README.md +++ b/README.md @@ -98,7 +98,7 @@ Layer 1: rulesets/ (≤50 行/语言,模板源) └─ 指向 Skills 和 docs Layer 2: codex/skills/ (按需加载,$skill-name 触发) - ├─ tsl-guide: TSL 渐进式语法教学(962 行) + ├─ tsl-guide: TSL 渐进式语法教学 ├─ commit-message: 提交信息规范 ├─ style-cleanup: 代码风格整理 └─ bulk-refactor-workflow: 批量重构流程 @@ -107,6 +107,14 @@ Layer 3: docs/ (权威静态文档) └─ 完整语法手册/代码风格/工具链配置 ``` +**各层职责**: + +| 层级 | 加载方式 | 内容 | 作用 | +| ------- | ------------------------------ | ------------------------------ | -------------------------- | +| Layer 1 | 自动,始终在上下文 | 硬约束与安全红线 | 快速判断能做/不能做 | +| Layer 2 | `$` 触发或代理判定 | 操作指南、最佳实践、工作流 | 指导具体怎么做 | +| Layer 3 | 按需读取特定章节 | 完整语言手册、代码风格、工具链 | 最终权威(冲突时以此为准) | + **目录结构**: - `rulesets/index.md`:规则集索引(跨语言) @@ -115,7 +123,25 @@ Layer 3: docs/ (权威静态文档) - `rulesets/python/index.md`:Python 核心约定(45 行) - `rulesets/markdown/index.md`:Markdown 核心约定(31 行,仅代码格式化) -详见:`AGENTS.md` +更多说明:`rulesets/index.md` + +### 性能指标 + +| 指标 | 优化前 | 优化后 | 改善 | +| ------------- | ------- | ------ | ---- | +| .agents 规模 | ~500 行 | 167 行 | -67% | +| 持久化 tokens | ~12,500 | ~4,200 | -66% | + +### 维护原则 + +**.agents/(Layer 1)修改规则**: + +- 可做:增加安全漏洞类型、更新核心约定、添加硬性约束 +- 不可做:添加推荐型最佳实践(→ skill)、详细语法解释(→ skill/docs)、超过 50 行(→ 拆分) + +**Skills(Layer 2)创建规则**: + +- 可做:增加新流程、从零教授新语言、添加跨语言通用知识 ## SKILLS(Codex CLI) diff --git a/SKILLS.md b/SKILLS.md index 0543995..2bea1e8 100644 --- a/SKILLS.md +++ b/SKILLS.md @@ -1,8 +1,7 @@ # SKILLS -本文件定义:如何在仓库中落地与维护 **Codex CLI -skills**(实验功能),并给出与本 Playbook(`docs/` + -`.agents/`)配套的技能编写建议与内置技能清单。 +本文件定义:如何在仓库中落地与维护 **Codex CLI skills**(实验功能), +并给出与本 Playbook(`docs/` + `rulesets/`)配套的技能编写建议与内置技能清单。 > 提示:Codex skills 是“按用户安装”的(默认在 > `~/.codex/skills`)。本仓库将 skills 以可分发的形式放在 @@ -136,16 +135,17 @@ python docs/standards/playbook/scripts/playbook.py -config playbook.toml --- -## 8. 本 Playbook 内置 skills +## 8. 本 Playbook 原生 skills -位于 `codex/skills/`: +位于 `codex/skills/`(Playbook 自维护部分),当前共 4 个。 +第三方 superpowers 列表见第 9 节。 ### 语言特定 Skills - **`tsl-guide`**:TSL/TSF 语法与编码完整指南 - 渐进式教学体系:基础语法 → 高级特性 → 函数库 → 最佳实践 - 包含 4 个子文档:primer.md / advanced.md / functions_index.md / common_patterns.md - - 总计 962 行,按需加载 + - 总计约 1000 行,按需加载 - 触发词:TSL 语法, 写 TSL, TSL 函数, TSL class, 矩阵操作, TS-SQL 等 ### 通用工作流 Skills @@ -156,17 +156,10 @@ python docs/standards/playbook/scripts/playbook.py -config playbook.toml --- -## 9. 运行时排障 +## 9. Third-party Skills (superpowers) -- 不触发: - - 确认已启用 `[features] skills = true` - - 确认 skill 已安装到 `$CODEX_HOME/skills//SKILL.md` - - 重启 `codex`(skills 只在启动时加载) -- 触发错:减少不同 skill 的 `description` - 关键词重叠;让触发词更具体(语言/工具/目录名/流程名)。 -- 启动报错:通常是 YAML frontmatter 不合法或字段超长;修复后重启即可。 - -### Third-party Skills (superpowers) +来源:`codex/skills/.sources/superpowers.list`(第三方来源清单)。 +本节仅列出 superpowers 体系 skills,与本 Playbook 原生 skills 分离。 @@ -185,3 +178,19 @@ python docs/standards/playbook/scripts/playbook.py -config playbook.toml - writing-plans - writing-skills + +--- + +## 10. 运行时排障 + +- 不触发: + - 确认已启用 `[features] skills = true` + - 确认 skill 已安装到 `$CODEX_HOME/skills//SKILL.md` + - 重启 `codex`(skills 只在启动时加载) +- 触发错:减少不同 skill 的 `description` + 关键词重叠;让触发词更具体(语言/工具/目录名/流程名)。 +- 启动报错:通常是 YAML frontmatter 不合法或字段超长;修复后重启即可。 + +--- + +**最后更新**:2026-01-26 diff --git a/codex/skills/tsl-guide/SKILL.md b/codex/skills/tsl-guide/SKILL.md index d2a5336..d1b9884 100644 --- a/codex/skills/tsl-guide/SKILL.md +++ b/codex/skills/tsl-guide/SKILL.md @@ -162,6 +162,39 @@ col_0 := matrix[:, 0]; 2. **按需加载**:只读取一个子文档(避免贪婪加载) 3. **必要时检索函数库**:先索引,再定位片段 +### 典型场景与 Token 消耗 + +**场景 1:编写简单的 TSL 函数** + +```text +1. 自动读取 .agents/tsl/index.md(44 行) +2. 触发 $tsl-guide,加载 SKILL.md +3. 生成代码 + +Token 消耗:~6,000 tokens +``` + +**场景 2:编写 TSL 类** + +```text +1. 自动读取 .agents/tsl/index.md(44 行) +2. 触发 $tsl-guide,加载 SKILL.md + references/advanced.md +3. 生成代码 + +Token 消耗:~10,000 tokens +``` + +**场景 3:查询 TSL 函数库条目** + +```text +1. 自动读取 .agents/tsl/index.md(44 行) +2. 触发 $tsl-guide,加载 references/functions_index.md +3. 使用 rg 定位函数片段 +4. 返回答案 + +Token 消耗:~8,000 tokens +``` + --- ## ⚠️ 函数库使用规则 diff --git a/playbook.toml.example b/playbook.toml.example index f8274b7..0f63c5b 100644 --- a/playbook.toml.example +++ b/playbook.toml.example @@ -13,6 +13,7 @@ [sync_templates] # project_name = "MyProject" # 可选:替换 {{PROJECT_NAME}} +# main_language = "tsl" # 可选:替换 {{MAIN_LANGUAGE}}(未配置时取 sync_standards.langs[0],否则 tsl) # date = "2026-01-23" # 可选:替换 {{DATE}},默认今天 # force = false # 可选:覆盖已有目录 # no_backup = false # 可选:跳过备份 @@ -20,7 +21,7 @@ [sync_standards] # langs = ["tsl", "cpp"] # 必填:要同步的语言 -# gitattr_mode = "append" # append|overwrite|block|skip +# gitattr_mode = "append" # append(补全缺失)|overwrite(覆盖)|block(插入块)|skip(跳过) [install_skills] # mode = "list" # list|all diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..0bc8a0d --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,5 @@ +[project] +name = "playbook" +version = "0.0.0" +description = "Playbook templates and tooling" +requires-python = ">=3.10" diff --git a/rulesets/index.md b/rulesets/index.md index 2606c63..3a8de3f 100644 --- a/rulesets/index.md +++ b/rulesets/index.md @@ -2,7 +2,7 @@ > **重要**:本目录位于 **playbook 仓库**,作为**规则集模板源**。 > -> - **模板源**:`playbook/rulesets/` → 通过 `sync_standards.*` 同步 → 目标项目的 `.agents/` +> - **模板源**:`playbook/rulesets/` → 通过 `scripts/playbook.py` 的 `[sync_standards]` 同步 → 目标项目的 `.agents/` > - **AI 读取**:目标项目根目录的 `.agents/`,而非此处 > - **使用流程**: > @@ -20,5 +20,10 @@ - `rulesets/python/`:Python 相关规则集 - `rulesets/markdown/`:Markdown 相关规则集(仅代码格式化) -目标项目落地时,通过 `scripts/sync_standards.*` +目标项目落地时,通过 `scripts/playbook.py` 的 `[sync_standards]` 将规则集从 `rulesets//` 同步到目标项目根目录的 `.agents//`。 + +## 三层架构(分层知识库) + +`rulesets/` 是三层架构中的 **Layer 1**(语言级硬规则,≤50 行/语言)。 +完整分层说明见 `README.md` 的“rulesets/(规则集模板库 - 三层架构)”。 diff --git a/scripts/plan_progress.py b/scripts/plan_progress.py new file mode 100644 index 0000000..237e6d6 --- /dev/null +++ b/scripts/plan_progress.py @@ -0,0 +1,225 @@ +#!/usr/bin/env python3 +import re +import sys +from datetime import datetime +from pathlib import Path +from typing import Optional + +PLAN_PREFIX = "[PLAN]" +PLAN_SECTION_HEADER = "## Plan 状态记录" +PLAN_FILE_RE = re.compile(r"^(\d{4}-\d{2}-\d{2})-.+\.md$") +VALID_STATUSES = {"in-progress", "done", "blocked"} + + +def usage() -> str: + return ( + "Usage:\\n" + " python scripts/plan_progress.py select -plans -progress \\n" + " python scripts/plan_progress.py record -plan -status -progress [-note ]\\n" + " python scripts/plan_progress.py -h\\n" + "Options:\\n" + " -plans DIR\\n" + " -plan PATH\\n" + " -status in-progress|done|blocked\\n" + " -progress FILE\\n" + " -note TEXT\\n" + " -h, -help Show this help.\\n" + ) + + +def parse_flags(args: list[str]) -> dict[str, str]: + flags: dict[str, str] = {} + idx = 0 + while idx < len(args): + arg = args[idx] + if arg in ("-h", "-help"): + raise ValueError("help") + if not arg.startswith("-"): + raise ValueError(f"unexpected arg: {arg}") + if idx + 1 >= len(args): + raise ValueError(f"missing value for {arg}") + flags[arg] = args[idx + 1] + idx += 2 + return flags + + +def normalize_plan_key(plan_value: str, cwd: Path) -> str: + try: + return Path(plan_value).resolve().relative_to(cwd.resolve()).as_posix() + except ValueError: + return Path(plan_value).as_posix() + + +def load_plan_records(progress_path: Path, cwd: Path) -> dict[str, str]: + if not progress_path.exists(): + return {} + text = progress_path.read_text(encoding="utf-8") + records: dict[str, str] = {} + for line in text.splitlines(): + if not line.startswith(PLAN_PREFIX): + continue + payload = line[len(PLAN_PREFIX) :].strip() + if not payload: + continue + segments = [seg.strip() for seg in payload.split("|")] + if not segments: + continue + plan_path = segments[0] + status = None + for seg in segments[1:]: + if "=" not in seg: + continue + key, value = seg.split("=", 1) + if key.strip() == "status": + status = value.strip() + if not plan_path or status is None: + continue + records[normalize_plan_key(plan_path, cwd)] = status + return records + + +def list_plan_files(plans_dir: Path, cwd: Path) -> list[tuple[str, Path, str]]: + entries: list[tuple[str, Path, str]] = [] + for path in plans_dir.iterdir(): + if not path.is_file(): + continue + match = PLAN_FILE_RE.match(path.name) + if not match: + continue + date_value = match.group(1) + try: + rel = path.resolve().relative_to(cwd.resolve()).as_posix() + except ValueError: + rel = path.as_posix() + entries.append((date_value, path, rel)) + return entries + + +def select_plan(plans_dir: Path, progress_path: Path) -> tuple[int, str]: + cwd = Path.cwd() + if not plans_dir.is_dir(): + return 2, f"ERROR: plans dir not found: {plans_dir}" + plans = list_plan_files(plans_dir, cwd) + if not plans: + return 2, "ERROR: no plan files found" + + records = load_plan_records(progress_path, cwd) + + in_progress = [item for item in plans if records.get(item[2]) == "in-progress"] + if in_progress: + in_progress.sort(key=lambda item: (item[0], item[2])) + return 0, in_progress[-1][2] + + pending = [ + item + for item in plans + if records.get(item[2]) not in ("done", "blocked") + ] + if not pending: + return 2, "ERROR: no pending plans" + + pending.sort(key=lambda item: (item[0], item[2])) + return 0, pending[-1][2] + + +def ensure_plan_section(text: str) -> str: + if PLAN_SECTION_HEADER in text: + return text + suffix = text + if suffix and not suffix.endswith("\n"): + suffix += "\n" + if suffix: + suffix += "\n" + suffix += PLAN_SECTION_HEADER + "\n" + return suffix + + +def normalize_note(note: str) -> str: + cleaned = note.replace("\n", " ").replace("|", " ").strip() + return cleaned + + +def record_status(plan: str, status: str, progress_path: Path, note: Optional[str]) -> tuple[int, str]: + if status not in VALID_STATUSES: + return 2, f"ERROR: invalid status: {status}" + if not plan: + return 2, "ERROR: plan is required" + + progress_path.parent.mkdir(parents=True, exist_ok=True) + if progress_path.exists(): + text = progress_path.read_text(encoding="utf-8") + else: + text = "# 开发进度追踪\n" + + text = ensure_plan_section(text) + if not text.endswith("\n"): + text += "\n" + + date_value = datetime.now().strftime("%Y-%m-%d") + plan_path = Path(plan).as_posix() + line = f"{PLAN_PREFIX} {plan_path} | status={status} | date={date_value}" + if note: + cleaned = normalize_note(note) + if cleaned: + line += f" | note={cleaned}" + text += line + "\n" + progress_path.write_text(text, encoding="utf-8") + return 0, line + + +def main(argv: list[str]) -> int: + if not argv: + print(usage(), file=sys.stderr) + return 2 + if argv[0] in ("-h", "-help"): + print(usage()) + return 0 + + mode = argv[0] + if mode not in ("select", "record"): + print(f"ERROR: unknown mode: {mode}", file=sys.stderr) + print(usage(), file=sys.stderr) + return 2 + + try: + flags = parse_flags(argv[1:]) + except ValueError as exc: + if str(exc) == "help": + print(usage()) + return 0 + print(f"ERROR: {exc}", file=sys.stderr) + print(usage(), file=sys.stderr) + return 2 + + if mode == "select": + plans = flags.get("-plans") + progress = flags.get("-progress") + if not plans or not progress: + print("ERROR: -plans and -progress are required", file=sys.stderr) + print(usage(), file=sys.stderr) + return 2 + code, message = select_plan(Path(plans), Path(progress)) + if code != 0: + print(message, file=sys.stderr) + return code + print(message) + return 0 + + plan = flags.get("-plan") + status = flags.get("-status") + progress = flags.get("-progress") + note = flags.get("-note") + if not plan or not status or not progress: + print("ERROR: -plan, -status, and -progress are required", file=sys.stderr) + print(usage(), file=sys.stderr) + return 2 + code, message = record_status(plan, status, Path(progress), note) + if code != 0: + print(message, file=sys.stderr) + return code + print(message) + return 0 + + +if __name__ == "__main__": + sys.exit(main(sys.argv[1:])) diff --git a/scripts/playbook.py b/scripts/playbook.py index 1489ea2..39b012e 100644 --- a/scripts/playbook.py +++ b/scripts/playbook.py @@ -168,6 +168,45 @@ def normalize_langs(raw: object) -> list[str]: return cleaned +def resolve_main_language(config: dict, context: dict) -> str: + raw = config.get("main_language") + if raw is not None and str(raw).strip(): + return str(raw).strip() + + full_config = context.get("config", {}) + if isinstance(full_config, dict): + sync_conf = full_config.get("sync_standards") + if isinstance(sync_conf, dict): + langs_raw = sync_conf.get("langs") + if langs_raw is not None: + try: + langs = normalize_langs(langs_raw) + except ValueError: + langs = [] + if langs: + return langs[0] + + return "tsl" + + +def resolve_playbook_scripts(project_root: Path, context: dict) -> str: + playbook_scripts = PLAYBOOK_ROOT / "scripts" + try: + rel = playbook_scripts.resolve().relative_to(project_root.resolve()) + return rel.as_posix() + except ValueError: + full_config = context.get("config", {}) + if isinstance(full_config, dict): + vendor_conf = full_config.get("vendor") + if isinstance(vendor_conf, dict): + target_dir = vendor_conf.get("target_dir") + if target_dir: + target_str = str(target_dir).strip().rstrip("/").rstrip("\\") + if target_str: + return f"{target_str}/scripts" + return "docs/standards/playbook/scripts" + + def read_git_commit(root: Path) -> str: try: result = subprocess.run( @@ -348,10 +387,20 @@ def vendor_action(config: dict, context: dict) -> int: return 0 -def replace_placeholders(text: str, project_name: str | None, date_value: str) -> str: +def replace_placeholders( + text: str, + project_name: str | None, + date_value: str, + main_language: str | None, + playbook_scripts: str | None, +) -> str: result = text.replace("{{DATE}}", date_value) if project_name: result = result.replace("{{PROJECT_NAME}}", project_name) + if main_language: + result = result.replace("{{MAIN_LANGUAGE}}", main_language) + if playbook_scripts: + result = result.replace("{{PLAYBOOK_SCRIPTS}}", playbook_scripts) return result @@ -370,10 +419,18 @@ def rename_template_files(root: Path) -> None: template.rename(target) -def replace_placeholders_in_dir(root: Path, project_name: str | None, date_value: str) -> None: +def replace_placeholders_in_dir( + root: Path, + project_name: str | None, + date_value: str, + main_language: str | None, + playbook_scripts: str | None, +) -> None: for file_path in root.rglob("*.md"): text = file_path.read_text(encoding="utf-8") - updated = replace_placeholders(text, project_name, date_value) + updated = replace_placeholders( + text, project_name, date_value, main_language, playbook_scripts + ) if updated != text: file_path.write_text(updated, encoding="utf-8") @@ -401,9 +458,13 @@ def update_agents_section( end_marker: str, project_name: str | None, date_value: str, + main_language: str | None, + playbook_scripts: str | None, ) -> None: template_text = template_path.read_text(encoding="utf-8") - template_text = replace_placeholders(template_text, project_name, date_value) + template_text = replace_placeholders( + template_text, project_name, date_value, main_language, playbook_scripts + ) block = extract_block_lines(template_text, start_marker, end_marker) if not block: log("Skip: markers not found in template") @@ -454,6 +515,8 @@ def sync_templates_action(config: dict, context: dict) -> int: return 2 project_name = config.get("project_name") + main_language = resolve_main_language(config, context) + playbook_scripts = resolve_playbook_scripts(project_root, context) date_value = config.get("date") or datetime.now().strftime("%Y-%m-%d") force = bool(config.get("force", False)) no_backup = bool(config.get("no_backup", False)) @@ -472,7 +535,13 @@ def sync_templates_action(config: dict, context: dict) -> int: backup_path(memory_dst, no_backup) copytree(memory_src, memory_dst) rename_template_files(memory_dst) - replace_placeholders_in_dir(memory_dst, project_name, date_value) + replace_placeholders_in_dir( + memory_dst, + project_name, + date_value, + main_language, + playbook_scripts, + ) log("Synced: memory-bank/") if prompts_src.is_dir(): @@ -484,7 +553,13 @@ def sync_templates_action(config: dict, context: dict) -> int: ensure_dir(prompts_dst.parent) copytree(prompts_src, prompts_dst) rename_template_files(prompts_dst) - replace_placeholders_in_dir(prompts_dst, project_name, date_value) + replace_placeholders_in_dir( + prompts_dst, + project_name, + date_value, + main_language, + playbook_scripts, + ) log("Synced: docs/prompts/") if agents_src.is_file(): @@ -496,7 +571,14 @@ def sync_templates_action(config: dict, context: dict) -> int: start_marker = "" end_marker = "" update_agents_section( - agents_dst, agents_src, start_marker, end_marker, project_name, date_value + agents_dst, + agents_src, + start_marker, + end_marker, + project_name, + date_value, + main_language, + playbook_scripts, ) if rules_src.is_file(): @@ -506,7 +588,9 @@ def sync_templates_action(config: dict, context: dict) -> int: else: backup_path(rules_dst, no_backup) text = rules_src.read_text(encoding="utf-8") - text = replace_placeholders(text, project_name, date_value) + text = replace_placeholders( + text, project_name, date_value, main_language, playbook_scripts + ) rules_dst.write_text(text + "\n", encoding="utf-8") log("Synced: AGENT_RULES.md") @@ -912,7 +996,11 @@ def main(argv: list[str]) -> int: root = (config_path.parent / root).resolve() else: root = config_path.parent - context = {"project_root": root.resolve(), "config_path": config_path.resolve()} + context = { + "project_root": root.resolve(), + "config_path": config_path.resolve(), + "config": config, + } for name in ORDER: if name in config: diff --git a/templates/AGENTS.template.md b/templates/AGENTS.template.md index cb8c917..844c544 100644 --- a/templates/AGENTS.template.md +++ b/templates/AGENTS.template.md @@ -2,15 +2,6 @@ -## 规则优先级 - -1. 系统/开发者指令与安全约束 -2. 项目私有规则:`AGENT_RULES.local.md`(如存在) -3. 仓库规则:`.agents/` 与本文件 -4. `AGENT_RULES.md` - 执行流程 - ---- - ## 快速导航 @@ -25,35 +16,23 @@ ### 核心规则 -- **项目私有规则**:[AGENT_RULES.local.md](./AGENT_RULES.local.md) -- **执行流程**:[AGENT_RULES.md](./AGENT_RULES.md) -- **AI 行为规范**:[docs/prompts/system/agent-behavior.md](docs/prompts/system/agent-behavior.md) +- [AGENT_RULES.md](./AGENT_RULES.md) - 执行流程与优先级 ### 项目上下文 -- **项目定位**:[memory-bank/project-brief.md](memory-bank/project-brief.md) -- **技术栈**:[memory-bank/tech-stack.md](memory-bank/tech-stack.md) -- **架构设计**:[memory-bank/architecture.md](memory-bank/architecture.md) -- **进度追踪**:[memory-bank/progress.md](memory-bank/progress.md) -- **架构决策**:[memory-bank/decisions.md](memory-bank/decisions.md) +- [memory-bank/project-brief.md](memory-bank/project-brief.md) - 项目定位 +- [memory-bank/tech-stack.md](memory-bank/tech-stack.md) - 技术栈 +- [memory-bank/architecture.md](memory-bank/architecture.md) - 架构设计 +- [memory-bank/progress.md](memory-bank/progress.md) - 进度追踪 +- [memory-bank/decisions.md](memory-bank/decisions.md) - 架构决策 ### 工作流程 -- **需求澄清**:[docs/prompts/coding/clarify.md](docs/prompts/coding/clarify.md) -- **验证检查**:[docs/prompts/coding/verify.md](docs/prompts/coding/verify.md) +- [docs/prompts/coding/clarify.md](docs/prompts/coding/clarify.md) - 需求澄清 +- [docs/prompts/coding/verify.md](docs/prompts/coding/verify.md) - 验证检查 +- [docs/prompts/system/agent-behavior.md](docs/prompts/system/agent-behavior.md) - AI 行为规范 ---- - -## 新会话开始时 - -**AI 应该做的**: - -1. 读取 [AGENT_RULES.local.md](./AGENT_RULES.local.md)(如存在) -2. 读取 [AGENT_RULES.md](./AGENT_RULES.md) -3. 读取 [memory-bank/](memory-bank/) 核心文档 -4. 读取 [docs/prompts/system/agent-behavior.md](docs/prompts/system/agent-behavior.md) -5. 查看 `docs/plans/` 下最新计划(如有) --- diff --git a/templates/AGENT_RULES.template.md b/templates/AGENT_RULES.template.md index 58a9c30..53eca53 100644 --- a/templates/AGENT_RULES.template.md +++ b/templates/AGENT_RULES.template.md @@ -9,28 +9,44 @@ 3. 仓库规则:`.agents/` 与 `AGENTS.md` 4. 本文件 +## 安全红线 + +- 不得在代码/日志/注释中写入明文密钥、密码、Token +- 修改鉴权/权限逻辑必须说明动机与风险 +- 不确定是否敏感时按敏感信息处理 + ## 上下文加载(每次会话开始) **必读文档**(按顺序): 1. `AGENT_RULES.local.md` - 项目私有规则(如存在,优先级高于本文件) -2. `memory-bank/project-brief.md` - 项目定位、边界、约束 -3. `memory-bank/tech-stack.md` - 技术栈、工具链 -4. `memory-bank/architecture.md` - 架构设计、模块职责 -5. `docs/plans/` - 最新实施计划(如存在) +2. `.agents/index.md` - 语言规则入口(如存在) +3. `memory-bank/project-brief.md` - 项目定位、边界、约束 +4. `memory-bank/tech-stack.md` - 技术栈、工具链 +5. `memory-bank/architecture.md` - 架构设计、模块职责 +6. `memory-bank/decisions.md` - 重要决策记录(如存在) +7. `memory-bank/progress.md` - 执行进度与状态(如存在) +8. `docs/plans/` - 最新实施计划(如存在) **目的**:让 AI 快速理解项目全貌,避免重复解释。 ## 主循环 -1. 选择当前 Plan 文档(优先 `docs/plans/` 最新计划) -2. 阅读 Plan 内容与执行顺序 -3. 执行该 Plan 内所有可执行子任务 -4. 校验输出结果(运行测试/检查日志) -5. **更新 `memory-bank/progress.md`**(记录已完成事项) -6. 如存在歧义/风险/决策点,在回复中明确提出,并视需要记录到 `memory-bank/decisions.md` -7. 若 Plan 已全部完成,更新 Plan 状态并在 `memory-bank/progress.md` 记录完成 -8. 若 Plan 因缺少信息而阻塞,在 `memory-bank/progress.md` 标记阻塞原因 +0. 选择 Plan: + - 运行 `python {{PLAYBOOK_SCRIPTS}}/plan_progress.py select -plans docs/plans -progress memory-bank/progress.md` + - 如无可执行 Plan,说明情况并询问用户下一步(新增 Plan/切换任务/结束) +1. 标记开始: + - `python {{PLAYBOOK_SCRIPTS}}/plan_progress.py record -plan -status in-progress -progress memory-bank/progress.md` +2. 阅读 Plan: + - 理解目标、子任务与验证标准 +3. 逐步执行: + - 按顺序执行子任务 + - 每步完成后进行必要验证(测试/日志/diff) + - 遇到阻塞立即记录并停止 +4. 记录结果(写入 `memory-bank/progress.md`): + - 完成:`python {{PLAYBOOK_SCRIPTS}}/plan_progress.py record -plan -status done -progress memory-bank/progress.md` + - 阻塞:`python {{PLAYBOOK_SCRIPTS}}/plan_progress.py record -plan -status blocked -progress memory-bank/progress.md -note <原因>` +5. 如存在歧义/风险/决策点,在回复中明确提出,并视需要记录到 `memory-bank/decisions.md` ## Plan 规则 @@ -39,11 +55,9 @@ - `Parent Plan`(上层/集成计划链接) - `Verification Scope`(local 或 integration) - `Verification Gate`(must-pass) -- **不允许中断任务**:Plan 中不应包含必然失败或依赖未确认的信息;未确认项必须在 brainstorming 阶段解决后再产出 Plan +- **不允许中断任务**:Plan 中不应包含必然失败或依赖未确认的信息;未确认项必须在 `$brainstorming` 阶段解决后再产出 Plan - **验证必须可通过**:Plan 内验证应为当前阶段可通过的局部验证;需要集成验证的内容放入上层/集成 Plan - 不因等待确认而中断可执行步骤;待确认事项在回复中列出 -- 执行并验证该 Plan 中所有可执行的子任务 -- 若因缺少信息/决策而阻塞:在 `memory-bank/progress.md` 记录阻塞原因 - 每轮只处理一个 Plan - **小步快跑**:每个 Plan 应该可快速完成 - **可验证**:每个 Plan 必须包含验证步骤 @@ -60,7 +74,7 @@ - **重要决策**:记录到 `memory-bank/decisions.md`(ADR 格式) - **待确认事项**:在回复中列出并等待确认 -- **进度留痕**:记录到 `memory-bank/progress.md`(持久化) +- **进度留痕**:通过 `{{PLAYBOOK_SCRIPTS}}/plan_progress.py` 写入 `memory-bank/progress.md`,该文件为 Plan 状态唯一权威 ## 需要确认的场景 @@ -75,11 +89,11 @@ 每个 Plan 完成后,必须验证: -- [ ] 代码修改符合 `.agents/` 下的规则 -- [ ] 相关测试通过 +- [ ] 代码修改符合 `.agents/` 下的规则(如有) +- [ ] 相关测试通过(如有测试且未被豁免) - [ ] 换行符正确 - [ ] 无语法错误 -- [ ] 更新了 `memory-bank/progress.md` +- [ ] 已更新 `memory-bank/progress.md` --- diff --git a/templates/README.md b/templates/README.md index eb6488d..8132711 100644 --- a/templates/README.md +++ b/templates/README.md @@ -53,7 +53,7 @@ full = false python docs/standards/playbook/scripts/playbook.py -config playbook.toml ``` -参数说明见 `docs/standards/playbook/playbook.toml.example`。 +参数说明见 `playbook.toml.example`(仓库根目录)或 vendoring 后的 `docs/standards/playbook/playbook.toml.example`。 ### 部署行为 @@ -92,12 +92,18 @@ project/ | 占位符 | 说明 | 自动替换 | | ------------------------- | ------------ | -------- | | `{{DATE}}` | 日期 | ✅ 是 | -| `{{PROJECT_NAME}}` | 项目名称 | ❌ 手动 | +| `{{PROJECT_NAME}}` | 项目名称 | ✅ 可选 | | `{{PROJECT_GOAL}}` | 项目目标 | ❌ 手动 | | `{{PROJECT_DESCRIPTION}}` | 项目描述 | ❌ 手动 | -| `{{MAIN_LANGUAGE}}` | 主语言 | ❌ 手动 | +| `{{MAIN_LANGUAGE}}` | 主语言 | ✅ 可选 | +| `{{PLAYBOOK_SCRIPTS}}` | 脚本路径 | ✅ 是 | | 其他 `{{...}}` | 项目特定内容 | ❌ 手动 | +`{{PROJECT_NAME}}` 可通过 `sync_templates.project_name` 自动替换;未配置时保持原样。 +`{{MAIN_LANGUAGE}}` 可通过 `sync_templates.main_language` 或 `sync_standards.langs[0]` 自动替换; +未配置时默认 `tsl`。 +`{{PLAYBOOK_SCRIPTS}}` 自动替换为 Playbook 脚本路径(默认 `docs/standards/playbook/scripts`)。 + ## 模板说明 ### memory-bank/ @@ -126,7 +132,9 @@ project/ 执行流程规范,定义 AI 的工作循环和约束。 如需项目私有规则,建议创建 `AGENT_RULES.local.md`,其优先级高于 `AGENT_RULES.md`, -且不会被同步脚本覆盖。 +且不会被 `playbook.py` 覆盖。 +主循环会根据 `memory-bank/progress.md` 的 Plan 状态与 `docs/plans/` 文件名日期, +自动选择最新未完成的 Plan,并要求通过 `scripts/plan_progress.py` 写入进度。 ### 示例:不跑测试的计划提示词 @@ -170,27 +178,30 @@ project/ ### ci/、cpp/、python/ -语言和 CI 配置模板。通过 playbook.py 的 `[sync_templates]` 部署: +语言和 CI 配置模板。通过 playbook.py 的 `[vendor]` 复制到快照中: -| 目录 | 内容 | 部署位置 | -| ----------- | ----------------------------------------- | ---------- | -| `ci/gitea/` | Gitea Actions 工作流 | `.gitea/` | -| `cpp/` | .clang-format, .clangd, CMakeLists.txt 等 | 项目根目录 | -| `python/` | pyproject.toml, .editorconfig 等 | 项目根目录 | +| 目录 | 内容 | 部署位置 | +| ----------- | ----------------------------------------- | ------------------------ | +| `ci/gitea/` | Gitea Actions 工作流 | 快照 `templates/ci/` | +| `cpp/` | .clang-format, .clangd, CMakeLists.txt 等 | 快照 `templates/cpp/` | +| `python/` | pyproject.toml, .editorconfig 等 | 快照 `templates/python/` | + +> 注意:这些模板通过 `[vendor]` 复制到快照的 `templates/` 目录,需手动从快照复制到项目根目录使用。 **使用方式**: ```toml -# playbook.toml +# playbook.toml - 生成包含这些模板的快照 [playbook] project_root = "/path/to/project" -[sync_templates] -project_name = "MyProject" +[vendor] +langs = ["tsl", "cpp", "python"] ``` ```bash -python docs/standards/playbook/scripts/playbook.py -config playbook.toml +python scripts/playbook.py -config playbook.toml +# 然后手动从 docs/standards/playbook/templates/ 复制所需配置到项目根目录 ``` ## 与 playbook 其他部分的关系 @@ -202,7 +213,8 @@ playbook/ ├── docs/ # 权威静态文档 ├── templates/ # 本目录:项目架构模板 → 部署到 memory-bank/ 等 └── scripts/ - └── playbook.py # 统一入口:vendor/sync_templates/sync_standards/... + ├── playbook.py # 统一入口:vendor/sync_templates/sync_standards/... + └── plan_progress.py # Plan 选择与进度记录 ``` ## 完整部署流程 @@ -218,4 +230,4 @@ python docs/standards/playbook/scripts/playbook.py -config playbook.toml --- -**最后更新**:2026-01-21 +**最后更新**:2026-01-26 diff --git a/templates/ci/README.md b/templates/ci/README.md index e0c4372..398dc59 100644 --- a/templates/ci/README.md +++ b/templates/ci/README.md @@ -6,6 +6,9 @@ - `gitea/`:Gitea Actions(GitHub Actions 语法) +说明:`templates/ci/gitea/.gitea/` 结构用于与目标项目根目录的 `.gitea/` +保持一致,便于直接复制到项目根目录。 + ## 使用(Gitea Actions) 前提:目标项目已经 vendoring Playbook(例如 `docs/standards/playbook/`)。 diff --git a/templates/memory-bank/progress.template.md b/templates/memory-bank/progress.template.md index 8820aeb..5bbff10 100644 --- a/templates/memory-bank/progress.template.md +++ b/templates/memory-bank/progress.template.md @@ -1,32 +1,8 @@ # 开发进度追踪 -## 当前阶段:{{CURRENT_PHASE}} +## 已知问题 -### 最近完成 - -#### {{DATE}} - -- [x] {{COMPLETED_1}} -- [x] {{COMPLETED_2}} - -### 进行中 - -- [ ] {{IN_PROGRESS_1}} -- [ ] {{IN_PROGRESS_2}} - -### 待办 - -#### {{CATEGORY_1}} - -- [ ] {{TODO_1}} -- [ ] {{TODO_2}} - -#### {{CATEGORY_2}} - -- [ ] {{TODO_3}} -- [ ] {{TODO_4}} - -### 已知问题 + #### {{ISSUE_CATEGORY_1}} @@ -34,7 +10,7 @@ - **临时方案**:{{WORKAROUND_1}} - **长期方案**:{{SOLUTION_1}} -### 里程碑 +## 里程碑 #### M1: {{MILESTONE_1}}(目标:{{TARGET_DATE_1}}) @@ -46,14 +22,9 @@ - [ ] {{MILESTONE_2_TASK_1}} - [ ] {{MILESTONE_2_TASK_2}} ---- +## Plan 状态记录 -## 更新日志 - -### {{DATE}} - -- {{LOG_1}} -- {{LOG_2}} + --- diff --git a/tests/README.md b/tests/README.md index 0238ba1..717c888 100644 --- a/tests/README.md +++ b/tests/README.md @@ -9,6 +9,12 @@ tests/ ├── README.md # 本文件:测试文档 ├── cli/ # Python CLI 测试(unittest) │ └── test_playbook_cli.py # playbook.py 基础功能测试 +├── test_format_md_action.py # format_md 动作测试 +├── test_gitattributes_modes.py # gitattr_mode 行为测试 +├── test_plan_progress_cli.py # plan_progress CLI 测试 +├── test_superpowers_list_sync.py # superpowers 列表一致性测试 +├── test_sync_templates_placeholders.py # 占位符替换测试 +├── test_toml_edge_cases.py # TOML 解析边界测试 ├── templates/ # 模板验证测试 │ ├── validate_python_templates.sh # Python 模板验证 │ ├── validate_cpp_templates.sh # C++ 模板验证 @@ -27,6 +33,9 @@ cd /path/to/playbook # 1. 运行 Python CLI 测试 python -m unittest discover -s tests/cli -v +# 1.1 运行其他 Python 测试(tests/ 下的 test_*.py) +python -m unittest discover -s tests -p "test_*.py" -v + # 2. 运行模板验证测试 sh tests/templates/validate_python_templates.sh sh tests/templates/validate_cpp_templates.sh diff --git a/tests/test_format_md_action.py b/tests/test_format_md_action.py new file mode 100644 index 0000000..b9d3555 --- /dev/null +++ b/tests/test_format_md_action.py @@ -0,0 +1,58 @@ +import os +import subprocess +import sys +import tempfile +import unittest +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +SCRIPT = ROOT / "scripts" / "playbook.py" + + +def run_cli(*args, env=None): + return subprocess.run( + [sys.executable, str(SCRIPT), *args], + capture_output=True, + text=True, + env=env, + ) + + +class FormatMdActionTests(unittest.TestCase): + def test_format_md_invokes_prettier_from_path(self): + with tempfile.TemporaryDirectory() as tmp_dir: + root = Path(tmp_dir) + (root / "README.md").write_text("# Title\n", encoding="utf-8") + + bin_dir = root / "bin" + bin_dir.mkdir() + prettier = bin_dir / "prettier" + prettier.write_text( + "#!/usr/bin/env python3\n" + "from pathlib import Path\n" + "Path(\".prettier_called\").write_text(\"ok\")\n", + encoding="utf-8", + ) + prettier.chmod(0o755) + + config_body = f""" +[playbook] +project_root = \"{tmp_dir}\" + +[format_md] +# tool defaults to prettier +# keep default globs +""" + config_path = root / "playbook.toml" + config_path.write_text(config_body, encoding="utf-8") + + env = os.environ.copy() + env["PATH"] = f"{bin_dir}:{env.get('PATH', '')}" + + result = run_cli("-config", str(config_path), env=env) + self.assertEqual(result.returncode, 0, msg=result.stderr) + self.assertTrue((root / ".prettier_called").exists()) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_gitattributes_modes.py b/tests/test_gitattributes_modes.py new file mode 100644 index 0000000..2004b7f --- /dev/null +++ b/tests/test_gitattributes_modes.py @@ -0,0 +1,94 @@ +import subprocess +import sys +import tempfile +import unittest +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +SCRIPT = ROOT / "scripts" / "playbook.py" +SOURCE_GITATTR = ROOT / ".gitattributes" + + +def run_cli(*args, cwd=None): + return subprocess.run( + [sys.executable, str(SCRIPT), *args], + capture_output=True, + text=True, + cwd=cwd, + ) + + +def read_entries(path: Path) -> list[str]: + entries = [] + for line in path.read_text(encoding="utf-8").splitlines(): + stripped = line.strip() + if not stripped or stripped.startswith("#"): + continue + entries.append(stripped) + return entries + + +class GitattributesModeTests(unittest.TestCase): + def _run_sync(self, root: Path, mode: str) -> subprocess.CompletedProcess: + config_body = f""" +[playbook] +project_root = \"{root}\" + +[sync_standards] +langs = [\"tsl\"] +gitattr_mode = \"{mode}\" +""" + config_path = root / "playbook.toml" + config_path.write_text(config_body, encoding="utf-8") + return run_cli("-config", str(config_path), cwd=root) + + def test_gitattr_mode_skip(self): + with tempfile.TemporaryDirectory() as tmp_dir: + root = Path(tmp_dir) + sentinel = "*.keep text eol=lf\n" + (root / ".gitattributes").write_text(sentinel, encoding="utf-8") + + result = self._run_sync(root, "skip") + self.assertEqual(result.returncode, 0) + self.assertEqual( + (root / ".gitattributes").read_text(encoding="utf-8"), + sentinel, + ) + + def test_gitattr_mode_overwrite(self): + with tempfile.TemporaryDirectory() as tmp_dir: + root = Path(tmp_dir) + (root / ".gitattributes").write_text("bad\n", encoding="utf-8") + + result = self._run_sync(root, "overwrite") + self.assertEqual(result.returncode, 0) + self.assertEqual( + (root / ".gitattributes").read_text(encoding="utf-8"), + SOURCE_GITATTR.read_text(encoding="utf-8"), + ) + + def test_gitattr_mode_block(self): + with tempfile.TemporaryDirectory() as tmp_dir: + root = Path(tmp_dir) + result = self._run_sync(root, "block") + self.assertEqual(result.returncode, 0) + content = (root / ".gitattributes").read_text(encoding="utf-8") + self.assertIn("# BEGIN playbook .gitattributes", content) + self.assertIn("# END playbook .gitattributes", content) + + def test_gitattr_mode_append(self): + with tempfile.TemporaryDirectory() as tmp_dir: + root = Path(tmp_dir) + src_entries = read_entries(SOURCE_GITATTR) + (root / ".gitattributes").write_text( + src_entries[0] + "\n", encoding="utf-8" + ) + + result = self._run_sync(root, "append") + self.assertEqual(result.returncode, 0) + content = (root / ".gitattributes").read_text(encoding="utf-8") + self.assertIn("Added from playbook .gitattributes", content) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_plan_progress_cli.py b/tests/test_plan_progress_cli.py new file mode 100644 index 0000000..e7e3b89 --- /dev/null +++ b/tests/test_plan_progress_cli.py @@ -0,0 +1,106 @@ +import subprocess +import sys +import tempfile +import unittest +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +SCRIPT = ROOT / "scripts" / "plan_progress.py" + + +def run_cli(*args, cwd=None): + return subprocess.run( + [sys.executable, str(SCRIPT), *args], + capture_output=True, + text=True, + cwd=cwd, + ) + + +class PlanProgressCliTests(unittest.TestCase): + def test_select_prefers_in_progress(self): + with tempfile.TemporaryDirectory() as tmp_dir: + root = Path(tmp_dir) + plans_dir = root / "docs" / "plans" + plans_dir.mkdir(parents=True) + (plans_dir / "2026-01-01-old.md").write_text("old", encoding="utf-8") + (plans_dir / "2026-01-02-new.md").write_text("new", encoding="utf-8") + + progress = root / "memory-bank" / "progress.md" + progress.parent.mkdir(parents=True) + progress.write_text( + "[PLAN] docs/plans/2026-01-01-old.md | status=in-progress | date=2026-01-03\n", + encoding="utf-8", + ) + + result = run_cli( + "select", + "-plans", + "docs/plans", + "-progress", + "memory-bank/progress.md", + cwd=root, + ) + + self.assertEqual(result.returncode, 0) + self.assertEqual(result.stdout.strip(), "docs/plans/2026-01-01-old.md") + + def test_select_skips_done_and_blocked(self): + with tempfile.TemporaryDirectory() as tmp_dir: + root = Path(tmp_dir) + plans_dir = root / "docs" / "plans" + plans_dir.mkdir(parents=True) + (plans_dir / "2026-01-01-a.md").write_text("a", encoding="utf-8") + (plans_dir / "2026-01-02-b.md").write_text("b", encoding="utf-8") + + progress = root / "memory-bank" / "progress.md" + progress.parent.mkdir(parents=True) + progress.write_text( + "\n".join( + [ + "[PLAN] docs/plans/2026-01-02-b.md | status=done | date=2026-01-03", + "[PLAN] docs/plans/2026-01-01-a.md | status=blocked | date=2026-01-03", + "", + ] + ), + encoding="utf-8", + ) + + result = run_cli( + "select", + "-plans", + "docs/plans", + "-progress", + "memory-bank/progress.md", + cwd=root, + ) + + self.assertNotEqual(result.returncode, 0) + self.assertIn("no pending plans", (result.stdout + result.stderr).lower()) + + def test_record_creates_section(self): + with tempfile.TemporaryDirectory() as tmp_dir: + root = Path(tmp_dir) + progress = root / "memory-bank" / "progress.md" + + result = run_cli( + "record", + "-plan", + "docs/plans/2026-01-03-demo.md", + "-status", + "done", + "-progress", + "memory-bank/progress.md", + "-note", + "done", + cwd=root, + ) + + self.assertEqual(result.returncode, 0) + text = progress.read_text(encoding="utf-8") + self.assertIn("## Plan 状态记录", text) + self.assertIn("status=done", text) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_superpowers_list_sync.py b/tests/test_superpowers_list_sync.py new file mode 100644 index 0000000..3dd723b --- /dev/null +++ b/tests/test_superpowers_list_sync.py @@ -0,0 +1,42 @@ +import unittest +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +SKILLS_MD = ROOT / "SKILLS.md" +SOURCES_LIST = ROOT / "codex" / "skills" / ".sources" / "superpowers.list" + + +def read_sources_list() -> list[str]: + return [ + line.strip() + for line in SOURCES_LIST.read_text(encoding="utf-8").splitlines() + if line.strip() and not line.strip().startswith("#") + ] + + +def read_skills_md_list() -> list[str]: + lines = SKILLS_MD.read_text(encoding="utf-8").splitlines() + start = "" + end = "" + try: + start_idx = lines.index(start) + 1 + end_idx = lines.index(end) + except ValueError as exc: + raise AssertionError("superpowers markers missing in SKILLS.md") from exc + + items = [] + for line in lines[start_idx:end_idx]: + stripped = line.strip() + if not stripped.startswith("-"): + continue + items.append(stripped.lstrip("- ").strip()) + return items + + +class SuperpowersListSyncTests(unittest.TestCase): + def test_superpowers_list_matches_skills_md(self): + self.assertEqual(read_sources_list(), read_skills_md_list()) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_sync_templates_placeholders.py b/tests/test_sync_templates_placeholders.py new file mode 100644 index 0000000..5737ccb --- /dev/null +++ b/tests/test_sync_templates_placeholders.py @@ -0,0 +1,51 @@ +import subprocess +import sys +import tempfile +import unittest +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +SCRIPT = ROOT / "scripts" / "playbook.py" + + +def run_cli(*args): + return subprocess.run( + [sys.executable, str(SCRIPT), *args], + capture_output=True, + text=True, + ) + + +class SyncTemplatesPlaceholdersTests(unittest.TestCase): + def test_main_language_placeholder_replaced(self): + with tempfile.TemporaryDirectory() as tmp_dir: + config_body = f""" +[playbook] +project_root = \"{tmp_dir}\" + +[sync_templates] +project_name = \"Demo\" +full = true + +[sync_standards] +langs = [\"cpp\", \"tsl\"] +""" + config_path = Path(tmp_dir) / "playbook.toml" + config_path.write_text(config_body, encoding="utf-8") + + result = run_cli("-config", str(config_path)) + self.assertEqual(result.returncode, 0, msg=result.stderr) + + agents_md = Path(tmp_dir) / "AGENTS.md" + text = agents_md.read_text(encoding="utf-8") + self.assertIn(".agents/cpp/index.md", text) + self.assertNotIn("{{MAIN_LANGUAGE}}", text) + + rules_md = Path(tmp_dir) / "AGENT_RULES.md" + rules_text = rules_md.read_text(encoding="utf-8") + self.assertIn("docs/standards/playbook/scripts/plan_progress.py", rules_text) + self.assertNotIn("{{PLAYBOOK_SCRIPTS}}", rules_text) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_toml_edge_cases.py b/tests/test_toml_edge_cases.py new file mode 100644 index 0000000..a64e641 --- /dev/null +++ b/tests/test_toml_edge_cases.py @@ -0,0 +1,23 @@ +import unittest + +from scripts import playbook + + +class TomlEdgeCaseTests(unittest.TestCase): + def test_minimal_parser_allows_dotted_section_name(self): + raw = """ +[a.b] +key = 1 +""" + data = playbook.loads_toml_minimal(raw) + self.assertIn("a.b", data) + self.assertEqual(data["a.b"]["key"], 1) + + def test_minimal_parser_rejects_multiline_string(self): + raw = '[section]\nvalue = """line1\nline2"""\n' + with self.assertRaises(ValueError): + playbook.loads_toml_minimal(raw) + + +if __name__ == "__main__": + unittest.main()