mirror of
https://github.com/OthmanAdi/planning-with-files.git
synced 2026-09-14 16:09:40 +08:00
f118ab5025
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.
459 lines
16 KiB
Python
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()
|