diff --git a/.agent/commands/bootstrap.md b/.agent/commands/bootstrap.md index 8ac1e8e..8150a6f 100644 --- a/.agent/commands/bootstrap.md +++ b/.agent/commands/bootstrap.md @@ -5,9 +5,9 @@ Read the project’s existing AGENTS.md and `.agent/config.toml` if present; fol Input: project repository and its architecture, README and backlog. 1. Inspect existing instructions, files, Git status and verification tools. Preserve user changes. -2. Copy or merge `.agent/commands/`, `.agent/config.example.toml`, `scripts/round.py`, `templates/` - and `SPEC.md`, plus `examples/one-round.md` for its linked walkthrough. Optionally install `.agents/skills/reconcile-loop/`, the `.claude/commands/` - wrappers and `.claude/agents/` subagents. They reference the same procedures. Preserve these relative paths and retain the +2. Run the pack's `scripts/install.py` against the project root, adding `--claude`/`--codex` for + the runner entry points, or copy the same paths manually. Merge every `*.pack-new` file it + reports into the project's version, then delete it. Preserve relative paths and retain the pack’s MIT notice with copied materials; do not replace an existing project’s license. 3. Merge [the project contract](../../templates/AGENTS.md) into the target AGENTS.md; do not copy the pattern library's contributor contract. Adapt `.agent/config.example.toml` into diff --git a/README.md b/README.md index 6367410..305f35d 100644 --- a/README.md +++ b/README.md @@ -15,6 +15,28 @@ The architecture defines the target; one backlog records current status and depe bound execution, PRs carry evidence, and a fresh planner reconstructs state without previous chats. Only the next executable frontier needs detailed planning. +## Quick start + +For people, from a clean checkout of your project: + +```sh +git clone https://github.com/TKontu/Reconcile-loop ../Reconcile-loop +python3 ../Reconcile-loop/scripts/install.py . --claude --codex +``` + +Then ask your agent to follow [bootstrap](.agent/commands/bootstrap.md) (`/bootstrap` in Claude Code) +and commit the result. Each round runs `takeoff → plan-round → execute-task (one per executor) → +review-round → merge-round → reconcile`. To update, pull the pack, rerun the installer and merge any +`*.pack-new` files. + +For agents, paste this prompt: + +> Adopt Reconcile-loop in this repository. Clone https://github.com/TKontu/Reconcile-loop into a +> temporary directory, run its `scripts/install.py` against this repository root from a clean +> checkout (add `--claude` and/or `--codex` for the runners in use), then follow +> `.agent/commands/bootstrap.md`. Stop with a ready first item or named setup gaps; do not dispatch, +> publish or merge. + ## Adopt it Bring your own architecture, README and backlog. Ask an agent to follow @@ -32,8 +54,19 @@ The pack contains: - A standard-library [round CLI](scripts/round.py) for local records and packet/result checks. - One [worked round](examples/one-round.md). -Copy/merge `.agent/`, `scripts/round.py`, `templates/`, `SPEC.md` and `examples/one-round.md`, -preserving their relative paths. +Install from a clone or release of this repository into a clean checkout of your project: + +```sh +python3 path/to/Reconcile-loop/scripts/install.py path/to/project --claude --codex +``` + +It copies `.agent/`, `scripts/round.py`, `templates/`, `SPEC.md`, `examples/one-round.md` and the +MIT notice (`.agent/LICENSE`), preserving relative paths, and ignores `.agent-runs/` and HANDOFF.md. +The runner flags are optional and remembered. `.agent/pack.lock` records the pack version and +installed file hashes. Rerun the same command from a newer pack to update: unmodified files are +replaced, while locally edited or pre-existing files are kept with the new version beside them as +`*.pack-new` to merge and delete. It never writes your README, AGENTS.md or `.agent/config.toml`. +Use `--dry-run` to preview. Copying the same paths manually remains supported. Keep your project's README and merge its operating contract from the template. Optional entry points are one `.agents/skills/reconcile-loop/` skill, ten `.claude/commands/` wrappers and three `.claude/agents/` subagents (review lens, CI watcher, quick fixer). All use the same canonical diff --git a/scripts/install.py b/scripts/install.py new file mode 100644 index 0000000..f33697d --- /dev/null +++ b/scripts/install.py @@ -0,0 +1,160 @@ +"""Install or update the Reconcile-loop pack in a project, preserving local edits.""" + +import argparse +import hashlib +import json +import subprocess +import sys +from pathlib import Path + +PACK = Path(__file__).resolve().parents[1] +LOCK = ".agent/pack.lock" +CORE = [ + "SPEC.md", + ".agent/commands/*.md", + ".agent/config.example.toml", + "scripts/round.py", + "templates/*", + "examples/one-round.md", +] +RUNNERS = { + "claude": [".claude/commands/*.md", ".claude/agents/*.md"], + "codex": [".agents/skills/reconcile-loop/SKILL.md"], +} +RENAMED = {".agent/LICENSE": "LICENSE"} +IGNORED = [".agent-runs/", "HANDOFF.md"] + + +def digest(path): + return hashlib.sha256(path.read_bytes()).hexdigest() + + +def git(repo, *args): + try: + return subprocess.check_output( + ["git", "-C", str(repo), *args], text=True, stderr=subprocess.PIPE + ).strip() + except subprocess.CalledProcessError as error: + raise ValueError(error.stderr.strip() or f"git {args[0]} failed") from None + + +def version(): + try: + return git(PACK, "describe", "--tags", "--always", "--dirty") + except (OSError, ValueError): + return "unknown" + + +def sources(runners): + """Map installed relative path to pack source for the selected runners.""" + files = {target: PACK / source for target, source in RENAMED.items()} + for pattern in CORE + [p for runner in runners for p in RUNNERS[runner]]: + for path in sorted(PACK.glob(pattern)): + if path.is_file(): + files[path.relative_to(PACK).as_posix()] = path + return files + + +def plan(repo, files, locked): + """Return (action, path) pairs; local edits and foreign files are never replaced.""" + actions = [] + for name, source in files.items(): + target = repo / name + new = digest(source) + if not target.exists(): + actions.append(("skip-deleted" if name in locked else "add", name)) + elif digest(target) == new: + actions.append(("same", name)) + elif locked.get(name) == digest(target): + actions.append(("update", name)) + else: + actions.append(("conflict", name)) + for name in sorted(set(locked) - set(files)): + target = repo / name + if target.exists() and digest(target) == locked[name]: + actions.append(("remove", name)) + elif target.exists(): + actions.append(("keep-removed", name)) + return actions + + +def apply(repo, files, locked, actions): + recorded = {} + for action, name in actions: + target = repo / name + if action in {"add", "update"}: + target.parent.mkdir(parents=True, exist_ok=True) + target.write_bytes(files[name].read_bytes()) + if action in {"add", "update", "same"}: + recorded[name] = digest(target) + elif action == "conflict": + target.with_name(target.name + ".pack-new").write_bytes(files[name].read_bytes()) + if name in locked: + recorded[name] = locked[name] + elif action == "skip-deleted": + recorded[name] = locked[name] + elif action == "remove": + target.unlink() + return recorded + + +def ignore(repo): + path = repo / ".gitignore" + lines = path.read_text().splitlines() if path.exists() else [] + missing = [entry for entry in IGNORED if entry not in lines] + if missing: + path.write_text("".join(f"{line}\n" for line in lines + missing)) + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("target", type=Path, help="project repository root") + parser.add_argument("--claude", action="store_true", help="also install Claude Code files") + parser.add_argument("--codex", action="store_true", help="also install the .agents skill") + parser.add_argument("--dry-run", action="store_true", help="report actions only") + args = parser.parse_args() + try: + repo = args.target.resolve() + if repo == PACK: + raise ValueError("Target is the pack itself") + if Path(git(repo, "rev-parse", "--show-toplevel")).resolve() != repo: + raise ValueError("Target must be a repository root") + if not args.dry_run and git(repo, "status", "--porcelain"): + raise ValueError("Start from a clean checkout") + lock_path = repo / LOCK + previous = json.loads(lock_path.read_text()) if lock_path.exists() else {} + locked = previous.get("files", {}) + runners = sorted( + set(previous.get("runners", [])) + | {name for name in RUNNERS if getattr(args, name)} + ) + files = sources(runners) + actions = plan(repo, files, locked) + for action, name in actions: + if action != "same": + print(f"{action}: {name}") + if args.dry_run: + return 0 + recorded = apply(repo, files, locked, actions) + lock_path.parent.mkdir(parents=True, exist_ok=True) + lock_path.write_text( + json.dumps( + {"version": 1, "pack": version(), "runners": runners, "files": recorded}, + indent=2, + sort_keys=True, + ) + + "\n" + ) + ignore(repo) + conflicts = [name for action, name in actions if action == "conflict"] + if conflicts: + print(f"Merge {len(conflicts)} *.pack-new file(s), then delete them.") + print("Next: follow .agent/commands/bootstrap.md to configure the project.") + return 0 + except (OSError, ValueError, json.JSONDecodeError) as error: + print(f"Cannot install: {error}", file=sys.stderr) + return 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/test_install.py b/tests/test_install.py new file mode 100644 index 0000000..f955396 --- /dev/null +++ b/tests/test_install.py @@ -0,0 +1,192 @@ +"""Behavior checks for installing and updating the pack in an adopting repository.""" + +import hashlib +import json +import re +import shutil +import subprocess +import sys +import tempfile +import unittest +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +CORE = [ + "SPEC.md", + ".agent/commands/takeoff.md", + ".agent/config.example.toml", + ".agent/LICENSE", + "scripts/round.py", + "templates/assignment.md", + "examples/one-round.md", +] + + +def digest(path): + return hashlib.sha256(path.read_bytes()).hexdigest() + + +class InstallTests(unittest.TestCase): + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + self.addCleanup(self.tmp.cleanup) + base = Path(self.tmp.name) + self.pack = base / "pack" + shutil.copytree( + ROOT, + self.pack, + ignore=shutil.ignore_patterns(".git", ".agent-runs", "__pycache__"), + ) + self.repo = base / "project" + self.repo.mkdir() + self.git("init", "-q", "-b", "main") + self.git("config", "user.name", "Test") + self.git("config", "user.email", "test@example.invalid") + (self.repo / "README.md").write_text("Project\n") + (self.repo / ".gitignore").write_text("build/\n") + self.commit("base") + + def git(self, *args): + return subprocess.check_output( + ["git", "-C", str(self.repo), *args], text=True + ).strip() + + def commit(self, message): + self.git("add", "-A") + self.git("commit", "-qm", message) + + def install(self, *args, ok=True): + result = subprocess.run( + [sys.executable, str(self.pack / "scripts/install.py"), str(self.repo), *args], + text=True, + capture_output=True, + check=False, + ) + if ok: + self.assertEqual(result.returncode, 0, result.stderr) + else: + self.assertNotEqual(result.returncode, 0) + return result + + def lock(self): + return json.loads((self.repo / ".agent/pack.lock").read_text()) + + def test_fresh_install_copies_core_and_records_hashes(self): + self.install() + for path in CORE: + self.assertTrue((self.repo / path).is_file(), path) + self.assertFalse((self.repo / ".claude").exists()) + self.assertFalse((self.repo / ".agents").exists()) + self.assertFalse((self.repo / ".agent/config.toml").exists()) + self.assertEqual((self.repo / "README.md").read_text(), "Project\n") + lock = self.lock() + self.assertEqual(lock["runners"], []) + self.assertEqual(lock["files"]["SPEC.md"], digest(self.repo / "SPEC.md")) + ignored = (self.repo / ".gitignore").read_text().splitlines() + self.assertEqual(ignored[0], "build/") + self.assertIn(".agent-runs/", ignored) + self.assertIn("HANDOFF.md", ignored) + help_text = subprocess.run( + [sys.executable, str(self.repo / "scripts/round.py"), "--help"], + capture_output=True, + text=True, + check=False, + ) + self.assertEqual(help_text.returncode, 0, help_text.stderr) + + def test_runner_wrappers_are_opt_in_and_remembered(self): + self.install("--claude", "--codex") + self.assertTrue((self.repo / ".claude/commands/takeoff.md").is_file()) + self.assertTrue((self.repo / ".agents/skills/reconcile-loop/SKILL.md").is_file()) + self.assertEqual(self.lock()["runners"], ["claude", "codex"]) + self.commit("install") + (self.pack / ".claude/commands/takeoff.md").write_text("new wrapper\n") + self.install() + self.assertEqual( + (self.repo / ".claude/commands/takeoff.md").read_text(), "new wrapper\n" + ) + + def test_installed_links_resolve(self): + self.install("--claude", "--codex") + for file in self.repo.rglob("*.md"): + if ".git" in file.relative_to(self.repo).parts: + continue + for target in re.findall(r"\]\(([^)]+)\)", file.read_text()): + if "://" in target or target.startswith("#"): + continue + if file.name == "README.md" and file.parent == self.repo: + continue + self.assertTrue( + (file.parent / target.split("#")[0]).is_file(), + f"{file.relative_to(self.repo)}: {target}", + ) + + def test_update_replaces_unmodified_and_preserves_local_edits(self): + self.install() + (self.repo / "SPEC.md").write_text("local protocol edit\n") + self.commit("install and adapt") + (self.pack / "SPEC.md").write_text("upstream protocol\n") + (self.pack / "templates/item.md").write_text("upstream item\n") + result = self.install() + self.assertEqual((self.repo / "SPEC.md").read_text(), "local protocol edit\n") + self.assertEqual( + (self.repo / "SPEC.md.pack-new").read_text(), "upstream protocol\n" + ) + self.assertEqual((self.repo / "templates/item.md").read_text(), "upstream item\n") + self.assertIn("SPEC.md", result.stdout) + self.assertEqual( + self.lock()["files"]["templates/item.md"], + digest(self.repo / "templates/item.md"), + ) + + def test_existing_project_file_is_not_overwritten(self): + (self.repo / "scripts").mkdir() + (self.repo / "scripts/round.py").write_text("project script\n") + self.commit("own script") + self.install() + self.assertEqual((self.repo / "scripts/round.py").read_text(), "project script\n") + self.assertTrue((self.repo / "scripts/round.py.pack-new").is_file()) + self.assertNotIn("scripts/round.py", self.lock()["files"]) + + def test_removed_pack_file_is_deleted_only_when_unmodified(self): + self.install() + (self.repo / "templates/handoff.md").write_text("kept locally\n") + self.commit("install") + (self.pack / "templates/item.md").unlink() + (self.pack / "templates/handoff.md").unlink() + self.install() + self.assertFalse((self.repo / "templates/item.md").exists()) + self.assertEqual((self.repo / "templates/handoff.md").read_text(), "kept locally\n") + self.assertNotIn("templates/item.md", self.lock()["files"]) + + def test_locally_deleted_file_stays_deleted_across_updates(self): + self.install() + (self.repo / "examples/one-round.md").unlink() + self.commit("drop example") + self.install() + self.assertEqual(self.git("status", "--porcelain"), "") + self.install() + self.assertFalse((self.repo / "examples/one-round.md").exists()) + + def test_dirty_checkout_dry_run_and_self_target_are_refused_or_inert(self): + (self.repo / "README.md").write_text("dirty\n") + rejected = self.install(ok=False) + self.assertIn("clean", rejected.stderr) + self.assertFalse((self.repo / ".agent").exists()) + self.install("--dry-run") + self.git("checkout", "--", "README.md") + planned = self.install("--dry-run") + self.assertIn("SPEC.md", planned.stdout) + self.assertFalse((self.repo / ".agent").exists()) + self.assertEqual(self.git("status", "--porcelain"), "") + own = subprocess.run( + [sys.executable, str(self.pack / "scripts/install.py"), str(self.pack)], + capture_output=True, + text=True, + check=False, + ) + self.assertNotEqual(own.returncode, 0) + + +if __name__ == "__main__": + unittest.main()