Files
bench/manager/core/adapters/claude/emit.py
T

170 lines
6.1 KiB
Python
Raw Normal View History

#!/usr/bin/env python3
"""Claude Code hook → board bridge (the Claude adapter's event edge).
Registered by this adapter's `wire` for SessionStart, PreToolUse(Bash),
PostToolUse, Stop and SessionEnd. Reads Claude's raw hook payload from
stdin, translates it into the board's NORMALIZED event schema — the fixed
contract every adapter speaks — and POSTs it to /api/events.
Normalized event (v1):
{"v": 1, "session": str, "kind": str, "summary": str,
"file"?: str, "cmd"?: str, "detail"?: str, "ok"?: bool,
"running"?: bool, "agent"?: str, "task"?: str}
kinds: session end idle edit read search command test check git plan
subagent web other
Fails silently and fast: a session must never slow down or break because
the board isn't running.
"""
import json
import os
import re
import sys
import urllib.request
from pathlib import Path
EDIT_TOOLS = {"Edit", "Write", "MultiEdit", "NotebookEdit"}
def _txt(value, cap=600):
return value[:cap] if isinstance(value, str) else ""
def _relpath(path):
if not isinstance(path, str):
return ""
path = re.sub(r"^.*?/\.worktrees/[^/]+/", "", path)
root = os.environ.get("CLAUDE_PROJECT_DIR") or os.getcwd()
if path.startswith(root):
path = path[len(root):].lstrip("/")
return path
def _resp_text(resp):
if isinstance(resp, str):
return resp
if isinstance(resp, dict):
return "\n".join(str(resp[k]) for k in ("stdout", "stderr") if resp.get(k))
if isinstance(resp, list):
return "\n".join(i["text"] for i in resp
if isinstance(i, dict) and isinstance(i.get("text"), str))
return ""
def classify(hook, tool, tool_input, resp):
if hook == "SessionStart":
return {"kind": "session", "summary": "session started"}
if hook == "SessionEnd":
return {"kind": "end", "summary": "session ended"}
if hook == "Stop":
return {"kind": "idle", "summary": "finished responding — idle"}
if tool in EDIT_TOOLS:
f = _relpath(tool_input.get("file_path"))
return {"kind": "edit", "file": f, "summary": f"edited {f or '?'}"}
if tool == "Read":
f = _relpath(tool_input.get("file_path"))
return {"kind": "read", "file": f, "summary": f"read {f or '?'}"}
if tool in ("Glob", "Grep"):
return {"kind": "search", "summary": f"searched {_txt(tool_input.get('pattern'), 60) or '…'}"}
if tool == "TodoWrite":
todos = tool_input.get("todos") or []
done = sum(1 for t in todos if t.get("status") == "completed")
doing = [t.get("content", "") for t in todos if t.get("status") == "in_progress"]
summary = f"plan: {done}/{len(todos)} done"
if doing:
summary += f" — now: {_txt(doing[0], 70)}"
detail = "\n".join(
f"[{'x' if t.get('status') == 'completed' else '>' if t.get('status') == 'in_progress' else ' '}] "
+ _txt(t.get("content", ""), 120) for t in todos)
return {"kind": "plan", "summary": summary, "detail": detail}
if tool == "Task":
return {"kind": "subagent", "summary": f"subagent: {_txt(tool_input.get('description'), 60)}"}
if tool in ("WebFetch", "WebSearch"):
target = tool_input.get("url") or tool_input.get("query")
return {"kind": "web", "summary": f"{tool.lower()}: {_txt(target, 60)}"}
if tool == "Bash":
cmd = _txt(tool_input.get("command"), 240)
running = hook == "PreToolUse"
out = "" if running else _resp_text(resp)[:1200]
kind, ok = "command", None
if re.search(r"\bpytest\b", cmd):
kind = "test"
elif "lint-imports" in cmd or re.search(r"type-check|vue-tsc|\bnpm (run )?test\b|\bvitest\b", cmd):
kind = "check"
elif re.match(r"\s*git (commit|add|push|checkout|switch|merge|worktree)", cmd):
kind = "git"
if running:
summary = f"running: {cmd[:90]}"
elif kind == "test":
passed = re.search(r"(\d+) passed", out)
failed = re.search(r"(\d+) failed", out) or re.search(r"(\d+) error", out)
if failed:
ok = False
summary = f"pytest — {failed.group(0)}" + (f", {passed.group(0)}" if passed else "")
elif passed:
ok = True
summary = f"pytest — {passed.group(0)}"
else:
summary = f"ran: {cmd[:90]}"
elif kind == "check":
if "broken" in out:
ok = not re.search(r"[1-9]\d* broken", out)
summary = f"ran: {cmd[:90]}"
elif kind == "git":
m = re.search(r"""-m ["']([^"']{1,90})""", cmd)
summary = f"git: {m.group(1) if m else cmd[:80]}"
else:
summary = f"ran: {cmd[:90]}"
return {"kind": kind, "running": running, "ok": ok, "summary": summary,
"cmd": cmd, "detail": out[:900]}
return {"kind": "other", "summary": tool or hook or "event"}
def board_port():
port = os.environ.get("BOARD_PORT")
if port:
return port
try:
env_file = Path(__file__).resolve().parents[3] / "local" / ".env"
for line in env_file.read_text().splitlines():
key, _, value = line.strip().partition("=")
if key.strip() == "BOARD_PORT":
port = value.strip().strip("'\"")
except Exception:
pass
return port or "26071"
try:
payload = json.load(sys.stdin)
except Exception:
payload = {}
tool_input = payload.get("tool_input")
event = {
"v": 1,
"session": payload.get("session_id") or "unknown",
"agent": os.environ.get("BOARD_AGENT_ID"),
"task": os.environ.get("BOARD_TASK"),
**classify(payload.get("hook_event_name") or "?",
payload.get("tool_name") or "",
tool_input if isinstance(tool_input, dict) else {},
payload.get("tool_response")),
}
try:
request = urllib.request.Request(
f"http://127.0.0.1:{board_port()}/api/events",
data=json.dumps(event).encode("utf-8"),
headers={"Content-Type": "application/json"},
)
urllib.request.urlopen(request, timeout=1).read()
except Exception:
pass
sys.exit(0)