Files
yuhao bc536c9beb fix(ci): make root-skill validation refuse to recommend destroying edits
validate_root_skills.py compared source and mirror with a symmetric `!=`,
so it detected divergence but never direction, and printed the same advice
either way: "Run sync_root_skills.py and commit."

That advice is only correct when the harness source moved ahead. When the
edit is in the generated skills/ mirror -- as in #360 -- sync regenerates
the mirror from the source and silently deletes the change. The validator
then passes, so CI turns green and certifies the loss.

Detect the direction. When the mirror holds lines the source lacks, name
them, point at the source file, and never suggest the destructive command.
Regenerable drift keeps the old advice. Also make sync_root_skills.py
report each file it creates or overwrites instead of writing in silence.

Verified: in-sync, mirror-edited, source-edited, missing-mirror, and
idempotent no-op.
2026-07-09 13:05:56 +00:00

94 lines
3.0 KiB
Python

#!/usr/bin/env python3
"""Sync repo-root skills/ from harness-local SKILL.md files."""
from __future__ import annotations
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parents[2]
ROOT_SKILLS_DIR = REPO_ROOT / "skills"
def _canonical_skill_id(source: Path) -> str:
rel = source.relative_to(REPO_ROOT)
parts = rel.parts
if "cli_anything" in parts:
package_index = parts.index("cli_anything") + 1
if package_index < len(parts):
package_name = parts[package_index]
return f"cli-anything-{package_name.replace('_', '-')}"
software_dir = parts[0]
return f"cli-anything-{software_dir.replace('_', '-')}"
def _rewrite_name_frontmatter(content: str, skill_id: str) -> str:
if not content.startswith("---\n"):
return content
parts = content.split("---\n", 2)
if len(parts) < 3:
return content
_, frontmatter, body = parts
lines = frontmatter.splitlines(keepends=True)
rewritten: list[str] = []
replaced = False
i = 0
while i < len(lines):
line = lines[i]
if not replaced and line.startswith("name:"):
rewritten.append(f'name: "{skill_id}"\n')
replaced = True
i += 1
while i < len(lines) and (lines[i].startswith(" ") or lines[i].startswith("\t")):
i += 1
continue
rewritten.append(line)
i += 1
if not replaced:
rewritten.insert(0, f'name: "{skill_id}"\n')
frontmatter = "".join(rewritten)
return f"---\n{frontmatter}---\n{body}"
def _discover_sources() -> list[Path]:
sources: list[Path] = []
sources.extend(sorted(REPO_ROOT.glob("*/agent-harness/cli_anything/*/skills/SKILL.md")))
sources.extend(sorted(REPO_ROOT.glob("*/agent-harness/cli_anything/*/SKILL.md")))
return [path for path in sources if path.is_file()]
def main() -> int:
sources = _discover_sources()
ROOT_SKILLS_DIR.mkdir(parents=True, exist_ok=True)
written = 0
for source in sources:
skill_id = _canonical_skill_id(source)
target = ROOT_SKILLS_DIR / skill_id / "SKILL.md"
target.parent.mkdir(parents=True, exist_ok=True)
content = source.read_text(encoding="utf-8")
expected = _rewrite_name_frontmatter(content, skill_id)
if target.is_file() and target.read_text(encoding="utf-8") == expected:
continue
# Overwriting is destructive: the mirror may hold edits the source lacks.
# Say so, so the loss is never silent. validate_root_skills.py refuses
# in that direction before CI ever reaches here.
action = "overwrite" if target.is_file() else "create"
print(f"{action}: {target.relative_to(REPO_ROOT)} <- {source.relative_to(REPO_ROOT)}")
target.write_text(expected, encoding="utf-8")
written += 1
print(f"Synced {written} root skill(s); {len(sources) - written} already up to date.")
return 0
if __name__ == "__main__":
raise SystemExit(main())