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:
@@ -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
|
||||
@@ -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:]))
|
||||
@@ -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
@@ -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": "## TSL(tsl/tsf)",
|
||||
"cpp": "## C++(cpp)",
|
||||
"python": "## Python(python)",
|
||||
"typescript": "## TypeScript(typescript)",
|
||||
"markdown": "## Markdown(markdown)",
|
||||
}
|
||||
|
||||
|
||||
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"本快照为裁剪版 Playbook(langs: {','.join(langs)})。",
|
||||
"",
|
||||
"## 跨语言(common)",
|
||||
"",
|
||||
"- 提交信息与版本号:`common/commit_message.md`",
|
||||
]
|
||||
for lang in langs:
|
||||
if lang == "tsl":
|
||||
lines += [
|
||||
"",
|
||||
"## TSL(tsl)",
|
||||
"",
|
||||
"- 代码风格:`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 += [
|
||||
"",
|
||||
"## Python(python)",
|
||||
"",
|
||||
"- 代码风格:`python/style_guide.md`",
|
||||
"- 工具链:`python/tooling.md`",
|
||||
"- 配置清单:`python/configuration.md`",
|
||||
]
|
||||
elif lang == "typescript":
|
||||
lines += [
|
||||
"",
|
||||
"## TypeScript(typescript)",
|
||||
"",
|
||||
"- 代码风格:`typescript/code_style.md`",
|
||||
"- 命名规范:`typescript/naming.md`",
|
||||
"- 工具链:`typescript/toolchain.md`",
|
||||
"- 配置清单:`typescript/configuration.md`",
|
||||
]
|
||||
elif lang == "markdown":
|
||||
lines += [
|
||||
"",
|
||||
"## Markdown(markdown)",
|
||||
"",
|
||||
"- 代码块与行内代码格式:`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):
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user