Files
istosandClaude Fable 5 cc931a9174 Start cleanly when bench is the project itself
Three layout defects broke ./start.sh in the self-hosted repo, one of
which (missing local/state/) broke every fresh vendored install too:

- install.py resolved the project as TM.parent, a hardcoded vendored-
  layout assumption. It now asks git for the toplevel from the manager's
  directory (same resolution as config._repo_root), falling back to the
  parent when git is unavailable — vendored installs still find the host
  repo, self-hosted bench finds itself instead of its parent.

- adapters/claude/wire hardcoded ".task-manager/" into the emit hook
  command and plansDirectory. Both are now derived from the manager's
  path relative to the project root, so vendored installs keep the
  .task-manager/ prefix and self-hosted bench gets prefix-free paths.
  _is_ours also recognises the emit.py suffix, so settings wired with
  the old literal path count as stale and are repaired idempotently.

- board.py and state.py created the sessions/agent dirs without
  parents=True; local/state/ is gitignored and ships empty, so a virgin
  checkout died with FileNotFoundError before serving. Boot now creates
  the whole chain, wiring or no wiring.

tests/test_self_hosting.py (stdlib unittest) covers both layouts' wiring,
stale-path repair, idempotent re-runs, refusal without .claude/, root
resolution with and without git, and an integration boot of board.py
from a scratch checkout with no local/state/.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 19:55:57 +02:00

175 lines
6.4 KiB
Python
Executable File

#!/usr/bin/env python3
"""Claude adapter: wire live-session visibility into the host project.
Contract (same for every adapter): called by install.py with the project
root as argv[1] (and optionally --dry-run). Idempotently ensures the
project's own sessions report events to the board; prints a report; exit 0
on ok/fixed, 1 on a project that cannot be wired.
For Claude Code that means .claude/settings.json: plansDirectory pointing
at the task manager's plans, and the five event hooks running this
adapter's emit.py. Fully present → "ok", touches nothing. Partial, stale
(old .tasks/ or manager/hooks paths) or duplicated → repaired in place.
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
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 = {
"SessionStart": None,
"PreToolUse": "Bash",
"PostToolUse": "*",
"Stop": None,
"SessionEnd": None,
}
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, 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
or "manager/core/adapters/claude/emit.py" in cmd)
def _matcher_ok(event_matcher: str | None, group_matcher) -> bool:
if event_matcher == "Bash":
return group_matcher == "Bash"
return group_matcher in (None, "", "*")
def _event_status(groups, matcher, emit_cmd: str) -> str:
ours = [
(group.get("matcher"), hook)
for group in groups if isinstance(group, dict)
for hook in (group.get("hooks") or []) if _is_ours(hook)
]
if not ours:
return "missing"
if len(ours) == 1:
group_matcher, hook = ours[0]
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, emit_cmd: str) -> None:
groups = hooks_cfg.get(event)
if not isinstance(groups, list):
groups = []
hooks_cfg[event] = groups
for group in groups:
if isinstance(group, dict) and isinstance(group.get("hooks"), list):
group["hooks"] = [h for h in group["hooks"] if not _is_ours(h)]
groups[:] = [g for g in groups
if not (isinstance(g, dict) and g.get("hooks") == [])]
target = next(
(g for g in groups
if isinstance(g, dict) and _matcher_ok(matcher, g.get("matcher"))),
None)
if target is None:
target = {"hooks": []} if matcher is None else {"matcher": matcher, "hooks": []}
groups.append(target)
target.setdefault("hooks", []).append(
{"type": "command", "command": emit_cmd, "timeout": 5})
def main() -> int:
args = [a for a in sys.argv[1:] if a != "--dry-run"]
dry_run = "--dry-run" in sys.argv[1:]
project = Path(args[0]).resolve() if args else Path.cwd()
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.")
return 1
if not (HERE / "emit.py").is_file():
print(f"error: {HERE / 'emit.py'} is missing — the adapter looks broken.")
return 1
settings: dict = {}
if settings_path.is_file():
try:
settings = json.loads(settings_path.read_text(encoding="utf-8"))
except json.JSONDecodeError as exc:
print(f"error: {settings_path} is not valid JSON ({exc}) — fix it first.")
return 1
if not isinstance(settings, dict):
print(f"error: {settings_path} does not contain a JSON object.")
return 1
report: list[str] = []
changed = False
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
changed = True
hooks_cfg = settings.get("hooks")
if not isinstance(hooks_cfg, dict):
hooks_cfg = {}
settings["hooks"] = hooks_cfg
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, 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, emit_cmd)
changed = True
print(f"adapter: claude\nsettings: {settings_path}\n")
print("\n".join(f" {line}" for line in report))
if not changed:
print("\nEverything already in place — nothing to do.")
return 0
if dry_run:
print("\nDry run — no changes written.")
return 0
settings_path.parent.mkdir(exist_ok=True)
settings_path.write_text(json.dumps(settings, indent=2) + "\n", encoding="utf-8")
print("\nWrote settings. Note: running Claude sessions snapshot their hooks "
"at startup — restart them to pick this up.")
return 0
if __name__ == "__main__":
sys.exit(main())