Squashed 'docs/standards/playbook/' changes from c3f8137..25d895d

25d895d 🐛 fix(gitea_workflow): clean up temp repos after job steps
2bc3b11 🐛 fix(gitea_workflow): clean up temporary repo dirs in workflows
98c3f30 📝 docs(agent_rules): allow plan execution on current branch
16c7230 📝 docs(prompts): define custom verify layering
8efc4dd 🐛 fix(skills): quote commit-message description
bc8498f 🐛 fix(ci): install tomli for gitea tests
55cda3b 🐛 fix(tests): report missing toml parser clearly
c0729c7 🐛 fix(playbook): import Optional for cli compatibility
63e24bf 📦 deps(skills): sync thirdparty skills
d2f9356 🐛 fix(ci): isolate gitea workflow repos
588b81d 🐛 fix(ci): inline gitea workflow bootstrap
e0b1c3a ♻️ refactor(skills): standardize first-party skill contracts
2c5050d ♻️ refactor(skills): rename repo skills source dir
f049dfb 📦 deps(skills): drop duplicate first-party superpowers skills
234b335  feat(workflow): add superpowers planning and execution state tracking
c1702a6 📝 docs(markdown): format tracked markdown and drop stale templates
2325409 📝 docs(markdown): clarify optional markdownlint usage
214c44e 🔧 chore(markdown): add markdownlint baseline and lint fixes
a22b324 📝 docs(templates): add execution and memory-bank prompt templates
223a797 📝 docs(templates): update README for Claude Code and current features
4ac8672 📝 docs: simplify README + platform-agnostic tools + auto-create local rules
2431c9d 📝 docs: add claude_md config and use cross-platform paths
d64b248 📝 docs: fix README.md inaccuracies and add Claude Code info
c8d6bf2 🐛 fix(playbook): use relative paths in CLAUDE.md when not at project root
6518f0f  feat(playbook): auto-create CLAUDE.md with path discovery
6ec9a45  feat(skills): add skill_link symlink support + platform-agnostic prompt
9f8b6b5 📝 docs: update README and config example for Claude Code support
79cff6c 📝 docs(skills): add Claude Code platform support
452c6f5  feat(playbook): auto-inject AGENTS.md into CLAUDE.md
e1dbf3c 🐛 fix(skills): remove dual-path from commit-message skill
f3a7259 🔧 chore(ci): use prepare_repo.sh in both workflows
da08212 🔧 chore(ci): extract prepare_repo.sh and clean up workflows
7ade85e 🗑️ remove(tsl): drop syntax_book/, data/ source and build script
f94dba0 ♻️ refactor(skills): update playbook.py and tests for thirdparty/ layout
b3df412 ♻️ refactor(skills): separate thirdparty skills into thirdparty/ subdirectory
64950e7 📦 deps(skills): sync thirdparty skills
a2e3cb0  feat(playbook): add no_backup deploy controls
8609d59 🐛 fix(docs): repair reference catalog source links
956da11 🐛 fix(playbook): publish hidden ci test fixes
3f67754 📦 deps(skills): sync thirdparty skills
08ca87b 📦 deps(skills): add karpathy thirdparty sync
96b705b 📝 docs(tsl): rebuild canonical syntax and routing manual
3ed5052 📦 deps(skills): sync thirdparty skills
60108dd 📦 deps(skills): sync thirdparty skills
da85d4e 🐛 fix(thirdparty): prune nested project snapshots
a2a697e 📦 deps(skills): sync thirdparty skills
9df610a 🐛 fix(thirdparty): exclude duplicated superpowers skills
33dd5bb 🐛 fix(thirdparty): preserve optional manifest fields
91b0ea7 🐛 fix(thirdparty): preserve manifest during snapshot update
2e26f98 🔧 chore(thirdparty): generalize skills sync pipeline
5b9c1e3 📦 deps(skills): sync superpowers
2f2d34a 📝 docs(readme): normalize subtree command spacing
62db7db 🐛 fix(ci): serialize superpowers update and sync
3463223 🐛 fix(ci): use literal superpowers sync paths
48f6de8 📦 deps(skills): sync superpowers
4b23529 🔧 chore(ci): merge superpowers update and sync workflow
a56d75b 📦 deps(skills): sync superpowers
84bcefa 🔧 chore(ci): use ci[bot] commit author name
00a07e5 📦 deps(skills): sync superpowers
7b84daf 🐛 fix(templates): enforce main loop progress tracking
51373d7 🔧 chore(ci): automate superpowers sync workflow
eaaa39c 🐛 fix(ci): prevent stale superpowers sync from restoring skills block
79755c6 📦 deps(skills): sync superpowers
836d878 📦 deps(skills): sync superpowers
8216c9f 📦 deps(skills): sync superpowers
9439505 🐛 fix(playbook): address reported repo issues

git-subtree-dir: docs/standards/playbook
git-subtree-split: 25d895d8b3f56624ccfe99ad7289e9eb49e0f316
This commit is contained in:
csh
2026-05-24 13:04:14 +08:00
parent 35e6a301aa
commit 3d83740f88
292 changed files with 30774 additions and 218828 deletions
+71
View File
@@ -0,0 +1,71 @@
param(
[string]$RepoRoot = (Resolve-Path (Join-Path $PSScriptRoot "..")).Path
)
$ErrorActionPreference = "Stop"
$targets = @(
(Join-Path $RepoRoot "docs/tsl/index.md"),
(Join-Path $RepoRoot "docs/tsl/syntax"),
(Join-Path $RepoRoot "docs/tsl/finance"),
(Join-Path $RepoRoot "docs/tsl/reference")
)
$excludeFragments = @(
"\docs\plans\",
"\archive\",
"\docs\tsl\legacy\",
"\docs\tsl\syntax_book\"
)
$pattern = '(?i)\b(?:syntax_book|legacy)\b'
function Get-RelativePath([string]$basePath, [string]$childPath) {
$baseUri = [System.Uri]::new(($basePath.TrimEnd('\', '/') + [System.IO.Path]::DirectorySeparatorChar))
$childUri = [System.Uri]::new($childPath)
return [System.Uri]::UnescapeDataString($baseUri.MakeRelativeUri($childUri).ToString()).Replace('\', '/')
}
$files = foreach ($target in $targets) {
if (!(Test-Path -LiteralPath $target)) {
throw "Target not found: $target"
}
if ((Get-Item -LiteralPath $target).PSIsContainer) {
Get-ChildItem -LiteralPath $target -Recurse -File -Filter *.md
}
else {
Get-Item -LiteralPath $target
}
}
$activeFiles = $files |
Where-Object {
$fullName = $_.FullName
foreach ($fragment in $excludeFragments) {
if ($fullName -like "*$fragment*") {
return $false
}
}
return $true
} |
Sort-Object FullName -Unique
$hits = foreach ($file in $activeFiles) {
Select-String -Path $file.FullName -Pattern $pattern -AllMatches
}
if ($hits.Count -eq 0) {
Write-Output "No legacy references found in active TSL docs."
exit 0
}
Write-Output "Legacy references found in active TSL docs:"
foreach ($hit in $hits) {
$relativePath = Get-RelativePath -basePath $RepoRoot -childPath $hit.Path
Write-Output ("{0}:{1}: {2}" -f $relativePath, $hit.LineNumber, $hit.Line.Trim())
}
Write-Output ("Total files scanned: {0}" -f $activeFiles.Count)
Write-Output ("Total legacy hits: {0}" -f $hits.Count)
exit 1
+584
View File
@@ -0,0 +1,584 @@
#!/usr/bin/env python3
from contextlib import contextmanager
import os
import platform
import re
import sys
import threading
import time
from pathlib import Path
from typing import Optional
try:
import fcntl
except ImportError: # pragma: no cover
fcntl = None
try:
import msvcrt
except ImportError: # pragma: no cover
msvcrt = None
PLAN_STATUS_START = "<!-- plan-status:start -->"
PLAN_STATUS_END = "<!-- plan-status:end -->"
WORKFLOW_STATE_START = "<!-- workflow-state:start -->"
WORKFLOW_STATE_END = "<!-- workflow-state:end -->"
PLAN_FILE_RE = re.compile(r"^(\d{4}-\d{2}-\d{2})-.+\.md$")
PLAN_LINE_RE = re.compile(
r"^- \[(?P<check>[ xX])\] `(?P<plan>[^`]+)` "
r"(?P<status>done|blocked|pending|in-progress|skipped)"
r"(?:: (?P<note>.*))?$"
)
FINISH_STATUSES = {"done", "blocked", "skipped"}
ENV_BLOCKED_RE = re.compile(r"^env:([^:]+):(.+)$")
WORKFLOW_PHASES = {"brainstorming", "planning", "executing", "done", "blocked"}
THREAD_LOCKS: dict[str, threading.Lock] = {}
THREAD_LOCKS_GUARD = threading.Lock()
def usage() -> str:
return (
"Usage:\n"
" python scripts/main_loop.py claim -plans <dir> -progress <file>\n"
" python scripts/main_loop.py finish -plan <path> -status <status> "
"-progress <file> [-note <text>]\n"
" python scripts/main_loop.py record -progress <file> -phase <phase> "
"[-spec <path>] [-plan <path>] [-executor <name>] "
"[-constraints <csv>]\n"
" python scripts/main_loop.py -h\n"
"Options:\n"
" -plans DIR\n"
" -plan PATH\n"
" -status done|blocked|skipped\n"
" -progress FILE\n"
" -phase brainstorming|planning|executing|done|blocked\n"
" -spec PATH\n"
" -executor NAME\n"
" -constraints CSV\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) -> str:
raw = plan_value.strip().replace("\\", "/")
raw = raw.lstrip("./")
if raw.startswith("docs/superpowers/plans/"):
return raw[len("docs/superpowers/plans/") :]
marker = "/docs/superpowers/plans/"
if marker in raw:
return raw.split(marker, 1)[1]
return raw
def normalize_note(note: str) -> str:
return note.replace("\n", " ").replace("\r", " ").replace("`", "'").strip()
def render_plan_line(plan_key: str, status: str, note: Optional[str]) -> str:
checked = "x" if status == "done" else " "
suffix = status
if note:
suffix += f": {note}"
return f"- [{checked}] `{plan_key}` {suffix}"
def list_plan_files(plans_dir: Path) -> list[str]:
entries: list[str] = []
for path in plans_dir.iterdir():
if not path.is_file():
continue
if not PLAN_FILE_RE.match(path.name):
continue
entries.append(path.name)
return sorted(entries)
def find_block(lines: list[str]) -> Optional[tuple[int, int]]:
start_idx = None
for idx, line in enumerate(lines):
if line.strip() == PLAN_STATUS_START:
start_idx = idx
break
if start_idx is None:
return None
for idx in range(start_idx + 1, len(lines)):
if lines[idx].strip() == PLAN_STATUS_END:
return start_idx, idx
return None
def find_named_block(
lines: list[str], start_marker: str, end_marker: str
) -> Optional[tuple[int, int]]:
start_idx = None
for idx, line in enumerate(lines):
if line.strip() == start_marker:
start_idx = idx
break
if start_idx is None:
return None
for idx in range(start_idx + 1, len(lines)):
if lines[idx].strip() == end_marker:
return start_idx, idx
return None
def parse_entries(
lines: list[str], start_idx: int, end_idx: int
) -> list[tuple[str, str, Optional[str], int]]:
entries: list[tuple[str, str, Optional[str], int]] = []
for idx in range(start_idx + 1, end_idx):
line = lines[idx].strip()
match = PLAN_LINE_RE.match(line)
if not match:
continue
plan_key = normalize_plan_key(match.group("plan"))
status = match.group("status")
note = match.group("note")
entries.append((plan_key, status, note, idx))
return entries
def render_progress_lines(plans: list[str]) -> list[str]:
lines = [
"# 当前进展",
"",
"## Workflow State",
"",
WORKFLOW_STATE_START,
WORKFLOW_STATE_END,
"",
"## Plan Status",
"",
PLAN_STATUS_START,
]
for plan_key in plans:
lines.append(render_plan_line(plan_key, "pending", None))
lines.append(PLAN_STATUS_END)
return lines
def render_workflow_state_lines(
phase: Optional[str] = None,
spec: Optional[str] = None,
plan: Optional[str] = None,
executor: Optional[str] = None,
constraints: Optional[str] = None,
) -> list[str]:
lines = [WORKFLOW_STATE_START]
if phase:
lines.append(f"phase: {phase}")
if spec:
lines.append(f"spec: {spec}")
if plan:
lines.append(f"plan: {plan}")
if executor:
lines.append(f"executor: {executor}")
if constraints:
lines.append(f"constraints: {constraints}")
lines.append(WORKFLOW_STATE_END)
return lines
def parse_workflow_state(
lines: list[str], start_idx: int, end_idx: int
) -> dict[str, str]:
state: dict[str, str] = {}
for idx in range(start_idx + 1, end_idx):
line = lines[idx].strip()
if ": " not in line:
continue
key, value = line.split(": ", 1)
if key in {"phase", "spec", "plan", "executor", "constraints"}:
state[key] = value
return state
def parse_env_blocked_note(note: Optional[str]) -> Optional[tuple[str, str]]:
if not note:
return None
match = ENV_BLOCKED_RE.match(note)
if not match:
return None
return match.group(1), match.group(2)
def detect_env() -> Optional[str]:
mapping = {"windows": "windows", "linux": "linux", "darwin": "darwin"}
return mapping.get(platform.system().lower())
def load_progress_lines(progress_path: Path) -> list[str]:
progress_path.parent.mkdir(parents=True, exist_ok=True)
if progress_path.exists():
return progress_path.read_text(encoding="utf-8").splitlines()
return []
def write_progress_lines(progress_path: Path, lines: list[str]) -> None:
progress_path.write_text("\n".join(lines) + "\n", encoding="utf-8")
def get_thread_lock(lock_path: Path) -> threading.Lock:
key = str(lock_path.resolve())
with THREAD_LOCKS_GUARD:
lock = THREAD_LOCKS.get(key)
if lock is None:
lock = threading.Lock()
THREAD_LOCKS[key] = lock
return lock
@contextmanager
def locked_progress(progress_path: Path):
progress_path.parent.mkdir(parents=True, exist_ok=True)
lock_path = progress_path.with_name(f"{progress_path.name}.lock")
thread_lock = get_thread_lock(lock_path)
with thread_lock:
with lock_path.open("a+b") as lock_file:
if fcntl is not None:
fcntl.flock(lock_file.fileno(), fcntl.LOCK_EX)
elif msvcrt is not None: # pragma: no cover
while True:
try:
lock_file.seek(0)
msvcrt.locking(lock_file.fileno(), msvcrt.LK_LOCK, 1)
break
except OSError:
time.sleep(0.05)
try:
hold_ms = os.environ.get("PLAYBOOK_MAIN_LOOP_HOLD_LOCK_MS")
if hold_ms:
time.sleep(max(0.0, float(hold_ms) / 1000.0))
yield
finally:
if fcntl is not None:
fcntl.flock(lock_file.fileno(), fcntl.LOCK_UN)
elif msvcrt is not None: # pragma: no cover
lock_file.seek(0)
msvcrt.locking(lock_file.fileno(), msvcrt.LK_UNLCK, 1)
def ensure_section(lines: list[str], heading: str) -> list[str]:
if any(line.strip() == heading for line in lines):
return lines
if lines and lines[-1] != "":
lines.append("")
lines.extend([heading, ""])
return lines
def ensure_block_with_lines(
lines: list[str],
start_marker: str,
end_marker: str,
default_lines: list[str],
heading: Optional[str] = None,
) -> tuple[list[str], int, int]:
block = find_named_block(lines, start_marker, end_marker)
if block:
return lines, block[0], block[1]
if not lines:
lines = ["# 当前进展", ""]
if heading:
lines = ensure_section(lines, heading)
if lines and lines[-1] != "":
lines.append("")
insert_at = len(lines)
lines[insert_at:insert_at] = default_lines
return lines, insert_at, insert_at + len(default_lines) - 1
def ensure_plan_block(
lines: list[str], progress_path: Path, plan_keys: list[str]
) -> tuple[list[str], int, int]:
lines, _, _ = ensure_workflow_state_block(lines)
lines, start_idx, end_idx = ensure_block_with_lines(
lines,
PLAN_STATUS_START,
PLAN_STATUS_END,
[PLAN_STATUS_START, PLAN_STATUS_END],
"## Plan Status",
)
write_progress_lines(progress_path, lines)
return lines, start_idx, end_idx
def ensure_workflow_state_block(
lines: list[str],
) -> tuple[list[str], int, int]:
return ensure_block_with_lines(
lines,
WORKFLOW_STATE_START,
WORKFLOW_STATE_END,
[WORKFLOW_STATE_START, WORKFLOW_STATE_END],
"## Workflow State",
)
def update_workflow_state(
lines: list[str],
phase: Optional[str] = None,
spec: Optional[str] = None,
plan: Optional[str] = None,
executor: Optional[str] = None,
constraints: Optional[str] = None,
) -> list[str]:
lines, start_idx, end_idx = ensure_workflow_state_block(lines)
state = parse_workflow_state(lines, start_idx, end_idx)
if phase is not None:
state["phase"] = phase
if spec is not None:
state["spec"] = spec
if plan is not None:
state["plan"] = plan
if executor is not None:
state["executor"] = executor
if constraints is not None:
state["constraints"] = constraints
lines[start_idx : end_idx + 1] = render_workflow_state_lines(
state.get("phase"),
state.get("spec"),
state.get("plan"),
state.get("executor"),
state.get("constraints"),
)
return lines
def ensure_all_plans_present(
lines: list[str], start_idx: int, end_idx: int, progress_path: Path, plan_keys: list[str]
) -> list[tuple[str, str, Optional[str], int]]:
entries = parse_entries(lines, start_idx, end_idx)
existing = {plan_key for plan_key, _, _, _ in entries}
missing = [plan_key for plan_key in plan_keys if plan_key not in existing]
if missing:
insert_lines = [render_plan_line(plan_key, "pending", None) for plan_key in missing]
lines[end_idx:end_idx] = insert_lines
write_progress_lines(progress_path, lines)
end_idx += len(insert_lines)
entries = parse_entries(lines, start_idx, end_idx)
return entries
def filter_existing_entries(
entries: list[tuple[str, str, Optional[str], int]], plan_keys: list[str]
) -> list[tuple[str, str, Optional[str], int]]:
available = set(plan_keys)
return [entry for entry in entries if entry[0] in available]
def choose_claim_entry(
entries: list[tuple[str, str, Optional[str], int]], current_env: Optional[str]
) -> Optional[tuple[str, Optional[str], int]]:
for plan_key, status, note, idx in entries:
if status == "in-progress":
return plan_key, note, idx
for plan_key, status, note, idx in entries:
if status == "pending":
return plan_key, note, idx
if current_env:
for plan_key, status, note, idx in entries:
if status != "blocked":
continue
env_info = parse_env_blocked_note(note)
if env_info and env_info[0] == current_env:
return plan_key, note, idx
return None
def claim_plan(plans_dir: Path, progress_path: Path) -> tuple[int, str]:
if not plans_dir.is_dir():
return 2, f"ERROR: plans dir not found: {plans_dir}"
plan_keys = list_plan_files(plans_dir)
if not plan_keys:
return 2, "ERROR: no plan files found"
with locked_progress(progress_path):
lines = load_progress_lines(progress_path)
try:
lines, start_idx, end_idx = ensure_plan_block(lines, progress_path, plan_keys)
except ValueError as exc:
return 2, f"ERROR: {exc}"
entries = ensure_all_plans_present(lines, start_idx, end_idx, progress_path, plan_keys)
entries = filter_existing_entries(entries, plan_keys)
chosen = choose_claim_entry(entries, detect_env())
if not chosen:
return 0, "NOOP: no claimable plans"
plan_key, note, idx = chosen
lines[idx] = render_plan_line(plan_key, "in-progress", note)
lines = update_workflow_state(
lines,
phase="executing",
plan=(plans_dir / plan_key).as_posix(),
)
write_progress_lines(progress_path, lines)
output = [f"PLAN={(plans_dir / plan_key).as_posix()}"]
if note:
output.append(f"NOTE={note}")
return 0, "\n".join(output)
def finish_plan(
plan: str, status: str, progress_path: Path, note: Optional[str]
) -> tuple[int, str]:
if status not in FINISH_STATUSES:
return 2, f"ERROR: invalid status: {status}"
if not plan:
return 2, "ERROR: plan is required"
plan_key = normalize_plan_key(plan)
with locked_progress(progress_path):
lines = load_progress_lines(progress_path)
try:
lines, start_idx, end_idx = ensure_plan_block(lines, progress_path, [plan_key])
except ValueError as exc:
return 2, f"ERROR: {exc}"
entries = parse_entries(lines, start_idx, end_idx)
rendered_note = normalize_note(note) if note else None
updated_line = render_plan_line(plan_key, status, rendered_note)
for entry_plan, _, _, idx in entries:
if entry_plan == plan_key:
lines[idx] = updated_line
workflow_phase = "done" if status == "done" else "blocked"
lines = update_workflow_state(
lines,
phase=workflow_phase,
plan=f"docs/superpowers/plans/{plan_key}",
)
write_progress_lines(progress_path, lines)
return 0, updated_line
lines[end_idx:end_idx] = [updated_line]
workflow_phase = "done" if status == "done" else "blocked"
lines = update_workflow_state(
lines,
phase=workflow_phase,
plan=f"docs/superpowers/plans/{plan_key}",
)
write_progress_lines(progress_path, lines)
return 0, updated_line
def record_workflow_state(
progress_path: Path,
phase: str,
spec: Optional[str],
plan: Optional[str],
executor: Optional[str],
constraints: Optional[str],
) -> tuple[int, str]:
if phase not in WORKFLOW_PHASES:
return 2, f"ERROR: invalid phase: {phase}"
with locked_progress(progress_path):
lines = load_progress_lines(progress_path)
lines = update_workflow_state(lines, phase, spec, plan, executor, constraints)
write_progress_lines(progress_path, lines)
return 0, "OK"
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 {"claim", "finish", "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 == "claim":
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 = claim_plan(Path(plans), Path(progress))
if code != 0:
print(message, file=sys.stderr)
return code
print(message)
return 0
if mode == "record":
progress = flags.get("-progress")
phase = flags.get("-phase")
spec = flags.get("-spec")
plan = flags.get("-plan")
executor = flags.get("-executor")
constraints = flags.get("-constraints")
if not progress or not phase:
print("ERROR: -progress and -phase are required", file=sys.stderr)
print(usage(), file=sys.stderr)
return 2
code, message = record_workflow_state(
Path(progress), phase, spec, plan, executor, constraints
)
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 = finish_plan(plan, status, Path(progress), note)
if code != 0:
print(message, file=sys.stderr)
return code
print(message)
return 0
if __name__ == "__main__":
raise SystemExit(main(sys.argv[1:]))
-296
View File
@@ -1,296 +0,0 @@
#!/usr/bin/env python3
import platform
import re
import sys
from pathlib import Path
from typing import Optional
PLAN_STATUS_START = "<!-- plan-status:start -->"
PLAN_STATUS_END = "<!-- plan-status:end -->"
PLAN_FILE_RE = re.compile(r"^(\d{4}-\d{2}-\d{2})-.+\.md$")
PLAN_LINE_RE = re.compile(
r"^- \[(?P<check>[ xX])\] `(?P<plan>[^`]+)` (?P<status>done|blocked|pending|in-progress|skipped)(?:: (?P<note>.*))?$"
)
VALID_STATUSES = {"done", "blocked", "pending", "in-progress", "skipped"}
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 done|blocked|pending|in-progress|skipped\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) -> str:
raw = plan_value.strip().replace("\\", "/")
raw = raw.lstrip("./")
if raw.startswith("docs/plans/"):
return raw[len("docs/plans/") :]
marker = "/docs/plans/"
if marker in raw:
return raw.split(marker, 1)[1]
return raw
def render_plan_line(plan_key: str, status: str, note: Optional[str]) -> str:
checked = "x" if status == "done" else " "
if status == "blocked":
suffix = "blocked"
if note:
suffix += f": {note}"
elif status == "pending":
suffix = "pending"
elif status == "in-progress":
suffix = "in-progress"
elif status == "skipped":
suffix = "skipped"
if note:
suffix += f": {note}"
else:
suffix = "done"
return f"- [{checked}] `{plan_key}` {suffix}"
def normalize_note(note: str) -> str:
cleaned = note.replace("\n", " ").replace("\r", " ").replace("`", "'").strip()
return cleaned
def list_plan_files(plans_dir: Path) -> list[str]:
entries: list[str] = []
for path in plans_dir.iterdir():
if not path.is_file():
continue
if not PLAN_FILE_RE.match(path.name):
continue
try:
rel = path.resolve().relative_to(plans_dir.resolve()).as_posix()
except ValueError:
rel = path.name
entries.append(rel)
return sorted(entries)
def find_block(lines: list[str]) -> Optional[tuple[int, int]]:
start_idx = None
for idx, line in enumerate(lines):
if line.strip() == PLAN_STATUS_START:
start_idx = idx
break
if start_idx is None:
return None
for idx in range(start_idx + 1, len(lines)):
if lines[idx].strip() == PLAN_STATUS_END:
return start_idx, idx
return None
def parse_entries(lines: list[str], start_idx: int, end_idx: int) -> list[tuple[str, str, Optional[str], int]]:
entries: list[tuple[str, str, Optional[str], int]] = []
for idx in range(start_idx + 1, end_idx):
line = lines[idx].strip()
match = PLAN_LINE_RE.match(line)
if not match:
continue
plan_key = normalize_plan_key(match.group("plan"))
status = match.group("status")
note = match.group("note")
entries.append((plan_key, status, note, idx))
return entries
def render_progress_lines(plans: list[str]) -> list[str]:
lines = ["# Plan 状态", "", PLAN_STATUS_START]
for plan_key in plans:
lines.append(render_plan_line(plan_key, "pending", None))
lines.append(PLAN_STATUS_END)
return lines
ENV_BLOCKED_RE = re.compile(r"^env:([^:]+):(.+)$")
def parse_env_blocked_note(note: Optional[str]) -> Optional[tuple[str, str]]:
"""Parse 'env:windows:Task2,Task4' format. Returns (env, tasks) or None."""
if not note:
return None
match = ENV_BLOCKED_RE.match(note)
if match:
return match.group(1), match.group(2)
return None
def detect_env() -> Optional[str]:
mapping = {"windows": "windows", "linux": "linux", "darwin": "darwin"}
return mapping.get(platform.system().lower())
def select_plan(plans_dir: Path, progress_path: Path) -> tuple[int, str]:
if not plans_dir.is_dir():
return 2, f"ERROR: plans dir not found: {plans_dir}"
plan_keys = list_plan_files(plans_dir)
if not plan_keys:
return 2, "ERROR: no plan files found"
progress_path.parent.mkdir(parents=True, exist_ok=True)
if progress_path.exists():
lines = progress_path.read_text(encoding="utf-8").splitlines()
else:
lines = []
block = find_block(lines)
if not block:
lines = render_progress_lines(plan_keys)
progress_path.write_text("\n".join(lines) + "\n", encoding="utf-8")
return 0, (plans_dir / plan_keys[0]).as_posix()
start_idx, end_idx = block
entries = parse_entries(lines, start_idx, end_idx)
existing = {plan for plan, _, _, _ in entries}
missing = [plan for plan in plan_keys if plan not in existing]
if missing:
insert_lines = [render_plan_line(plan, "pending", None) for plan in missing]
lines[end_idx:end_idx] = insert_lines
end_idx += len(insert_lines)
progress_path.write_text("\n".join(lines) + "\n", encoding="utf-8")
entries = parse_entries(lines, start_idx, end_idx)
for plan_key, status, note, _ in entries:
if status in ("pending", "in-progress"):
return 0, (plans_dir / plan_key).as_posix()
# Check for env-blocked Plans if current environment is detected
current_env = detect_env()
if current_env:
for plan_key, status, note, _ in entries:
if status == "blocked":
env_info = parse_env_blocked_note(note)
if env_info and env_info[0] == current_env:
return 0, (plans_dir / plan_key).as_posix()
return 2, "ERROR: no pending plans"
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():
lines = progress_path.read_text(encoding="utf-8").splitlines()
else:
lines = []
plan_key = normalize_plan_key(plan)
block = find_block(lines)
if not block:
lines = render_progress_lines([plan_key])
block = find_block(lines)
if not block:
return 2, "ERROR: failed to create plan status block"
start_idx, end_idx = block
entries = parse_entries(lines, start_idx, end_idx)
rendered_note = None
if status in ("blocked", "skipped") and note:
rendered_note = normalize_note(note)
updated_line = render_plan_line(plan_key, status, rendered_note)
updated = False
for entry_plan, _, _, idx in entries:
if entry_plan == plan_key:
lines[idx] = updated_line
updated = True
break
if not updated:
lines[end_idx:end_idx] = [updated_line]
progress_path.write_text("\n".join(lines) + "\n", encoding="utf-8")
return 0, updated_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__":
raise SystemExit(main(sys.argv[1:]))
+484 -190
View File
@@ -1,9 +1,12 @@
#!/usr/bin/env python3
import re
import sys
from datetime import datetime, timezone
from pathlib import Path
from shutil import copy2, copytree, rmtree, which
import subprocess
import importlib.util
from typing import Optional
try:
import tomllib
@@ -21,10 +24,40 @@ ORDER = [
]
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", "deploy_root", "agents_home", "codex_home", "skill_link"}
DOCS_INDEX_SECTION_HEADINGS = {
"common": "## 跨语言(common",
"tsl": "## TSLtsl/tsf",
"cpp": "## C++cpp",
"python": "## Pythonpython",
"typescript": "## TypeScripttypescript",
"markdown": "## Markdownmarkdown",
}
def usage() -> str:
return "Usage:\n python scripts/playbook.py -config <path>\n python scripts/playbook.py -h"
return (
"Usage:\n"
" python scripts/playbook.py -config <path>\n"
" python scripts/playbook.py -record-spec <spec_path> -progress <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:
@@ -141,8 +174,63 @@ def loads_toml_minimal(raw: str) -> dict:
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 = path.read_text(encoding="utf-8")
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)
@@ -176,43 +264,87 @@ 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 normalize_relative_dir(raw: object, label: str) -> str:
value = str(raw).strip()
if not value:
raise ValueError(f"{label} is empty")
path = Path(value)
if path.is_absolute() or ".." in path.parts:
raise ValueError(f"invalid {label}: {value}")
normalized = path.as_posix()
return "." if normalized == "" else normalized
def resolve_playbook_scripts(project_root: Path, context: dict) -> str:
playbook_scripts = PLAYBOOK_ROOT / "scripts"
def join_deploy_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:
try:
rel = playbook_scripts.resolve().relative_to(project_root.resolve())
return rel.as_posix()
rel = PLAYBOOK_ROOT.resolve().relative_to(project_root.resolve())
if str(rel) != ".":
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"
pass
return None
def config_requires_deploy_root(config: dict) -> bool:
for key in (
"vendor",
"sync_rules",
"sync_memory_bank",
"sync_prompts",
"sync_standards",
"install_skills",
):
if key in config:
return True
return False
def resolve_configured_deploy_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"
)
if raw is not None and str(raw).strip():
return normalize_relative_dir(raw, "deploy_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
if config_requires_deploy_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"
)
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_docs_prefix(context: dict) -> str:
return join_deploy_subpath(resolve_deploy_root(context), "docs")
def resolve_playbook_scripts(context: dict) -> str:
return join_deploy_subpath(resolve_deploy_root(context), "scripts")
def read_git_commit(root: Path) -> str:
@@ -228,88 +360,75 @@ def read_git_commit(root: Path) -> str:
return result.stdout.strip() or "N/A"
def write_docs_index(dest_prefix: Path, langs: list[str]) -> None:
lines = [
"# 文档导航(Docs Index",
def extract_docs_index_sections(lines: list[str]) -> dict[str, list[str]]:
heading_to_key = {value: key for key, value in DOCS_INDEX_SECTION_HEADINGS.items()}
starts: list[tuple[int, str]] = []
for idx, line in enumerate(lines):
key = heading_to_key.get(line)
if key is not None:
starts.append((idx, key))
sections: dict[str, list[str]] = {}
for idx, (start, key) in enumerate(starts):
end = starts[idx + 1][0] if idx + 1 < len(starts) else len(lines)
section_lines = lines[start:end]
while section_lines and section_lines[-1] == "":
section_lines = section_lines[:-1]
sections[key] = section_lines
return sections
def build_docs_index_lines(langs: list[str], source_path: Path | None = None) -> list[str]:
docs_index_path = source_path or (PLAYBOOK_ROOT / "docs" / "index.md")
source_lines = docs_index_path.read_text(encoding="utf-8").splitlines()
title = source_lines[0] if source_lines else "# 文档导航(Docs Index"
sections = extract_docs_index_sections(source_lines)
ordered_keys = ["common", *langs]
result = [
title,
"",
f"本快照为裁剪版 Playbooklangs: {','.join(langs)})。",
"",
"## 跨语言(common",
"",
"- 提交信息与版本号:`common/commit_message.md`",
]
for lang in langs:
if lang == "tsl":
lines += [
"",
"## TSLtsl",
"",
"- 代码风格:`tsl/code_style.md`",
"- 命名规范:`tsl/naming.md`",
"- 语法手册:`tsl/syntax_book/index.md`",
"- 工具链与验证命令(模板):`tsl/toolchain.md`",
]
elif lang == "cpp":
lines += [
"",
"## C++cpp",
"",
"- 代码风格:`cpp/code_style.md`",
"- 命名规范:`cpp/naming.md`",
"- 工具链与验证命令(模板):`cpp/toolchain.md`",
"- 第三方依赖(Conan):`cpp/dependencies_conan.md`",
"- clangd 配置:`cpp/clangd.md`",
]
elif lang == "python":
lines += [
"",
"## Pythonpython",
"",
"- 代码风格:`python/style_guide.md`",
"- 工具链:`python/tooling.md`",
"- 配置清单:`python/configuration.md`",
]
elif lang == "typescript":
lines += [
"",
"## TypeScripttypescript",
"",
"- 代码风格:`typescript/code_style.md`",
"- 命名规范:`typescript/naming.md`",
"- 工具链:`typescript/toolchain.md`",
"- 配置清单:`typescript/configuration.md`",
]
elif lang == "markdown":
lines += [
"",
"## Markdownmarkdown",
"",
"- 代码块与行内代码格式:`markdown/index.md`",
]
for idx, key in enumerate(ordered_keys):
section = sections.get(key)
if section is None:
raise ValueError(f"docs/index.md is missing section for {key}")
if idx > 0:
result.append("")
result.extend(section)
return result
def write_docs_index(dest_prefix: Path, langs: list[str]) -> None:
lines = build_docs_index_lines(langs)
docs_index = dest_prefix / "docs/index.md"
ensure_dir(docs_index.parent)
docs_index.write_text("\n".join(lines) + "\n", encoding="utf-8")
def write_snapshot_readme(dest_prefix: Path, langs: list[str]) -> None:
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")
lines = [
"# Playbook(裁剪快照)",
"",
f"本目录为从 Playbook vendoring 的裁剪快照(langs: {','.join(langs)})。",
f"本目录为从 Playbook 部署到项目内的裁剪快照(langs: {','.join(langs)})。",
"",
"## 使用",
"",
"在目标项目根目录执行:",
"",
"```sh",
"python docs/standards/playbook/scripts/playbook.py -config playbook.toml",
f"python {scripts_path} -config playbook.toml",
"```",
"",
"配置示例:`docs/standards/playbook/playbook.toml.example`",
f"配置示例:`{join_deploy_subpath(deploy_root, 'playbook.toml.example')}`",
"",
"文档入口:",
"",
"- `docs/standards/playbook/docs/index.md`",
f"- `{docs_index_path}`",
"- `.agents/index.md`",
]
(dest_prefix / "README.md").write_text("\n".join(lines) + "\n", encoding="utf-8")
@@ -337,11 +456,8 @@ def vendor_action(config: dict, context: dict) -> int:
print(f"ERROR: {exc}", file=sys.stderr)
return 2
target_dir = config.get("target_dir", "docs/standards/playbook")
target_path = Path(target_dir)
if target_path.is_absolute() or ".." in target_path.parts:
print(f"ERROR: invalid target_dir: {target_dir}", file=sys.stderr)
return 2
deploy_root = context["deploy_root"]
target_path = Path(deploy_root)
project_root: Path = context["project_root"]
dest_prefix = project_root / target_path
@@ -362,7 +478,7 @@ def vendor_action(config: dict, context: dict) -> int:
copy2(gitattributes_src, dest_prefix / ".gitattributes")
copytree(PLAYBOOK_ROOT / "scripts", dest_prefix / "scripts")
copytree(PLAYBOOK_ROOT / "codex", dest_prefix / "codex")
copytree(PLAYBOOK_ROOT / "skills", dest_prefix / "skills")
copy2(PLAYBOOK_ROOT / "SKILLS.md", dest_prefix / "SKILLS.md")
common_docs = PLAYBOOK_ROOT / "docs/common"
@@ -415,7 +531,7 @@ 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, langs)
write_snapshot_readme(dest_prefix, deploy_root, langs)
write_source_file(dest_prefix, langs)
log(f"Vendored snapshot -> {dest_prefix}")
@@ -426,14 +542,11 @@ 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
@@ -448,41 +561,16 @@ def backup_path(path: Path, no_backup: bool) -> None:
log(f"Backed up: {path} -> {backup}")
def rename_template_files(root: Path) -> None:
for template in root.rglob("*.template.md"):
target = template.with_name(template.name.replace(".template.md", ".md"))
template.rename(target)
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, main_language, playbook_scripts
)
if updated != text:
file_path.write_text(updated, encoding="utf-8")
def replace_placeholders_in_file(
file_path: Path,
project_name: str | None,
date_value: str,
main_language: str | None,
playbook_scripts: str | None,
) -> None:
if file_path.suffix != ".md":
return
text = file_path.read_text(encoding="utf-8")
updated = replace_placeholders(
text, project_name, date_value, main_language, playbook_scripts
)
updated = replace_placeholders(text, project_name, date_value, playbook_scripts)
if updated != text:
file_path.write_text(updated, encoding="utf-8")
@@ -501,7 +589,6 @@ def sync_directory(
target_dir: Path,
project_name: str | None,
date_value: str,
main_language: str | None,
playbook_scripts: str | None,
force: bool,
no_backup: bool,
@@ -522,7 +609,6 @@ def sync_directory(
target_file,
project_name,
date_value,
main_language,
playbook_scripts,
)
@@ -550,12 +636,11 @@ 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, main_language, playbook_scripts
template_text, project_name, date_value, playbook_scripts
)
block = extract_block_lines(template_text, start_marker, end_marker)
if not block:
@@ -631,8 +716,7 @@ def sync_agents_template(context: dict) -> int:
return 0
project_name = resolve_project_name(context)
main_language = resolve_main_language({}, context)
playbook_scripts = resolve_playbook_scripts(project_root, context)
playbook_scripts = resolve_playbook_scripts(context)
date_value = resolve_template_date(context)
agents_dst = project_root / "AGENTS.md"
@@ -658,12 +742,85 @@ def sync_agents_template(context: dict) -> int:
end_marker,
project_name,
date_value,
main_language,
playbook_scripts,
)
sync_claude_md(project_root, context.get("config", {}))
return 0
_CLAUDE_BLOCK_START = "<!-- playbook:claude:start -->"
_CLAUDE_BLOCK_END = "<!-- playbook:claude:end -->"
_CLAUDE_MD_CANDIDATES = ["CLAUDE.md", ".claude/CLAUDE.md"]
def sync_claude_md(project_root: Path, config: dict) -> None:
claude_md_config = config.get("playbook", {}).get("claude_md")
claude_md: Path | None = None
if claude_md_config:
claude_md = project_root / claude_md_config
else:
for candidate in _CLAUDE_MD_CANDIDATES:
path = project_root / candidate
if path.exists():
claude_md = path
break
if claude_md is None:
claude_md = project_root / "CLAUDE.md"
rel_prefix = ""
try:
rel = claude_md.parent.resolve().relative_to(project_root.resolve())
if rel != Path("."):
depth = len(rel.parts)
rel_prefix = "../" * depth
except ValueError:
pass
block_lines = [
_CLAUDE_BLOCK_START,
"",
f"@{rel_prefix}AGENTS.md",
f"@{rel_prefix}AGENT_RULES.md",
"",
_CLAUDE_BLOCK_END,
]
if not claude_md.exists():
ensure_dir(claude_md.parent)
claude_md.write_text("\n".join(block_lines) + "\n", encoding="utf-8")
log(f"Created {claude_md.relative_to(project_root)} with playbook block.")
return
text = claude_md.read_text(encoding="utf-8")
if _CLAUDE_BLOCK_START in text:
lines = text.splitlines()
updated: list[str] = []
in_block = False
replaced = False
for line in lines:
if not replaced and line.strip() == _CLAUDE_BLOCK_START:
updated.extend(block_lines)
in_block = True
replaced = True
continue
if in_block:
if line.strip() == _CLAUDE_BLOCK_END:
in_block = False
continue
updated.append(line)
claude_md.write_text("\n".join(updated) + "\n", encoding="utf-8")
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")
log("Appended playbook block to CLAUDE.md")
def should_sync_agents(config: dict) -> bool:
for key in ("sync_rules", "sync_memory_bank", "sync_prompts", "sync_standards"):
if key in config:
@@ -690,18 +847,32 @@ def sync_rules_action(config: dict, context: dict) -> int:
return 0
project_name = resolve_project_name(context)
main_language = resolve_main_language(config, context)
playbook_scripts = resolve_playbook_scripts(project_root, context)
playbook_scripts = resolve_playbook_scripts(context)
date_value = config.get("date") or datetime.now().strftime("%Y-%m-%d")
no_backup = bool(config.get("no_backup", False))
backup_path(rules_dst, no_backup)
text = rules_src.read_text(encoding="utf-8")
text = replace_placeholders(
text, project_name, date_value, main_language, playbook_scripts
)
rules_dst.write_text(text + "\n", encoding="utf-8")
text = replace_placeholders(text, project_name, date_value, playbook_scripts)
rules_dst.write_text(text.rstrip("\n") + "\n", encoding="utf-8")
log("Synced: AGENT_RULES.md")
local_rules = project_root / "AGENT_RULES.local.md"
if not local_rules.exists():
local_rules.write_text(
"# AGENT_RULES.local\n"
"\n"
"项目私有规则(优先级高于 AGENT_RULES.md)。\n"
"\n"
"在此记录:\n"
"\n"
"- 项目特有的注意事项与常见陷阱\n"
"- 同一错误发生 2 次以上时的修正规则\n"
"- 团队约定的额外约束\n",
encoding="utf-8",
)
log("Created: AGENT_RULES.local.md")
return 0
@@ -718,8 +889,7 @@ def sync_memory_bank_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)
playbook_scripts = resolve_playbook_scripts(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))
@@ -731,7 +901,6 @@ def sync_memory_bank_action(config: dict, context: dict) -> int:
memory_dst,
project_name,
date_value,
main_language,
playbook_scripts,
force,
no_backup,
@@ -753,8 +922,7 @@ def sync_prompts_action(config: dict, context: dict) -> int:
return 2
project_name = resolve_project_name(context)
main_language = resolve_main_language(config, context)
playbook_scripts = resolve_playbook_scripts(project_root, context)
playbook_scripts = resolve_playbook_scripts(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))
@@ -767,7 +935,6 @@ def sync_prompts_action(config: dict, context: dict) -> int:
prompts_dst,
project_name,
date_value,
main_language,
playbook_scripts,
force,
no_backup,
@@ -830,8 +997,13 @@ def update_agents_block(agents_md: Path, block_lines: list[str]) -> None:
def create_agents_index(agents_root: Path, langs: list[str], docs_prefix: str | None) -> None:
agents_index = agents_root / "index.md"
if agents_index.exists():
return
lang_descriptions = {
"tsl": "TSL 相关规则集(由 playbook 同步;适用于 `.tsl`/`.tsf`",
"cpp": "C++ 相关规则集(由 playbook 同步;适用于 C++23/Modules",
"python": "Python 相关规则集(由 playbook 同步)",
"typescript": "TypeScript/JavaScript 相关规则集(由 playbook 同步)",
"markdown": "Markdown 相关规则集(仅代码格式化)",
}
lines = [
"# .agents(多语言)",
"",
@@ -839,11 +1011,11 @@ def create_agents_index(agents_root: Path, langs: list[str], docs_prefix: str |
"",
"建议约定:",
"",
"- `.agents/tsl/`TSL 相关规则集(由 playbook 同步;适用于 `.tsl`/`.tsf`",
"- `.agents/cpp/`C++ 相关规则集(由 playbook 同步;适用于 C++23/Modules",
"- `.agents/python/`Python 相关规则集(由 playbook 同步)",
"- `.agents/typescript/`TypeScript/JavaScript 相关规则集(由 playbook 同步)",
"- `.agents/markdown/`Markdown 相关规则集(仅代码格式化)",
]
for lang in langs:
description = lang_descriptions.get(lang, "相关规则集(由 playbook 同步)")
lines.append(f"- `.agents/{lang}/`{description}")
lines += [
"",
"规则发生冲突时,建议以“更靠近代码的目录规则更具体”为准。",
"",
@@ -856,32 +1028,45 @@ def create_agents_index(agents_root: Path, langs: list[str], docs_prefix: str |
"",
"标准快照文档入口:",
"",
f"- {docs_prefix or 'docs/standards/playbook/docs/'}",
f"- {docs_prefix or 'docs/'}",
]
agents_index.write_text("\n".join(lines) + "\n", encoding="utf-8")
log("Created .agents/index.md")
log("Synced .agents/index.md")
def rewrite_agents_docs_links(agents_dir: Path, docs_prefix: str) -> None:
def rewrite_docs_links_in_markdown(root: Path, docs_prefix: str, recursive: bool) -> None:
replacements = {
"`docs/tsl/": f"`{docs_prefix}/tsl/",
"`docs/cpp/": f"`{docs_prefix}/cpp/",
"`docs/python/": f"`{docs_prefix}/python/",
"`docs/typescript/": f"`{docs_prefix}/typescript/",
"`docs/markdown/": f"`{docs_prefix}/markdown/",
"`docs/common/": f"`{docs_prefix}/common/",
"tsl": f"{docs_prefix}/tsl/",
"cpp": f"{docs_prefix}/cpp/",
"python": f"{docs_prefix}/python/",
"typescript": f"{docs_prefix}/typescript/",
"markdown": f"{docs_prefix}/markdown/",
"common": f"{docs_prefix}/common/",
}
for md_path in agents_dir.glob("*.md"):
iterator = root.rglob("*.md") if recursive else root.glob("*.md")
patterns = [
(re.compile(rf"(?<![\w./-])docs/{section}/"), replacement)
for section, replacement in replacements.items()
]
for md_path in iterator:
if not md_path.is_file():
continue
text = md_path.read_text(encoding="utf-8")
updated = text
for old, new in replacements.items():
updated = updated.replace(old, new)
for pattern, replacement in patterns:
updated = pattern.sub(replacement, updated)
if updated != text:
md_path.write_text(updated, encoding="utf-8")
def rewrite_agents_docs_links(agents_dir: Path, docs_prefix: str) -> None:
rewrite_docs_links_in_markdown(agents_dir, docs_prefix, recursive=False)
def rewrite_skill_docs_links(skill_dir: Path, docs_prefix: str) -> None:
rewrite_docs_links_in_markdown(skill_dir, docs_prefix, recursive=True)
def read_gitattributes_entries(path: Path) -> list[str]:
entries: list[str] = []
for line in path.read_text(encoding="utf-8").splitlines():
@@ -993,13 +1178,7 @@ def sync_standards_action(config: dict, context: dict) -> int:
copytree(src, dst)
log(f"Synced .agents/{lang} from standards.")
docs_prefix = None
try:
rel_snapshot = PLAYBOOK_ROOT.resolve().relative_to(project_root.resolve())
if str(rel_snapshot) != ".":
docs_prefix = f"{rel_snapshot.as_posix()}/docs"
except ValueError:
docs_prefix = None
docs_prefix = resolve_docs_prefix(context)
if docs_prefix:
for lang in langs:
@@ -1075,7 +1254,8 @@ def install_skills_action(config: dict, context: dict) -> int:
if not agents_home.is_absolute():
agents_home = (context["project_root"] / agents_home).resolve()
skills_src_root = PLAYBOOK_ROOT / "codex/skills"
skills_src_root = PLAYBOOK_ROOT / "skills"
skills_thirdparty_root = skills_src_root / "thirdparty"
if not skills_src_root.is_dir():
print(f"ERROR: skills source not found: {skills_src_root}", file=sys.stderr)
return 2
@@ -1084,38 +1264,104 @@ def install_skills_action(config: dict, context: dict) -> int:
ensure_dir(skills_dst_root)
if mode == "all":
skills = [
path.name
own_skills = [
(path.name, skills_src_root, "own")
for path in skills_src_root.iterdir()
if path.is_dir() and not path.name.startswith(".")
if path.is_dir() and not path.name.startswith(".") and path.name != "thirdparty"
]
third_skills = [
(path.name, skills_thirdparty_root, "thirdparty")
for path in skills_thirdparty_root.iterdir()
if path.is_dir() and not path.name.startswith(".")
] if skills_thirdparty_root.is_dir() else []
skill_entries = own_skills + third_skills
elif mode == "list":
try:
skills = normalize_names(config.get("skills"), "skills")
names = normalize_names(config.get("skills"), "skills")
except ValueError as exc:
print(f"ERROR: {exc}", file=sys.stderr)
return 2
skill_entries = []
for name in names:
if (skills_src_root / name).is_dir():
skill_entries.append((name, skills_src_root, "own"))
elif skills_thirdparty_root.is_dir() and (skills_thirdparty_root / name).is_dir():
skill_entries.append((name, skills_thirdparty_root, "thirdparty"))
else:
print(f"ERROR: skill not found: {name}", file=sys.stderr)
return 2
else:
print("ERROR: mode must be list or all", file=sys.stderr)
return 2
timestamp = datetime.now().strftime("%Y%m%d%H%M%S")
for name in skills:
src = skills_src_root / name
if not src.is_dir():
print(f"ERROR: skill not found: {name}", file=sys.stderr)
return 2
no_backup = bool(config.get("no_backup", False))
for name, src_root, origin in skill_entries:
src = src_root / name
dst = skills_dst_root / name
if dst.exists():
backup = skills_dst_root / f"{name}.bak.{timestamp}"
dst.rename(backup)
log(f"Backed up existing skill: {name} -> {backup.name}")
if no_backup:
rmtree(dst)
else:
backup = skills_dst_root / f"{name}.bak.{timestamp}"
dst.rename(backup)
log(f"Backed up existing skill: {name} -> {backup.name}")
copytree(src, dst)
log(f"Installed: {name}")
rewrite_skill_docs_links(dst, resolve_docs_prefix(context))
tag = " [thirdparty]" if origin == "thirdparty" else ""
log(f"Installed: {name}{tag}")
skill_link_raw = config.get("skill_link")
if skill_link_raw:
skill_link_home = Path(str(skill_link_raw)).expanduser()
if not skill_link_home.is_absolute():
skill_link_home = (context["project_root"] / skill_link_home).resolve()
_create_skills_symlink(skill_link_home / "skills", skills_dst_root)
return 0
def _is_junction(path: Path) -> bool:
if sys.platform != "win32":
return False
try:
import ctypes.wintypes
attrs = ctypes.windll.kernel32.GetFileAttributesW(str(path))
return attrs != -1 and bool(attrs & 0x400)
except Exception:
return False
def _create_skills_symlink(link_path: Path, target_path: Path) -> None:
if link_path.is_symlink() or _is_junction(link_path):
if link_path.resolve() == target_path.resolve():
log(f"Symlink already up to date: {link_path}")
return
if link_path.is_symlink():
link_path.unlink()
elif _is_junction(link_path):
link_path.rmdir()
elif link_path.exists():
log(f"Skip symlink: {link_path} exists and is not a symlink")
return
ensure_dir(link_path.parent)
try:
link_path.symlink_to(target_path, target_is_directory=True)
log(f"Created symlink: {link_path} -> {target_path}")
except OSError:
if sys.platform == "win32":
result = subprocess.run(
["cmd", "/c", "mklink", "/J", str(link_path), str(target_path)],
capture_output=True,
)
if result.returncode == 0:
log(f"Created junction: {link_path} -> {target_path}")
else:
log(f"Warning: could not create junction {link_path}")
else:
log(f"Warning: could not create symlink {link_path}")
def format_md_action(config: dict, context: dict) -> int:
tool = str(config.get("tool", "prettier")).lower()
if tool != "prettier":
@@ -1170,6 +1416,47 @@ def main(argv: list[str]) -> int:
if "-h" in argv or "-help" in argv:
print(usage())
return 0
spec_path = parse_cli_value(argv, "-record-spec")
if spec_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_workflow_state(
Path(progress_path),
"planning",
spec_path,
None,
None,
None,
)
if code != 0:
print(message, file=sys.stderr)
return code
print(message)
return 0
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_workflow_state(
Path(progress_path),
"planning",
None,
plan_path,
"executing-plans",
"karpathy-guidelines,.agents,AGENT_RULES",
)
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
@@ -1192,10 +1479,17 @@ def main(argv: list[str]) -> int:
root = (config_path.parent / root).resolve()
else:
root = config_path.parent
resolved_root = root.resolve()
try:
deploy_root = resolve_configured_deploy_root(config, resolved_root)
except ValueError as exc:
print(f"ERROR: {exc}", file=sys.stderr)
return 2
context = {
"project_root": root.resolve(),
"project_root": resolved_root,
"config_path": config_path.resolve(),
"config": config,
"deploy_root": deploy_root,
}
if should_sync_agents(config):
+328
View File
@@ -0,0 +1,328 @@
[CmdletBinding()]
param(
[Parameter(Mandatory = $true)]
[string]$Path
)
Set-StrictMode -Version Latest
$ErrorActionPreference = "Stop"
function Resolve-AuditTargets {
param(
[string]$InputPath
)
if (-not (Test-Path -LiteralPath $InputPath)) {
throw "Path not found: $InputPath"
}
$item = Get-Item -LiteralPath $InputPath
if ($item.PSIsContainer) {
return @(Get-ChildItem -LiteralPath $item.FullName -Recurse -File -Filter "*.md" | Sort-Object FullName)
}
if ($item.Extension -ne ".md") {
throw "Only Markdown files are supported: $($item.FullName)"
}
return @($item)
}
function Get-FirstMeaningfulLine {
param(
[string[]]$Lines
)
foreach ($line in $Lines) {
if (-not [string]::IsNullOrWhiteSpace($line)) {
return $line.Trim()
}
}
return ""
}
function Get-BlockPreview {
param(
[string]$Code
)
$preview = ""
foreach ($line in ($Code -split "`r?`n")) {
if (-not [string]::IsNullOrWhiteSpace($line)) {
$preview = $line.Trim()
break
}
}
if ([string]::IsNullOrWhiteSpace($preview)) {
return "<empty>"
}
if ($preview.Length -gt 100) {
return $preview.Substring(0, 100) + "..."
}
return $preview
}
function Get-SkipReason {
param(
[string]$Language,
[string]$Code
)
if ($Language -eq "text") {
return "text block"
}
$trimmed = $Code.Trim()
if ([string]::IsNullOrWhiteSpace($trimmed)) {
return "empty block"
}
if ($trimmed -match '(?m)^\s*statement;\s*$') {
return "grammar placeholder"
}
if ($trimmed -match '…+' -or $trimmed -match '(?m)^\s*(//\s*)?\.{3,}\s*$') {
return "ellipsis placeholder"
}
if ($trimmed -match '<[^>\r\n]+>') {
return "angle-bracket placeholder"
}
$meaningfulLines = @(
($Code -split "`r?`n" | Where-Object { -not [string]::IsNullOrWhiteSpace($_) } | ForEach-Object { $_.Trim() })
)
if ($meaningfulLines.Count -gt 1 -and
$meaningfulLines[0] -eq "begin" -and
(($meaningfulLines | Select-Object -Skip 1) -match '^(?i:(function|unit|type|class|const|var|namespace|uses))')) {
return "mixed non-standalone snippet"
}
return $null
}
function Get-CompileKind {
param(
[string]$Code
)
$firstLine = Get-FirstMeaningfulLine -Lines ($Code -split "`r?`n")
if ($firstLine -match '^(?i:(function|unit|type|class|const|var|namespace|uses|\{\$))') {
return "tsf"
}
return "tsl"
}
function Invoke-TslCompile {
param(
[string]$Code,
[ValidateSet("tsl", "tsf")]
[string]$Kind
)
$tempRoot = Join-Path ([System.IO.Path]::GetTempPath()) ("tsl-doc-audit-" + [guid]::NewGuid().ToString("N"))
New-Item -ItemType Directory -Path $tempRoot | Out-Null
$process = $null
try {
$sourcePath = Join-Path $tempRoot ("snippet." + $Kind)
[System.IO.File]::WriteAllText($sourcePath, $Code, [System.Text.UTF8Encoding]::new($false))
$psi = New-Object System.Diagnostics.ProcessStartInfo
$psi.FileName = 'tsl'
$psi.Arguments = "-COMPILE `"$sourcePath`""
$psi.RedirectStandardOutput = $true
$psi.RedirectStandardError = $true
$psi.RedirectStandardInput = $true
$psi.UseShellExecute = $false
$psi.CreateNoWindow = $true
$process = New-Object System.Diagnostics.Process
$process.StartInfo = $psi
$process.Start() | Out-Null
$process.StandardInput.WriteLine('exit')
$process.StandardInput.Flush()
$process.WaitForExit(30000) | Out-Null
$stdout = $process.StandardOutput.ReadToEnd()
$stderr = $process.StandardError.ReadToEnd()
$outputText = ($stdout + "`n" + $stderr).Trim()
$lowerText = $outputText.ToLowerInvariant()
$success = $lowerText -match 'compile success'
$failure = $lowerText -match 'compile error'
if ($success) {
return [pscustomobject]@{ Success = $true; Output = $outputText }
}
if ($failure) {
return [pscustomobject]@{ Success = $false; Output = $outputText }
}
return [pscustomobject]@{ Success = $false; Output = (if ($outputText) { $outputText } else { "<no output>" }) }
}
finally {
if ($process -and -not $process.HasExited) {
$process.Kill()
$process.WaitForExit()
}
if (Test-Path -LiteralPath $tempRoot) {
Remove-Item -LiteralPath $tempRoot -Recurse -Force
}
}
}
function Wrap-AsFunctionBody {
param(
[string]$Code
)
$body = $Code -split "`r?`n" | ForEach-Object { " $_" }
return @(
"function __doc_check__();"
"begin"
$body
"end;"
""
) -join "`n"
}
function Parse-MarkdownBlocks {
param(
[string]$Content
)
$blocks = New-Object System.Collections.Generic.List[object]
$lines = $Content -split "`r?`n"
$inFence = $false
$fenceLang = ""
$fenceStartLine = 0
$buffer = New-Object System.Collections.Generic.List[string]
for ($i = 0; $i -lt $lines.Length; $i++) {
$line = $lines[$i]
if (-not $inFence) {
if ($line -match '^\s*```([A-Za-z0-9_-]*)\s*$') {
$inFence = $true
$fenceLang = $Matches[1].ToLowerInvariant()
$fenceStartLine = $i + 1
$buffer.Clear()
}
continue
}
if ($line -match '^\s*```\s*$') {
$blocks.Add([pscustomobject]@{
Language = $fenceLang
StartLine = $fenceStartLine
Code = ($buffer -join "`n")
})
$inFence = $false
$fenceLang = ""
$fenceStartLine = 0
$buffer.Clear()
continue
}
$buffer.Add($line)
}
return $blocks
}
$targets = Resolve-AuditTargets -InputPath $Path
$grandPass = 0
$grandSkip = 0
$grandFail = 0
foreach ($target in $targets) {
$content = Get-Content -LiteralPath $target.FullName -Raw
$blocks = Parse-MarkdownBlocks -Content $content
$results = New-Object System.Collections.Generic.List[object]
foreach ($block in $blocks) {
if ($block.Language -notin @("tsl", "text")) {
continue
}
$skipReason = Get-SkipReason -Language $block.Language -Code $block.Code
if ($null -ne $skipReason) {
$results.Add([pscustomobject]@{
Status = "skip"
StartLine = $block.StartLine
Preview = Get-BlockPreview -Code $block.Code
Detail = $skipReason
})
continue
}
$kind = Get-CompileKind -Code $block.Code
$compile = Invoke-TslCompile -Code $block.Code -Kind $kind
if ($compile.Success) {
$results.Add([pscustomobject]@{
Status = "pass"
StartLine = $block.StartLine
Preview = Get-BlockPreview -Code $block.Code
Detail = $kind
})
continue
}
if ($kind -eq "tsl") {
$wrappedCode = Wrap-AsFunctionBody -Code $block.Code
$wrappedCompile = Invoke-TslCompile -Code $wrappedCode -Kind "tsf"
if ($wrappedCompile.Success) {
$results.Add([pscustomobject]@{
Status = "pass"
StartLine = $block.StartLine
Preview = Get-BlockPreview -Code $block.Code
Detail = "wrapped tsf"
})
continue
}
$compile = $wrappedCompile
}
$results.Add([pscustomobject]@{
Status = "fail"
StartLine = $block.StartLine
Preview = Get-BlockPreview -Code $block.Code
Detail = ($compile.Output.Trim())
})
}
$passCount = @($results | Where-Object Status -eq "pass").Count
$skipCount = @($results | Where-Object Status -eq "skip").Count
$failCount = @($results | Where-Object Status -eq "fail").Count
$grandPass += $passCount
$grandSkip += $skipCount
$grandFail += $failCount
Write-Output "$($target.FullName): pass=$passCount skip=$skipCount fail=$failCount"
foreach ($failure in ($results | Where-Object Status -eq "fail")) {
Write-Output " FAIL line $($failure.StartLine): $($failure.Preview)"
foreach ($detailLine in ($failure.Detail -split "`r?`n")) {
if (-not [string]::IsNullOrWhiteSpace($detailLine)) {
Write-Output " $detailLine"
}
}
}
}
Write-Output "TOTAL: pass=$grandPass skip=$grandSkip fail=$grandFail"
if ($grandFail -gt 0) {
exit 1
}
exit 0