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>
175 lines
6.4 KiB
Python
Executable File
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())
|