mirror of
https://github.com/OthmanAdi/planning-with-files.git
synced 2026-09-14 16:09:40 +08:00
5fddf55cda
The Claude Code plugin scan reads skills/*/SKILL.md and does not recurse, so each of the five language variants was registered for every plugin user and paid its description in every session's system prompt. Measured with claude plugin details on v3.10.1, that was about 1,010 of the plugin's roughly 2,254 always-on tokens. Moving the variants to skills/i18n/planning-with-files-<lang>/ takes them out of the plugin scan while keeping every skill name, every skill-route install command and every install destination unchanged: npx skills add resolves --skill by name over a recursive scan and still installs to ~/.claude/skills/planning-with-files-<lang>/. The five language slash commands can no longer invoke a plugin-registered skill by name, so they now read the translated SKILL.md from the skill-route path or the plugin marketplace path, which keeps the translated body and the translated templates in play. They also state that the status tokens stay literal English, since check-complete.sh matches them with grep -F.
446 lines
16 KiB
Python
446 lines
16 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 is not just another IDE mirror: this directory IS the root of the
|
|
# npm package `planning-with-files`, so whatever it contains is what
|
|
# `npm install planning-with-files` delivers. It shipped 12 of the 20
|
|
# canonical scripts, missing inject-plan.sh itself, which meant the npm
|
|
# channel carried a skill that could not inject a plan at all. Everything
|
|
# language-neutral is synced here for that reason.
|
|
# package.json and README.md stay npm-specific and are not synced.
|
|
".pi": _build_manifest(
|
|
".pi/skills/planning-with-files",
|
|
ref_style="flat",
|
|
include_scripts=True,
|
|
extra_scripts=[
|
|
"scripts/inject-plan.sh",
|
|
"scripts/gate-stop.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",
|
|
],
|
|
),
|
|
|
|
".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/i18n/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/i18n/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()
|