Files
othmanadi__planning-with-files/scripts/sync-ide-folders.py
T
OthmanAdi f118ab5025 feat: answer Claude Code hook events in one Python process
hooks/claude-hook.sh answered every event by running resolve-plan-dir.sh
and inject-plan.sh, about 130 forks per UserPromptSubmit. Under Git Bash
on Windows a fork costs about 90 ms, so a prompt took 7 to 12 s against
the 10 s hook timeout and Claude Code discarded the plan context, and
PreToolUse added 5 s before every tool call. Measured on the reporting
machine: 8.1, 10.2 and 7.1 s per prompt before, 0.33 s after.

scripts/inject-plan.py is a byte-identical twin of inject-plan.sh plus
the dispatcher logic of claude-hook.sh. The launcher finds a CPython 3
with a fork-free PATH walk, runs the twin with -I -B and falls back to
the shell chain on any non-zero exit; PWF_FAST_PATH=0 forces the shell
chain; a directory without planning state exits before any fork. The
launcher hands the twin its own $PWD spelling so both routes share one
turn-marker and one progress-guard slot per plan. skill-hook.sh uses the
twin after eligibility and isolates its own two interpreter calls. Both
dispatchers run the json_string awk under LC_ALL=C so a character outside
the BMP no longer leaves as a lone surrogate on Windows. The twin ships in
every scripts directory that ships inject-plan.sh.

tests/test_inject_plan_python_parity.py runs both implementations over
the same fixtures and asserts identical stdout, including what the
second-model review found and this commit fixes: NUL bytes in
.active_plan, the awk "phases: /N" quirk for a plan with no completed
phase, the CRLF progress tail under Git for Windows sed, cache slots
shared across routes, and non-BMP characters through the JSON encoder.
2026-09-07 09:37:36 +02:00

459 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/loop.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",
]
# Every hook-bearing SKILL.md dispatches through skill-hook.sh. Its sibling
# injector needs ledger-summary.sh and resolve-plan-dir.sh; Stop also needs
# gate-stop.sh and the shared check-complete.sh. Ship the complete dependency
# chain in every variant's own scripts directory.
HOOK_DISPATCH_SCRIPTS = [
"scripts/inject-plan.sh",
"scripts/inject-plan.py",
"scripts/ledger-summary.sh",
"scripts/resolve-plan-dir.sh",
"scripts/skill-hook.sh",
"scripts/gate-stop.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/inject-plan.py",
"scripts/ledger-append.sh",
"scripts/ledger-append.ps1",
"scripts/ledger-summary.sh",
"scripts/ledger-summary.ps1",
"scripts/phase-status.sh",
"scripts/phase-status.ps1",
"scripts/skill-hook.sh",
]
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/inject-plan.py",
"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",
"scripts/skill-hook.sh",
],
),
".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()
# The plugin fallback resolves helpers beside the root injector. Keep the new
# standalone adapter and its loop asset in the same verified inventory.
IDE_MANIFESTS["."] = {
"scripts/inject-plan.sh": "scripts/inject-plan.sh",
"scripts/inject-plan.py": "scripts/inject-plan.py",
"scripts/skill-hook.sh": "scripts/skill-hook.sh",
"templates/loop.md": "templates/loop.md",
}
# ─── 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()