feat(playbook): add install_mode deployment config

BREAKING CHANGE: [vendor] and playbook.deploy_root are no longer supported. Use [playbook].install_mode and playbook.playbook_root instead.
This commit is contained in:
csh 2026-06-01 10:49:16 +08:00
parent 2c3a559a30
commit f2ab57b39f
15 changed files with 376 additions and 184 deletions

View File

@ -1,6 +1,7 @@
---
name: 🧪 Playbook 测试套件
on:
"on":
push:
branches:
- main
@ -54,7 +55,8 @@ jobs:
git clone "$REPO_URL" "$REPO_DIR"
if git -C "$REPO_DIR" cat-file -e "${TARGET_SHA}^{commit}" 2>/dev/null; then
if git -C "$REPO_DIR" cat-file -e \
"${TARGET_SHA}^{commit}" 2>/dev/null; then
git -C "$REPO_DIR" checkout -f "$TARGET_SHA"
else
if [ -n "$TARGET_REF" ]; then
@ -90,16 +92,11 @@ jobs:
set -euo pipefail
echo "========================================"
echo "🧪 Python CLI 测试"
echo "🧪 Python 测试"
echo "========================================"
cd "$REPO_DIR"
python3 -m unittest discover -s tests/cli -v
echo "========================================"
echo "🧪 Python 扩展测试"
echo "========================================"
echo "覆盖CLI、subtree/snapshot 部署路线、模板同步、文档一致性"
python3 -m unittest discover -s tests -p "test_*.py" -v
echo "========================================"

View File

@ -58,6 +58,8 @@ python scripts/playbook.py -config playbook.toml
```toml
[playbook]
project_root = "/path/to/project"
playbook_root = "docs/standards/playbook"
install_mode = "snapshot"
[sync_rules]
# force = true # 可选
@ -81,12 +83,12 @@ project_name = "MyProject"
```bash
# spec 写完后
python <deploy_root>/scripts/playbook.py \
python <playbook_root>/scripts/playbook.py \
-record-spec docs/superpowers/specs/<topic>-design.md \
-progress memory-bank/progress.md
# plan 写完后
python <deploy_root>/scripts/playbook.py \
python <playbook_root>/scripts/playbook.py \
-record-plan docs/superpowers/plans/<topic>.md \
-progress memory-bank/progress.md
```
@ -199,10 +201,11 @@ TSL 相关问题直接查阅 `rulesets/tsl/index.md` 与 `docs/tsl/`。
先区分三个路径概念:
- `project_root`:目标项目根目录。
- `deploy_root`:相对于 `project_root` 的项目内目标目录。
- 外部 clone 出来的 Playbook 路径(如 `/opt/playbook`):只是执行部署脚本的位置,不是部署目标。
- `playbook_root`:相对于 `project_root` 的项目内 Playbook 根目录。
- `install_mode``subtree` 表示 Playbook 已由 git subtree 放在项目内;`snapshot` 表示从外部 clone 安装裁剪快照。
- 外部 clone 出来的 Playbook 路径(如 `/opt/playbook`):只是执行安装脚本的位置,不是项目内 Playbook 根目录。
以 TSL 为例Playbook 在项目内的默认部署根是 `docs/standards/playbook`;如果你把 `deploy_root` 改成 `custom/playbook`,则部署结果会落到 `<project_root>/custom/playbook`,文档和脚本入口也会跟着变成 `custom/playbook/docs/...`、`custom/playbook/scripts/...`。
以 TSL 为例Playbook 在项目内的默认根是 `docs/standards/playbook`;如果你把 `playbook_root` 改成 `custom/playbook`,则快照会落到 `<project_root>/custom/playbook`,文档和脚本入口也会跟着变成 `custom/playbook/docs/...`、`custom/playbook/scripts/...`。
#### 方式一:`git subtree`
@ -214,6 +217,8 @@ git subtree add --prefix docs/standards/playbook https://git.mytsl.cn/csh/playbo
cat <<'EOF' > playbook.toml
[playbook]
project_root = "."
playbook_root = "docs/standards/playbook"
install_mode = "subtree"
[sync_standards]
langs = ["tsl"]
@ -249,6 +254,8 @@ git commit -m ":package: deps(playbook): add tsl standards"
# playbook.toml
[playbook]
project_root = "."
playbook_root = "docs/standards/playbook"
install_mode = "subtree"
[sync_standards]
langs = ["tsl", "cpp"]
@ -277,19 +284,17 @@ git commit -m ":package: deps(playbook): add tsl standards"
git clone https://git.mytsl.cn/csh/playbook.git /opt/playbook
```
2. 在目标项目根创建 `playbook.toml`,并用 `deploy_root` 指定项目内的部署根。例如:
2. 在目标项目根创建 `playbook.toml`,并用 `playbook_root` 指定项目内的 Playbook 根。例如:
- `project_root` 写目标项目根目录。
- `deploy_root` 写目标项目内的相对路径。
- 不要把外部 clone 的路径(如 `/opt/playbook`)写进 `deploy_root`;那只是你执行脚本的位置。
- `playbook_root` 写目标项目内的相对路径。
- `install_mode = "snapshot"` 表示由外部 clone 安装项目内快照。
- 不要把外部 clone 的路径(如 `/opt/playbook`)写进 `playbook_root`;那只是你执行脚本的位置。
```toml
[playbook]
project_root = "."
deploy_root = "custom/playbook"
[vendor]
langs = ["tsl"]
playbook_root = "custom/playbook"
install_mode = "snapshot"
[sync_standards]
langs = ["tsl"]
@ -308,10 +313,10 @@ git commit -m ":package: deps(playbook): add tsl standards"
说明:
- 这里的 `[vendor]` 是“把 Playbook 快照部署进目标项目”的执行步骤,不是第三种正式部署路线
- `deploy_root` 永远表示目标项目内的部署目录;它不是外部 clone 出来的 Playbook 仓库路径。
- 外部 clone 场景下必须显式填写 `deploy_root`;脚本不会替你补默认部署目录。
- 如果 `deploy_root = "custom/playbook"`,部署后的项目内入口会是 `custom/playbook/scripts/playbook.py`、`custom/playbook/docs/index.md`。
- `install_mode = "snapshot"` 会先把 Playbook 裁剪快照安装到目标项目的 `playbook_root`,再执行后续同步动作
- `playbook_root` 永远表示目标项目内的 Playbook 根;它不是外部 clone 出来的 Playbook 仓库路径。
- 外部 clone 场景下必须显式填写 `playbook_root`;脚本不会替你补默认项目内根目录。
- 如果 `playbook_root = "custom/playbook"`,部署后的项目内入口会是 `custom/playbook/scripts/playbook.py`、`custom/playbook/docs/index.md`。
---
@ -347,7 +352,7 @@ git commit -m ":package: deps(playbook): add tsl standards"
├── .gitattributes # 行尾/文本规范
├── AGENTS.md # Codex 入口(由 playbook 自动生成/更新)
├── CLAUDE.md # Claude Code 入口(自动注入 @AGENTS.md
├── <deploy_root>/ # 本 Playbook 在项目内的部署根(默认 docs/standards/playbook
├── <playbook_root>/ # 本 Playbook 在项目内的根(默认 docs/standards/playbook
│ ├── docs/
│ ├── rulesets/
│ ├── scripts/

View File

@ -110,10 +110,10 @@ no_backup = true
如果你的项目已经把本 Playbook 部署到项目内,则在目标项目里执行:
```bash
python <deploy_root>/scripts/playbook.py -config playbook.toml
python <playbook_root>/scripts/playbook.py -config playbook.toml
```
其中 `<deploy_root>` 默认为 `docs/standards/playbook`
其中 `<playbook_root>` 默认为 `docs/standards/playbook`
---
@ -158,7 +158,7 @@ python <deploy_root>/scripts/playbook.py -config playbook.toml
- Python`docs/python/style_guide.md`、`docs/python/tooling.md`、`docs/python/configuration.md`
若你的项目把本 Playbook 部署到项目内,文档根路径为
`<deploy_root>/docs/...`;其中 `<deploy_root>` 默认为 `docs/standards/playbook`,也可以按项目配置改成 `custom/playbook` 等自定义目录。
`<playbook_root>/docs/...`;其中 `<playbook_root>` 默认为 `docs/standards/playbook`,也可以按项目配置改成 `custom/playbook` 等自定义目录。
---

View File

@ -2,14 +2,10 @@
# 配置文件所在目录默认作为 project_root。
[playbook]
# project_root = "." # 可选:目标项目根目录
# deploy_root = "docs/standards/playbook" # 项目内已部署时可省略;从外部 clone 执行时必填;值始终是相对于 project_root 的项目内路径,不是外部 clone 的 playbook 路径
# claude_md = "CLAUDE.md" # 可选CLAUDE.md 位置(默认自动检测:根目录 → .claude/CLAUDE.md → 自动创建)
[vendor]
# 当从外部 clone 的 playbook 向项目内部署快照时启用
# 外部 clone 路径只用于执行脚本;快照仍会写入 <project_root>/<deploy_root>
# langs = ["tsl"] # 可选:默认仅 tsl
# project_root = "." # 可选:目标项目根目录
# playbook_root = "docs/standards/playbook" # 项目内 Playbook 根;相对于 project_root不是外部 clone 的 playbook 路径
# install_mode = "subtree" # subtree|snapshotsubtree 表示已在项目内snapshot 表示从外部 clone 安装快照
# claude_md = "CLAUDE.md" # 可选CLAUDE.md 位置(默认自动检测:根目录 → .claude/CLAUDE.md → 自动创建)
[sync_rules]
# 同步 AGENT_RULES.md配置节存在即启用

View File

@ -14,7 +14,6 @@ except ModuleNotFoundError: # Python < 3.11
tomllib = None
ORDER = [
"vendor",
"sync_rules",
"sync_memory_bank",
"sync_prompts",
@ -29,7 +28,14 @@ MAIN_LOOP_SPEC = importlib.util.spec_from_file_location("playbook_main_loop", MA
assert MAIN_LOOP_SPEC and MAIN_LOOP_SPEC.loader
MAIN_LOOP = importlib.util.module_from_spec(MAIN_LOOP_SPEC)
MAIN_LOOP_SPEC.loader.exec_module(MAIN_LOOP)
PATH_CONFIG_KEYS = {"project_root", "deploy_root", "agents_home", "codex_home", "skill_link"}
PATH_CONFIG_KEYS = {
"project_root",
"playbook_root",
"deploy_root",
"agents_home",
"codex_home",
"skill_link",
}
DOCS_INDEX_SECTION_HEADINGS = {
"common": "## 跨语言common",
"tsl": "## TSLtsl/tsf",
@ -275,13 +281,13 @@ def normalize_relative_dir(raw: object, label: str) -> str:
return "." if normalized == "" else normalized
def join_deploy_subpath(root: str, child: str) -> str:
def join_playbook_subpath(root: str, child: str) -> str:
if root in ("", "."):
return child.lstrip("/")
return f"{root.rstrip('/')}/{child.lstrip('/')}"
def resolve_in_project_deploy_root(project_root: Path) -> str | None:
def resolve_in_project_playbook_root(project_root: Path) -> str | None:
try:
rel = PLAYBOOK_ROOT.resolve().relative_to(project_root.resolve())
if str(rel) != ".":
@ -291,9 +297,8 @@ def resolve_in_project_deploy_root(project_root: Path) -> str | None:
return None
def config_requires_deploy_root(config: dict) -> bool:
def config_uses_playbook_root(config: dict) -> bool:
for key in (
"vendor",
"sync_rules",
"sync_memory_bank",
"sync_prompts",
@ -305,46 +310,74 @@ def config_requires_deploy_root(config: dict) -> bool:
return False
def resolve_configured_deploy_root(config: dict, project_root: Path) -> str:
def validate_removed_config(config: dict) -> None:
if "vendor" in config:
raise ValueError(
"[vendor] is no longer supported; use [playbook].install_mode = "
'"snapshot" and configure languages in [sync_standards]'
)
playbook_config = config.get("playbook", {})
if not isinstance(playbook_config, dict):
raise ValueError("[playbook] must be a table")
if "deploy_root" in playbook_config:
raise ValueError(
"playbook.deploy_root is no longer supported; use playbook.playbook_root"
)
if "intall_mode" in playbook_config:
raise ValueError(
"playbook.intall_mode is not supported; use playbook.install_mode"
)
def resolve_install_mode(config: dict) -> str:
playbook_config = config.get("playbook", {})
raw = "subtree"
if (
isinstance(playbook_config, dict)
and playbook_config.get("install_mode") is not None
):
raw = playbook_config.get("install_mode")
mode = str(raw).strip().lower()
if mode not in ("subtree", "snapshot"):
raise ValueError(
"playbook.install_mode must be one of: subtree, snapshot"
)
return mode
def resolve_configured_playbook_root(config: dict, project_root: Path) -> str:
playbook_config = config.get("playbook", {})
raw = None
if isinstance(playbook_config, dict):
raw = playbook_config.get("deploy_root")
vendor_config = config.get("vendor", {})
if isinstance(vendor_config, dict) and vendor_config.get("target_dir") is not None:
raise ValueError(
"vendor.target_dir is no longer supported; use [playbook].deploy_root"
)
raw = playbook_config.get("playbook_root")
if raw is not None and str(raw).strip():
return normalize_relative_dir(raw, "deploy_root")
return normalize_relative_dir(raw, "playbook_root")
in_project_deploy_root = resolve_in_project_deploy_root(project_root)
if in_project_deploy_root is not None:
return in_project_deploy_root
in_project_playbook_root = resolve_in_project_playbook_root(project_root)
if in_project_playbook_root is not None:
return in_project_playbook_root
if config_requires_deploy_root(config):
if resolve_install_mode(config) == "snapshot" or config_uses_playbook_root(config):
raise ValueError(
"playbook.deploy_root is required when running from an external clone; "
"set it to the target project's relative deployment path"
"playbook.playbook_root is required when running from an external clone; "
"set it to the target project's relative Playbook path"
)
return "docs/standards/playbook"
def resolve_deploy_root(context: dict) -> str:
project_root: Path = context["project_root"]
in_project_deploy_root = resolve_in_project_deploy_root(project_root)
if in_project_deploy_root is not None:
return in_project_deploy_root
return context["deploy_root"]
def resolve_playbook_root(context: dict) -> str:
return context["playbook_root"]
def resolve_docs_prefix(context: dict) -> str:
return join_deploy_subpath(resolve_deploy_root(context), "docs")
return join_playbook_subpath(resolve_playbook_root(context), "docs")
def resolve_playbook_scripts(context: dict) -> str:
return join_deploy_subpath(resolve_deploy_root(context), "scripts")
return join_playbook_subpath(resolve_playbook_root(context), "scripts")
def read_git_commit(root: Path) -> str:
@ -408,9 +441,11 @@ def write_docs_index(dest_prefix: Path, langs: list[str]) -> None:
docs_index.write_text("\n".join(lines) + "\n", encoding="utf-8", newline="\n")
def write_snapshot_readme(dest_prefix: Path, deploy_root: str, langs: list[str]) -> None:
scripts_path = join_deploy_subpath(deploy_root, "scripts/playbook.py")
docs_index_path = join_deploy_subpath(deploy_root, "docs/index.md")
def write_snapshot_readme(
dest_prefix: Path, playbook_root: str, langs: list[str]
) -> None:
scripts_path = join_playbook_subpath(playbook_root, "scripts/playbook.py")
docs_index_path = join_playbook_subpath(playbook_root, "docs/index.md")
lines = [
"# Playbook裁剪快照",
"",
@ -424,7 +459,7 @@ def write_snapshot_readme(dest_prefix: Path, deploy_root: str, langs: list[str])
f"python {scripts_path} -config playbook.toml",
"```",
"",
f"配置示例:`{join_deploy_subpath(deploy_root, 'playbook.toml.example')}`",
f"配置示例:`{join_playbook_subpath(playbook_root, 'playbook.toml.example')}`",
"",
"文档入口:",
"",
@ -453,23 +488,51 @@ def write_source_file(dest_prefix: Path, langs: list[str]) -> None:
)
def vendor_action(config: dict, context: dict) -> int:
def snapshot_langs(config: dict) -> list[str]:
sync_standards = config.get("sync_standards")
if isinstance(sync_standards, dict):
return normalize_langs(sync_standards.get("langs"))
return normalize_langs(None)
def is_generated_snapshot(path: Path) -> bool:
source = path / "SOURCE.md"
if not source.is_file():
return False
try:
langs = normalize_langs(config.get("langs"))
return "Generated-by: scripts/playbook.py" in source.read_text(encoding="utf-8")
except OSError:
return False
def install_snapshot(config: dict, context: dict) -> int:
try:
langs = snapshot_langs(config)
except ValueError as exc:
print(f"ERROR: {exc}", file=sys.stderr)
return 2
deploy_root = context["deploy_root"]
target_path = Path(deploy_root)
playbook_root = context["playbook_root"]
target_path = Path(playbook_root)
project_root: Path = context["project_root"]
dest_prefix = project_root / target_path
dest_standards = dest_prefix.parent
if dest_prefix.resolve() == PLAYBOOK_ROOT.resolve():
log(f"Snapshot already installed at {dest_prefix}")
return 0
ensure_dir(dest_standards)
if dest_prefix.exists():
if not is_generated_snapshot(dest_prefix):
print(
"ERROR: refusing to replace existing playbook_root because it is "
"not a generated snapshot; use install_mode = \"subtree\" for a "
"git subtree-managed Playbook",
file=sys.stderr,
)
return 2
timestamp = datetime.now().strftime("%Y%m%d%H%M%S")
backup = dest_standards / f"{dest_prefix.name}.bak.{timestamp}"
dest_prefix.rename(backup)
@ -535,13 +598,29 @@ def vendor_action(config: dict, context: dict) -> int:
copy2(example_config, dest_prefix / "playbook.toml.example")
write_docs_index(dest_prefix, langs)
write_snapshot_readme(dest_prefix, deploy_root, langs)
write_snapshot_readme(dest_prefix, playbook_root, langs)
write_source_file(dest_prefix, langs)
log(f"Vendored snapshot -> {dest_prefix}")
log(f"Installed snapshot -> {dest_prefix}")
return 0
def validate_subtree_install(context: dict) -> int:
project_root: Path = context["project_root"]
playbook_root = context["playbook_root"]
expected_root = (project_root / playbook_root).resolve()
if PLAYBOOK_ROOT.resolve() == expected_root:
return 0
print(
"ERROR: install_mode = \"subtree\" requires running the project-local "
f"Playbook script at "
f"{join_playbook_subpath(playbook_root, 'scripts/playbook.py')}",
file=sys.stderr,
)
return 2
def replace_placeholders(
text: str,
project_name: str | None,
@ -1413,8 +1492,6 @@ def format_md_action(config: dict, context: dict) -> int:
def run_action(name: str, config: dict, context: dict) -> int:
print(f"[action] {name}")
if name == "vendor":
return vendor_action(config, context)
if name == "sync_rules":
return sync_rules_action(config, context)
if name == "sync_memory_bank":
@ -1489,6 +1566,13 @@ def main(argv: list[str]) -> int:
return 2
config = load_config(config_path)
try:
validate_removed_config(config)
install_mode = resolve_install_mode(config)
except ValueError as exc:
print(f"ERROR: {exc}", file=sys.stderr)
return 2
playbook_config = config.get("playbook", {})
project_root = playbook_config.get("project_root")
if project_root:
@ -1499,7 +1583,7 @@ def main(argv: list[str]) -> int:
root = config_path.parent
resolved_root = root.resolve()
try:
deploy_root = resolve_configured_deploy_root(config, resolved_root)
playbook_root = resolve_configured_playbook_root(config, resolved_root)
except ValueError as exc:
print(f"ERROR: {exc}", file=sys.stderr)
return 2
@ -1507,9 +1591,19 @@ def main(argv: list[str]) -> int:
"project_root": resolved_root,
"config_path": config_path.resolve(),
"config": config,
"deploy_root": deploy_root,
"install_mode": install_mode,
"playbook_root": playbook_root,
}
if install_mode == "snapshot":
result = install_snapshot(config, context)
if result != 0:
return result
elif config_uses_playbook_root(config):
result = validate_subtree_install(context)
if result != 0:
return result
if should_sync_agents(config):
result = sync_agents_template(context)
if result != 0:

View File

@ -82,6 +82,8 @@ templates/
# playbook.toml
[playbook]
project_root = "/path/to/project"
playbook_root = "docs/standards/playbook"
install_mode = "snapshot"
# 同步 AGENT_RULES.md配置节存在即启用
[sync_rules]
@ -98,15 +100,15 @@ project_name = "MyProject"
```
```bash
python <deploy_root>/scripts/playbook.py -config playbook.toml
python <playbook_root>/scripts/playbook.py -config playbook.toml
```
参数说明见 `playbook.toml.example`(仓库根目录)或项目内的
`<deploy_root>/playbook.toml.example`。
`<playbook_root>/playbook.toml.example`。
其中 `<deploy_root>` 默认为 `docs/standards/playbook`
其中 `<playbook_root>` 默认为 `docs/standards/playbook`
也可以按项目配置改成 `custom/playbook` 等自定义目录;
对应文档入口会变成 `<deploy_root>/docs/...`。
对应文档入口会变成 `<playbook_root>/docs/...`。
如果你当前是在 **外部 clone 的 playbook 仓库** 中执行,而不是在目标项目内执行快照,请使用:
@ -114,8 +116,8 @@ python <deploy_root>/scripts/playbook.py -config playbook.toml
python scripts/playbook.py -config playbook.toml
```
此时 `[vendor]` 会把快照写入 `<project_root>/<deploy_root>`
后续再在目标项目内使用 `<deploy_root>/scripts/playbook.py`
此时 `install_mode = "snapshot"` 会把快照写入 `<project_root>/<playbook_root>`
后续再在目标项目内使用 `<playbook_root>/scripts/playbook.py`
做同步更新。
### 配置节说明
@ -296,8 +298,7 @@ project/
### ci/、cpp/、python/
语言和 CI 配置模板。通过 playbook.py 的 `[vendor]`
复制到快照中:
语言和 CI 配置模板。`install_mode = "snapshot"` 安装快照时会复制这些模板:
- `ci/gitea/`Gitea Actions 工作流与辅助脚本。
部署到快照 `templates/ci/`
@ -307,7 +308,7 @@ project/
- `python/``pyproject.toml`、`.editorconfig` 等文件。
部署到快照 `templates/python/`
> 注意:这些模板通过 `[vendor]` 复制到快照的 `templates/` 目录,需手动从快照复制到项目根目录使用。
> 注意:这些模板复制到快照的 `templates/` 目录,需手动从快照复制到项目根目录使用。
> 其中 `ci/gitea/` 应按 `templates/ci/README.md` 的说明,整块复制 `.gitea/` 目录,而不只是复制 workflows。
**使用方式**
@ -316,14 +317,16 @@ project/
# playbook.toml - 生成包含这些模板的快照
[playbook]
project_root = "/path/to/project"
playbook_root = "docs/standards/playbook"
install_mode = "snapshot"
[vendor]
[sync_standards]
langs = ["tsl", "cpp", "python"]
```
```bash
python scripts/playbook.py -config playbook.toml
# 然后手动从 <deploy_root>/templates/ 复制所需配置到项目根目录
# 然后手动从 <playbook_root>/templates/ 复制所需配置到项目根目录
```
## 与 playbook 其他部分的关系
@ -335,14 +338,14 @@ playbook/
├── docs/ # 权威静态文档
├── templates/ # 本目录:项目架构模板 → 部署到 memory-bank/ 等
└── scripts/
└── playbook.py # 统一入口:vendor / sync_*
└── playbook.py # 统一入口:snapshot install / sync_*
```
## 完整部署流程
```bash
# 1. 准备配置并执行统一入口
python <deploy_root>/scripts/playbook.py -config playbook.toml
python <playbook_root>/scripts/playbook.py -config playbook.toml
# 2. 编辑 memory-bank/*.md 填写项目信息

View File

@ -16,7 +16,7 @@ tests/
├── test_no_backup_flags.py # no_backup 行为测试
├── test_playbook_typing_imports.py # playbook.py typing 导入兼容性测试
├── test_sync_directory_actions.py # sync_memory_bank/sync_prompts 行为测试
├── test_vendor_snapshot_templates.py # vendor 快照模板完整性测试
├── test_vendor_snapshot_templates.py # snapshot 快照模板完整性测试
├── test_main_loop_cli.py # main_loop CLI 测试
├── test_thirdparty_skills_pipeline.py # thirdparty skills 流水线配置与同步产物测试
├── test_sync_templates_placeholders.py # 占位符替换测试sync_rules/sync_standards
@ -70,7 +70,7 @@ sh tests/integration/check_doc_links.sh
- CLI 参数解析与帮助信息
- TOML 配置解析与动作顺序
- vendor/sync_rules/sync_memory_bank/sync_prompts/sync_standards 等基础动作落地
- snapshot install/sync_rules/sync_memory_bank/sync_prompts/sync_standards 等基础动作落地
### 2. 模板验证测试 (templates/)

View File

@ -125,7 +125,8 @@ class PlaybookCliTests(unittest.TestCase):
config_body = f"""
[playbook]
project_root = "."
deploy_root = "{CUSTOM_DEPLOY_ROOT}"
playbook_root = "{CUSTOM_DEPLOY_ROOT}"
install_mode = "snapshot"
[format_md]
@ -142,7 +143,7 @@ langs = ["tsl"]
self.assertIn("sync_standards", output)
self.assertIn("format_md", output)
def test_format_md_only_does_not_require_deploy_root(self):
def test_format_md_only_does_not_require_playbook_root(self):
with tempfile.TemporaryDirectory() as tmp_dir:
config_body = f"""
[playbook]
@ -157,15 +158,55 @@ project_root = "{tmp_dir}"
self.assertEqual(result.returncode, 0)
def test_vendor_creates_snapshot(self):
def test_vendor_section_is_rejected(self):
with tempfile.TemporaryDirectory() as tmp_dir:
root = Path(tmp_dir)
config_body = f"""
[playbook]
project_root = "{tmp_dir}"
playbook_root = "{CUSTOM_DEPLOY_ROOT}"
install_mode = "snapshot"
[vendor]
langs = ["tsl"]
"""
config_path = write_config(root, "playbook.toml", config_body)
result = run_cli("-config", str(config_path))
self.assertNotEqual(result.returncode, 0)
self.assertIn("[vendor]", result.stdout + result.stderr)
def test_deploy_root_is_rejected(self):
with tempfile.TemporaryDirectory() as tmp_dir:
root = Path(tmp_dir)
config_body = f"""
[playbook]
project_root = "{tmp_dir}"
deploy_root = "{CUSTOM_DEPLOY_ROOT}"
install_mode = "snapshot"
[vendor]
[sync_standards]
langs = ["tsl"]
"""
config_path = write_config(root, "playbook.toml", config_body)
result = run_cli("-config", str(config_path))
self.assertNotEqual(result.returncode, 0)
self.assertIn("deploy_root", result.stdout + result.stderr)
self.assertIn("playbook_root", result.stdout + result.stderr)
def test_snapshot_install_creates_snapshot(self):
with tempfile.TemporaryDirectory() as tmp_dir:
root = Path(tmp_dir)
config_body = f"""
[playbook]
project_root = "{tmp_dir}"
playbook_root = "{CUSTOM_DEPLOY_ROOT}"
install_mode = "snapshot"
[sync_standards]
langs = ["tsl"]
"""
config_path = write_config(root, "playbook.toml", config_body)
@ -176,15 +217,16 @@ langs = ["tsl"]
self.assertEqual(result.returncode, 0)
self.assertTrue(snapshot.is_file())
def test_vendor_docs_index_uses_new_tsl_entrypoints(self):
def test_snapshot_docs_index_uses_new_tsl_entrypoints(self):
with tempfile.TemporaryDirectory() as tmp_dir:
root = Path(tmp_dir)
config_body = f"""
[playbook]
project_root = "{tmp_dir}"
deploy_root = "{CUSTOM_DEPLOY_ROOT}"
playbook_root = "{CUSTOM_DEPLOY_ROOT}"
install_mode = "snapshot"
[vendor]
[sync_standards]
langs = ["tsl"]
"""
config_path = write_config(root, "playbook.toml", config_body)
@ -201,14 +243,15 @@ langs = ["tsl"]
self.assertIn("`tsl/reference/index.md`", text)
self.assertNotIn("`tsl/syntax_book/index.md`", text)
def test_external_clone_requires_explicit_deploy_root(self):
def test_external_clone_requires_explicit_playbook_root(self):
with tempfile.TemporaryDirectory() as tmp_dir:
root = Path(tmp_dir)
config_body = f"""
[playbook]
project_root = "{tmp_dir}"
install_mode = "snapshot"
[vendor]
[sync_standards]
langs = ["tsl"]
"""
config_path = write_config(root, "playbook.toml", config_body)
@ -216,14 +259,34 @@ langs = ["tsl"]
result = run_cli("-config", str(config_path))
self.assertNotEqual(result.returncode, 0)
self.assertIn("deploy_root", result.stdout + result.stderr)
self.assertIn("playbook_root", result.stdout + result.stderr)
def test_subtree_mode_requires_project_local_script(self):
with tempfile.TemporaryDirectory() as tmp_dir:
root = Path(tmp_dir)
config_body = f"""
[playbook]
project_root = "{tmp_dir}"
playbook_root = "{CUSTOM_DEPLOY_ROOT}"
install_mode = "subtree"
[sync_standards]
langs = ["tsl"]
"""
config_path = write_config(root, "playbook.toml", config_body)
result = run_cli("-config", str(config_path))
self.assertNotEqual(result.returncode, 0)
self.assertIn("project-local Playbook script", result.stdout + result.stderr)
def test_sync_memory_bank_creates_memory_bank(self):
with tempfile.TemporaryDirectory() as tmp_dir:
config_body = f"""
[playbook]
project_root = "{tmp_dir}"
deploy_root = "{CUSTOM_DEPLOY_ROOT}"
playbook_root = "{CUSTOM_DEPLOY_ROOT}"
install_mode = "snapshot"
[sync_memory_bank]
project_name = "Demo"
@ -242,7 +305,8 @@ project_name = "Demo"
config_body = f"""
[playbook]
project_root = "{tmp_dir}"
deploy_root = "{CUSTOM_DEPLOY_ROOT}"
playbook_root = "{CUSTOM_DEPLOY_ROOT}"
install_mode = "snapshot"
[sync_standards]
langs = ["tsl"]
@ -265,7 +329,8 @@ langs = ["tsl"]
f"""
[playbook]
project_root = "{tmp_dir}"
deploy_root = "{CUSTOM_DEPLOY_ROOT}"
playbook_root = "{CUSTOM_DEPLOY_ROOT}"
install_mode = "snapshot"
[sync_standards]
langs = ["tsl"]
@ -282,7 +347,8 @@ no_backup = true
f"""
[playbook]
project_root = "{tmp_dir}"
deploy_root = "{CUSTOM_DEPLOY_ROOT}"
playbook_root = "{CUSTOM_DEPLOY_ROOT}"
install_mode = "snapshot"
[sync_standards]
langs = ["tsl", "cpp"]
@ -304,7 +370,8 @@ no_backup = true
config_body = f"""
[playbook]
project_root = "{tmp_dir}"
deploy_root = "{CUSTOM_DEPLOY_ROOT}"
playbook_root = "{CUSTOM_DEPLOY_ROOT}"
install_mode = "snapshot"
[sync_standards]
langs = ["tsl", "markdown"]
@ -326,7 +393,8 @@ langs = ["tsl", "markdown"]
config_body = f"""
[playbook]
project_root = "{tmp_dir}"
deploy_root = "{CUSTOM_DEPLOY_ROOT}"
playbook_root = "{CUSTOM_DEPLOY_ROOT}"
install_mode = "snapshot"
[sync_standards]
langs = ["tsl"]
@ -352,7 +420,8 @@ langs = ["tsl"]
config_body = f"""
[playbook]
project_root = "{tmp_dir}"
deploy_root = "{CUSTOM_DEPLOY_ROOT}"
playbook_root = "{CUSTOM_DEPLOY_ROOT}"
install_mode = "snapshot"
[install_skills]
agents_home = "{target}"
@ -453,7 +522,8 @@ skills = ["brainstorming"]
config_body = f"""
[playbook]
project_root = "{tmp_root}"
deploy_root = "{CUSTOM_DEPLOY_ROOT}"
playbook_root = "{CUSTOM_DEPLOY_ROOT}"
install_mode = "snapshot"
[install_skills]
agents_home = "{target}"
@ -475,7 +545,8 @@ skills = ["karpathy-guidelines"]
config_body = f"""
[playbook]
project_root = "{tmp_dir}"
deploy_root = "{CUSTOM_DEPLOY_ROOT}"
playbook_root = "{CUSTOM_DEPLOY_ROOT}"
install_mode = "snapshot"
[install_skills]
agents_home = "{target}"
@ -496,7 +567,8 @@ skills = ["tsl-guide"]
config_body = f"""
[playbook]
project_root = "{tmp_dir}"
deploy_root = "{CUSTOM_DEPLOY_ROOT}"
playbook_root = "{CUSTOM_DEPLOY_ROOT}"
install_mode = "snapshot"
[install_skills]
codex_home = "{target}"
@ -511,17 +583,15 @@ skills = ["brainstorming"]
self.assertNotEqual(result.returncode, 0)
self.assertIn("codex_home", result.stdout + result.stderr)
def test_external_clone_flow_rewrites_links_with_configured_deploy_root(self):
def test_external_clone_flow_rewrites_links_with_configured_playbook_root(self):
with tempfile.TemporaryDirectory() as tmp_dir:
root = Path(tmp_dir)
agents_home = root / "agents-home"
config_body = f"""
[playbook]
project_root = "{tmp_dir}"
deploy_root = "{CUSTOM_DEPLOY_ROOT}"
[vendor]
langs = ["tsl"]
playbook_root = "{CUSTOM_DEPLOY_ROOT}"
install_mode = "snapshot"
[sync_standards]
langs = ["tsl"]
@ -545,23 +615,28 @@ skills = ["style-cleanup"]
def test_deployed_snapshot_rewrites_links_from_snapshot_location(self):
with tempfile.TemporaryDirectory() as tmp_dir:
root = Path(tmp_dir)
vendor_config = write_config(
install_config = write_config(
root,
"vendor.toml",
"install.toml",
f"""
[playbook]
project_root = "{tmp_dir}"
deploy_root = "{CUSTOM_DEPLOY_ROOT}"
playbook_root = "{CUSTOM_DEPLOY_ROOT}"
install_mode = "snapshot"
[vendor]
[sync_standards]
langs = ["tsl"]
""",
)
vendor_result = run_cli("-config", str(vendor_config))
self.assertEqual(vendor_result.returncode, 0, msg=vendor_result.stdout + vendor_result.stderr)
install_result = run_cli("-config", str(install_config))
self.assertEqual(
install_result.returncode,
0,
msg=install_result.stdout + install_result.stderr,
)
vendored_script = root / CUSTOM_DEPLOY_ROOT / "scripts" / "playbook.py"
snapshot_script = root / CUSTOM_DEPLOY_ROOT / "scripts" / "playbook.py"
agents_home = root / "local-agents"
sync_config = write_config(
root,
@ -569,7 +644,8 @@ langs = ["tsl"]
f"""
[playbook]
project_root = "{tmp_dir}"
deploy_root = "{CUSTOM_DEPLOY_ROOT}"
playbook_root = "{CUSTOM_DEPLOY_ROOT}"
install_mode = "snapshot"
[sync_standards]
langs = ["tsl"]
@ -582,7 +658,7 @@ skills = ["style-cleanup"]
""",
)
sync_result = run_script(vendored_script, "-config", str(sync_config))
sync_result = run_script(snapshot_script, "-config", str(sync_config))
self.assertEqual(sync_result.returncode, 0, msg=sync_result.stdout + sync_result.stderr)
self.assert_style_cleanup_tsl_docs_prefix(
root, agents_home, f"{CUSTOM_DEPLOY_ROOT}/docs"
@ -593,7 +669,8 @@ skills = ["style-cleanup"]
config_body = f"""
[playbook]
project_root = "{tmp_dir}"
deploy_root = "{CUSTOM_DEPLOY_ROOT}"
playbook_root = "{CUSTOM_DEPLOY_ROOT}"
install_mode = "snapshot"
[sync_memory_bank]
project_name = "Demo"
@ -619,7 +696,8 @@ project_name = "Demo"
config_body = f"""
[playbook]
project_root = "{tmp_dir}"
deploy_root = "{CUSTOM_DEPLOY_ROOT}"
playbook_root = "{CUSTOM_DEPLOY_ROOT}"
install_mode = "snapshot"
[sync_memory_bank]
project_name = "Demo"
@ -650,7 +728,8 @@ project_name = "Demo"
config_body = f"""
[playbook]
project_root = "{tmp_dir}"
deploy_root = "{CUSTOM_DEPLOY_ROOT}"
playbook_root = "{CUSTOM_DEPLOY_ROOT}"
install_mode = "snapshot"
[sync_memory_bank]
project_name = "Demo"
@ -682,7 +761,8 @@ project_name = "Demo"
config_body = f"""
[playbook]
project_root = "{tmp_dir}"
deploy_root = "{CUSTOM_DEPLOY_ROOT}"
playbook_root = "{CUSTOM_DEPLOY_ROOT}"
install_mode = "snapshot"
[sync_memory_bank]
project_name = "Demo"
@ -708,7 +788,8 @@ project_name = "Demo"
config_body = f"""
[playbook]
project_root = "{tmp_dir}"
deploy_root = "{CUSTOM_DEPLOY_ROOT}"
playbook_root = "{CUSTOM_DEPLOY_ROOT}"
install_mode = "snapshot"
[sync_memory_bank]
project_name = "Demo"
@ -730,7 +811,8 @@ project_name = "Demo"
config_body = f"""
[playbook]
project_root = "{tmp_dir}"
deploy_root = "{CUSTOM_DEPLOY_ROOT}"
playbook_root = "{CUSTOM_DEPLOY_ROOT}"
install_mode = "snapshot"
[install_skills]
agents_home = "{agents_home}"
@ -759,7 +841,8 @@ skills = ["commit-message"]
config_body = f"""
[playbook]
project_root = "{tmp_dir}"
deploy_root = "{CUSTOM_DEPLOY_ROOT}"
playbook_root = "{CUSTOM_DEPLOY_ROOT}"
install_mode = "snapshot"
[install_skills]
agents_home = "{agents_home}"
@ -784,7 +867,8 @@ no_backup = true
config_body = f"""
[playbook]
project_root = "{tmp_dir}"
deploy_root = "{CUSTOM_DEPLOY_ROOT}"
playbook_root = "{CUSTOM_DEPLOY_ROOT}"
install_mode = "snapshot"
[install_skills]
agents_home = "{agents_home}"

View File

@ -85,6 +85,8 @@ class DeploymentRoutesE2ETests(unittest.TestCase):
"""
[playbook]
project_root = "."
playbook_root = "docs/standards/playbook"
install_mode = "subtree"
[sync_rules]
no_backup = true
@ -119,7 +121,7 @@ no_backup = true
self.assertIn("@AGENT_RULES.md", text)
self.assertIn("<!-- playbook:claude:start -->", text)
def test_external_clone_deployment_vendors_snapshot_and_updates_claude(self):
def test_external_clone_deployment_installs_snapshot_and_updates_claude(self):
with tempfile.TemporaryDirectory() as tmp_dir:
tmp_root = Path(tmp_dir)
external_clone = tmp_root / "playbook"
@ -137,10 +139,8 @@ no_backup = true
f"""
[playbook]
project_root = "."
deploy_root = "{CUSTOM_DEPLOY_ROOT.as_posix()}"
[vendor]
langs = ["tsl", "markdown"]
playbook_root = "{CUSTOM_DEPLOY_ROOT.as_posix()}"
install_mode = "snapshot"
[sync_rules]
no_backup = true

View File

@ -34,7 +34,8 @@ class GitattributesModeTests(unittest.TestCase):
config_body = f"""
[playbook]
project_root = \"{root}\"
deploy_root = "{DEFAULT_DEPLOY_ROOT}"
playbook_root = "{DEFAULT_DEPLOY_ROOT}"
install_mode = "snapshot"
[sync_standards]
langs = [\"tsl\"]

View File

@ -27,7 +27,8 @@ class NoBackupFlagsTests(unittest.TestCase):
config_body = f"""
[playbook]
project_root = "{tmp_dir}"
deploy_root = "{DEFAULT_DEPLOY_ROOT}"
playbook_root = "{DEFAULT_DEPLOY_ROOT}"
install_mode = "snapshot"
[sync_rules]
force = true
@ -56,7 +57,8 @@ no_backup = true
config_body = f"""
[playbook]
project_root = "{tmp_dir}"
deploy_root = "{DEFAULT_DEPLOY_ROOT}"
playbook_root = "{DEFAULT_DEPLOY_ROOT}"
install_mode = "snapshot"
[sync_standards]
langs = ["tsl"]
@ -85,7 +87,8 @@ no_backup = true
config_body = f"""
[playbook]
project_root = "{tmp_dir}"
deploy_root = "{DEFAULT_DEPLOY_ROOT}"
playbook_root = "{DEFAULT_DEPLOY_ROOT}"
install_mode = "snapshot"
[install_skills]
agents_home = "{root / 'agents'}"

View File

@ -29,7 +29,8 @@ class SyncDirectoryActionsTests(unittest.TestCase):
config_body = f"""
[playbook]
project_root = "{tmp_dir}"
deploy_root = "{DEFAULT_DEPLOY_ROOT}"
playbook_root = "{DEFAULT_DEPLOY_ROOT}"
install_mode = "snapshot"
[sync_memory_bank]
project_name = "Demo"
@ -54,7 +55,8 @@ project_name = "Demo"
config_body = f"""
[playbook]
project_root = "{tmp_dir}"
deploy_root = "{DEFAULT_DEPLOY_ROOT}"
playbook_root = "{DEFAULT_DEPLOY_ROOT}"
install_mode = "snapshot"
[sync_prompts]
"""
@ -82,7 +84,8 @@ deploy_root = "{DEFAULT_DEPLOY_ROOT}"
config_body = f"""
[playbook]
project_root = "{tmp_dir}"
deploy_root = "{DEFAULT_DEPLOY_ROOT}"
playbook_root = "{DEFAULT_DEPLOY_ROOT}"
install_mode = "snapshot"
[sync_memory_bank]
project_name = "Demo"

View File

@ -222,7 +222,8 @@ class SyncTemplatesPlaceholdersTests(unittest.TestCase):
config_body = f"""
[playbook]
project_root = \"{tmp_dir}\"
deploy_root = "{DEFAULT_DEPLOY_ROOT}"
playbook_root = "{DEFAULT_DEPLOY_ROOT}"
install_mode = "snapshot"
[sync_rules]
@ -267,31 +268,16 @@ langs = [\"cpp\", \"tsl\"]
self.assertNotIn("{{PLAYBOOK_SCRIPTS}}", rules_text)
self.assertFalse(rules_text.endswith("\n\n"))
def test_sync_standards_rewrites_typescript_docs_prefix_for_vendored_playbook(self):
def test_sync_standards_rewrites_typescript_docs_prefix_for_snapshot_playbook(self):
with tempfile.TemporaryDirectory() as tmp_dir:
root = Path(tmp_dir)
vendor_config = root / "vendor.toml"
vendor_config.write_text(
install_config = root / "install.toml"
install_config.write_text(
f"""
[playbook]
project_root = "{tmp_dir}"
deploy_root = "{DEFAULT_DEPLOY_ROOT}"
[vendor]
langs = ["typescript"]
""",
encoding="utf-8",
)
vendor_result = run_cli("-config", str(vendor_config))
self.assertEqual(vendor_result.returncode, 0, msg=vendor_result.stderr)
sync_config = root / "sync.toml"
sync_config.write_text(
f"""
[playbook]
project_root = "{tmp_dir}"
deploy_root = "{DEFAULT_DEPLOY_ROOT}"
playbook_root = "{DEFAULT_DEPLOY_ROOT}"
install_mode = "snapshot"
[sync_standards]
langs = ["typescript"]
@ -299,11 +285,28 @@ langs = ["typescript"]
encoding="utf-8",
)
vendored_script = (
install_result = run_cli("-config", str(install_config))
self.assertEqual(install_result.returncode, 0, msg=install_result.stderr)
sync_config = root / "sync.toml"
sync_config.write_text(
f"""
[playbook]
project_root = "{tmp_dir}"
playbook_root = "{DEFAULT_DEPLOY_ROOT}"
install_mode = "snapshot"
[sync_standards]
langs = ["typescript"]
""",
encoding="utf-8",
)
snapshot_script = (
root / "docs" / "standards" / "playbook" / "scripts" / "playbook.py"
)
sync_result = run_script(
vendored_script, "-config", str(sync_config), cwd=root
snapshot_script, "-config", str(sync_config), cwd=root
)
self.assertEqual(sync_result.returncode, 0, msg=sync_result.stderr)
@ -317,7 +320,8 @@ langs = ["typescript"]
config_body = f"""
[playbook]
project_root = "{tmp_dir}"
deploy_root = "{DEFAULT_DEPLOY_ROOT}"
playbook_root = "{DEFAULT_DEPLOY_ROOT}"
install_mode = "snapshot"
[sync_rules]

View File

@ -66,19 +66,20 @@ class TslEntrypointsConsistencyTests(unittest.TestCase):
self.assertIn("方式一git subtree", text)
self.assertIn("方式二:外部 clone 后执行部署", text)
self.assertIn("`project_root`:目标项目根目录", text)
self.assertIn("`deploy_root`:相对于 `project_root` 的项目内目标目录", text)
self.assertIn("`playbook_root`:相对于 `project_root` 的项目内 Playbook 根目录", text)
self.assertIn("不是外部 clone 出来的 Playbook 仓库路径", text)
self.assertIn("外部 clone 场景下必须显式填写 `deploy_root`", text)
self.assertIn("外部 clone 场景下必须显式填写 `playbook_root`", text)
self.assertNotIn("方式二:手动复制快照", text)
self.assertNotIn("方式三CLI 裁剪复制", text)
self.assertNotIn("如果省略 `deploy_root`,默认仍部署到 `docs/standards/playbook`", text)
self.assertNotIn("如果省略 `playbook_root`,默认仍部署到 `docs/standards/playbook`", text)
def test_playbook_example_defines_deploy_root_as_target_path(self):
def test_playbook_example_defines_playbook_root_as_target_path(self):
text = PLAYBOOK_EXAMPLE.read_text(encoding="utf-8")
self.assertIn('deploy_root = "docs/standards/playbook"', text)
self.assertIn('playbook_root = "docs/standards/playbook"', text)
self.assertIn('install_mode = "subtree"', text)
self.assertIn("相对于 project_root", text)
self.assertIn("不是外部 clone 的 playbook 路径", text)
self.assertIn("从外部 clone 执行时必填", text)
self.assertIn("snapshot 表示从外部 clone 安装快照", text)
self.assertNotIn("target_dir", text)
def test_deployment_docs_do_not_reference_legacy_terms(self):

View File

@ -17,15 +17,16 @@ def run_cli(*args):
)
class VendorSnapshotTemplatesTests(unittest.TestCase):
def test_vendor_includes_core_templates(self):
class SnapshotTemplatesTests(unittest.TestCase):
def test_snapshot_install_includes_core_templates(self):
with tempfile.TemporaryDirectory() as tmp_dir:
config_body = f"""
[playbook]
project_root = "{tmp_dir}"
deploy_root = "{DEFAULT_DEPLOY_ROOT}"
playbook_root = "{DEFAULT_DEPLOY_ROOT}"
install_mode = "snapshot"
[vendor]
[sync_standards]
langs = ["tsl"]
"""
config_path = Path(tmp_dir) / "playbook.toml"