From 44537ae653968a72e14f6039be04ac63ef93e337 Mon Sep 17 00:00:00 2001 From: istos Date: Thu, 30 Jul 2026 07:09:46 +0200 Subject: [PATCH 1/2] Ship a pristine board on install: first boot clears distribution cards MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A vendored clone of bench arrives carrying bench's own task cards, plans and reference documents, so a new user's first board opened pre-loaded with our backlog. install.py now detects first boot — a vendored install (project root != manager root) whose manager/local/ has never been populated (no .env, no state/) — and only then clears the stage directories, tasks/archive/, plans/ and reference/, keeping task-template.md and the .gitkeep files and printing every removal. --dry-run lists instead of removing. After cleaning it stamps manager/local/state/, so the guard is permanently false on every later run even if the adapter wire fails, and a host's own cards are never touched. Self-hosted repos (bench itself, including fresh dev clones) are never cleaned: their tasks/ is the repo's history. Covered end-to-end in tests/test_install_first_boot.py by running install.py as a subprocess against scratch host layouts; README notes the first-boot behaviour under "Install into a repo". Co-Authored-By: Claude Fable 5 --- README.md | 4 + install.py | 54 +++++++++++++ tests/test_install_first_boot.py | 126 +++++++++++++++++++++++++++++++ 3 files changed, 184 insertions(+) create mode 100644 tests/test_install_first_boot.py diff --git a/README.md b/README.md index 5f0b409..17f1a10 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,10 @@ git clone .task-manager && rm -rf .task-manager/.git ./.task-manager/start.sh # wires the project (idempotent) and serves ``` +The first `start.sh` clears the distribution's own cards from `tasks/`, +`plans/` and `reference/` (printing each removal), so a fresh install +starts with an empty board. + Commit `.task-manager/` into the host repo — core is vendored on purpose, so clones work offline and updates show up in the host's own diffs. diff --git a/install.py b/install.py index c6ddf6a..91461f0 100644 --- a/install.py +++ b/install.py @@ -9,11 +9,19 @@ resolves the adapter (BOARD_AGENT_ADAPTER in manager/local/.env, default "claude"; local/adapters/ overrides core/adapters/) and runs its `wire` executable against the project root. Safe to run any time — after dropping .task-manager/ into a new repo, and after every update.sh. + +The distribution repo tracks its own development on its own board, so a +fresh clone arrives carrying those cards. The very first run in a host +project — vendored, before manager/local/ has ever been populated — +clears the stage directories, tasks/archive/, plans/ and reference/ +(keeping task-template.md and .gitkeep files, printing every removal) and +then stamps manager/local/state/ so the guard is false on every later run. """ from __future__ import annotations import os +import shutil import subprocess import sys from pathlib import Path @@ -22,6 +30,9 @@ TM = Path(__file__).resolve().parent LOCAL = TM / "manager" / "local" CORE = TM / "manager" / "core" +STAGE_DIRS = ["backlog", "to-do", "in-progress", "review", "done"] +KEEP = {".gitkeep", "task-template.md"} + def _project_root() -> Path: """The host project's root: the git toplevel seen from the manager's @@ -44,6 +55,48 @@ def _project_root() -> Path: PROJECT = _project_root() +def _content_dirs(tm: Path) -> list[Path]: + tasks = tm / "tasks" + return ([tasks / stage for stage in STAGE_DIRS] + + [tasks / "archive", tm / "plans", tm / "reference"]) + + +def first_boot_leftovers(tm: Path, project: Path) -> list[Path]: + """The distribution's own cards, plans and reference documents — the + paths a first boot must clear. Empty in every other situation: + + - self-hosted (the manager IS the repo): tasks/ is that repo's own + history, never distribution residue — including a fresh dev clone; + - already wired (local/.env or local/state/ exists): anything in the + stage directories can only be the host project's own work.""" + if project.resolve() == tm.resolve(): + return [] + local = tm / "manager" / "local" + if (local / ".env").exists() or (local / "state").exists(): + return [] + return [child + for d in _content_dirs(tm) if d.is_dir() + for child in sorted(d.iterdir()) if child.name not in KEEP] + + +def first_boot_clean(dry_run: bool) -> None: + """First boot only: remove the distribution's shipped content and stamp + local/state/ so this never runs again — even if the adapter wire fails + (a host without .claude/ still gets the board via start.sh) or the host + creates cards before the next run.""" + leftovers = first_boot_leftovers(TM, PROJECT) + if leftovers: + print("first boot — clearing the distribution's own cards:") + verb = "would remove" if dry_run else "removed" + for path in leftovers: + print(f" {verb} {path.relative_to(TM)}") + if not dry_run: + shutil.rmtree(path) if path.is_dir() else path.unlink() + print() + if not dry_run and PROJECT != TM: + (LOCAL / "state").mkdir(parents=True, exist_ok=True) + + def adapter_name() -> str: if os.environ.get("BOARD_AGENT_ADAPTER"): return os.environ["BOARD_AGENT_ADAPTER"] @@ -57,6 +110,7 @@ def adapter_name() -> str: def main() -> int: + first_boot_clean(dry_run="--dry-run" in sys.argv[1:]) name = adapter_name() for base in (LOCAL / "adapters", CORE / "adapters"): wire = base / name / "wire" diff --git a/tests/test_install_first_boot.py b/tests/test_install_first_boot.py new file mode 100644 index 0000000..d14b9f9 --- /dev/null +++ b/tests/test_install_first_boot.py @@ -0,0 +1,126 @@ +"""install.py's first-boot cleaning: a vendored clone's very first run +clears the distribution's own cards so a new host starts with a pristine +board, and no later run ever touches the host's own. Run with: +python3 -m unittest discover -s tests + +install.py is exercised end-to-end as a subprocess against scratch host +layouts — the same entry point start.sh uses — so what is asserted is +what a real first boot does to disk. +""" + +import shutil +import subprocess +import sys +import tempfile +import unittest +from pathlib import Path + +REPO = Path(__file__).resolve().parents[1] +STAGES = ["backlog", "to-do", "in-progress", "review", "done"] +KEEP = {".gitkeep", "task-template.md"} + + +def make_host(root: Path) -> Path: + """A host project with a freshly vendored .task-manager: the real + install.py and claude adapter, plus the distribution's shipped cards.""" + host = root / "host" + (host / ".claude").mkdir(parents=True) + tm = host / ".task-manager" + tm.mkdir() + shutil.copy(REPO / "install.py", tm / "install.py") + shutil.copytree(REPO / "manager" / "core" / "adapters" / "claude", + tm / "manager" / "core" / "adapters" / "claude") + for stage in STAGES + ["archive"]: + d = tm / "tasks" / stage + d.mkdir(parents=True) + (d / ".gitkeep").touch() + (d / "00-shipped-card.md").write_text("# Shipped\n", encoding="utf-8") + (tm / "tasks" / "task-template.md").write_text("# Template\n", encoding="utf-8") + for extra in ["plans", "reference"]: + d = tm / extra + d.mkdir() + (d / ".gitkeep").touch() + (d / "shipped.md").write_text("shipped\n", encoding="utf-8") + (tm / "reference" / "shots").mkdir() + (tm / "reference" / "shots" / "board.png").write_bytes(b"png") + return tm + + +def run_install(tm: Path, *args: str) -> subprocess.CompletedProcess: + return subprocess.run( + [sys.executable, str(tm / "install.py"), *args], + capture_output=True, text=True, cwd=tm.parent) + + +def shipped_files(tm: Path) -> list[Path]: + """Every file under the cleaned directories that first boot should + have removed — empty means the board is pristine.""" + return [p + for top in [tm / "tasks", tm / "plans", tm / "reference"] + for p in top.rglob("*") + if p.is_file() and p.name not in KEEP] + + +class FirstBoot(unittest.TestCase): + def setUp(self): + self.scratch = Path(tempfile.mkdtemp()).resolve() + self.addCleanup(shutil.rmtree, self.scratch, True) + self.tm = make_host(self.scratch) + + def test_first_run_clears_shipped_content_and_prints_each_removal(self): + result = run_install(self.tm) + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + self.assertEqual(shipped_files(self.tm), []) + for stage in STAGES + ["archive"]: + self.assertTrue((self.tm / "tasks" / stage / ".gitkeep").is_file()) + self.assertTrue((self.tm / "tasks" / "task-template.md").is_file()) + self.assertTrue((self.tm / "plans" / ".gitkeep").is_file()) + self.assertTrue((self.tm / "reference" / ".gitkeep").is_file()) + for line in ["tasks/backlog/00-shipped-card.md", + "tasks/archive/00-shipped-card.md", + "plans/shipped.md", "reference/shots"]: + self.assertIn(f"removed {line}", result.stdout) + self.assertTrue((self.tm / "manager" / "local" / "state").is_dir()) + + def test_second_run_removes_nothing_and_host_cards_survive(self): + run_install(self.tm) + card = self.tm / "tasks" / "backlog" / "20-host-card.md" + card.write_text("# The host's own\n", encoding="utf-8") + result = run_install(self.tm) + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + self.assertNotIn("removed", result.stdout) + self.assertTrue(card.is_file()) + self.assertIn("ok", result.stdout) + + def test_dry_run_lists_without_removing(self): + result = run_install(self.tm, "--dry-run") + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + self.assertIn("would remove tasks/backlog/00-shipped-card.md", + result.stdout) + self.assertNotIn("removed ", result.stdout) + self.assertNotEqual(shipped_files(self.tm), []) + self.assertFalse((self.tm / "manager" / "local" / "state").exists()) + + def test_existing_env_file_disarms_the_guard(self): + local = self.tm / "manager" / "local" + local.mkdir(parents=True) + (local / ".env").write_text("BOARD_PORT=26071\n", encoding="utf-8") + result = run_install(self.tm) + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + self.assertNotIn("removed", result.stdout) + self.assertNotEqual(shipped_files(self.tm), []) + + def test_self_hosted_repo_is_never_cleaned(self): + """When the manager IS the repo (bench itself, or a dev clone of + it), tasks/ is that repo's history — even unwired, never touched.""" + subprocess.run(["git", "init", "-q", str(self.tm)], check=True, + capture_output=True) + (self.tm / ".claude").mkdir() + result = run_install(self.tm) + self.assertNotIn("removed", result.stdout) + self.assertNotEqual(shipped_files(self.tm), []) + self.assertFalse((self.tm / "manager" / "local" / "state").exists()) + + +if __name__ == "__main__": + unittest.main() From e800cf1a919c778ae11a5d3358a0db8214266e22 Mon Sep 17 00:00:00 2001 From: istos Date: Thu, 30 Jul 2026 07:22:33 +0200 Subject: [PATCH 2/2] Address PR review: symlink-safe removal, guard-gated stamp, hermetic tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The first-boot guard is now its own predicate gating both the clean and the local/state/ stamp, so a disarmed run (pre-existing local/.env, or self-hosted) touches nothing at all — before, it still created the stamp directory on any vendored run. Removal unlinks symlinks instead of following them into rmtree. Tests filter BOARD_* out of the subprocess environment, assert the stamp stays absent when .env disarms the guard, and cover the symlink case. Co-Authored-By: Claude Fable 5 --- install.py | 31 +++++++++++++++++++++---------- tests/test_install_first_boot.py | 19 ++++++++++++++++++- 2 files changed, 39 insertions(+), 11 deletions(-) diff --git a/install.py b/install.py index 91461f0..da91a03 100644 --- a/install.py +++ b/install.py @@ -61,19 +61,24 @@ def _content_dirs(tm: Path) -> list[Path]: + [tasks / "archive", tm / "plans", tm / "reference"]) -def first_boot_leftovers(tm: Path, project: Path) -> list[Path]: - """The distribution's own cards, plans and reference documents — the - paths a first boot must clear. Empty in every other situation: +def first_boot(tm: Path, project: Path) -> bool: + """True only on a vendored install's very first run — the one moment + anything in the stage directories can only be the distribution's own. + False in every other situation: - self-hosted (the manager IS the repo): tasks/ is that repo's own history, never distribution residue — including a fresh dev clone; - already wired (local/.env or local/state/ exists): anything in the stage directories can only be the host project's own work.""" if project.resolve() == tm.resolve(): - return [] + return False local = tm / "manager" / "local" - if (local / ".env").exists() or (local / "state").exists(): - return [] + return not (local / ".env").exists() and not (local / "state").exists() + + +def first_boot_leftovers(tm: Path) -> list[Path]: + """The distribution's shipped cards, plans and reference documents — + the paths a first boot must clear.""" return [child for d in _content_dirs(tm) if d.is_dir() for child in sorted(d.iterdir()) if child.name not in KEEP] @@ -83,17 +88,23 @@ def first_boot_clean(dry_run: bool) -> None: """First boot only: remove the distribution's shipped content and stamp local/state/ so this never runs again — even if the adapter wire fails (a host without .claude/ still gets the board via start.sh) or the host - creates cards before the next run.""" - leftovers = first_boot_leftovers(TM, PROJECT) + creates cards before the next run. Off first boot nothing is touched, + not even the stamp.""" + if not first_boot(TM, PROJECT): + return + leftovers = first_boot_leftovers(TM) if leftovers: print("first boot — clearing the distribution's own cards:") verb = "would remove" if dry_run else "removed" for path in leftovers: print(f" {verb} {path.relative_to(TM)}") if not dry_run: - shutil.rmtree(path) if path.is_dir() else path.unlink() + if path.is_dir() and not path.is_symlink(): + shutil.rmtree(path) + else: + path.unlink() print() - if not dry_run and PROJECT != TM: + if not dry_run: (LOCAL / "state").mkdir(parents=True, exist_ok=True) diff --git a/tests/test_install_first_boot.py b/tests/test_install_first_boot.py index d14b9f9..4fa77cd 100644 --- a/tests/test_install_first_boot.py +++ b/tests/test_install_first_boot.py @@ -8,6 +8,7 @@ layouts — the same entry point start.sh uses — so what is asserted is what a real first boot does to disk. """ +import os import shutil import subprocess import sys @@ -47,9 +48,10 @@ def make_host(root: Path) -> Path: def run_install(tm: Path, *args: str) -> subprocess.CompletedProcess: + env = {k: v for k, v in os.environ.items() if not k.startswith("BOARD_")} return subprocess.run( [sys.executable, str(tm / "install.py"), *args], - capture_output=True, text=True, cwd=tm.parent) + capture_output=True, text=True, cwd=tm.parent, env=env) def shipped_files(tm: Path) -> list[Path]: @@ -109,6 +111,21 @@ class FirstBoot(unittest.TestCase): self.assertEqual(result.returncode, 0, result.stdout + result.stderr) self.assertNotIn("removed", result.stdout) self.assertNotEqual(shipped_files(self.tm), []) + self.assertFalse((local / "state").exists()) + + def test_symlinked_leftover_is_unlinked_not_followed(self): + """A symlink among the leftovers is removed as a link — the + directory it points to survives untouched.""" + outside = self.scratch / "outside" + outside.mkdir() + (outside / "precious.md").write_text("keep me\n", encoding="utf-8") + link = self.tm / "tasks" / "backlog" / "10-linked" + link.symlink_to(outside) + result = run_install(self.tm) + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + self.assertFalse(link.is_symlink()) + self.assertFalse(link.exists()) + self.assertTrue((outside / "precious.md").is_file()) def test_self_hosted_repo_is_never_cleaned(self): """When the manager IS the repo (bench itself, or a dev clone of