diff --git a/install.py b/install.py index 1124707..c6ddf6a 100644 --- a/install.py +++ b/install.py @@ -19,11 +19,31 @@ import sys from pathlib import Path TM = Path(__file__).resolve().parent -PROJECT = TM.parent LOCAL = TM / "manager" / "local" CORE = TM / "manager" / "core" +def _project_root() -> Path: + """The host project's root: the git toplevel seen from the manager's + directory (same resolution as config._repo_root). Vendored installs + drop .task-manager/.git at clone time, so this finds the host repo; + when the manager IS the repo (self-hosted), it finds that repo itself. + No git → fall back to the vendored-layout assumption, the parent.""" + try: + out = subprocess.check_output( + ["git", "-C", str(TM), "rev-parse", "--show-toplevel"], + text=True, stderr=subprocess.DEVNULL, + ).strip() + if out: + return Path(out) + except (subprocess.CalledProcessError, OSError): + pass + return TM.parent + + +PROJECT = _project_root() + + def adapter_name() -> str: if os.environ.get("BOARD_AGENT_ADAPTER"): return os.environ["BOARD_AGENT_ADAPTER"] diff --git a/manager/core/adapters/claude/wire b/manager/core/adapters/claude/wire index 44c150e..1f937dd 100755 --- a/manager/core/adapters/claude/wire +++ b/manager/core/adapters/claude/wire @@ -16,13 +16,21 @@ Other hooks and settings are never touched. from __future__ import annotations import json +import os import sys from pathlib import Path HERE = Path(__file__).resolve().parent -PLANS_DIR = "./.task-manager/plans" -EMIT_CMD = 'python3 "$CLAUDE_PROJECT_DIR/.task-manager/manager/core/adapters/claude/emit.py"' -HOOK = {"type": "command", "command": EMIT_CMD, "timeout": 5} +TM = HERE.parents[3] # …/manager/core/adapters/claude → the manager's root + + +def _tm_prefix(project: Path) -> str: + """The manager's path relative to the project root, as a prefix for + settings entries — ".task-manager/" vendored one level inside a host + repo, "" when the manager IS the project (self-hosted bench).""" + rel = os.path.relpath(TM, project) + return "" if rel == "." else rel.replace(os.sep, "/") + "/" + # event -> matcher the adapter's hook group should carry (None = no matcher). EVENT_MATCHERS = { @@ -36,9 +44,12 @@ EVENT_MATCHERS = { def _is_ours(hook) -> bool: """Any hook invoking one of our emit.py locations is ours — including - the legacy .tasks/hooks and manager/hooks paths, which get repaired.""" + the legacy .tasks/hooks and manager/hooks paths, and the old hardcoded + .task-manager path in a layout that no longer uses it; all repaired.""" cmd = str(hook.get("command", "")) if isinstance(hook, dict) else "" - return "emit.py" in cmd and (".task-manager" in cmd or ".tasks" in cmd) + return "emit.py" in cmd and ( + ".task-manager" in cmd or ".tasks" in cmd + or "manager/core/adapters/claude/emit.py" in cmd) def _matcher_ok(event_matcher: str | None, group_matcher) -> bool: @@ -47,7 +58,7 @@ def _matcher_ok(event_matcher: str | None, group_matcher) -> bool: return group_matcher in (None, "", "*") -def _event_status(groups, matcher) -> str: +def _event_status(groups, matcher, emit_cmd: str) -> str: ours = [ (group.get("matcher"), hook) for group in groups if isinstance(group, dict) @@ -57,13 +68,13 @@ def _event_status(groups, matcher) -> str: return "missing" if len(ours) == 1: group_matcher, hook = ours[0] - if (hook.get("command") == EMIT_CMD and hook.get("type") == "command" + if (hook.get("command") == emit_cmd and hook.get("type") == "command" and hook.get("timeout") == 5 and _matcher_ok(matcher, group_matcher)): return "ok" return "repair" -def _fix_event(hooks_cfg: dict, event: str, matcher: str | None) -> None: +def _fix_event(hooks_cfg: dict, event: str, matcher: str | None, emit_cmd: str) -> None: groups = hooks_cfg.get(event) if not isinstance(groups, list): groups = [] @@ -80,7 +91,8 @@ def _fix_event(hooks_cfg: dict, event: str, matcher: str | None) -> None: if target is None: target = {"hooks": []} if matcher is None else {"matcher": matcher, "hooks": []} groups.append(target) - target.setdefault("hooks", []).append(dict(HOOK)) + target.setdefault("hooks", []).append( + {"type": "command", "command": emit_cmd, "timeout": 5}) def main() -> int: @@ -90,6 +102,11 @@ def main() -> int: claude_dir = project / ".claude" settings_path = claude_dir / "settings.json" + prefix = _tm_prefix(project) + plans_dir = f"./{prefix}plans" + emit_cmd = (f'python3 "$CLAUDE_PROJECT_DIR/{prefix}' + f'manager/core/adapters/claude/emit.py"') + if not claude_dir.is_dir(): print(f"{project} is not a .claude-initialised project " f"(no .claude/ directory) — the claude adapter has nothing to wire.") @@ -112,12 +129,12 @@ def main() -> int: report: list[str] = [] changed = False - if settings.get("plansDirectory") == PLANS_DIR: + if settings.get("plansDirectory") == plans_dir: report.append("plansDirectory ok") else: old = settings.get("plansDirectory") report.append(f"plansDirectory {'set' if old is None else f'fixed (was {old!r})'}") - settings["plansDirectory"] = PLANS_DIR + settings["plansDirectory"] = plans_dir changed = True hooks_cfg = settings.get("hooks") @@ -127,13 +144,13 @@ def main() -> int: for event, matcher in EVENT_MATCHERS.items(): groups = hooks_cfg.get(event) if isinstance(hooks_cfg.get(event), list) else [] - status = _event_status(groups, matcher) + status = _event_status(groups, matcher, emit_cmd) label = f"{event}{f'[{matcher}]' if matcher else ''}" if status == "ok": report.append(f"hook {label:<22} ok") else: report.append(f"hook {label:<22} {'added' if status == 'missing' else 'repaired'}") - _fix_event(hooks_cfg, event, matcher) + _fix_event(hooks_cfg, event, matcher, emit_cmd) changed = True print(f"adapter: claude\nsettings: {settings_path}\n") diff --git a/manager/core/board.py b/manager/core/board.py index 0adf01b..b923718 100644 --- a/manager/core/board.py +++ b/manager/core/board.py @@ -56,8 +56,10 @@ def main() -> None: webbrowser.open(url) return - config.SESSIONS_DIR.mkdir(exist_ok=True) - config.AGENT_DIR.mkdir(exist_ok=True) + # parents=True: local/state/ is gitignored and ships empty, so a fresh + # checkout has neither it nor its children — boot must create the chain. + config.SESSIONS_DIR.mkdir(parents=True, exist_ok=True) + config.AGENT_DIR.mkdir(parents=True, exist_ok=True) events.load_disk_sessions() threading.Thread(target=watch.watcher, daemon=True).start() threading.Thread(target=github.poller, daemon=True).start() diff --git a/manager/core/state.py b/manager/core/state.py index 3eaf1fd..d387f43 100644 --- a/manager/core/state.py +++ b/manager/core/state.py @@ -43,7 +43,7 @@ def broadcast(payload: dict) -> None: def persist(name: str, record: dict) -> None: try: - config.SESSIONS_DIR.mkdir(exist_ok=True) + config.SESSIONS_DIR.mkdir(parents=True, exist_ok=True) with (config.SESSIONS_DIR / name).open("a", encoding="utf-8") as fh: fh.write(json.dumps(record) + "\n") except OSError: diff --git a/tests/test_self_hosting.py b/tests/test_self_hosting.py new file mode 100644 index 0000000..bc60b76 --- /dev/null +++ b/tests/test_self_hosting.py @@ -0,0 +1,264 @@ +"""Layout resolution: the manager must start cleanly both vendored one +level inside a host repo (.task-manager/) and self-hosted, where this repo +is simultaneously the distribution and the project. + +Stdlib only, like everything else here: + + python3 -m unittest discover -s tests -v +""" + +from __future__ import annotations + +import json +import os +import shutil +import socket +import subprocess +import sys +import tempfile +import time +import unittest +import urllib.request +from pathlib import Path + +REPO = Path(__file__).resolve().parents[1] +ADAPTER_FILES = ("wire", "emit.py") + +EMIT_SUFFIX = "manager/core/adapters/claude/emit.py" +OLD_EMIT_CMD = ('python3 "$CLAUDE_PROJECT_DIR/.task-manager/' + 'manager/core/adapters/claude/emit.py"') + + +class TempDirTestCase(unittest.TestCase): + def setUp(self): + # resolve(): macOS tempdirs live behind the /var → /private/var + # symlink, and both wire and git report resolved paths. + self.tmp = Path(tempfile.mkdtemp(prefix="bench-test-")).resolve() + self.addCleanup(shutil.rmtree, self.tmp, True) + + +def make_manager(tm_root: Path) -> None: + """A minimal manager tree at tm_root: install.py plus the claude + adapter's wire and emit.py, copied from this repo.""" + adapter = tm_root / "manager" / "core" / "adapters" / "claude" + adapter.mkdir(parents=True) + for name in ADAPTER_FILES: + shutil.copy(REPO / "manager" / "core" / "adapters" / "claude" / name, + adapter / name) + shutil.copy(REPO / "install.py", tm_root / "install.py") + + +def run(cmd: list, cwd: Path) -> subprocess.CompletedProcess: + env = {k: v for k, v in os.environ.items() if not k.startswith("BOARD_")} + return subprocess.run([str(a) for a in cmd], cwd=str(cwd), env=env, + capture_output=True, text=True) + + +def wire(tm_root: Path, project: Path) -> subprocess.CompletedProcess: + script = tm_root / "manager" / "core" / "adapters" / "claude" / "wire" + return run([sys.executable, script, project], cwd=project) + + +def settings(project: Path) -> dict: + return json.loads((project / ".claude" / "settings.json").read_text()) + + +def our_hook_commands(cfg: dict) -> set: + return { + hook["command"] + for groups in cfg.get("hooks", {}).values() + for group in groups + for hook in group.get("hooks", []) + if "emit.py" in hook.get("command", "") + } + + +class WireLayouts(TempDirTestCase): + def test_self_hosted_paths_have_no_task_manager_segment(self): + project = self.tmp / "bench" + make_manager(project) + (project / ".claude").mkdir() + + result = wire(project, project) + + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + cfg = settings(project) + self.assertEqual(cfg["plansDirectory"], "./plans") + self.assertEqual( + our_hook_commands(cfg), + {f'python3 "$CLAUDE_PROJECT_DIR/{EMIT_SUFFIX}"'}) + + def test_vendored_paths_keep_task_manager_segment(self): + host = self.tmp / "host" + make_manager(host / ".task-manager") + (host / ".claude").mkdir() + + result = wire(host / ".task-manager", host) + + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + cfg = settings(host) + self.assertEqual(cfg["plansDirectory"], "./.task-manager/plans") + self.assertEqual( + our_hook_commands(cfg), + {f'python3 "$CLAUDE_PROJECT_DIR/.task-manager/{EMIT_SUFFIX}"'}) + + def test_stale_hardcoded_path_is_repaired_in_place(self): + project = self.tmp / "bench" + make_manager(project) + (project / ".claude").mkdir() + stale_hook = {"type": "command", "command": OLD_EMIT_CMD, "timeout": 5} + (project / ".claude" / "settings.json").write_text(json.dumps({ + "plansDirectory": "./.task-manager/plans", + "hooks": {"SessionStart": [{"hooks": [stale_hook]}], + "PreToolUse": [{"matcher": "Bash", "hooks": [stale_hook]}]}, + })) + + result = wire(project, project) + + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + cfg = settings(project) + self.assertEqual(cfg["plansDirectory"], "./plans") + self.assertEqual( + our_hook_commands(cfg), + {f'python3 "$CLAUDE_PROJECT_DIR/{EMIT_SUFFIX}"'}) + hooks = [hook for group in cfg["hooks"]["SessionStart"] + for hook in group["hooks"]] + self.assertEqual(len(hooks), 1, "stale hook must be replaced, not kept") + + def test_second_run_is_a_no_op(self): + for project, tm_root in ( + (self.tmp / "bench", self.tmp / "bench"), + (self.tmp / "host", self.tmp / "host" / ".task-manager"), + ): + with self.subTest(project=project.name): + make_manager(tm_root) + (project / ".claude").mkdir() + wire(tm_root, project) + before = (project / ".claude" / "settings.json").read_text() + + result = wire(tm_root, project) + + self.assertEqual(result.returncode, 0) + self.assertIn("nothing to do", result.stdout) + self.assertEqual( + (project / ".claude" / "settings.json").read_text(), before) + + def test_refuses_project_without_claude_dir(self): + project = self.tmp / "bench" + make_manager(project) + + result = wire(project, project) + + self.assertEqual(result.returncode, 1) + self.assertIn("not a .claude-initialised project", result.stdout) + + +class InstallRootResolution(TempDirTestCase): + def _git_init(self, path: Path) -> None: + subprocess.run(["git", "init", "--quiet", str(path)], check=True, + capture_output=True) + + def test_vendored_resolves_host_repo_root(self): + host = self.tmp / "host" + make_manager(host / ".task-manager") + self._git_init(host) + (host / ".claude").mkdir() + + result = run([sys.executable, host / ".task-manager" / "install.py"], + cwd=host) + + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + self.assertEqual(settings(host)["plansDirectory"], + "./.task-manager/plans") + + def test_self_hosted_resolves_own_repo_not_parent(self): + project = self.tmp / "bench" + make_manager(project) + self._git_init(project) + (project / ".claude").mkdir() + + result = run([sys.executable, project / "install.py"], cwd=project) + + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + self.assertEqual(settings(project)["plansDirectory"], "./plans") + self.assertFalse((self.tmp / ".claude").exists(), + "must not wire the parent of the repo") + + def test_no_git_falls_back_to_parent(self): + host = self.tmp / "host" + make_manager(host / ".task-manager") + (host / ".claude").mkdir() + + result = run([sys.executable, host / ".task-manager" / "install.py"], + cwd=host) + + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + self.assertEqual(settings(host)["plansDirectory"], + "./.task-manager/plans") + + def test_refusal_names_the_resolved_root(self): + project = self.tmp / "bench" + make_manager(project) + self._git_init(project) + + result = run([sys.executable, project / "install.py"], cwd=project) + + self.assertEqual(result.returncode, 1) + self.assertIn(f"{project} is not a .claude-initialised project", + result.stdout) + + +class VirginBoot(TempDirTestCase): + """board.py must boot and serve from a checkout with no local/state/ + and no wiring at all — start.sh tolerates a failed wire on purpose.""" + + def test_board_boots_without_state_dirs_or_wiring(self): + tm_root = self.tmp / "bench" + shutil.copytree(REPO / "manager" / "core", + tm_root / "manager" / "core", + ignore=shutil.ignore_patterns("__pycache__")) + (tm_root / "manager" / "local").mkdir() + for stage in ("backlog", "to-do", "in-progress", "review", "done"): + (tm_root / "tasks" / stage).mkdir(parents=True) + + probe = socket.socket() + probe.bind(("127.0.0.1", 0)) + port = probe.getsockname()[1] + probe.close() + + env = {k: v for k, v in os.environ.items() if not k.startswith("BOARD_")} + proc = subprocess.Popen( + [sys.executable, str(tm_root / "manager" / "core" / "board.py"), + "--port", str(port), "--no-open"], + env=env, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True) + try: + state = None + for _ in range(50): + if proc.poll() is not None: + break + try: + with urllib.request.urlopen( + f"http://127.0.0.1:{port}/api/state", + timeout=1) as response: + state = json.load(response) + break + except OSError: + time.sleep(0.2) + + if proc.poll() is not None: + out, err = proc.communicate() + self.fail(f"board died (rc={proc.returncode}):\n{out}\n{err}") + self.assertIsNotNone(state, "board never answered /api/state") + local_state = tm_root / "manager" / "local" / "state" + self.assertTrue((local_state / "sessions").is_dir()) + self.assertTrue((local_state / "agent").is_dir()) + finally: + proc.terminate() + proc.wait(timeout=10) + for stream in (proc.stdout, proc.stderr): + if stream: + stream.close() + + +if __name__ == "__main__": + unittest.main()