Merge branch 'task/02-start-cleanly-when-bench-is-the-project'

This commit is contained in:
istos
2026-07-29 21:54:06 +02:00
5 changed files with 320 additions and 17 deletions
+21 -1
View File
@@ -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"]
+30 -13
View File
@@ -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")
+4 -2
View File
@@ -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()
+1 -1
View File
@@ -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:
+264
View File
@@ -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()