diff --git a/CLAUDE.md b/CLAUDE.md index 7e270c5..77b582d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -33,6 +33,7 @@ standalone repository; inspect configuration before proposing publication. - `hook.py` — pre-write lease enforcement and context delivery. - `session.py` — harness lifecycle registration, heartbeat, and termination. - `install` — preflighted vendoring, compatibility shims, hook merge, and skill installation. +- `VERSION` — semantic source/install release identifier exposed by both entry points. - `skills/coord/` — canonical concise skill installed into both harness discovery locations. - `tests/` — behavioral installer, CLI, documentation, and packaging tests. - `DESIGN.md` — architectural rationale and security boundaries. @@ -96,6 +97,9 @@ When changing installation or packaging: preservation, compatibility entry points, and identical dual-harness skills. 4. Confirm the installer vendors docs under `.coord/` without overwriting target-root `AGENTS.md` or `CLAUDE.md`. +5. Bump `VERSION` for every shipped installed change: major for incompatibility, minor for new + backward-compatible behavior, and patch for compatible fixes. Drift checks remain content + based rather than trusting the version marker alone. When changing the skill: diff --git a/DESIGN.md b/DESIGN.md index 5563bf6..219b0e2 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -200,6 +200,13 @@ Behavioral checks cover: ## Implementation language and versioning +Each standalone release carries a semantic `VERSION`, vendored into `.coord/VERSION` and exposed +by `coord --version`. The installer doctor reports both source and installed versions but still +compares every managed byte and executable mode; equal version strings are not integrity evidence. +Legacy installations without the marker are explicitly `unversioned` and can be repaired through +the same preflighted atomic plan as any upgrade. This adds observability without coupling runtime +SQLite schema state to package release numbering. + Python plus the standard-library SQLite driver remains the current best fit: hooks can run before a Nix activation or build bootstrap, deployment has no compiled-artifact handoff, and the transaction boundary lives in SQLite rather than process memory. A Go rewrite would improve diff --git a/README.md b/README.md index b90bcbe..08b1da7 100644 --- a/README.md +++ b/README.md @@ -41,6 +41,22 @@ conflicting one: `--check` is read-only and exits nonzero on drift. Installation preflights all source files, identity, wrappers, and hook JSON before replacing any target file. +For a version-aware diagnosis and an explicit repair workflow, use: + +```sh +./install /path/to/repository --doctor +./install /path/to/repository --repair +.coord/coord --version +``` + +`--doctor` reports the source and installed versions, lists every stale or missing managed file, +and prints the exact repair command. An older installation without a version marker is reported as +`unversioned`. `--repair` uses the same fully preflighted, atomic replacement path as an ordinary +upgrade and preserves unrelated hooks, settings, skills, and repository content. +Run these commands from the authoritative standalone coord checkout whose release you want to +compare. A vendored `.coord/install . --doctor` can detect damage within its own installed release, +but it cannot discover a newer standalone release by itself. + An installation provides: | Path | Purpose | @@ -59,6 +75,12 @@ history, or runtime database. Codex pins hook source hashes and may prompt to trust a changed hook after an upgrade. Review and approve it with `/hooks`; that prompt is an intentional harness security boundary. +The source release is recorded in `VERSION` using semantic `MAJOR.MINOR.PATCH` form and is vendored +as `.coord/VERSION`. Maintainers bump it whenever installed behavior or packaging changes: major +for incompatible changes, minor for backward-compatible functionality, and patch for compatible +fixes. Version equality is informational; byte-for-byte doctor checks remain authoritative for +detecting drift within a release. + ## Everyday use Hooks normally register the current runtime and export enough identity for commands to infer it. diff --git a/VERSION b/VERSION new file mode 100644 index 0000000..6e8bf73 --- /dev/null +++ b/VERSION @@ -0,0 +1 @@ +0.1.0 diff --git a/coord b/coord index 507a4a0..24cd50b 100755 --- a/coord +++ b/coord @@ -26,6 +26,7 @@ from typing import Any HERE = pathlib.Path(__file__).resolve().parent ROOT = HERE.parent if HERE.name == ".coord" else HERE +VERSION = (HERE / "VERSION").read_text(encoding="utf-8").strip() SPEC = importlib.util.spec_from_file_location("coord_store", HERE / "store.py") storelib = importlib.util.module_from_spec(SPEC) assert SPEC.loader is not None @@ -1045,6 +1046,7 @@ def cmd_work_queue(args: argparse.Namespace) -> int: def build_parser() -> argparse.ArgumentParser: parser = argparse.ArgumentParser(prog="coord") + parser.add_argument("--version", action="version", version=f"coord {VERSION}") parser.add_argument( "-i", "--identity", diff --git a/install b/install index fd2d656..b39ca51 100755 --- a/install +++ b/install @@ -9,6 +9,7 @@ import os from pathlib import Path import re import secrets +import shlex import stat import sys from typing import Any @@ -21,6 +22,7 @@ CORE_FILES = ( "CLAUDE.md", "DESIGN.md", "README.md", + "VERSION", "coord", "hook.py", "install", @@ -34,6 +36,9 @@ SKILL_FILES = ( "agents/openai.yaml", ) PROJECT_ID_RE = re.compile(r"[A-Za-z0-9][A-Za-z0-9._-]{0,127}") +VERSION_RE = re.compile( + r"(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)" +) CLAUDE_COMMANDS = { "SessionStart": ('python3 "$CLAUDE_PROJECT_DIR/.coord/session.py"', 10), @@ -224,10 +229,32 @@ def project_id(target: Path, requested: str, *, check: bool) -> str: return selected +def source_version() -> str: + try: + version = (HERE / "VERSION").read_text(encoding="utf-8").strip() + except OSError as exc: + raise ValueError(f"cannot read source VERSION: {exc}") from exc + if not VERSION_RE.fullmatch(version): + raise ValueError(f"refusing invalid source VERSION: {version!r}") + return version + + +def installed_version(target: Path) -> str: + marker = target / ".coord" / "VERSION" + if not marker.is_file(): + return "unversioned" + try: + version = marker.read_text(encoding="utf-8").strip() + except OSError: + return "unreadable" + return version if VERSION_RE.fullmatch(version) else "invalid" + + def install(target: Path, *, requested_id: str, check: bool) -> list[str]: target = target.expanduser().resolve() if not target.is_dir(): raise ValueError(f"target is not a directory: {target}") + source_version() selected_id = project_id(target, requested_id, check=check) destination = target / ".coord" planned: list[tuple[Path, bytes, bool]] = [] @@ -309,28 +336,66 @@ def install(target: Path, *, requested_id: str, check: bool) -> list[str]: def main() -> int: + try: + release = source_version() + except ValueError as exc: + print(f"coord-install: {exc}", file=sys.stderr) + return 2 parser = argparse.ArgumentParser(prog="coord-install") parser.add_argument("target", nargs="?", default=".") parser.add_argument("--project-id", default="") - parser.add_argument("--check", action="store_true") + mode = parser.add_mutually_exclusive_group() + mode.add_argument("--check", action="store_true", help="check for drift") + mode.add_argument( + "--doctor", + action="store_true", + help="diagnose installation version and drift without changing files", + ) + mode.add_argument( + "--repair", + action="store_true", + help="repair managed files and hooks while preserving unrelated content", + ) + parser.add_argument( + "--version", action="version", version=f"coord-install {release}" + ) args = parser.parse_args() + target = Path(args.target).expanduser().resolve() + observed_version = installed_version(target) try: drift = install( - Path(args.target), requested_id=args.project_id, check=args.check + target, + requested_id=args.project_id, + check=args.check or args.doctor, ) except ValueError as exc: print(f"coord-install: {exc}", file=sys.stderr) return 2 - if args.check: + if args.check or args.doctor: + if args.doctor: + print(f"coord-install: source version {release}") + print(f"coord-install: installed version {observed_version}") if drift: print(f"coord-install: drift ({len(drift)} files)") for path in drift: print(f" {path}") + if args.doctor: + command = " ".join( + shlex.quote(value) + for value in (str(HERE / "install"), str(target), "--repair") + ) + print(f"coord-install: repair with: {command}") return 1 - print("coord-install: installation matches") + print(f"coord-install: installation matches {release}") return 0 - print(f"coord-install: {'updated' if drift else 'already current'}") - print(f"target {Path(args.target).expanduser().resolve()}") + if args.repair and drift: + action = "repaired" + elif drift: + action = "updated" + else: + action = "already current" + print(f"coord-install: {action} {release}") + print(f"target {target}") return 0 diff --git a/skills/coord/SKILL.md b/skills/coord/SKILL.md index ebfdce3..88d0731 100644 --- a/skills/coord/SKILL.md +++ b/skills/coord/SKILL.md @@ -73,3 +73,8 @@ Lock release alone is not integration proof; record the exact integrated target For architecture, failure modes, and recovery rules, read the repository’s `.coord/DESIGN.md`. Repository-specific instructions in `AGENTS.md` and `CLAUDE.md` always take precedence. + +When installation drift is suspected, run `/install "$PWD" --doctor` from the +target repository. It is read-only and prints source/installed versions plus an exact `--repair` +command. The vendored installer only knows its own release; use the authoritative standalone source +to detect upgrades, and review it before repairing because hook changes may require renewed trust. diff --git a/tests/test_install.py b/tests/test_install.py index ab42ddc..843e175 100755 --- a/tests/test_install.py +++ b/tests/test_install.py @@ -15,6 +15,7 @@ import unittest ROOT = Path(__file__).resolve().parents[1] INSTALL = ROOT / "install" +RELEASE = (ROOT / "VERSION").read_text(encoding="utf-8").strip() class InstallBehavior(unittest.TestCase): @@ -58,10 +59,68 @@ class InstallBehavior(unittest.TestCase): ) self.assertTrue((target / ".coord/AGENTS.md").is_file()) self.assertTrue((target / ".coord/CLAUDE.md").is_file()) + self.assertEqual( + (target / ".coord/VERSION").read_text().strip(), RELEASE + ) self.assertFalse((target / "AGENTS.md").exists()) self.assertFalse((target / "CLAUDE.md").exists()) checked = self.run_install(target, "--check") self.assertEqual(checked.returncode, 0, checked.stdout + checked.stderr) + self.assertIn(f"matches {RELEASE}", checked.stdout) + + def test_doctor_reports_unversioned_drift_and_repair_is_targeted(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + target = Path(temporary) + legacy = target / ".claude/coord" + legacy.mkdir(parents=True) + (legacy / "project-id").write_text("doctor-project\n") + stale = target / ".coord/coord" + stale.parent.mkdir(parents=True) + stale.write_text("stale managed file\n") + unrelated = target / ".agents/skills/local/SKILL.md" + unrelated.parent.mkdir(parents=True) + unrelated.write_text("operator content\n") + + diagnosed = self.run_install(target, "--doctor") + self.assertEqual(diagnosed.returncode, 1, diagnosed.stderr) + self.assertIn(f"source version {RELEASE}", diagnosed.stdout) + self.assertIn("installed version unversioned", diagnosed.stdout) + self.assertIn("drift", diagnosed.stdout) + self.assertIn("--repair", diagnosed.stdout) + self.assertEqual(stale.read_text(), "stale managed file\n") + self.assertFalse((target / ".coord/VERSION").exists()) + self.assertEqual(unrelated.read_text(), "operator content\n") + + repaired = self.run_install(target, "--repair") + self.assertEqual(repaired.returncode, 0, repaired.stderr) + self.assertIn(f"repaired {RELEASE}", repaired.stdout) + self.assertEqual(unrelated.read_text(), "operator content\n") + + healthy = self.run_install(target, "--doctor") + self.assertEqual(healthy.returncode, 0, healthy.stderr) + self.assertIn(f"installed version {RELEASE}", healthy.stdout) + self.assertIn(f"installation matches {RELEASE}", healthy.stdout) + + version = subprocess.run( + [str(target / ".coord/coord"), "--version"], + cwd=target, + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + ) + self.assertEqual(version.returncode, 0, version.stderr) + self.assertEqual(version.stdout.strip(), f"coord {RELEASE}") + installer_version = subprocess.run( + [str(target / ".coord/install"), "--version"], + cwd=target, + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + ) + self.assertEqual(installer_version.returncode, 0, installer_version.stderr) + self.assertEqual( + installer_version.stdout.strip(), f"coord-install {RELEASE}" + ) def test_legacy_upgrade_preserves_hooks_identity_and_both_entrypoints(self) -> None: with tempfile.TemporaryDirectory() as temporary: