feat(workflow): adopt Matt Pocock ticket workflow

Replace the Superpowers plan pipeline with grill-with-docs, specs, local tickets, and ticket-native execution.

BREAKING CHANGE: Remove the legacy Plan CLI, prompt templates, and Superpowers skills.
This commit is contained in:
csh
2026-08-10 16:55:46 +08:00
parent 29d110110b
commit 699b431cac
164 changed files with 9039 additions and 12273 deletions
+69 -354
View File
@@ -1,41 +1,22 @@
#!/usr/bin/env python3
import argparse
import re
import sys
import tomllib
from datetime import datetime, timezone
from pathlib import Path
from shutil import copy2, copytree, ignore_patterns, rmtree, which
import subprocess
import importlib.util
from typing import Optional
try:
import tomllib
except ModuleNotFoundError: # Python < 3.11
tomllib = None
ORDER = [
"sync_rules",
"sync_memory_bank",
"sync_prompts",
"sync_standards",
"install_skills",
"format_md",
]
SCRIPT_DIR = Path(__file__).resolve().parent
PLAYBOOK_ROOT = SCRIPT_DIR.parent
MAIN_LOOP_SCRIPT = SCRIPT_DIR / "main_loop.py"
MAIN_LOOP_SPEC = importlib.util.spec_from_file_location("playbook_main_loop", MAIN_LOOP_SCRIPT)
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",
"playbook_root",
"deploy_root",
"agents_home",
"codex_home",
"skill_link",
}
DOCS_INDEX_SECTION_HEADINGS = {
"common": "## 跨语言(common",
"tsl": "## TSLtsl/tsf",
@@ -46,199 +27,8 @@ DOCS_INDEX_SECTION_HEADINGS = {
}
def usage() -> str:
return (
"Usage:\n"
" python scripts/playbook.py -config <path>\n"
" python scripts/playbook.py -record-plan <plan_path> -progress <path>\n"
" python scripts/playbook.py -h"
)
def parse_cli_value(argv: list[str], flag: str) -> Optional[str]:
if flag not in argv:
return None
idx = argv.index(flag)
if idx + 1 >= len(argv):
return None
value = argv[idx + 1].strip()
return value or None
def strip_inline_comment(value: str) -> str:
in_single = False
in_double = False
escape = False
for idx, ch in enumerate(value):
if escape:
escape = False
continue
if in_double and ch == "\\":
escape = True
continue
if ch == "'" and not in_double:
in_single = not in_single
continue
if ch == '"' and not in_single:
in_double = not in_double
continue
if ch == "#" and not in_single and not in_double:
return value[:idx].rstrip()
return value
def split_list_items(raw: str) -> list[str]:
items: list[str] = []
buf: list[str] = []
in_single = False
in_double = False
escape = False
for ch in raw:
if escape:
buf.append(ch)
escape = False
continue
if in_double and ch == "\\":
buf.append(ch)
escape = True
continue
if ch == "'" and not in_double:
in_single = not in_single
buf.append(ch)
continue
if ch == '"' and not in_single:
in_double = not in_double
buf.append(ch)
continue
if ch == "," and not in_single and not in_double:
items.append("".join(buf).strip())
buf = []
continue
buf.append(ch)
tail = "".join(buf).strip()
if tail:
items.append(tail)
return items
def parse_toml_value(raw: str) -> object:
value = raw.strip()
if not value:
return ""
if value.startswith("[") and value.endswith("]"):
inner = value[1:-1].strip()
if not inner:
return []
return [parse_toml_value(item) for item in split_list_items(inner)]
lowered = value.lower()
if lowered == "true":
return True
if lowered == "false":
return False
if value[0] in ("'", '"') and value[-1] == value[0]:
if value[0] == "'":
return value[1:-1]
import ast
try:
return ast.literal_eval(value)
except (ValueError, SyntaxError):
return value[1:-1]
try:
if "." in value:
return float(value)
return int(value)
except ValueError:
return value
def loads_toml_minimal(raw: str) -> dict:
data: dict[str, dict] = {}
current = None
for line in raw.splitlines():
stripped = line.strip()
if not stripped or stripped.startswith("#"):
continue
if stripped.startswith("[") and stripped.endswith("]"):
section = stripped[1:-1].strip()
if not section:
raise ValueError("empty section header")
current = data.setdefault(section, {})
if not isinstance(current, dict):
raise ValueError(f"invalid section: {section}")
continue
if "=" not in stripped:
raise ValueError(f"invalid line: {line}")
key, value = stripped.split("=", 1)
key = key.strip()
if not key:
raise ValueError("missing key")
value = strip_inline_comment(value.strip())
target = current if current is not None else data
target[key] = parse_toml_value(value)
return data
def normalize_path_config_strings(raw: str) -> str:
normalized_lines: list[str] = []
for line in raw.splitlines():
stripped = line.strip()
if not stripped or stripped.startswith("#") or "=" not in line:
normalized_lines.append(line)
continue
key_part, value_part = line.split("=", 1)
key = key_part.strip()
if key not in PATH_CONFIG_KEYS:
normalized_lines.append(line)
continue
value = strip_inline_comment(value_part.strip())
if len(value) < 2 or value[0] != '"' or value[-1] != '"' or "\\" not in value[1:-1]:
normalized_lines.append(line)
continue
inner = value[1:-1]
has_lone_backslash = False
probe_idx = 0
while probe_idx < len(inner):
if inner[probe_idx] != "\\":
probe_idx += 1
continue
if probe_idx + 1 < len(inner) and inner[probe_idx + 1] == "\\":
probe_idx += 2
continue
has_lone_backslash = True
break
if not has_lone_backslash:
normalized_lines.append(line)
continue
escaped: list[str] = []
idx = 0
while idx < len(inner):
ch = inner[idx]
if ch != "\\":
escaped.append(ch)
idx += 1
continue
if idx + 1 < len(inner) and inner[idx + 1] == "\\":
escaped.extend(["\\", "\\"])
idx += 2
continue
escaped.extend(["\\", "\\"])
idx += 1
normalized_lines.append(f'{key_part}= "{"".join(escaped)}"')
suffix = "\n" if raw.endswith("\n") else ""
return "\n".join(normalized_lines) + suffix
def load_config(path: Path) -> dict:
raw = normalize_path_config_strings(path.read_text(encoding="utf-8"))
if tomllib is not None:
return tomllib.loads(raw)
return loads_toml_minimal(raw)
return tomllib.loads(path.read_text(encoding="utf-8"))
def log(message: str) -> None:
@@ -300,7 +90,6 @@ def config_uses_playbook_root(config: dict) -> bool:
for key in (
"sync_rules",
"sync_memory_bank",
"sync_prompts",
"sync_standards",
"install_skills",
):
@@ -309,34 +98,12 @@ def config_uses_playbook_root(config: dict) -> bool:
return False
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]'
)
def resolve_install_mode(config: dict) -> str:
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
):
if 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"):
@@ -458,7 +225,7 @@ def write_snapshot_readme(
f"python {scripts_path} -config playbook.toml",
"```",
"",
f"配置示例:`{join_playbook_subpath(playbook_root, 'playbook.toml.example')}`",
f"配置示例:`{join_playbook_subpath(playbook_root, 'playbook.example.toml')}`",
"",
"文档入口:",
"",
@@ -600,9 +367,9 @@ def install_snapshot(config: dict, context: dict) -> int:
if templates_src.is_dir():
copytree(templates_src, dest_prefix / "templates" / lang)
example_config = PLAYBOOK_ROOT / "playbook.toml.example"
example_config = PLAYBOOK_ROOT / "playbook.example.toml"
if example_config.is_file():
copy2(example_config, dest_prefix / "playbook.toml.example")
copy2(example_config, dest_prefix / "playbook.example.toml")
write_docs_index(dest_prefix, langs)
write_snapshot_readme(dest_prefix, playbook_root, langs)
@@ -680,6 +447,13 @@ def resolve_template_target(
return target_root / rel
def format_sync_counts(label: str, written: int, skipped: int, force: bool) -> str:
message = f"Synced: {label} (written={written}, skipped={skipped})"
if skipped and not force:
message += " Existing files kept; use force to overwrite."
return message
def sync_directory(
template_dir: Path,
target_dir: Path,
@@ -689,7 +463,9 @@ def sync_directory(
playbook_root: str | None,
force: bool,
no_backup: bool,
) -> None:
) -> tuple[int, int]:
written = 0
skipped = 0
for template_file in template_dir.rglob("*"):
if not template_file.is_file():
continue
@@ -699,6 +475,7 @@ def sync_directory(
ensure_dir(target_file.parent)
if target_file.exists():
if not force:
skipped += 1
continue
backup_path(target_file, no_backup)
copy2(template_file, target_file)
@@ -709,6 +486,8 @@ def sync_directory(
playbook_scripts,
playbook_root,
)
written += 1
return written, skipped
def extract_block_lines(text: str, start: str, end: str) -> list[str]:
@@ -727,6 +506,30 @@ def extract_block_lines(text: str, start: str, end: str) -> list[str]:
return block
_AGENTS_BLOCK_START = "<!-- playbook:agents:start -->"
_AGENTS_BLOCK_END = "<!-- playbook:agents:end -->"
def preserve_agents_subblock(block: list[str], agents_text: str) -> list[str]:
existing = extract_block_lines(agents_text, _AGENTS_BLOCK_START, _AGENTS_BLOCK_END)
if not existing:
return block
start_index: int | None = None
end_index: int | None = None
for index, line in enumerate(block):
stripped = line.strip()
if stripped == _AGENTS_BLOCK_START:
start_index = index
elif stripped == _AGENTS_BLOCK_END and start_index is not None:
end_index = index
break
if start_index is None or end_index is None:
return block
return block[:start_index] + existing + block[end_index + 1 :]
def update_agents_section(
agents_path: Path,
template_path: Path,
@@ -747,12 +550,15 @@ def update_agents_section(
return
if not agents_path.exists():
agents_path.write_text(template_text + "\n", encoding="utf-8", newline="\n")
agents_path.write_text(
template_text.rstrip("\n") + "\n", encoding="utf-8", newline="\n"
)
log("Created: AGENTS.md")
return
agents_text = agents_path.read_text(encoding="utf-8")
if start_marker in agents_text:
block = preserve_agents_subblock(block, agents_text)
lines = agents_text.splitlines()
updated: list[str] = []
in_block = False
@@ -773,9 +579,6 @@ def update_agents_section(
)
log("Updated: AGENTS.md (section)")
else:
if ".agents/index.md" in agents_text:
log("Skip: AGENTS.md already references .agents/index.md")
return
updated = agents_text.rstrip("\n") + "\n\n" + "\n".join(block) + "\n"
agents_path.write_text(updated, encoding="utf-8", newline="\n")
log("Appended: AGENTS.md (section)")
@@ -796,7 +599,7 @@ def resolve_project_name(context: dict) -> str | None:
def resolve_template_date(context: dict) -> str:
config = context.get("config", {})
if isinstance(config, dict):
for key in ("sync_rules", "sync_memory_bank", "sync_prompts"):
for key in ("sync_rules", "sync_memory_bank"):
section = config.get(key)
if isinstance(section, dict):
value = section.get("date")
@@ -821,27 +624,11 @@ def sync_agents_template(context: dict) -> int:
playbook_root = resolve_playbook_root(context)
date_value = resolve_template_date(context)
agents_dst = project_root / "AGENTS.md"
if agents_dst.exists():
agents_text = agents_dst.read_text(encoding="utf-8")
if "<!-- playbook:framework:start -->" in agents_text:
start_marker = "<!-- playbook:framework:start -->"
end_marker = "<!-- playbook:framework:end -->"
elif "<!-- playbook:templates:start -->" in agents_text:
start_marker = "<!-- playbook:templates:start -->"
end_marker = "<!-- playbook:templates:end -->"
else:
start_marker = "<!-- playbook:templates:start -->"
end_marker = "<!-- playbook:templates:end -->"
else:
start_marker = "<!-- playbook:framework:start -->"
end_marker = "<!-- playbook:framework:end -->"
update_agents_section(
agents_dst,
project_root / "AGENTS.md",
agents_src,
start_marker,
end_marker,
"<!-- playbook:framework:start -->",
"<!-- playbook:framework:end -->",
project_name,
date_value,
playbook_scripts,
@@ -927,8 +714,6 @@ def sync_claude_md(project_root: Path, config: dict) -> None:
updated_text = "# CLAUDE.md\n\n" + updated_text.lstrip()
claude_md.write_text(updated_text, encoding="utf-8", newline="\n")
log("Updated CLAUDE.md (playbook block).")
elif "@AGENTS.md" in text:
log("Skip: CLAUDE.md already references AGENTS.md")
else:
appended = text.rstrip("\n") + "\n\n" + "\n".join(block_lines) + "\n"
claude_md.write_text(appended, encoding="utf-8", newline="\n")
@@ -936,7 +721,7 @@ def sync_claude_md(project_root: Path, config: dict) -> None:
def should_sync_agents(config: dict) -> bool:
for key in ("sync_rules", "sync_memory_bank", "sync_prompts", "sync_standards"):
for key in ("sync_rules", "sync_memory_bank", "sync_standards"):
if key in config:
return True
return False
@@ -1015,7 +800,7 @@ def sync_memory_bank_action(config: dict, context: dict) -> int:
memory_dst = project_root / "memory-bank"
ensure_dir(memory_dst)
sync_directory(
written, skipped = sync_directory(
memory_src,
memory_dst,
project_name,
@@ -1025,43 +810,7 @@ def sync_memory_bank_action(config: dict, context: dict) -> int:
force,
no_backup,
)
log("Synced: memory-bank/")
return 0
def sync_prompts_action(config: dict, context: dict) -> int:
project_root: Path = context["project_root"]
if project_root.resolve() == PLAYBOOK_ROOT.resolve():
log("Skip: playbook root equals project root.")
return 0
templates_dir = PLAYBOOK_ROOT / "templates"
prompts_src = templates_dir / "prompts"
if not prompts_src.is_dir():
print(f"ERROR: templates not found: {prompts_src}", file=sys.stderr)
return 2
project_name = resolve_project_name(context)
playbook_scripts = resolve_playbook_scripts(context)
playbook_root = resolve_playbook_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))
prompts_dst = project_root / "docs/prompts"
ensure_dir(prompts_dst.parent)
ensure_dir(prompts_dst)
sync_directory(
prompts_src,
prompts_dst,
project_name,
date_value,
playbook_scripts,
playbook_root,
force,
no_backup,
)
log("Synced: docs/prompts/")
log(format_sync_counts("memory-bank/", written, skipped, force))
return 0
@@ -1075,6 +824,7 @@ def render_agents_block(langs: list[str]) -> list[str]:
"",
"- 入口:`.agents/index.md`",
f"- 语言规则:{langs_line}" if langs_line else "- 语言规则:",
"",
"<!-- playbook:agents:end -->",
]
return lines
@@ -1109,9 +859,6 @@ def update_agents_block(agents_md: Path, block_lines: list[str]) -> None:
agents_md.write_text("\n".join(updated) + "\n", encoding="utf-8", newline="\n")
log("Updated AGENTS.md (playbook block).")
else:
if ".agents/index.md" in text:
log("Skip: AGENTS.md already references .agents/index.md")
return
updated = text.rstrip("\n") + "\n\n" + "\n".join(block_lines) + "\n"
agents_md.write_text(updated, encoding="utf-8", newline="\n")
log("Appended playbook block to AGENTS.md")
@@ -1234,8 +981,6 @@ def sync_gitattributes_append(
def sync_gitattributes_block(src: Path, dst: Path, no_backup: bool) -> None:
begin = "# BEGIN playbook .gitattributes"
end = "# END playbook .gitattributes"
begin_old = "# BEGIN tsl-playbook .gitattributes"
end_old = "# END tsl-playbook .gitattributes"
src_lines = src.read_text(encoding="utf-8").splitlines()
block_lines = [begin] + src_lines + [end]
@@ -1246,14 +991,14 @@ def sync_gitattributes_block(src: Path, dst: Path, no_backup: bool) -> None:
in_block = False
replaced = False
for line in original:
if line == begin or line == begin_old:
if line == begin:
if not replaced:
updated.extend(block_lines)
replaced = True
in_block = True
continue
if in_block:
if line == end or line == end_old:
if line == end:
in_block = False
continue
updated.append(line)
@@ -1369,9 +1114,6 @@ def normalize_globs(raw: object) -> list[str]:
def install_skills_action(config: dict, context: dict) -> int:
mode = str(config.get("mode", "list")).lower()
if "codex_home" in config:
print("ERROR: codex_home is no longer supported; use agents_home", file=sys.stderr)
return 2
agents_home = Path(config.get("agents_home", "~/.agents")).expanduser()
if not agents_home.is_absolute():
agents_home = (context["project_root"] / agents_home).resolve()
@@ -1521,8 +1263,6 @@ def run_action(name: str, config: dict, context: dict) -> int:
return sync_rules_action(config, context)
if name == "sync_memory_bank":
return sync_memory_bank_action(config, context)
if name == "sync_prompts":
return sync_prompts_action(config, context)
if name == "sync_standards":
return sync_standards_action(config, context)
if name == "install_skills":
@@ -1533,47 +1273,22 @@ def run_action(name: str, config: dict, context: dict) -> int:
def main(argv: list[str]) -> int:
if "-h" in argv or "-help" in argv:
print(usage())
return 0
parser = argparse.ArgumentParser(prog="playbook.py")
parser.add_argument("-config", dest="config_path", required=True, metavar="PATH")
args = parser.parse_args(argv)
spec_path = parse_cli_value(argv, "-record-spec")
if spec_path is not None:
print(
"ERROR: -record-spec has been removed; spec files are the record.",
file=sys.stderr,
)
return 2
plan_path = parse_cli_value(argv, "-record-plan")
if plan_path is not None:
progress_path = parse_cli_value(argv, "-progress")
if not progress_path:
print("ERROR: -progress is required.\n" + usage(), file=sys.stderr)
return 2
code, message = MAIN_LOOP.record_plan(Path(progress_path), plan_path)
if code != 0:
print(message, file=sys.stderr)
return code
print(message)
return 0
if "-config" not in argv:
print("ERROR: -config is required.\n" + usage(), file=sys.stderr)
return 2
idx = argv.index("-config")
if idx + 1 >= len(argv) or not argv[idx + 1]:
print("ERROR: -config requires a path.\n" + usage(), file=sys.stderr)
return 2
config_path = Path(argv[idx + 1]).expanduser()
config_path = Path(args.config_path).expanduser()
if not config_path.is_file():
print(f"ERROR: config not found: {config_path}", file=sys.stderr)
return 2
config = load_config(config_path)
try:
validate_removed_config(config)
config = load_config(config_path)
except tomllib.TOMLDecodeError as exc:
print(f"ERROR: invalid TOML in {config_path}: {exc}", file=sys.stderr)
return 2
try:
install_mode = resolve_install_mode(config)
except ValueError as exc:
print(f"ERROR: {exc}", file=sys.stderr)