Bench 1: the task board extracted as its own distribution

Everything core from cicero-pas's .task-manager, with instance data
reduced to skeleton: empty stage directories, the task template, empty
local/ scaffolding, and a README covering install (clone into
.task-manager/, vendored on purpose) and update (update.sh replaces core
wholesale; local/ and tasks/ survive).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
istos
2026-07-29 17:57:07 +02:00
co-authored by Claude Fable 5
commit 29cbb11eb5
45 changed files with 5230 additions and 0 deletions
+38
View File
@@ -0,0 +1,38 @@
# Task manager settings — copy to manager/local/.env (gitignored) and edit.
# Precedence: process environment > local/.env > these defaults.
# Port the board serves on. Pinned by default so the URL is bookmarkable;
# adapters and drivers read the same value, so changing it here changes it
# everywhere at once.
BOARD_PORT=26071
# Which agent adapter runs headless jobs (core/adapters/<name>, overridable
# in local/adapters/<name>). Ships with: claude.
BOARD_AGENT_ADAPTER=claude
# claude adapter: the binary jobs are launched with. Point it at a stub
# script to test the board's plumbing without spending tokens.
BOARD_CLAUDE_BIN=claude
# Where work-agent worktrees are created, relative to the repo root.
BOARD_WORKTREES=.worktrees
# GitHub plumbing. The gh CLI used for PRs (stub-able like claude); the git
# remote PRs are pushed to (empty = the repo's first remote); and how often
# to poll open PRs of cards sitting in review/ for reviews and checks.
BOARD_GH_BIN=gh
BOARD_GIT_REMOTE=
BOARD_PR_POLL_INTERVAL=60
# Seconds between disk polls of the stage directories.
BOARD_WATCH_INTERVAL=2
# In-memory caps: events kept per session, and board-level history
# (moves + agent lifecycle). Full logs always persist to local/state/.
BOARD_EVENTS_CAP=800
BOARD_HISTORY_CAP=300
# Where update.sh fetches the task-manager distribution from (a git URL).
# Updates replace manager/core/ and the top-level scripts; they never touch
# tasks/, plans/, reference/ or manager/local/.
BENCH_SOURCE=
+1
View File
@@ -0,0 +1 @@
1
+57
View File
@@ -0,0 +1,57 @@
# Agent adapters
An adapter makes the task manager work with a particular coding agent.
Core never speaks any vendor's language — it launches jobs and ingests
normalized events; adapters translate at the edge. `claude/` ships as the
default. Select with `BOARD_AGENT_ADAPTER` in `local/.env`; a directory of
the same name under `local/adapters/` overrides the core one.
## The contract
An adapter is a directory with two executables:
### `run` — execute one headless job to completion
- env in: `AGENT_PROMPT` (the full prompt), `AGENT_MODE` (`work` = may
mutate the checkout, `review` = read-only — map this intent to whatever
permission mechanism your agent has), `AGENT_CWD`, and the `BOARD_*`
passthrough (`BOARD_AGENT_ID`, `BOARD_TASK`, `BOARD_PORT`) which your
event bridge must forward with every event.
- stdout is captured by the board as the job log. The prompts instruct the
agent to end with marker lines (`NOT READY:`, `RELEVANCE REVIEW:`,
`PR REVIEW:`, `ADDRESSED:`) — the board parses them from this output, so
the agent's final text must reach stdout.
- exit 0 = completed; anything else = failed.
### `wire` — wire live-session visibility into the host project
Called by `install.py` with the project root as argv[1] (plus `--dry-run`).
Idempotently make the project's own interactive sessions report events —
however your platform allows (Claude Code: hooks; opencode: a plugin
subscribing to its event bus). Print a report; exit 0 on ok/fixed. If the
platform has no way to observe sessions, be a no-op with an honest message:
the board still runs headless jobs via `run`, you just lose the live
play-by-play.
### Events — the normalized schema (v1)
POST to `http://127.0.0.1:$BOARD_PORT/api/events`:
{"v": 1, "session": str, "kind": str, "summary": str,
"file"?: str, "cmd"?: str, "detail"?: str, "ok"?: bool,
"running"?: bool, "agent"?: $BOARD_AGENT_ID, "task"?: $BOARD_TASK}
kinds: `session end idle edit read search command test check git plan
subagent web other`. `running: true` marks an in-flight action (shown as
the live line, not appended to the timeline); follow it with the completed
event. `kind: idle` = finished responding; `kind: end` = session over.
Classification happens in YOUR emitter — core never sees vendor payloads.
## Writing one (e.g. for opencode)
- `run`: `opencode run "$AGENT_PROMPT"` with its permission config mapped
from `AGENT_MODE`; make sure the final output lands on stdout.
- `wire`: drop a plugin into the project that subscribes to tool events and
POSTs the normalized schema with the `BOARD_*` env forwarded.
- Events beat perfection: start with `session`/`end` plus a generic
`command` per tool call, refine kinds later.
+169
View File
@@ -0,0 +1,169 @@
#!/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)
@@ -0,0 +1,17 @@
#!/usr/bin/env python3
"""Print the --settings JSON that wires this adapter's event bridge into a
headless Claude session. Absolute path to emit.py, so it works from any
worktree regardless of what that checkout contains."""
import json
from pathlib import Path
EMIT = Path(__file__).resolve().parent / "emit.py"
hook = {"type": "command", "command": f'python3 "{EMIT}"', "timeout": 5}
plain = [{"hooks": [hook]}]
print(json.dumps({"hooks": {
"SessionStart": plain,
"Stop": plain,
"SessionEnd": plain,
"PreToolUse": [{"matcher": "Bash", "hooks": [hook]}],
"PostToolUse": [{"matcher": "*", "hooks": [hook]}],
}}))
+26
View File
@@ -0,0 +1,26 @@
#!/usr/bin/env bash
# Claude adapter: run one headless job to completion.
#
# Contract (same for every adapter):
# env in: AGENT_PROMPT the full prompt
# AGENT_MODE work | review ("may mutate" vs "read-only")
# AGENT_CWD working directory (already set as cwd by the board)
# BOARD_* passthrough for the event bridge
# stdout: captured by the board as the job log; the closing report's
# marker lines (NOT READY:, PR REVIEW:, ...) are parsed from it
# exit: 0 = completed; anything else = failed
#
# BOARD_CLAUDE_BIN overrides the binary (used by the test stubs).
set -euo pipefail
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
BIN="${BOARD_CLAUDE_BIN:-claude}"
SETTINGS="$(python3 "$HERE/hook_settings.py")"
if [ "${AGENT_MODE:-work}" = "review" ]; then
exec "$BIN" -p "$AGENT_PROMPT" --settings "$SETTINGS" \
--permission-mode default \
--disallowedTools Edit Write MultiEdit NotebookEdit
else
exec "$BIN" -p "$AGENT_PROMPT" --settings "$SETTINGS" \
--permission-mode acceptEdits
fi
+157
View File
@@ -0,0 +1,157 @@
#!/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 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}
# 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, which get 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)
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) -> 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) -> 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(dict(HOOK))
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"
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)
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)
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())
+523
View File
@@ -0,0 +1,523 @@
"""Headless Claude Code agents: launching, reaping, stopping, diffing.
Two kinds:
- work agents (start_agent) get an isolated git worktree + branch and may
edit and commit; the board moves their card in-progress → testing.
- review agents (start_review) are read-only, run in the main checkout, and
their relevance report is appended to the task file by the board.
Prompts live in .prompts/ and are read fresh on every launch.
"""
from __future__ import annotations
import os
import re
import subprocess
import threading
import time
from pathlib import Path
import config
import events
import state
from taskfiles import find_stage_of, move_task, read_task
def _clean_log(text: str, cap: int = 3000) -> str:
"""An agent's -p output is its final report; strip the hook-failure
noise other tools may have interleaved."""
lines = [l for l in text.strip().splitlines()
if not ("hook" in l and "failed" in l)]
return "\n".join(lines).strip()[-cap:]
def _file_report(record: dict, heading: str, report: str) -> None:
"""The report travels with the task, like every review does."""
stage = find_stage_of(record["task"])
if not stage or not report:
return
path = config.TASKS / stage / record["task"]
stamp = time.strftime("%Y-%m-%d %H:%M")
try:
with path.open("a", encoding="utf-8") as fh:
fh.write(f"\n\n---\n\n## {heading} — {stamp} ({record.get('name') or 'agent'})\n\n{report}\n")
except OSError:
pass
def _session_report(record: dict, report: str) -> None:
"""And it lands in the session's timeline as its closing entry."""
if not report or not record.get("session"):
return
events.ingest_event({
"v": 1, "session": record["session"], "kind": "report",
"summary": f"{record.get('name') or 'the agent'}'s report on {record['task']}",
"detail": report, "agent": record["id"], "task": record["task"],
})
# Short coastal names in the Bench spirit — one per running agent, so the
# board reads "Wren is on #09", not "agent 09-application-layer-091203".
NAMES = ["Wren", "Juno", "Basil", "Piper", "Sage", "Reed", "Olive", "Finch",
"Hazel", "Cleo", "Milo", "Fern", "Ada", "Otto", "Nell", "Skye"]
def _pick_name(stem: str) -> str:
"""Stable-ish per task (same task tends to get the same name back),
skipping names already worn by a running agent."""
with state.LOCK:
used = {r.get("name") for r in state.AGENTS.values() if r["status"] == "running"}
start = sum(ord(c) for c in stem) % len(NAMES)
for i in range(len(NAMES)):
name = NAMES[(start + i) % len(NAMES)]
if name not in used:
return name
return NAMES[start]
def _agent_public(record: dict) -> dict:
public = {k: record[k] for k in
("id", "task", "branch", "worktree", "status", "rc", "started", "session")}
public["mode"] = record.get("mode", "work")
public["name"] = record.get("name")
return public
def list_public() -> list[dict]:
with state.LOCK:
return [_agent_public(a) for a in state.AGENTS.values()]
def _assert_no_running_agent(filename: str) -> None:
with state.LOCK:
for record in state.AGENTS.values():
if record["task"] == filename and record["status"] == "running":
raise ValueError(f"an agent is already working on {filename}")
def _validate(filename: str, stage: str, allowed: set[str], why: str | None = None) -> None:
if Path(filename).name != filename or not filename.endswith(".md"):
raise ValueError("bad filename")
if stage not in allowed:
raise ValueError(why or f"agents cannot start from {stage}/")
if not (config.TASKS / stage / filename).is_file():
raise ValueError(f"{filename} is not in {stage}/ — refresh the board")
_assert_no_running_agent(filename)
def _launch(mode: str, prompt: str, cwd: Path, agent_id: str, filename: str, log_path: Path):
"""Run one headless job through the configured agent adapter.
The adapter contract: `run` gets AGENT_PROMPT and AGENT_MODE
(work = may mutate, review = read-only) plus the BOARD_* passthrough
for its event bridge; its stdout is the job log; exit 0 = completed.
"""
adapter = config.adapter_dir()
if adapter is None:
raise ValueError(
f"agent adapter '{config.ADAPTER}' not found — expected "
f"local/adapters/{config.ADAPTER}/run or core/adapters/{config.ADAPTER}/run")
env = config.child_env()
env.update({
"AGENT_PROMPT": prompt,
"AGENT_MODE": mode,
"AGENT_CWD": str(cwd),
"BOARD_AGENT_ID": agent_id,
"BOARD_TASK": filename,
"BOARD_PORT": str(state.serve_port),
})
log_file = log_path.open("wb")
try:
proc = subprocess.Popen(
[str(adapter / "run")], cwd=str(cwd), env=env,
stdout=log_file, stderr=subprocess.STDOUT, stdin=subprocess.DEVNULL)
except OSError as exc:
log_file.close()
raise ValueError(f"could not launch adapter {adapter}: {exc}")
return proc, log_file
def start_agent(filename: str, stage: str) -> dict:
# Moving a card to in-progress is the commitment; only then does work start.
_validate(filename, stage, {"in-progress"},
"work starts from in-progress/ — move the card there first")
stem = filename[:-3]
branch = f"task/{stem}"
worktree = config.WORKTREES / stem
def _git(*args: str) -> subprocess.CompletedProcess:
return subprocess.run(["git", "-C", str(config.REPO), *args],
capture_output=True, text=True)
branch_exists = _git("rev-parse", "--verify", "--quiet", branch).returncode == 0
continuing = worktree.exists()
if continuing:
# earlier work exists — the agent continues on it rather than refusing
current = subprocess.run(
["git", "-C", str(worktree), "branch", "--show-current"],
capture_output=True, text=True).stdout.strip()
if current != branch:
raise ValueError(
f"worktree {worktree} is on '{current}', not {branch} — fix it by hand")
base = _git("merge-base", "main", branch).stdout.strip() \
or _git("rev-parse", "HEAD").stdout.strip()
else:
config.WORKTREES.mkdir(exist_ok=True)
if branch_exists:
base = _git("merge-base", "main", branch).stdout.strip()
result = _git("worktree", "add", str(worktree), branch)
else:
base = _git("rev-parse", "HEAD").stdout.strip()
result = _git("worktree", "add", "-b", branch, str(worktree))
if result.returncode != 0:
raise ValueError(f"git worktree add failed: {result.stderr.strip()[:300]}")
task = read_task(config.TASKS / "in-progress" / filename, "in-progress")
agent_id = f"{stem}-{time.strftime('%H%M%S')}"
log_path = config.AGENT_DIR / "logs" / f"{agent_id}.log"
log_path.parent.mkdir(parents=True, exist_ok=True)
prompt = config.prompt("work.md").format(
branch=branch, filename=filename, body=task["body"])
proc, log_file = _launch("work", prompt, worktree, agent_id, filename, log_path)
name = _pick_name(stem)
record = {
"id": agent_id, "task": filename, "branch": branch,
"worktree": str(worktree), "base": base, "status": "running",
"rc": None, "started": time.time(), "session": None,
"log": str(log_path), "proc": proc, "origin": stage, "mode": "work",
"name": name,
}
with state.LOCK:
state.AGENTS[agent_id] = record
state.record_board_event({
"kind": "agent", "actor": "agent", "file": filename,
"summary": (f"{name} is back on {filename} — continuing branch {branch}"
if continuing else
f"{name} started on {filename} (branch {branch})"),
})
threading.Thread(target=_reap_agent, args=(agent_id, proc, log_file),
daemon=True).start()
return _agent_public(record)
def start_review(filename: str, stage: str) -> dict:
"""Fire a read-only agent that checks the task against the codebase."""
_validate(filename, stage, config.STAGE_DIRS)
task = read_task(config.TASKS / stage / filename, stage)
agent_id = f"review-{filename[:-3]}-{time.strftime('%H%M%S')}"
log_path = config.AGENT_DIR / "logs" / f"{agent_id}.log"
log_path.parent.mkdir(parents=True, exist_ok=True)
prompt = config.prompt("review.md").format(
stage=stage, filename=filename, body=task["body"])
proc, log_file = _launch("review", prompt, config.REPO, agent_id, filename, log_path)
name = _pick_name(filename)
record = {
"id": agent_id, "task": filename, "branch": None, "worktree": None,
"base": None, "status": "running", "rc": None, "started": time.time(),
"session": None, "log": str(log_path), "proc": proc,
"origin": stage, "mode": "review", "name": name,
}
with state.LOCK:
state.AGENTS[agent_id] = record
state.record_board_event({
"kind": "agent", "actor": "agent", "file": filename,
"summary": f"{name} is checking {filename} is still true of the codebase",
})
threading.Thread(target=_reap_review, args=(agent_id, proc, log_file),
daemon=True).start()
return _agent_public(record)
def _declined_reason(log_path: str) -> str | None:
"""First line of a `NOT READY:` marker in the agent's final output."""
try:
text = Path(log_path).read_text(encoding="utf-8", errors="replace")
except OSError:
return None
for match in re.finditer(r"^NOT READY:\s*(.*)$", text, re.MULTILINE):
reason = match.group(1).strip()
if reason.startswith("<"):
continue # the prompt's own template line, echoed into the log
return reason or "open questions"
return None
def _discard_untouched_worktree(record: dict) -> bool:
"""Remove worktree + branch, but only if the agent committed nothing."""
head = subprocess.run(
["git", "-C", record["worktree"], "rev-parse", "HEAD"],
capture_output=True, text=True)
if head.returncode != 0 or head.stdout.strip() != record["base"]:
return False
subprocess.run(["git", "-C", str(config.REPO), "worktree", "remove", "--force",
record["worktree"]], capture_output=True)
subprocess.run(["git", "-C", str(config.REPO), "branch", "-D", record["branch"]],
capture_output=True)
return True
def _finish(agent_id: str, proc: subprocess.Popen, log_file) -> tuple[dict, bool, int]:
rc = proc.wait()
log_file.close()
with state.LOCK:
record = state.AGENTS[agent_id]
stopped = record["status"] == "stopped"
record["status"] = "stopped" if stopped else ("done" if rc == 0 else "failed")
record["rc"] = rc
return record, stopped, rc
def _reap_agent(agent_id: str, proc: subprocess.Popen, log_file) -> None:
record, stopped, rc = _finish(agent_id, proc, log_file)
filename, branch = record["task"], record["branch"]
name = record.get("name") or "the agent"
declined = None if (stopped or rc != 0) else _declined_reason(record["log"])
if declined is not None:
with state.LOCK:
record["status"] = "declined"
# Send the card back for refinement and clear the way for a relaunch.
back_to = record["origin"] if record["origin"] in ("backlog", "to-do") else "to-do"
if find_stage_of(filename) == "in-progress":
try:
move_task(filename, "in-progress", back_to, actor="agent")
except ValueError:
pass
cleaned = _discard_untouched_worktree(record)
summary = (f"{name} declined {filename} — not ready: {declined}"
+ ("" if cleaned else f" (worktree {record['worktree']} kept: it has commits)"))
elif rc == 0 and not stopped:
if find_stage_of(filename) == "in-progress":
try:
move_task(filename, "in-progress", "review", actor="agent")
except ValueError:
pass
try:
report = _clean_log(Path(record["log"]).read_text(encoding="utf-8",
errors="replace"))
except OSError:
report = ""
_file_report(record, "Work report", report)
_session_report(record, report)
summary = f"{name} finished {filename} — review branch {branch}"
elif stopped:
summary = f"{name} was held on {filename} — nothing is lost"
else:
summary = f"{name} exited on {filename} rc={rc} — see its log"
state.record_board_event({"kind": "agent", "actor": "agent", "file": filename,
"summary": summary})
state.broadcast({"type": "agents"})
def start_pr_review(filename: str, stage: str) -> dict:
"""Fire a read-only agent that reviews the task's PR and posts the
verdict to GitHub as well as back to the board."""
_validate(filename, stage, {"review"},
"PR reviews run on cards in review/")
task = read_task(config.TASKS / stage / filename, stage)
if not task.get("pr"):
raise ValueError(f"{filename} has no PR yet — nothing to review")
branch = f"task/{filename[:-3]}"
name = _pick_name(filename)
agent_id = f"review-pr-{filename[:-3]}-{time.strftime('%H%M%S')}"
log_path = config.AGENT_DIR / "logs" / f"{agent_id}.log"
log_path.parent.mkdir(parents=True, exist_ok=True)
prompt = config.prompt("review-pr.md").format(
filename=filename, pr=task["pr"], branch=branch, body=task["body"])
proc, log_file = _launch("review", prompt, config.REPO, agent_id, filename, log_path)
record = {
"id": agent_id, "task": filename, "branch": branch, "worktree": None,
"base": None, "status": "running", "rc": None, "started": time.time(),
"session": None, "log": str(log_path), "proc": proc,
"origin": stage, "mode": "review", "name": name,
}
with state.LOCK:
state.AGENTS[agent_id] = record
state.record_board_event({
"kind": "agent", "actor": "agent", "file": filename,
"summary": f"{name} is reviewing {filename}'s PR",
})
threading.Thread(target=_reap_pr_review, args=(agent_id, proc, log_file),
daemon=True).start()
return _agent_public(record)
def start_pr_fix(filename: str, stage: str) -> dict:
"""Fire a work agent that addresses the review feedback on the task's PR,
working in the task's existing worktree (recreated from the branch if it
was cleaned up), committing and pushing to update the PR."""
_validate(filename, stage, {"review"},
"acting on a PR happens from review/")
task = read_task(config.TASKS / stage / filename, stage)
if not task.get("pr"):
raise ValueError(f"{filename} has no PR to act on")
stem = filename[:-3]
branch = f"task/{stem}"
worktree = config.WORKTREES / stem
if not worktree.exists():
if subprocess.run(["git", "-C", str(config.REPO), "rev-parse", "--verify",
"--quiet", branch], capture_output=True).returncode != 0:
raise ValueError(f"branch {branch} does not exist locally — nothing to act in")
config.WORKTREES.mkdir(exist_ok=True)
result = subprocess.run(
["git", "-C", str(config.REPO), "worktree", "add", str(worktree), branch],
capture_output=True, text=True)
if result.returncode != 0:
raise ValueError(f"could not recreate the worktree: {result.stderr.strip()[:200]}")
name = _pick_name(stem)
agent_id = f"fix-pr-{stem}-{time.strftime('%H%M%S')}"
log_path = config.AGENT_DIR / "logs" / f"{agent_id}.log"
log_path.parent.mkdir(parents=True, exist_ok=True)
prompt = config.prompt("act-pr.md").format(
filename=filename, branch=branch, pr=task["pr"], body=task["body"])
proc, log_file = _launch("work", prompt, worktree, agent_id, filename, log_path)
record = {
"id": agent_id, "task": filename, "branch": branch,
"worktree": str(worktree), "base": None, "status": "running",
"rc": None, "started": time.time(), "session": None,
"log": str(log_path), "proc": proc, "origin": stage, "mode": "work",
"name": name,
}
with state.LOCK:
state.AGENTS[agent_id] = record
state.record_board_event({
"kind": "agent", "actor": "agent", "file": filename,
"summary": f"{name} is acting on the review of {filename}'s PR",
})
threading.Thread(target=_reap_pr_fix, args=(agent_id, proc, log_file),
daemon=True).start()
return _agent_public(record)
def _reap_pr_fix(agent_id: str, proc: subprocess.Popen, log_file) -> None:
record, stopped, rc = _finish(agent_id, proc, log_file)
filename = record["task"]
name = record.get("name") or "the agent"
if rc == 0 and not stopped:
try:
text = Path(record["log"]).read_text(encoding="utf-8", errors="replace")
except OSError:
text = ""
idx = text.find("ADDRESSED:")
report = text[idx:].strip() if idx >= 0 else text.strip()[-1500:]
_file_report(record, "PR update", report)
_session_report(record, report)
summary = f"{name} acted on {filename}'s PR — re-review when ready"
elif stopped:
summary = f"{name} was held while acting on {filename}'s PR"
else:
summary = f"{name} failed acting on {filename}'s PR (rc={rc}) — see its log"
state.record_board_event({"kind": "agent", "actor": "agent", "file": filename,
"summary": summary})
state.broadcast({"type": "board"})
state.broadcast({"type": "agents"})
def _reap_pr_review(agent_id: str, proc: subprocess.Popen, log_file) -> None:
record, stopped, rc = _finish(agent_id, proc, log_file)
filename = record["task"]
name = record.get("name") or "the reviewer"
verdict = None
if rc == 0 and not stopped:
try:
text = Path(record["log"]).read_text(encoding="utf-8", errors="replace")
except OSError:
text = ""
idx = text.find("PR REVIEW:")
report = text[idx:].strip() if idx >= 0 else text.strip()[-1500:]
match = re.search(r"^PR REVIEW:\s*(APPROVE|REQUEST CHANGES)", report)
verdict = match.group(1) if match else None
_file_report(record, "PR review", report)
_session_report(record, report)
if stopped:
summary = f"{name}'s PR review of {filename} was held"
elif rc != 0 or verdict is None:
summary = f"{name}'s PR review of {filename} ended without a verdict — see its log"
else:
word = "approved it" if verdict == "APPROVE" else "asked for changes"
summary = f"{name} reviewed {filename}'s PR and {word}"
state.record_board_event({"kind": "agent", "actor": "agent", "file": filename,
"summary": summary})
state.broadcast({"type": "board"})
state.broadcast({"type": "agents"})
def _reap_review(agent_id: str, proc: subprocess.Popen, log_file) -> None:
record, stopped, rc = _finish(agent_id, proc, log_file)
filename = record["task"]
verdict = None
if rc == 0 and not stopped:
try:
text = Path(record["log"]).read_text(encoding="utf-8", errors="replace")
except OSError:
text = ""
idx = text.find("RELEVANCE REVIEW")
report = text[idx:].strip() if idx >= 0 else text.strip()[-1500:]
verdict = report.splitlines()[0] if report else None
_file_report(record, "Relevance review", report)
_session_report(record, report)
name = record.get("name") or "the review"
if stopped:
summary = f"{name}'s check of {filename} was held"
elif rc != 0:
summary = f"{name}'s check of {filename} exited rc={rc} — see its log"
else:
summary = f"{name} on {filename}: {verdict[:140] if verdict else 'report appended to the task'}"
state.record_board_event({"kind": "agent", "actor": "agent", "file": filename,
"summary": summary})
state.broadcast({"type": "board"})
state.broadcast({"type": "agents"})
def stop_agent(agent_id: str) -> dict:
with state.LOCK:
record = state.AGENTS.get(agent_id)
if record is None:
raise ValueError("unknown agent")
if record["status"] != "running":
raise ValueError("agent is not running")
record["status"] = "stopped"
proc = record["proc"]
proc.terminate()
return _agent_public(record)
def agent_diff(agent_id: str) -> dict:
with state.LOCK:
record = state.AGENTS.get(agent_id)
if record is None:
raise ValueError("unknown agent")
if not record.get("worktree"):
return {"agent": agent_id, "files": []}
result = subprocess.run(
["git", "-C", record["worktree"], "diff", "--numstat", record["base"]],
capture_output=True, text=True, timeout=10)
files = []
for line in result.stdout.splitlines():
parts = line.split("\t")
if len(parts) == 3:
plus, minus, name = parts
files.append({"file": name,
"plus": int(plus) if plus.isdigit() else 0,
"minus": int(minus) if minus.isdigit() else 0})
return {"agent": agent_id, "files": files}
File diff suppressed because it is too large Load Diff
+77
View File
@@ -0,0 +1,77 @@
#!/usr/bin/env python3
"""Live kanban board for ../tasks/ — board, session timeline, heads-up display.
python3 .task-manager/manager/board.py # serve on :26071, open a browser
python3 .task-manager/manager/board.py --port 9000 --no-open
The manager sits cleanly on top of the tasks/ directory: it reads and moves
task files, but the tasks work as a plain folder kanban without it. See
../CLAUDE.md for the workflow and the module map:
config.py paths, stages, launch configuration
state.py shared registries, event persistence, SSE fan-out
taskfiles.py reading/moving task files (the only code touching tasks/)
events.py hook payloads → displayable events, session registry
agents.py headless work/review agents: launch, reap, stop, diff
watch.py 2s disk poller narrating moves made outside the API
httpd.py HTTP routes, SSE stream, the page itself
.prompts/ agent prompt templates (read fresh on every launch)
Stdlib only, no install.
"""
from __future__ import annotations
import argparse
import errno
import threading
import webbrowser
from http.server import ThreadingHTTPServer
import config
import drive
import events
import github
import httpd
import state
import watch
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--port", type=int, default=config.PORT)
parser.add_argument("--no-open", action="store_true", help="don't open a browser")
args = parser.parse_args()
state.serve_port = args.port
url = f"http://127.0.0.1:{args.port}/"
try:
server = ThreadingHTTPServer(("127.0.0.1", args.port), httpd.Handler)
except OSError as exc:
if exc.errno != errno.EADDRINUSE:
raise
# The port is pinned, so this is nearly always the board already running.
print(f"Port {args.port} is already in use — assuming the board is up at {url}")
if not args.no_open:
webbrowser.open(url)
return
config.SESSIONS_DIR.mkdir(exist_ok=True)
config.AGENT_DIR.mkdir(exist_ok=True)
events.load_disk_sessions()
threading.Thread(target=watch.watcher, daemon=True).start()
threading.Thread(target=github.poller, daemon=True).start()
threading.Thread(target=github.reconcile, daemon=True).start()
drive.adopt()
print(f"Task board for {config.TASKS}\n {url}\n Ctrl-C to stop")
if not args.no_open:
webbrowser.open(url)
try:
server.serve_forever()
except KeyboardInterrupt:
print("\nstopped")
if __name__ == "__main__":
main()
+107
View File
@@ -0,0 +1,107 @@
"""Local commands: project-owned executables run against a task's worktree.
Core knows nothing about what any command does — the project does
(local/commands/<name>, executables the project owns and evolves). The
contract:
- env in: CMD_WORKTREE, CMD_BRANCH, CMD_TASK, CMD_REPO (+ BOARD_*)
- stdout/stderr → a log under local/state/commands/
- exit 0 = done; anything else = failed — either way the ticker narrates
the ending with the log's last line
One run per (task, command) at a time.
"""
from __future__ import annotations
import os
import subprocess
import threading
import time
from pathlib import Path
import config
import state
RUNNING: dict[str, dict] = {} # "task:name" -> record
def public() -> list[dict]:
with state.LOCK:
return [{"task": r["task"], "name": r["name"], "started": r["started"]}
for r in RUNNING.values()]
def run(name: str, filename: str) -> dict:
if not any(c["name"] == name for c in config.commands()):
raise ValueError(f"no such command: {name}")
if Path(filename).name != filename or not filename.endswith(".md"):
raise ValueError("bad filename")
key = f"{filename}:{name}"
with state.LOCK:
if key in RUNNING:
raise ValueError(f"{name} is already running on {filename}")
stem = filename[:-3]
branch = f"task/{stem}"
worktree = config.WORKTREES / stem
if not worktree.exists():
if subprocess.run(["git", "-C", str(config.REPO), "rev-parse", "--verify",
"--quiet", branch], capture_output=True).returncode != 0:
raise ValueError(f"no worktree and no branch {branch} — nothing to run against")
config.WORKTREES.mkdir(exist_ok=True)
result = subprocess.run(
["git", "-C", str(config.REPO), "worktree", "add", str(worktree), branch],
capture_output=True, text=True)
if result.returncode != 0:
raise ValueError(f"could not recreate the worktree: {result.stderr.strip()[:200]}")
log_dir = config.STATE / "commands"
log_dir.mkdir(parents=True, exist_ok=True)
log_path = log_dir / f"{stem}-{name}-{time.strftime('%H%M%S')}.log"
env = config.child_env()
env.update({
"CMD_WORKTREE": str(worktree),
"CMD_BRANCH": branch,
"CMD_TASK": filename,
"CMD_REPO": str(config.REPO),
"BOARD_PORT": str(state.serve_port),
})
log_file = log_path.open("wb")
try:
proc = subprocess.Popen(
[str(config.LOCAL / "commands" / name)], cwd=str(worktree), env=env,
stdout=log_file, stderr=subprocess.STDOUT, stdin=subprocess.DEVNULL)
except OSError as exc:
log_file.close()
raise ValueError(f"could not launch {name}: {exc}")
record = {"task": filename, "name": name, "started": time.time(),
"proc": proc, "log": str(log_path)}
with state.LOCK:
RUNNING[key] = record
state.record_board_event({
"kind": "agent", "actor": "board", "file": filename,
"summary": f"running {name} on {filename}'s worktree"})
state.broadcast({"type": "board"})
threading.Thread(target=_reap, args=(key, record, log_file), daemon=True).start()
return {"name": name, "task": filename}
def _reap(key: str, record: dict, log_file) -> None:
rc = record["proc"].wait()
log_file.close()
with state.LOCK:
RUNNING.pop(key, None)
try:
lines = [l.strip() for l in Path(record["log"]).read_text(
encoding="utf-8", errors="replace").splitlines() if l.strip()]
last = lines[-1][:150] if lines else ""
except OSError:
last = ""
verdict = "done" if rc == 0 else f"failed (rc={rc})"
state.record_board_event({
"kind": "agent", "actor": "board", "file": record["task"],
"summary": f"{record['name']} on {record['task']} {verdict}"
+ (f" — {last}" if last else "")})
state.broadcast({"type": "board"})
+145
View File
@@ -0,0 +1,145 @@
"""Paths, stages and launch configuration for the task manager.
Core knows about tasks, worktrees, PRs and events. It knows nothing about
any particular app (drivers do), agent vendor (adapters do), or project
(local/ does). Everything the other modules need to know about *where
things are* lives here. No state, no behaviour.
"""
from __future__ import annotations
import os
import subprocess
from pathlib import Path
CORE = Path(__file__).resolve().parent # manager/core — replaceable
MANAGER = CORE.parent # manager
LOCAL = MANAGER / "local" # project-owned, never replaced
TM_ROOT = MANAGER.parent # .task-manager
TASKS = TM_ROOT / "tasks" # stage directories only
STATE = LOCAL / "state" # runtime data (gitignored)
SESSIONS_DIR = STATE / "sessions" # per-session event logs, JSONL
AGENT_DIR = STATE / "agent" # headless-agent stdout logs
DRIVES_DIR = STATE / "drives" # driver stdout logs
# Ordered — this is the column order on the board.
STAGES = [
("backlog", "Backlog"),
("to-do", "To Do"),
("in-progress", "In Progress"),
("review", "Review"),
("done", "Done"),
]
STAGE_DIRS = {slug for slug, _ in STAGES}
STAGE_LABELS = dict(STAGES)
def _repo_root() -> Path:
try:
out = subprocess.check_output(
["git", "-C", str(MANAGER), "rev-parse", "--show-toplevel"],
text=True, stderr=subprocess.DEVNULL,
).strip()
return Path(out)
except (subprocess.CalledProcessError, OSError):
return TM_ROOT.parent
REPO = _repo_root()
def _load_env() -> dict[str, str]:
"""local/.env, overridden by the process environment. Stdlib-only
parser: KEY=VALUE lines, # comments, optional quotes around the value."""
values: dict[str, str] = {}
path = LOCAL / ".env"
if path.is_file():
for line in path.read_text(encoding="utf-8").splitlines():
line = line.strip()
if not line or line.startswith("#") or "=" not in line:
continue
key, _, value = line.partition("=")
values[key.strip()] = value.strip().strip("'\"")
values.update(os.environ)
return values
_ENV = _load_env()
def setting(key: str, default: str) -> str:
return _ENV.get(key, default)
def child_env() -> dict[str, str]:
"""Environment for adapter/driver child processes: the real environment
with local/.env settings folded in (process env still wins), so
BOARD_* settings reach the scripts that read them directly."""
return dict(_ENV)
# Pinned so the board is always at the same bookmarkable URL. Sits in the
# ephemeral-safe 10000–30000 range, clear of the other local dev servers.
PORT = int(setting("BOARD_PORT", "26071"))
# One isolated checkout per running work agent, relative to the repo root.
WORKTREES = REPO / setting("BOARD_WORKTREES", ".worktrees")
# Which agent adapter runs headless jobs. Resolution ladder: local wins.
ADAPTER = setting("BOARD_AGENT_ADAPTER", "claude")
# GitHub plumbing: the gh CLI (stub-able for tests) and the git remote PRs
# go to. Empty remote = auto-detect the first remote; no remote = no PRs.
GH_BIN = setting("BOARD_GH_BIN", "gh")
GIT_REMOTE = setting("BOARD_GIT_REMOTE", "")
PR_POLL_INTERVAL = float(setting("BOARD_PR_POLL_INTERVAL", "60"))
WATCH_INTERVAL = float(setting("BOARD_WATCH_INTERVAL", "2"))
EVENTS_CAP = int(setting("BOARD_EVENTS_CAP", "800"))
BOARD_EVENTS_CAP = int(setting("BOARD_HISTORY_CAP", "300"))
def prompt(name: str) -> str:
"""Prompt templates: core ships defaults, local/prompts/ overrides win.
Read fresh on every launch so edits apply without a restart."""
override = LOCAL / "prompts" / name
if override.is_file():
return override.read_text(encoding="utf-8")
return (CORE / "prompts" / name).read_text(encoding="utf-8")
def adapter_dir() -> Path | None:
"""The configured agent adapter's directory — local overrides core."""
for base in (LOCAL / "adapters", CORE / "adapters"):
candidate = base / ADAPTER
if (candidate / "run").is_file():
return candidate
return None
def driver_path() -> Path | None:
"""The project's app driver, if it has one."""
candidate = LOCAL / "driver" / "start"
return candidate if candidate.is_file() else None
def commands() -> list[dict]:
"""Project-owned commands: executables in local/commands/, surfaced as
chips on cards and run against the task's worktree. A `# help:` line
near the top becomes the tooltip."""
directory = LOCAL / "commands"
found = []
if directory.is_dir():
for path in sorted(directory.iterdir()):
if not path.is_file() or path.name.startswith(".") or not os.access(path, os.X_OK):
continue
help_text = ""
try:
for line in path.read_text(encoding="utf-8").splitlines()[:8]:
if line.startswith("# help:"):
help_text = line[len("# help:"):].strip()
break
except OSError:
pass
found.append({"name": path.name, "help": help_text})
return found
+218
View File
@@ -0,0 +1,218 @@
"""Drives: launching the project's app from a task's worktree.
Core knows nothing about how any app starts — the project's driver does
(local/driver/start, an executable the project owns and evolves). The
contract:
- env in: DRIVE_WORKTREE, DRIVE_BRANCH, DRIVE_TASK, DRIVE_REPO
- exit non-zero quickly and whatever was printed becomes the refusal
shown in the ticker ("port 5176 is yours", "branch has migrations")
- print `DRIVE URL: <url>` when the app is up — the card links there
- keep running until parked; the board SIGTERMs the process group
One drive at a time: you look at one version of the app.
"""
from __future__ import annotations
import os
import re
import signal
import subprocess
import threading
import time
from pathlib import Path
import config
import state
DRIVE: dict | None = None # the current (or last) drive
URL_RE = re.compile(r"^DRIVE URL:\s*(\S+)", re.MULTILINE)
STATE_FILE = config.STATE / "drive.json"
def _persist() -> None:
"""The drive outlives the board (it is its own process group), so its
identity lives on disk; a restarted board re-adopts it via adopt()."""
import json
try:
if DRIVE and DRIVE["status"] in ("starting", "up"):
STATE_FILE.parent.mkdir(parents=True, exist_ok=True)
STATE_FILE.write_text(json.dumps(
{k: DRIVE.get(k) for k in
("task", "status", "url", "started", "log", "pgid")}))
else:
STATE_FILE.unlink(missing_ok=True)
except OSError:
pass
def _alive(drive: dict) -> bool:
proc = drive.get("proc")
if proc is not None:
return proc.poll() is None
try:
os.killpg(drive["pgid"], 0)
return True
except (ProcessLookupError, PermissionError, KeyError, TypeError):
return False
def adopt() -> None:
"""Board startup: re-adopt a drive a previous board left running.
The file can lie (crash, reboot) — believe it only if the process
group is actually alive."""
global DRIVE
import json
try:
data = json.loads(STATE_FILE.read_text())
except (OSError, json.JSONDecodeError):
return
try:
os.killpg(int(data.get("pgid") or 0), 0)
except (ProcessLookupError, PermissionError, ValueError):
try:
STATE_FILE.unlink(missing_ok=True)
except OSError:
pass
return
DRIVE = {**data, "proc": None}
state.record_board_event({
"kind": "agent", "actor": "board", "file": DRIVE["task"],
"summary": f"re-adopted the running drive of {DRIVE['task']}"
+ (f" at {DRIVE['url']}" if DRIVE.get("url") else "")})
threading.Thread(target=_watch, args=(DRIVE, None, None), daemon=True).start()
def _record_event(filename: str, summary: str) -> None:
state.record_board_event({"kind": "agent", "actor": "board",
"file": filename, "summary": summary})
state.broadcast({"type": "board"})
def public() -> dict | None:
if DRIVE is None:
return None
return {k: DRIVE.get(k) for k in ("task", "status", "url", "started", "line", "reason")}
def start(filename: str) -> dict:
global DRIVE
driver = config.driver_path()
if driver is None:
raise ValueError(
"no driver: create local/driver/start (see core/driver.example/) — "
"an executable that launches this project's app from a worktree")
with state.LOCK:
if DRIVE and DRIVE["status"] in ("starting", "up"):
raise ValueError(f"already driving {DRIVE['task']} — park it first")
stem = filename[:-3] if filename.endswith(".md") else filename
branch = f"task/{stem}"
worktree = config.WORKTREES / stem
if not worktree.exists():
if subprocess.run(["git", "-C", str(config.REPO), "rev-parse", "--verify",
"--quiet", branch], capture_output=True).returncode != 0:
raise ValueError(f"no worktree and no branch {branch} — nothing to drive")
config.WORKTREES.mkdir(exist_ok=True)
result = subprocess.run(
["git", "-C", str(config.REPO), "worktree", "add", str(worktree), branch],
capture_output=True, text=True)
if result.returncode != 0:
raise ValueError(f"could not recreate the worktree: {result.stderr.strip()[:200]}")
config.DRIVES_DIR.mkdir(parents=True, exist_ok=True)
log_path = config.DRIVES_DIR / f"{stem}-{time.strftime('%H%M%S')}.log"
env = config.child_env()
env.update({
"DRIVE_WORKTREE": str(worktree),
"DRIVE_BRANCH": branch,
"DRIVE_TASK": filename,
"DRIVE_REPO": str(config.REPO),
"BOARD_PORT": str(state.serve_port),
})
log_file = log_path.open("wb")
try:
proc = subprocess.Popen(
[str(driver)], cwd=str(config.REPO), env=env,
stdout=log_file, stderr=subprocess.STDOUT, stdin=subprocess.DEVNULL,
start_new_session=True) # own process group: park kills everything
except OSError as exc:
log_file.close()
raise ValueError(f"could not launch the driver: {exc}")
DRIVE = {"task": filename, "status": "starting", "url": None,
"started": time.time(), "proc": proc, "log": str(log_path),
"pgid": proc.pid} # start_new_session: pid == pgid
_persist()
_record_event(filename, f"driver starting for {filename}")
threading.Thread(target=_watch, args=(DRIVE, proc, log_file), daemon=True).start()
return public()
def _tail(log: Path, cap: int = 4096) -> str:
try:
with log.open("rb") as fh:
fh.seek(0, 2)
size = fh.tell()
fh.seek(max(0, size - cap))
return fh.read().decode("utf-8", errors="replace")
except OSError:
return ""
def _watch(drive: dict, proc: subprocess.Popen | None, log_file) -> None:
"""Tail the driver's log for the URL and a live progress line;
narrate its ending. Works for owned and adopted drives alike."""
filename = drive["task"]
log = Path(drive["log"])
while _alive(drive):
text = _tail(log)
lines = [l.strip() for l in text.splitlines() if l.strip()]
if lines:
new_line = lines[-1][:200]
if drive.get("line") != new_line:
drive["line"] = new_line
state.broadcast({"type": "board"})
match = URL_RE.search(text)
if match and drive["status"] == "starting":
drive["status"] = "up"
drive["url"] = match.group(1)
_persist()
_record_event(filename, f"driving {filename} at {match.group(1)}")
time.sleep(1)
if log_file is not None:
log_file.close()
rc = proc.returncode if proc is not None else 0
quick = time.time() - drive["started"] < 30
if drive["status"] == "parked":
_record_event(filename, f"parked — {filename}'s drive is down")
elif rc != 0 and drive["status"] == "starting":
lines = [l.strip() for l in _tail(log).splitlines() if l.strip()]
reason = (lines[-1] if lines else f"rc={rc}")[:200]
drive["status"] = "refused"
drive["reason"] = reason
label = "refused" if quick else "failed to come up —"
_record_event(filename, f"driver {label} {filename}: {reason[:150]}")
else:
drive["status"] = "ended"
_record_event(filename, f"{filename}'s drive ended"
+ (f" (rc={rc})" if proc is not None else ""))
_persist()
def stop() -> dict:
global DRIVE
with state.LOCK:
if DRIVE is None or DRIVE["status"] not in ("starting", "up"):
raise ValueError("nothing is being driven")
DRIVE["status"] = "parked"
pgid = DRIVE.get("pgid")
filename = DRIVE["task"]
_persist()
_record_event(filename, f"parking {filename} — taking the app down…")
try:
os.killpg(pgid, signal.SIGTERM)
except (ProcessLookupError, PermissionError, TypeError):
pass
return public()
+22
View File
@@ -0,0 +1,22 @@
#!/usr/bin/env bash
# Example driver — copy this directory to manager/local/driver/ and make it
# launch YOUR project's app from a task worktree. The board runs `start` and
# owns it as a process group.
#
# The whole contract:
# env in: DRIVE_WORKTREE absolute path of the task's worktree checkout
# DRIVE_BRANCH its branch (task/<stem>)
# DRIVE_TASK the task filename
# DRIVE_REPO the main repo checkout
# refuse: exit non-zero QUICKLY; whatever you printed becomes the reason
# shown in the ticker ("port busy", "branch has migrations", …)
# ready: print exactly `DRIVE URL: http://...` — the card links there
# run: keep running until SIGTERM ("park"), then take the app down
#
# Typical duties, all project-specific and none of the board's business:
# preflight port checks, copying gitignored env files into the worktree,
# dependency install/link, starting processes, health checks, teardown.
set -euo pipefail
echo "this is the example driver — copy me to manager/local/driver/start and edit"
exit 1
+142
View File
@@ -0,0 +1,142 @@
"""Ingesting normalized events — the fixed contract every adapter speaks.
Adapters translate their vendor's payloads at the edge (see
adapters/*/emit*) and POST the normalized schema here:
{"v": 1, "session": str, "kind": str, "summary": str,
"file"?: str, "cmd"?: str, "detail"?: str, "ok"?: bool,
"running"?: bool, "agent"?: str, "task"?: str}
Core sanitises, updates the session registry, persists a slim record per
session, and pushes to connected browsers over SSE. It never interprets a
vendor's tool vocabulary — that knowledge lives in the adapter.
"""
from __future__ import annotations
import json
import time
import config
import state
from taskfiles import NUMBER_RE
KINDS = {"session", "end", "idle", "edit", "read", "search", "command",
"test", "check", "git", "plan", "subagent", "web", "report", "other"}
def session_label(meta: dict) -> str:
agent_id = meta.get("agentId") or ""
if agent_id:
record = state.AGENTS.get(agent_id) or {}
task = meta.get("task") or ""
num = NUMBER_RE.match(task)
who = record.get("name") or ("Review" if agent_id.startswith("review-") else "Agent")
return f"{who} · #{num.group(1)}" if num else who
return f"You · {meta['id'][:8]}"
def _txt(value, cap: int) -> str | None:
return value[:cap] if isinstance(value, str) and value else None
def ingest_event(raw: dict) -> None:
if not isinstance(raw, dict) or "kind" not in raw:
return # not a normalized event — adapters own translation
sid = str(raw.get("session") or "unknown")
kind = raw["kind"] if raw.get("kind") in KINDS else "other"
event = {"ts": time.time(), "session": sid, "kind": kind,
"summary": _txt(raw.get("summary"), 300) or kind}
for key, cap in (("file", 300), ("cmd", 300), ("detail", 900)):
value = _txt(raw.get(key), cap)
if value:
event[key] = value
if isinstance(raw.get("ok"), bool):
event["ok"] = raw["ok"]
if raw.get("running") is True:
event["running"] = True
agent_id = _txt(raw.get("agent"), 120)
task = _txt(raw.get("task"), 200)
with state.LOCK:
meta = state.SESSIONS.setdefault(sid, {
"id": sid, "started": event["ts"], "count": 0,
"agentId": None, "task": None, "status": "active",
})
just_linked = False
if agent_id:
meta["agentId"] = agent_id
record = state.AGENTS.get(agent_id)
if record is not None:
just_linked = record["session"] is None
record["session"] = sid
task = task or record["task"]
if task:
meta["task"] = task
meta["last"] = event["ts"]
meta["lastSummary"] = event["summary"]
meta["lastKind"] = kind
meta["status"] = {"end": "ended", "idle": "idle"}.get(kind, "active")
meta["label"] = session_label(meta)
if not event.get("running"):
meta["count"] += 1
state.EVENTS.setdefault(sid, []).append(event)
del state.EVENTS[sid][:-config.EVENTS_CAP]
meta_snapshot = dict(meta)
if not event.get("running"):
state.persist(f"{sid}.jsonl", event)
if just_linked:
# the agent's card can now show its live line instead of "warming up"
state.broadcast({"type": "agents"})
state.broadcast({"type": "event", "event": event, "session": meta_snapshot})
def load_disk_sessions() -> None:
"""Rebuild session metadata from state/sessions/ so past sessions replay."""
if not config.SESSIONS_DIR.is_dir():
return
for path in config.SESSIONS_DIR.glob("*.jsonl"):
sid = path.stem
if sid == "board" or sid in state.SESSIONS:
continue
try:
lines = path.read_text(encoding="utf-8").splitlines()
first = json.loads(lines[0])
last = json.loads(lines[-1])
except (OSError, json.JSONDecodeError, IndexError):
continue
meta = {
"id": sid, "started": first.get("ts"), "last": last.get("ts"),
"count": len(lines), "agentId": None, "task": None,
"status": "ended", "lastSummary": last.get("summary"),
"lastKind": last.get("kind"),
}
meta["label"] = session_label(meta)
state.SESSIONS[sid] = meta
board_log = config.SESSIONS_DIR / "board.jsonl"
if board_log.is_file():
try:
lines = board_log.read_text(encoding="utf-8").splitlines()[-100:]
state.BOARD_EVENTS.extend(json.loads(l) for l in lines)
except (OSError, json.JSONDecodeError):
pass
def session_events(sid: str) -> list[dict]:
with state.LOCK:
if sid in state.EVENTS:
return list(state.EVENTS[sid])
path = config.SESSIONS_DIR / f"{sid}.jsonl"
if not path.is_file() or "/" in sid or ".." in sid:
return []
events = []
try:
for line in path.read_text(encoding="utf-8").splitlines()[-config.EVENTS_CAP:]:
events.append(json.loads(line))
except (OSError, json.JSONDecodeError):
pass
return events
+382
View File
@@ -0,0 +1,382 @@
"""GitHub plumbing: opening PRs from agent worktree branches, requesting
Copilot reviews, and polling PR state for cards sitting in review/.
All of it is mechanical `git` + `gh` — no Claude involvement. The PR url is
written into the task file (`**PR:** <url>`), keeping the file the single
source of truth; only the volatile review/check state lives in memory.
"""
from __future__ import annotations
import json
import re
import shutil
import subprocess
import threading
import time
from pathlib import Path
import config
import drive as drive_mod
import state
from taskfiles import STATUS_RE, find_stage_of, move_task, read_task
PR_STATE: dict[str, dict] = {} # filename -> {verdict, detail, url, ts}
_OPENING: set[str] = set() # filenames with a PR-open in flight
def _run(cmd: list[str], cwd: Path | None = None, timeout: int = 60) -> subprocess.CompletedProcess:
return subprocess.run(cmd, cwd=str(cwd or config.REPO),
capture_output=True, text=True, timeout=timeout)
def remote() -> str | None:
if config.GIT_REMOTE:
return config.GIT_REMOTE
result = _run(["git", "remote"])
names = result.stdout.split()
return names[0] if names else None
def gh_available() -> bool:
return shutil.which(config.GH_BIN) is not None
def _branch_exists(branch: str) -> bool:
return _run(["git", "rev-parse", "--verify", "--quiet", branch]).returncode == 0
def _write_pr_line(filename: str, url: str) -> None:
stage = find_stage_of(filename)
if not stage:
return
path = config.TASKS / stage / filename
text = path.read_text(encoding="utf-8")
if re.search(r"^\*\*PR:\*\*", text, re.MULTILINE):
return
if STATUS_RE.search(text):
text = STATUS_RE.sub(lambda m: f"{m.group(0)}\n**PR:** {url}", text, count=1)
else:
text = f"**PR:** {url}\n\n" + text
path.write_text(text, encoding="utf-8")
def maybe_open_pr(filename: str) -> None:
"""Card entered review/ — open a PR for its branch if one can be opened.
Quiet when there is simply no branch (hand-written tasks); loud in the
ticker when a PR *should* be possible but something stands in the way.
"""
if filename in _OPENING:
return
_OPENING.add(filename)
try:
_open_pr(filename)
finally:
_OPENING.discard(filename)
def _open_pr(filename: str) -> None:
branch = f"task/{filename[:-3]}"
if not _branch_exists(branch):
return # nothing to publish — a hand-moved card without agent work
stage = find_stage_of(filename)
if stage != "review":
return
task = read_task(config.TASKS / stage / filename, stage)
if task.get("pr"):
return # already open
rname = remote()
if rname is None or not gh_available():
state.record_board_event({
"kind": "agent", "actor": "board", "file": filename,
"summary": f"no PR for {filename}: " +
("no git remote configured" if rname is None else "gh is not installed")})
return
# The PR's diff is computed against the remote main — refuse to open one
# that would drag unpushed main commits along with it.
_run(["git", "fetch", rname, "main"], timeout=120)
ahead = _run(["git", "rev-list", "--count", f"{rname}/main..main"]).stdout.strip()
if ahead.isdigit() and int(ahead) > 0:
state.record_board_event({
"kind": "agent", "actor": "board", "file": filename,
"summary": f"won't open a PR for {filename}: main is {ahead} commits "
f"ahead of {rname} — push main first, then move the card again"})
return
push = _run(["git", "push", "-u", rname, branch], timeout=180)
if push.returncode != 0:
state.record_board_event({
"kind": "agent", "actor": "board", "file": filename,
"summary": f"push failed for {branch}: {push.stderr.strip()[:140]}"})
return
body = (f"Task: `{filename}` — tracked in `.task-manager/tasks/review/`.\n\n"
f"Opened by the board when the card moved to review.")
log_tail = _agent_log_tail(filename)
if log_tail:
body += f"\n\n## Agent summary\n\n{log_tail}"
result = _run([config.GH_BIN, "pr", "create", "--head", branch, "--base", "main",
"--title", task["title"], "--body", body], timeout=120)
if result.returncode != 0:
state.record_board_event({
"kind": "agent", "actor": "board", "file": filename,
"summary": f"PR creation failed for {branch}: {result.stderr.strip()[:140]}"})
return
url = next((l.strip() for l in result.stdout.splitlines() if "/pull/" in l), result.stdout.strip())
_write_pr_line(filename, url)
state.record_board_event({
"kind": "agent", "actor": "board", "file": filename,
"summary": f"PR opened for {filename}: {url}"})
state.broadcast({"type": "board"})
_poll_pr(filename, url) # first CI/review snapshot without waiting a cycle
def _agent_log_tail(filename: str, cap: int = 1500) -> str:
with state.LOCK:
records = [r for r in state.AGENTS.values()
if r["task"] == filename and r.get("mode") == "work"]
if not records:
return ""
latest = max(records, key=lambda r: r["started"])
try:
text = Path(latest["log"]).read_text(encoding="utf-8", errors="replace").strip()
except OSError:
return ""
return text[-cap:]
def request_copilot(filename: str) -> str:
"""Ask GitHub Copilot to review the task's PR. Works iff Copilot code
review is enabled for the repo — otherwise gh returns an error we relay."""
stage = find_stage_of(filename)
if stage is None:
raise ValueError(f"{filename} is not on the board")
task = read_task(config.TASKS / stage / filename, stage)
url = task.get("pr")
if not url:
raise ValueError(f"{filename} has no PR yet")
number = url.rstrip("/").rsplit("/", 1)[-1]
result = _run([config.GH_BIN, "api", "-X", "POST",
f"repos/{{owner}}/{{repo}}/pulls/{number}/requested_reviewers",
"-f", "reviewers[]=copilot-pull-request-reviewer[bot]"], timeout=60)
if result.returncode != 0:
raise ValueError(f"Copilot said no: {result.stderr.strip()[:160]}")
entry = PR_STATE.setdefault(filename, {"verdict": "pending", "ci": None,
"url": url, "detail": "", "ts": 0})
entry["copilot"] = "asked"
state.record_board_event({
"kind": "agent", "actor": "board", "file": filename,
"summary": f"Copilot review requested on {filename}'s PR"})
state.broadcast({"type": "board"})
return url
def _check_state(check: dict) -> str:
"""One check-run or status-context → pass | fail | running."""
conclusion = (check.get("conclusion") or check.get("state") or "").upper()
status = (check.get("status") or "").upper()
if conclusion in ("FAILURE", "TIMED_OUT", "CANCELLED", "ERROR", "STARTUP_FAILURE"):
return "fail"
if conclusion in ("SUCCESS", "NEUTRAL", "SKIPPED"):
return "pass"
if status in ("IN_PROGRESS", "QUEUED", "PENDING", "WAITING", "REQUESTED"):
return "running"
return "running"
def _is_copilot(login) -> bool:
return "copilot" in str(login or "").lower()
def _poll_pr(filename: str, url: str) -> None:
number = url.rstrip("/").rsplit("/", 1)[-1]
result = _run([config.GH_BIN, "pr", "view", number,
"--json", "reviews,reviewRequests,statusCheckRollup,state"], timeout=60)
if result.returncode != 0:
return
try:
data = json.loads(result.stdout)
except json.JSONDecodeError:
return
reviews = data.get("reviews") or []
checks = data.get("statusCheckRollup") or []
changes = any(r.get("state") == "CHANGES_REQUESTED" for r in reviews)
approved = any(r.get("state") == "APPROVED" for r in reviews)
# Copilot: asked is a pending entry in reviewRequests; done is a review
# authored by the copilot bot — the request entry disappears once it lands.
cop_reviews = [r for r in reviews
if _is_copilot((r.get("author") or {}).get("login"))]
cop_requested = any(
_is_copilot(rr.get("login") or rr.get("slug") or rr.get("name"))
for rr in (data.get("reviewRequests") or []))
if cop_reviews:
copilot = {"APPROVED": "approved", "CHANGES_REQUESTED": "changes"}.get(
cop_reviews[-1].get("state"), "commented")
elif cop_requested:
copilot = "asked"
else:
copilot = PR_STATE.get(filename, {}).get("copilot")
copilot = "asked" if copilot == "asked" else None
states = [_check_state(c) for c in checks]
ci = ("fail" if "fail" in states
else "running" if "running" in states
else "pass" if states else None)
verdict = ("red" if (changes or ci == "fail")
else "green" if approved else "pending")
detail_bits = []
if reviews:
detail_bits.append(f"{len(reviews)} review{'s' if len(reviews) > 1 else ''}")
if ci:
detail_bits.append({"fail": "checks failing", "running": "checks running",
"pass": "checks ok"}[ci])
if copilot:
detail_bits.append("copilot " + {"asked": "asked", "approved": "approved",
"changes": "asked for changes",
"commented": "commented"}[copilot])
prev = PR_STATE.get(filename, {})
PR_STATE[filename] = {"verdict": verdict, "ci": ci, "copilot": copilot,
"url": url, "detail": " · ".join(detail_bits),
"ts": time.time()}
if prev.get("copilot") in (None, "asked") and copilot in ("approved", "changes", "commented"):
word = {"approved": "approved it", "changes": "asked for changes",
"commented": "commented"}[copilot]
state.record_board_event({
"kind": "agent", "actor": "board", "file": filename,
"summary": f"Copilot reviewed {filename}'s PR and {word}"})
state.broadcast({"type": "board"})
if prev.get("verdict") != verdict and verdict != "pending":
word = "approved" if verdict == "green" else "changes asked"
state.record_board_event({
"kind": "agent", "actor": "board", "file": filename,
"summary": f"GitHub on {filename}'s PR: {word}"})
state.broadcast({"type": "board"})
elif prev.get("ci") != ci and ci in ("fail", "pass") and prev.get("ci") is not None:
state.record_board_event({
"kind": "agent", "actor": "board", "file": filename,
"summary": f"CI on {filename}'s PR: "
+ ("all checks pass" if ci == "pass" else "checks failing")})
state.broadcast({"type": "board"})
def poller() -> None:
"""Watch PRs of cards sitting in review/. Silent when there are none."""
while True:
time.sleep(config.PR_POLL_INTERVAL)
if not gh_available():
continue
directory = config.TASKS / "review"
if not directory.is_dir():
continue
try:
for path in directory.glob("*.md"):
task = read_task(path, "review")
if task.get("pr"):
_poll_pr(task["file"], task["pr"])
except OSError:
continue
def public_state() -> dict:
return {f: {k: v.get(k) for k in ("verdict", "ci", "copilot", "detail", "url")}
for f, v in PR_STATE.items()}
def open_pr_async(filename: str) -> None:
threading.Thread(target=maybe_open_pr, args=(filename,), daemon=True).start()
def complete_task(filename: str, stage: str) -> dict:
"""The user chose "merge & clean up" on a move to done: park the drive
if it is this task's, merge the branch into main, push (which marks the
PR merged), remove the worktree and branches, then move the card.
Every step narrates; a conflict aborts cleanly and the card stays."""
if stage not in config.STAGE_DIRS or stage == "done":
raise ValueError("complete runs on a live-stage card")
if not (config.TASKS / stage / filename).is_file():
raise ValueError(f"{filename} is not in {stage}/ — refresh the board")
stem = filename[:-3]
branch = f"task/{stem}"
# 1. the app must not keep running code that is about to be merged away
d = drive_mod.DRIVE
if d and d.get("task") == filename and d.get("status") in ("starting", "up"):
drive_mod.stop()
for _ in range(40):
if not drive_mod._alive(d):
break
time.sleep(0.5)
merged = False
if _branch_exists(branch):
current = _run(["git", "branch", "--show-current"]).stdout.strip()
if current != "main":
raise ValueError(f"the repo is on '{current}', not main — switch first")
result = _run(["git", "merge", "--no-edit", branch], timeout=120)
if result.returncode != 0:
_run(["git", "merge", "--abort"])
detail = (result.stdout.strip() or result.stderr.strip())[-160:]
raise ValueError(f"merge conflict — resolve by hand ({detail})")
merged = True
state.record_board_event({
"kind": "agent", "actor": "board", "file": filename,
"summary": f"merged {branch} into main"})
rname = remote()
if rname:
push = _run(["git", "push", rname, "main"], timeout=180)
if push.returncode != 0:
state.record_board_event({
"kind": "agent", "actor": "board", "file": filename,
"summary": f"merged locally but the push failed — push main "
f"yourself ({push.stderr.strip()[:100]})"})
else:
_run(["git", "push", rname, "--delete", branch], timeout=60)
state.record_board_event({
"kind": "agent", "actor": "board", "file": filename,
"summary": f"pushed main (PR marked merged) and deleted {branch} on {rname}"})
worktree = config.WORKTREES / stem
if worktree.exists():
_run(["git", "worktree", "remove", "--force", str(worktree)])
_run(["git", "branch", "-d", branch])
PR_STATE.pop(filename, None)
state.record_board_event({
"kind": "agent", "actor": "board", "file": filename,
"summary": f"cleaned up: worktree and local branch for {stem} removed"})
move_task(filename, stage, "done", actor="you")
state.broadcast({"type": "board"})
return {"merged": merged}
def task_branches() -> list[str]:
"""Stems of all task/* branches — the UI uses this to say honestly
whether a review card has work attached."""
result = _run(["git", "for-each-ref", "--format=%(refname:short)",
"refs/heads/task/"])
return [ref[len("task/"):] for ref in result.stdout.split() if ref.startswith("task/")]
def reconcile() -> None:
"""Catch up on moves the watcher never saw (board was down): any card
already sitting in review/ with a branch but no PR gets its PR opened
now. Runs once at startup."""
time.sleep(3) # let the server settle first
directory = config.TASKS / "review"
if not directory.is_dir():
return
branches = set(task_branches())
for path in sorted(directory.glob("*.md")):
try:
task = read_task(path, "review")
except OSError:
continue
if not task.get("pr") and path.stem in branches:
maybe_open_pr(path.name)
+214
View File
@@ -0,0 +1,214 @@
"""HTTP surface: the page, the JSON API, and the SSE stream."""
from __future__ import annotations
import json
import queue
import subprocess
import time
from http.server import BaseHTTPRequestHandler
from urllib.parse import parse_qs, unquote, urlparse
import agents
import commands
import config
import drive
import events
import github
import state
import taskfiles
def state_payload() -> dict:
with state.LOCK:
sessions = sorted(
(dict(m) for m in state.SESSIONS.values()),
key=lambda m: m.get("last") or m.get("started") or 0, reverse=True)
board_events = list(state.BOARD_EVENTS[-80:])
return {
"board": taskfiles.collect(),
"sessions": sessions,
"agents": agents.list_public(),
"prs": github.public_state(),
"drive": drive.public(),
"hasDriver": config.driver_path() is not None,
"branches": github.task_branches(),
"commands": config.commands(),
"commandRuns": commands.public(),
"archivedCount": taskfiles.archived_count(),
"boardEvents": board_events,
"now": time.time(),
}
class Handler(BaseHTTPRequestHandler):
def log_message(self, fmt, *args): # quieter console
pass
def _send(self, code: int, body: bytes, content_type: str) -> None:
self.send_response(code)
self.send_header("Content-Type", content_type)
self.send_header("Content-Length", str(len(body)))
self.send_header("Cache-Control", "no-store")
self.end_headers()
self.wfile.write(body)
def _json(self, code: int, payload: dict) -> None:
self._send(code, json.dumps(payload).encode("utf-8"), "application/json")
def do_GET(self) -> None:
url = urlparse(self.path)
path = url.path
if path in ("/", "/index.html", "/board.html"):
page = config.CORE / "board.html"
if not page.is_file():
self._send(500, b"board.html is missing", "text/plain")
return
self._send(200, page.read_bytes(), "text/html; charset=utf-8")
elif path == "/api/tasks":
self._json(200, taskfiles.collect())
elif path == "/api/state":
self._json(200, state_payload())
elif path == "/api/session":
sid = (parse_qs(url.query).get("id") or [""])[0]
with state.LOCK:
meta = dict(state.SESSIONS.get(sid) or {})
self._json(200, {"meta": meta, "events": events.session_events(sid)})
elif path == "/api/diff":
agent_id = (parse_qs(url.query).get("agent") or [""])[0]
try:
self._json(200, agents.agent_diff(agent_id))
except (ValueError, subprocess.SubprocessError, OSError) as exc:
self._json(409, {"error": str(exc)})
elif path == "/api/stream":
self._stream()
elif path.startswith("/files/"):
self._extra_file(path)
else:
self._send(404, b"not found", "text/plain")
_MIME = {".html": "text/html; charset=utf-8",
".md": "text/markdown; charset=utf-8",
".txt": "text/plain; charset=utf-8",
".json": "application/json",
".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg",
".svg": "image/svg+xml", ".pdf": "application/pdf"}
def _extra_file(self, path: str) -> None:
"""Serve plans/ and reference/ files so the UI can open them."""
parts = path.split("/", 3)
if len(parts) != 4 or parts[2] not in ("plans", "reference"):
self._send(404, b"not found", "text/plain")
return
name = unquote(parts[3])
if "/" in name or ".." in name or name.startswith("."):
self._send(404, b"not found", "text/plain")
return
file_path = config.TM_ROOT / parts[2] / name
if not file_path.is_file():
self._send(404, b"not found", "text/plain")
return
ctype = self._MIME.get(file_path.suffix.lower(), "application/octet-stream")
self._send(200, file_path.read_bytes(), ctype)
def _stream(self) -> None:
self.send_response(200)
self.send_header("Content-Type", "text/event-stream")
self.send_header("Cache-Control", "no-store")
self.end_headers()
q: queue.Queue = queue.Queue(maxsize=500)
with state.LOCK:
state.CLIENTS.add(q)
try:
self.wfile.write(b"retry: 2000\n\n")
self.wfile.flush()
while True:
try:
msg = q.get(timeout=15)
self.wfile.write(f"data: {msg}\n\n".encode("utf-8"))
except queue.Empty:
self.wfile.write(b": ping\n\n")
self.wfile.flush()
except (BrokenPipeError, ConnectionResetError, OSError):
pass
finally:
with state.LOCK:
state.CLIENTS.discard(q)
def _read_body(self) -> dict:
length = int(self.headers.get("Content-Length") or 0)
return json.loads(self.rfile.read(length) or b"{}")
def do_POST(self) -> None:
path = self.path.split("?")[0]
try:
if path == "/api/move":
payload = self._read_body()
task = taskfiles.move_task(payload["file"], payload["from"], payload["to"])
self._json(200, {"task": task})
elif path == "/api/events":
events.ingest_event(self._read_body())
self._json(200, {"ok": True})
elif path == "/api/agent/start":
payload = self._read_body()
agent = agents.start_agent(payload["file"], payload["stage"])
self._json(200, {"agent": agent})
elif path == "/api/agent/review":
payload = self._read_body()
agent = agents.start_review(payload["file"], payload["stage"])
self._json(200, {"agent": agent})
elif path == "/api/agent/review-pr":
payload = self._read_body()
agent = agents.start_pr_review(payload["file"], payload["stage"])
self._json(200, {"agent": agent})
elif path == "/api/agent/act-pr":
payload = self._read_body()
agent = agents.start_pr_fix(payload["file"], payload["stage"])
self._json(200, {"agent": agent})
elif path == "/api/pr/copilot":
payload = self._read_body()
url = github.request_copilot(payload["file"])
self._json(200, {"url": url})
elif path == "/api/task/complete":
payload = self._read_body()
self._json(200, github.complete_task(payload["file"], payload["from"]))
elif path == "/api/archive":
payload = self._read_body()
result = taskfiles.archive_task(payload["file"], payload["from"])
with state.LOCK:
state.LAST_ARCHIVED = result
state.record_board_event({
"kind": "move", "file": result["file"], "from": result["from"],
"to": "archive", "actor": "you",
"summary": f"{result['file']} archived (from {result['from']}/) — ⌘Z brings it back"})
state.broadcast({"type": "board"})
self._json(200, result)
elif path == "/api/unarchive":
with state.LOCK:
last = state.LAST_ARCHIVED
state.LAST_ARCHIVED = None
if not last:
raise ValueError("nothing to unarchive — the undo covers the last archive this board made")
result = taskfiles.unarchive_task(last["file"], last["from"])
state.record_board_event({
"kind": "move", "file": result["file"], "from": "archive",
"to": result["to"], "actor": "you",
"summary": f"{result['file']} brought back to {result['to']}/"})
state.broadcast({"type": "board"})
self._json(200, result)
elif path == "/api/command/run":
payload = self._read_body()
self._json(200, commands.run(payload["name"], payload["file"]))
elif path == "/api/drive/start":
payload = self._read_body()
self._json(200, {"drive": drive.start(payload["file"])})
elif path == "/api/drive/stop":
self._json(200, {"drive": drive.stop()})
elif path == "/api/agent/stop":
payload = self._read_body()
agent = agents.stop_agent(payload["id"])
self._json(200, {"agent": agent})
else:
self._send(404, b"not found", "text/plain")
except (KeyError, ValueError, json.JSONDecodeError, OSError) as exc:
self._json(409, {"error": str(exc)})
+26
View File
@@ -0,0 +1,26 @@
You are addressing review feedback on a pull request for a task from this
repository's task board.
You are in the task's git worktree on branch `{branch}`. The PR is {pr}.
The task, for what the work was supposed to be:
--- TASK ---
{body}
--- END TASK ---
Do this properly:
- Read every review and comment on the PR: `gh pr view {branch} --json
reviews`, and the line comments via `gh api
repos/{{owner}}/{{repo}}/pulls/<number>/comments`.
- Address each point in the code. If you disagree with a point, do not
silently ignore it — leave it unchanged and say why in your summary.
- Follow repo CLAUDE.md: layering rules, definition of done. Run the tests
that cover what you changed until they pass.
- Commit in clear, reviewable commits and push the branch (`git push`) so
the PR updates.
- Do NOT move, rename or edit the task file itself.
End your reply with a report whose FIRST line is exactly
ADDRESSED: <one line on what changed>
followed by a bullet per review point: what was asked, and what you did
about it (or why you deliberately did not).
+31
View File
@@ -0,0 +1,31 @@
You are reviewing a pull request for a task on this repository's task board —
you are NOT implementing anything.
The task is `{filename}`, its branch is `{branch}`, and its PR is {pr}.
The task content, for what the work was supposed to be:
--- TASK ---
{body}
--- END TASK ---
Review the PR properly:
- Fetch the diff with `gh pr diff {branch}` (or by PR number) and read it all.
- Read the surrounding code where the diff touches it — judge the change in
context, not in isolation.
- Check the work against the task: does it do what the task asked? Is
anything missing, wrong, or beyond scope?
- Check it against CLAUDE.md at the repo root: layering rules, definition of
done, testing expectations.
You are read-only on the working tree: make NO edits, NO commits, move
nothing. You may and should run read-only commands (gh, git log, grep).
When you have a verdict, POST it to GitHub:
- approve: gh pr review {branch} --approve --body "<your summary>"
- request changes: gh pr review {branch} --request-changes --body "<your findings>"
Then end your reply with a report whose FIRST line is exactly
PR REVIEW: <APPROVE | REQUEST CHANGES>
followed by your findings in order of importance: what you checked, what is
good, what must change (file:line where possible), and anything a human
reviewer should look at themselves.
+23
View File
@@ -0,0 +1,23 @@
You are reviewing a task on this repository's task board for continued
relevance — you are NOT implementing it.
The task is `{stage}/{filename}`. Its content:
--- TASK ---
{body}
--- END TASK ---
Investigate the actual codebase and answer: is this task still relevant as
written? Specifically:
- Has the work already been done, fully or partly? Point at the code.
- Have the assumptions or references the task rests on changed since it was
written (renamed modules, replaced approaches, merged tasks)?
- Is anything in it now wrong or misleading?
You are read-only: make NO edits, NO commits, move nothing. Read CLAUDE.md
and the code; run read-only commands (grep, git log) as needed.
End with a report whose FIRST line is exactly
RELEVANCE REVIEW: <Still relevant | Partly done | Already done | Needs rewrite>
followed by concise evidence (what you checked, what changed) and a
recommendation: keep as is, update the task (say how), or move to done/drop.
+38
View File
@@ -0,0 +1,38 @@
You are picking up a task from this repository's task board.
You are in an isolated git worktree on branch `{branch}` created for this task.
All your work happens here: commit to this branch, do not push, do not merge,
and do not switch branches.
Read CLAUDE.md at the repo root first and follow it, including its
definition of done — run whatever checks it names until they pass.
The task is `{filename}`. Its content:
--- TASK ---
{body}
--- END TASK ---
Before doing anything else, read the task critically. If it contains open
questions, unresolved decisions, options still being weighed, or explicit
"TBD" / "open question" markers that only the task's author can settle, do
NOT start the work: make no edits and no commits, and end immediately with a
reply whose FIRST line is exactly
NOT READY: <one-line reason>
followed by a bullet list of the specific questions that block the task.
The board treats that marker as "send the task back for refinement". Only
questions that change what should be built count — implementation details
you can decide yourself by reading the codebase and CLAUDE.md do not.
Rules:
- Do NOT move, rename or edit the task file itself — the board manages its
stage and status line from outside this worktree.
- Use the TodoWrite tool to keep a step-by-step plan up to date while you work
(the board displays it live).
- Implement the task, cover new behaviour with tests, and run the relevant
test suites until they pass.
- Commit your work in clear, reviewable commits on this branch.
- Finish with a concise summary: what changed, how it was verified, and
anything a reviewer should look at first.
+75
View File
@@ -0,0 +1,75 @@
"""Shared in-memory state, persistence of event logs, and the SSE fan-out.
All cross-thread registries live here, guarded by LOCK where they are
mutated from several threads. Modules communicate through this state rather
than importing each other's internals.
"""
from __future__ import annotations
import json
import queue
import threading
import time
import config
LOCK = threading.Lock()
CLIENTS: set[queue.Queue] = set() # one queue per open SSE connection
SESSIONS: dict[str, dict] = {} # session_id -> meta
EVENTS: dict[str, list[dict]] = {} # session_id -> slim events
BOARD_EVENTS: list[dict] = [] # moves + agent lifecycle
AGENTS: dict[str, dict] = {} # agent_id -> launch record
EXPECTED_MOVES: dict[tuple[str, str], tuple[str, float]] = {} # (file, to) -> (actor, ts)
# The port actually being served; board.py sets it from --port at startup so
# launched agents know where to report events.
serve_port = config.PORT
# The last card archived through this board — the scope of the ⌘Z undo.
LAST_ARCHIVED: dict | None = None
def broadcast(payload: dict) -> None:
msg = json.dumps(payload)
with LOCK:
clients = list(CLIENTS)
for q in clients:
try:
q.put_nowait(msg)
except queue.Full:
pass
def persist(name: str, record: dict) -> None:
try:
config.SESSIONS_DIR.mkdir(exist_ok=True)
with (config.SESSIONS_DIR / name).open("a", encoding="utf-8") as fh:
fh.write(json.dumps(record) + "\n")
except OSError:
pass
def record_board_event(event: dict) -> None:
event["ts"] = time.time()
with LOCK:
BOARD_EVENTS.append(event)
del BOARD_EVENTS[:-config.BOARD_EVENTS_CAP]
persist("board.jsonl", event)
broadcast({"type": "board_event", "event": event})
def expect_move(filename: str, target: str, actor: str) -> None:
"""Tell the watcher who is about to move a file so it can attribute it."""
with LOCK:
EXPECTED_MOVES[(filename, target)] = (actor, time.time())
def claim_expected(filename: str, target: str) -> str:
with LOCK:
actor_ts = EXPECTED_MOVES.pop((filename, target), None)
# forget stale expectations while we're here
cutoff = time.time() - 30
for key in [k for k, (_, ts) in EXPECTED_MOVES.items() if ts < cutoff]:
EXPECTED_MOVES.pop(key, None)
return actor_ts[0] if actor_ts else "disk"
+170
View File
@@ -0,0 +1,170 @@
"""Reading and moving task files — the only module that touches tasks/.
The directory a task file sits in *is* its status (see ../CLAUDE.md). Nothing
here knows about agents or HTTP; it is the same folder kanban you could drive
by hand with mv.
"""
from __future__ import annotations
import re
import shutil
from pathlib import Path
import config
import state
TITLE_RE = re.compile(r"^#\s+(.+?)\s*$", re.MULTILINE)
STATUS_RE = re.compile(r"^\*\*Status:\*\*\s*(.+?)\s*$", re.MULTILINE)
PRIORITY_RE = re.compile(r"^\*\*Priority:\*\*\s*(.+?)\s*$", re.MULTILINE)
TYPE_RE = re.compile(r"^\*\*Type:\*\*\s*(.+?)\s*$", re.MULTILINE)
PR_RE = re.compile(r"^\*\*PR:\*\*\s*(\S+)\s*$", re.MULTILINE)
PR_VERDICT_RE = re.compile(r"^PR REVIEW:\s*(APPROVE|REQUEST CHANGES)", re.MULTILINE)
NUMBER_RE = re.compile(r"^(\d+)[-_]")
def _first(pattern: re.Pattern[str], text: str) -> str | None:
match = pattern.search(text)
return match.group(1) if match else None
def _split_reason(value: str | None) -> tuple[str | None, str | None]:
"""`High — because …` → (`High`, `because …`)."""
if not value:
return None, None
parts = re.split(r"\s+[—–-]\s+", value, maxsplit=1)
return parts[0].strip(), (parts[1].strip() if len(parts) > 1 else None)
def read_task(path: Path, stage: str) -> dict:
text = path.read_text(encoding="utf-8", errors="replace")
priority, priority_note = _split_reason(_first(PRIORITY_RE, text))
number_match = NUMBER_RE.match(path.name)
declared = _first(STATUS_RE, text)
# the latest appended `PR REVIEW:` marker wins — reviews accumulate
verdicts = PR_VERDICT_RE.findall(text)
return {
"pr": _first(PR_RE, text),
"prVerdict": {"APPROVE": "green", "REQUEST CHANGES": "red"}.get(
verdicts[-1] if verdicts else None),
"file": path.name,
"stage": stage,
"number": number_match.group(1) if number_match else None,
"title": _first(TITLE_RE, text) or path.stem,
"priority": priority,
"priorityNote": priority_note,
"type": _split_reason(_first(TYPE_RE, text))[0],
# Flagged in the UI when the file's own Status line contradicts the
# directory it is in — the board should never quietly paper over that.
"declaredStatus": declared,
"statusMismatch": bool(declared)
and declared.lower() != config.STAGE_LABELS[stage].lower(),
"mtime": path.stat().st_mtime,
"words": len(text.split()),
"body": text,
}
def collect() -> dict:
stages = []
for slug, label in config.STAGES:
directory = config.TASKS / slug
tasks = []
if directory.is_dir():
for path in sorted(directory.glob("*.md")):
tasks.append(read_task(path, slug))
tasks.sort(key=lambda t: (int(t["number"]) if t["number"] else 9999, t["file"]))
stages.append({"slug": slug, "label": label, "tasks": tasks})
extras = {}
for slug in ("plans", "reference"):
directory = config.TM_ROOT / slug
extras[slug] = (
sorted(p.name for p in directory.iterdir() if not p.name.startswith("."))
if directory.is_dir()
else []
)
return {"stages": stages, "extras": extras, "root": str(config.TASKS)}
def find_stage_of(filename: str) -> str | None:
for slug in config.STAGE_DIRS:
if (config.TASKS / slug / filename).is_file():
return slug
return None
ARCHIVE_FROM = {"backlog", "to-do", "done"}
def archive_task(filename: str, source: str) -> dict:
"""Archive: out of the flow but never deleted. tasks/archive/ is not a
stage — archived cards simply leave the board."""
if source not in ARCHIVE_FROM:
raise ValueError("archive takes cards from backlog, to-do or done only")
if Path(filename).name != filename or not filename.endswith(".md"):
raise ValueError("bad filename")
src = config.TASKS / source / filename
dst = config.TASKS / "archive" / filename
if not src.is_file():
raise ValueError(f"{filename} is no longer in {source}/ — refresh the board")
if dst.exists():
raise ValueError(f"{filename} already exists in archive/")
text = src.read_text(encoding="utf-8")
if STATUS_RE.search(text):
text = STATUS_RE.sub("**Status:** Archived", text, count=1)
dst.parent.mkdir(parents=True, exist_ok=True)
src.write_text(text, encoding="utf-8")
shutil.move(str(src), str(dst))
return {"file": filename, "from": source}
def unarchive_task(filename: str, target: str) -> dict:
"""⌘Z: bring the last archived card back where it came from."""
if target not in ARCHIVE_FROM:
raise ValueError("unknown stage to restore into")
src = config.TASKS / "archive" / filename
dst = config.TASKS / target / filename
if not src.is_file():
raise ValueError(f"{filename} is not in archive/")
if dst.exists():
raise ValueError(f"{filename} already exists in {target}/")
text = src.read_text(encoding="utf-8")
if STATUS_RE.search(text):
text = STATUS_RE.sub(f"**Status:** {config.STAGE_LABELS[target]}", text, count=1)
src.write_text(text, encoding="utf-8")
shutil.move(str(src), str(dst))
return {"file": filename, "to": target}
def archived_count() -> int:
directory = config.TASKS / "archive"
return len(list(directory.glob("*.md"))) if directory.is_dir() else 0
def move_task(filename: str, source: str, target: str, actor: str = "you") -> dict:
"""Move a task file between stage directories and fix its Status line."""
if source not in config.STAGE_DIRS or target not in config.STAGE_DIRS:
raise ValueError("unknown stage")
if Path(filename).name != filename or not filename.endswith(".md"):
raise ValueError("bad filename")
src = config.TASKS / source / filename
dst = config.TASKS / target / filename
if not src.is_file():
raise ValueError(f"{filename} is no longer in {source}/ — refresh the board")
if dst.exists():
raise ValueError(f"{filename} already exists in {target}/")
text = src.read_text(encoding="utf-8")
label = config.STAGE_LABELS[target]
if STATUS_RE.search(text):
text = STATUS_RE.sub(f"**Status:** {label}", text, count=1)
else: # no Status line to keep in step — insert one under the title
text = TITLE_RE.sub(lambda m: f"{m.group(0)}\n\n**Status:** {label}", text, count=1)
state.expect_move(filename, target, actor)
dst.parent.mkdir(parents=True, exist_ok=True)
src.write_text(text, encoding="utf-8")
shutil.move(str(src), str(dst))
return read_task(dst, target)
+54
View File
@@ -0,0 +1,54 @@
"""Disk watcher: the directories are the source of truth, so poll and narrate.
Catches moves the HTTP API never saw — a file dragged by hand, an agent, or
another tool — and attributes them via the expectations registered in state.
"""
from __future__ import annotations
import time
import config
import github
import state
def _board_sig() -> dict[str, set[str]]:
sig = {}
for slug in config.STAGE_DIRS:
directory = config.TASKS / slug
sig[slug] = {p.name for p in directory.glob("*.md")} if directory.is_dir() else set()
return sig
def watcher(interval: float | None = None) -> None:
interval = config.WATCH_INTERVAL if interval is None else interval
prev = _board_sig()
while True:
time.sleep(interval)
try:
cur = _board_sig()
except OSError:
continue
if cur == prev:
continue
prev_loc = {f: s for s, files in prev.items() for f in files}
cur_loc = {f: s for s, files in cur.items() for f in files}
for f, stage in sorted(cur_loc.items()):
if f in prev_loc and prev_loc[f] != stage:
actor = state.claim_expected(f, stage)
state.record_board_event({
"kind": "move", "file": f, "from": prev_loc[f], "to": stage,
"actor": actor,
"summary": f"{f} moved {prev_loc[f]} → {stage} ({actor})",
})
if stage == "review":
# a card entering review with a work branch gets a PR
github.open_pr_async(f)
elif f not in prev_loc:
state.record_board_event({
"kind": "new", "file": f, "to": stage, "actor": "disk",
"summary": f"{f} appeared in {stage}/",
})
prev = cur
state.broadcast({"type": "board"})
+5
View File
@@ -0,0 +1,5 @@
# Project-specific workflow notes
This file is yours — updates never touch manager/local/. Put here what an
agent or teammate needs that the core doc cannot know: post-merge chores,
what the driver assumes, what each local command is for.
View File
View File
View File
View File