Files
othmanadi__planning-with-files/scripts/sync-ide-folders.py
T
OthmanAdi b4a944f5bc fix: give the language variants the full script surface (#130)
The five language variants shipped 8 of the 20 scripts the canonical
skill ships. attest-plan, gate-stop, ledger-append, ledger-summary.ps1,
phase-status, plan-doctor, resolve-plan-dir.ps1 and set-active-plan had
never reached a non-English install, so the completion gate, the ledger,
phase status, plan-doctor and attestation were silently absent for the
39.8K installs those five listings carry. Issue #130 describes this
drift; it is a reason to close the gap, not a reason to delete the
variants.

sync-ide-folders.py covered only the three hook dispatch targets from
issue #212, so every future feature would have kept missing them. It now
delivers the whole language-neutral script surface.

The manifest's claim that variant scripts are language-neutral is false
and would have caused real damage if trusted: -de/session-catchup.py
contains German prose and five -ar scripts contain Arabic.
check-complete, init-session and session-catchup are therefore pinned as
translator-owned and never synced. The other twelve never existed in any
variant, so delivering them overwrites nothing: 60 files created, 0
updated.

session-catchup.py separately keeps its translations but was missing
configure_utf8_stdio() from v3.2.0, so a non-ASCII session log still
crashed the recovery hook on a Windows legacy code page, which is the
population most likely to run a translated variant. Backported into all
five by insertion, leaving their translated prose untouched.
2026-08-09 09:38:57 +02:00

430 lines
15 KiB
Python

#!/usr/bin/env python3
"""
sync-ide-folders.py — Syncs shared files from the canonical source
(skills/planning-with-files/) to all IDE-specific folders.
Run this from the repo root before releases:
python scripts/sync-ide-folders.py
What it syncs:
- Templates (findings.md, progress.md, task_plan.md)
- References (examples.md, reference.md)
- Scripts (check-complete.sh/.ps1, init-session.sh/.ps1, session-catchup.py)
What it NEVER touches:
- SKILL.md (IDE-specific frontmatter differs per IDE)
- IDE-specific files (hooks, prompts, package.json, steering files)
Use --dry-run to preview changes without writing anything.
Use --verify to check for drift without making changes (exits 1 if drift found).
"""
import argparse
import shutil
import sys
import hashlib
from pathlib import Path
# ─── Canonical source ──────────────────────────────────────────────
CANONICAL = Path("skills/planning-with-files")
# ─── Shared source files (relative to CANONICAL) ──────────────────
TEMPLATES = [
"templates/findings.md",
"templates/progress.md",
"templates/task_plan.md",
"templates/analytics_task_plan.md",
"templates/analytics_findings.md",
]
REFERENCES = [
"examples.md",
"reference.md",
]
SCRIPTS = [
"scripts/check-complete.sh",
"scripts/check-complete.ps1",
"scripts/init-session.sh",
"scripts/init-session.ps1",
"scripts/session-catchup.py",
"scripts/resolve-plan-dir.sh",
"scripts/resolve-plan-dir.ps1",
"scripts/set-active-plan.sh",
"scripts/set-active-plan.ps1",
"scripts/attest-plan.sh",
"scripts/attest-plan.ps1",
"scripts/plan-doctor.sh",
]
# Hook dispatch targets (issue #212): every hook-bearing SKILL.md dispatches
# its UserPromptSubmit/PreToolUse/PreCompact scalars to inject-plan.sh, and
# inject-plan.sh shells its sibling ledger-summary.sh in autonomous/gated mode.
# ledger-summary.sh in turn shells its sibling resolve-plan-dir.sh; when the
# resolver is missing it falls back to plan_dir="." and injects a false
# "phases: 0/0 complete" into an autonomous loop. All three must ship in every
# variant's own scripts/ dir or the dispatch resolves to nothing, or to a lying
# summary, on that host (tests/test_skill_hook_dispatch_parity.py pins this).
HOOK_DISPATCH_SCRIPTS = [
"scripts/inject-plan.sh",
"scripts/ledger-summary.sh",
"scripts/resolve-plan-dir.sh",
]
# .agents/ ships the FULL canonical surface (no IDE adapter layer exists to
# carry the rest): hook dispatchers, ledger tooling, and every template.
AGENTS_EXTRA_SCRIPTS = [
"scripts/gate-stop.sh",
"scripts/inject-plan.sh",
"scripts/ledger-append.sh",
"scripts/ledger-append.ps1",
"scripts/ledger-summary.sh",
"scripts/ledger-summary.ps1",
"scripts/phase-status.sh",
"scripts/phase-status.ps1",
]
AGENTS_EXTRA_TEMPLATES = [
"templates/task_plan_autonomous.md",
]
# ─── IDE sync manifests ───────────────────────────────────────────
# Each IDE maps: canonical_source_file -> target_path (relative to repo root)
# Only files listed here are synced. Everything else is untouched.
def _build_manifest(base, *, ref_style="flat", template_dirs=None,
include_scripts=True, extra_template_dirs=None,
extra_scripts=None):
"""Build a sync manifest for an IDE folder.
Args:
base: IDE skill folder path (e.g. ".gemini/skills/planning-with-files")
ref_style: "flat" = examples.md at root, "subdir" = references/examples.md
template_dirs: list of template subdirs (default: ["templates/"])
include_scripts: whether to sync scripts
extra_template_dirs: additional dirs to also receive template copies
extra_scripts: scripts beyond the shared SCRIPTS list (e.g. the hook
dispatch targets in HOOK_DISPATCH_SCRIPTS)
"""
manifest = {}
b = Path(base)
# Templates
if template_dirs is None:
template_dirs = ["templates/"]
for tdir in template_dirs:
for t in TEMPLATES:
filename = Path(t).name # e.g. "findings.md"
manifest[t] = str(b / tdir / filename)
# Extra template locations (e.g. assets/templates/ in codex, codebuddy)
if extra_template_dirs:
for tdir in extra_template_dirs:
for t in TEMPLATES:
filename = Path(t).name
manifest[f"{t}__extra_{tdir}"] = str(b / tdir / filename)
# References
if ref_style == "flat":
for r in REFERENCES:
manifest[r] = str(b / r)
elif ref_style == "subdir":
for r in REFERENCES:
manifest[r] = str(b / "references" / r)
# ref_style == "skip" means don't sync references (IDE uses custom format)
# Scripts
if include_scripts:
for s in SCRIPTS:
manifest[s] = str(b / s)
# Extra scripts (hook dispatch targets and other per-IDE additions)
if extra_scripts:
for s in extra_scripts:
manifest[s] = str(b / s)
return manifest
IDE_MANIFESTS = {
".cursor": _build_manifest(
".cursor/skills/planning-with-files",
ref_style="flat",
include_scripts=True,
extra_scripts=HOOK_DISPATCH_SCRIPTS,
# Cursor hooks (.cursor/hooks/) are IDE-specific, not synced. The
# skill's own scripts/ ARE synced: the SKILL.md hook scalars dispatch
# to them (issue #212), so shipping none left the dispatch dead.
),
".gemini": _build_manifest(
".gemini/skills/planning-with-files",
ref_style="subdir",
include_scripts=True,
),
".codex": _build_manifest(
".codex/skills/planning-with-files",
ref_style="subdir",
include_scripts=True,
extra_scripts=HOOK_DISPATCH_SCRIPTS,
),
# .openclaw, .kilocode, .adal, .agent removed in v2.24.0 (IDE audit)
# These IDEs use the standard Agent Skills spec — install via npx skills add
".pi": _build_manifest(
".pi/skills/planning-with-files",
ref_style="flat",
include_scripts=True,
# package.json and README.md are IDE-specific, not synced
),
".continue": _build_manifest(
".continue/skills/planning-with-files",
ref_style="flat",
template_dirs=[], # Continue has no templates dir
include_scripts=True,
# .continue/prompts/ is IDE-specific, not synced
),
".codebuddy": _build_manifest(
".codebuddy/skills/planning-with-files",
ref_style="subdir",
include_scripts=True,
extra_scripts=HOOK_DISPATCH_SCRIPTS,
),
".factory": _build_manifest(
".factory/skills/planning-with-files",
ref_style="skip", # Uses combined references.md, not synced
include_scripts=True,
extra_scripts=HOOK_DISPATCH_SCRIPTS,
),
".opencode": _build_manifest(
".opencode/skills/planning-with-files",
ref_style="flat",
include_scripts=False,
extra_scripts=HOOK_DISPATCH_SCRIPTS,
),
# Mastracode: templates/references maintained under .mastracode/; only the
# hook dispatch targets are synced from canonical.
".mastracode": _build_manifest(
".mastracode/skills/planning-with-files",
ref_style="skip",
template_dirs=[],
include_scripts=False,
extra_scripts=HOOK_DISPATCH_SCRIPTS,
),
# Kiro: maintained under .kiro/ (skill + wrappers); not synced from canonical scripts/.
".kiro": {},
}
# Scripts the language variants ship in TRANSLATED form: check-complete and
# init-session carry localized user-facing output, and session-catchup.py
# carries localized recovery prose (verified: -de/session-catchup.py contains
# German text, five -ar scripts contain Arabic). include_scripts=True would
# overwrite all of them with the English canonical, so these are never synced
# and stay owned by their translators.
LANG_TRANSLATED_SCRIPTS = [
"scripts/check-complete.sh",
"scripts/check-complete.ps1",
"scripts/init-session.sh",
"scripts/init-session.ps1",
"scripts/session-catchup.py",
]
# Everything else the canonical skill ships. These 12 never existed in any
# variant, so delivering them is purely additive: non-English users were
# missing the completion gate, the ledger, phase-status, plan-doctor and
# attestation entirely, which is the drift issue #130 describes. Their few
# user-facing strings are English until a translator PR localizes them; a
# missing script is a silently dead feature, an English string is not.
LANG_EXTRA_SCRIPTS = HOOK_DISPATCH_SCRIPTS + [
"scripts/attest-plan.sh",
"scripts/attest-plan.ps1",
"scripts/gate-stop.sh",
"scripts/ledger-append.sh",
"scripts/ledger-append.ps1",
"scripts/ledger-summary.ps1",
"scripts/phase-status.sh",
"scripts/phase-status.ps1",
"scripts/plan-doctor.sh",
"scripts/resolve-plan-dir.ps1",
"scripts/set-active-plan.sh",
"scripts/set-active-plan.ps1",
]
# Language variants (skills/planning-with-files-<lang>/): SKILL.md, templates
# and references are real translations and must NEVER be overwritten with the
# English canonical. Same for LANG_TRANSLATED_SCRIPTS above. The rest of the
# canonical script surface is synced so non-English installs get the same
# features, not a subset (issue #212 covered only the 3 dispatch targets).
for _lang in ("ar", "de", "es", "zh", "zht"):
_base = f"skills/planning-with-files-{_lang}"
IDE_MANIFESTS[_base] = _build_manifest(
_base,
ref_style="skip",
template_dirs=[],
include_scripts=False,
extra_scripts=LANG_EXTRA_SCRIPTS,
)
def _build_agents_manifest():
"""Manifest for .agents/skills/ — the cross-tool Agent Skills standard.
Shipped in-repo so a plain git clone is natively discoverable by every
tool that reads .agents/skills/ (Zed, Amp, Warp, Devin, Antigravity,
Gemini CLI, Cursor). SKILL.md itself is version-bumped by bump-version.py
and deliberately not synced here, matching every other IDE folder.
"""
base = ".agents/skills/planning-with-files"
manifest = _build_manifest(base, ref_style="flat", include_scripts=True)
b = Path(base)
for s in AGENTS_EXTRA_SCRIPTS:
manifest[s] = str(b / s)
for t in AGENTS_EXTRA_TEMPLATES:
manifest[t] = str(b / "templates" / Path(t).name)
return manifest
IDE_MANIFESTS[".agents"] = _build_agents_manifest()
# ─── Utility functions ─────────────────────────────────────────────
def file_hash(path):
"""Return SHA-256 hash of a file, or None if it doesn't exist."""
try:
return hashlib.sha256(Path(path).read_bytes()).hexdigest()
except FileNotFoundError:
return None
def sync_file(src, dst, *, dry_run=False):
"""Copy src to dst. Returns (action, detail) tuple.
Actions: "updated", "created", "skipped" (already identical), "missing_src"
"""
if not src.exists():
return "missing_src", f"Canonical file not found: {src}"
src_hash = file_hash(src)
dst_hash = file_hash(dst)
if src_hash == dst_hash:
return "skipped", "Already up to date"
action = "created" if dst_hash is None else "updated"
if not dry_run:
dst.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(src, dst)
return action, f"{'Would ' if dry_run else ''}{action}: {dst}"
# ─── Main ──────────────────────────────────────────────────────────
def parse_args(argv=None):
"""Parse CLI arguments for sync behavior."""
parser = argparse.ArgumentParser(
description=(
"Sync shared planning-with-files assets from canonical source "
"to IDE-specific folders."
)
)
parser.add_argument(
"--dry-run",
action="store_true",
help="Preview changes without writing files.",
)
parser.add_argument(
"--verify",
action="store_true",
help="Check for drift only; exit with code 1 if drift is found.",
)
return parser.parse_args(argv)
def main(argv=None):
args = parse_args(argv)
dry_run = args.dry_run
verify = args.verify
# Must run from repo root
if not CANONICAL.exists():
print(f"Error: Canonical source not found at {CANONICAL}/")
print("Run this script from the repo root.")
sys.exit(1)
print(f"{'[DRY RUN] ' if dry_run else ''}{'[VERIFY] ' if verify else ''}"
f"Syncing from {CANONICAL}/\n")
stats = {"updated": 0, "created": 0, "skipped": 0, "missing_src": 0, "drift": 0}
for ide_name, manifest in sorted(IDE_MANIFESTS.items()):
# Skip IDEs whose base directory doesn't exist
ide_root = Path(ide_name)
if not ide_root.exists():
continue
print(f" {ide_name}/")
ide_changes = 0
for canonical_key, target_path in sorted(manifest.items()):
# Handle __extra_ keys (canonical key contains __extra_ suffix)
canonical_rel = canonical_key.split("__extra_")[0]
src = CANONICAL / canonical_rel
dst = Path(target_path)
if verify:
# Verify mode: just check for drift
src_hash = file_hash(src)
dst_hash = file_hash(dst)
if src_hash and dst_hash and src_hash != dst_hash:
print(f" DRIFT: {dst}")
stats["drift"] += 1
ide_changes += 1
elif src_hash and not dst_hash:
print(f" MISSING: {dst}")
stats["drift"] += 1
ide_changes += 1
else:
action, detail = sync_file(src, dst, dry_run=dry_run)
stats[action] += 1
if action in ("updated", "created"):
print(f" {action.upper()}: {dst}")
ide_changes += 1
if ide_changes == 0:
print(" (up to date)")
# Summary
print(f"\n{'-' * 50}")
if verify:
total_drift = stats["drift"]
if total_drift > 0:
print(f"DRIFT DETECTED: {total_drift} file(s) out of sync.")
print("Run 'python scripts/sync-ide-folders.py' to fix.")
sys.exit(1)
else:
print("All IDE folders are in sync.")
sys.exit(0)
else:
print(f" Updated: {stats['updated']}")
print(f" Created: {stats['created']}")
print(f" Skipped: {stats['skipped']} (already up to date)")
if stats["missing_src"] > 0:
print(f" Missing: {stats['missing_src']} (canonical source not found)")
if dry_run:
print("\n This was a dry run. No files were modified.")
print(" Run without --dry-run to apply changes.")
if __name__ == "__main__":
main()