feat: add versioned coord doctor and repair
This commit is contained in:
parent
c9a16229b1
commit
c118c433c6
8 changed files with 171 additions and 6 deletions
|
|
@ -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:
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
22
README.md
22
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.
|
||||
|
|
|
|||
1
VERSION
Normal file
1
VERSION
Normal file
|
|
@ -0,0 +1 @@
|
|||
0.1.0
|
||||
2
coord
2
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",
|
||||
|
|
|
|||
77
install
77
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
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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 `<standalone-coord>/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.
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue