✨ feat(playbook): add plan progress tracking and rules updates
This commit is contained in:
@@ -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 <dir> -progress <file>\\n"
|
||||
" python scripts/plan_progress.py record -plan <path> -status <status> -progress <file> [-note <text>]\\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:]))
|
||||
+97
-9
@@ -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 = "<!-- playbook:templates:start -->"
|
||||
end_marker = "<!-- playbook:templates:end -->"
|
||||
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:
|
||||
|
||||
Reference in New Issue
Block a user