Merge branch 'task/01-install-ships-pristine-board'

This commit is contained in:
istos
2026-07-30 07:23:24 +02:00
3 changed files with 212 additions and 0 deletions
+4
View File
@@ -13,6 +13,10 @@ git clone <this repo> .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.
+65
View File
@@ -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,59 @@ 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(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 False
local = tm / "manager" / "local"
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]
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. 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:
if path.is_dir() and not path.is_symlink():
shutil.rmtree(path)
else:
path.unlink()
print()
if not dry_run:
(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 +121,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"
+143
View File
@@ -0,0 +1,143 @@
"""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 os
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:
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, env=env)
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), [])
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
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()