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
+3
View File
@@ -0,0 +1,3 @@
manager/local/.env
manager/local/state/
__pycache__/
+409
View File
@@ -0,0 +1,409 @@
# Task Workflow
Tasks move through a kanban of directories. The directory a file sits in **is**
its status — there is no other source of truth.
```
.task-manager/
├── CLAUDE.md ← This file (core-owned; replaced by updates)
├── install.py ← Wires the project via the agent adapter (see below)
├── start.sh ← One-command start: install + port handling + board
├── stop.sh ← Safe stop: refuses while agents run (--force overrides)
├── update.sh ← Replace core/ from the distribution repo; local/ survives
├── tasks/ ← Task state, nothing else. Works as a plain folder
│ ├── backlog/…done/ ← kanban even if manager/ is deleted or ignored.
│ └── archive/ ← Archived cards: out of the flow, never deleted
├── plans/ ← Claude Code plan files (via plansDirectory setting)
├── reference/ ← Supporting documents referenced by tasks
└── manager/
├── core/ ← The tool. Replaced WHOLESALE by update.sh — never
│ │ put anything project-specific here.
│ ├── VERSION, board.py, config.py … httpd.py, board.html
│ ├── prompts/ ← Default agent prompt templates
│ ├── adapters/ ← Agent-vendor integrations (claude/ ships; README)
│ └── driver.example/
└── local/ ← This project's half. Updates never touch it.
├── .env ← Settings (gitignored; defaults in core/.env.example)
├── CLAUDE.md ← Project-specific workflow notes — read it too
├── driver/start ← How THIS project's app launches from a worktree
├── commands/ ← Project chores run against a task's worktree
├── prompts/ ← Prompt overrides (same filename beats the default)
├── adapters/ ← Adapter overrides/additions
└── state/ ← Runtime data: sessions, agent logs, drives (gitignored)
```
Three layers, one law: **core knows about tasks, worktrees, PRs and events —
it knows nothing about any particular app, agent vendor, or project.**
Drivers know apps, adapters know vendors, `local/` knows this project.
`tasks/` holds only state and works as a plain folder kanban even if
`manager/` is deleted; the board narrates hand-moves when it happens to run.
Module map for `manager/core/` (dependencies flow strictly left to right):
```
config → state → taskfiles → events / github / drive → agents → watch / httpd → board.py
```
- `config.py` — paths, stages, settings, prompt/adapter/driver resolution
- `state.py` — shared registries, event log persistence, SSE fan-out
- `taskfiles.py` — reading and moving task files; the only code touching tasks/
- `events.py` — ingests NORMALIZED events (the adapter contract), session registry
- `github.py` — PR opening, Copilot requests, review/CI polling
- `drive.py` — runs the project driver, tracks the one live drive
- `agents.py` — headless work/review jobs, launched through the adapter
- `watch.py` — 2s disk poller narrating moves made outside the API
- `httpd.py` — HTTP routes, the SSE stream, serving the page
- `board.py` — argparse + startup wiring only
## Agent adapters
Headless jobs run through an adapter (`BOARD_AGENT_ADAPTER`, default
`claude`), so the manager works with other coding agents too. An adapter is
a directory with `run` (execute one job: `AGENT_PROMPT` + `AGENT_MODE`
work|review in, stdout = the log, markers parsed from it) and `wire`
(idempotently give the host project live-session visibility). Adapters
translate their vendor's events into the board's normalized schema at the
edge — core never sees vendor payloads. The full contract, including the
event schema, lives in `core/adapters/README.md`.
## Drives
The **⛭ drive** chip on a review card launches the app locally *from that
task's worktree*, so you can click around the actual feature before
merging. How an app starts is project knowledge, so it lives in the
project's driver — `local/driver/start`, an executable the board runs and
owns: refuse fast with a printed reason, print `DRIVE URL: <url>` when up,
run until parked (SIGTERM). No driver → the chip says so and the tooltip
explains what to create; `core/driver.example/` documents the contract.
One drive at a time; **park** takes it down.
## The activity bar and the archive
The bottom bar is one place: what happened, and where things go. **Activity**
expands the full event log (filters, the plans/ and reference/ listings, a
resize grip); collapsed, the latest event ticks along the bar. The
**Archive** tray anchors the right end — drag a card from `backlog/`,
`to-do/` or `done/` anywhere onto the bar and it moves to `tasks/archive/`:
out of every column, never deleted, Status set to `Archived`. The toast says
⌘Z brings it back, and it does — nothing in this system removes work
without an undo in the same breath. Cards in the working stages
(in-progress, review) cannot be archived; finish or walk them back first.
## Local commands
Projects grow chores that belong to a specific checkout — applying a
branch's DB migrations, reseeding, rebuilding assets. Those are
**local commands**: executables in `manager/local/commands/`, surfaced as
`$`-glyph chips on cards that have a branch (in progress and review) and
run against that task's worktree (recreated from the branch if needed).
The contract mirrors the driver's: env in (`CMD_WORKTREE`, `CMD_BRANCH`,
`CMD_TASK`, `CMD_REPO`), output to a log under `local/state/commands/`,
and the ticker narrates the ending either way with the log's last line.
A `# help:` line near the top of the script becomes the chip's tooltip.
Commands arm on first click and run on the second.
## Updating
`./.task-manager/update.sh` fetches the distribution repo (`BENCH_SOURCE`
in `local/.env`), replaces `manager/core/` wholesale plus the top-level
scripts, and touches nothing else — tasks, driver, prompt overrides, `.env`
and state all survive. Then re-run `install.py` (idempotent re-wire) and
restart the board.
## Installing into a project
```bash
python3 .task-manager/install.py # idempotent; --dry-run to preview
```
Checks that the containing directory is a `.claude`-initialised project and
delegates to the configured agent adapter's `wire` — for Claude that means
`.claude/settings.json`: `plansDirectory` and the five event hooks running
the adapter's `emit.py`. Fully present → reports "ok" and touches nothing;
partial, stale (old `.tasks/` paths) or duplicated → repaired in place. Other
hooks and settings are never touched, so it is safe to run any time — e.g.
after dropping `.task-manager/` into a new repo.
## Seeing the board
```bash
./.task-manager/start.sh # the usual way: install + port + board
python3 .task-manager/manager/board.py # or run the server directly
```
`start.sh` runs `install.py` (idempotent), then sorts out the port before
serving in the foreground (Ctrl-C stops it). Three cases:
- this project's board already answers on the port → just reopens the browser
- the port is free → starts on it
- something else occupies it → takes the next free port **and persists it to
`manager/.env`**, so the hooks and agents — which read the same file —
follow the board rather than reporting to a port it no longer serves.
Extra arguments pass through to `board.py` (e.g. `./start.sh --no-open`).
`stop.sh` is the counterpart. It identifies the board by asking the port's
API for its tasks root, so it never kills a foreign process squatting there.
While agents are running it refuses — stopping the board loses their endings
(auto-move, PR opening, decline handling) even though the agent processes
themselves survive — and names who is working on what; `--force` overrides.
Port **26071 is pinned** so the URL is always the same one to bookmark. Running
the command again while it is already up just reopens that tab rather than
failing on a port clash.
All settings live in `manager/core/.env.example` with their defaults documented —
the port, the claude binary agents launch with, the agent permission mode,
the worktrees directory, the watch interval and the in-memory caps. Copy it
to `manager/local/.env` (gitignored) to override locally; real environment
variables beat `.env`, which beats the defaults. The hook bridge reads the
same `.env`, so changing `BOARD_PORT` moves the board, the agents and the
hooks together.
Stdlib only, no install. It reads the directories on every request, so refreshing
the page shows current disk state. Dragging a card between columns does both
steps of a move for you — it renames the file and rewrites its **Status:** line.
Cards whose Status line disagrees with the directory they sit in are flagged
`status drift`.
## Live view
The UI follows the **Bench** design system — cool sea neutrals, IBM Plex Sans
for anything a person wrote and Plex Mono for anything a machine produced,
and colour that only ever means state: `--accent` (surf) an agent alive,
`--calm` (pine) settled or passed, `--alarm` (terracotta) blocked, failed or
HIGH, `--idle` (driftwood) done. The one looping animation ("breathe") means
an agent is working; a blinking caret means output is still arriving. Night
theme by default; the header button switches to Daylight. Tokens live at the
top of `manager/board.html`.
The board has three views (header switcher):
- **Board** — the kanban, live. Active Claude Code sessions appear as chips in
the header; a card an agent is working on carries a live activity line; the
bottom ticker narrates the latest events and every move is attributed
(`you` / `agent` / `disk`).
- **Sessions** — a flight recorder per session: a chronological timeline of
reads, edits, test runs, commits and card moves, with filters and expandable
output. Sessions persist to `.sessions/*.jsonl`, so past ones can be replayed.
- **Focus** — a heads-up display for one session: the task it holds, its live
TodoWrite plan, the definition-of-done checks (pytest / lint-imports /
frontend), and per-file diff stats from its worktree.
Liveness comes from Claude Code hooks configured in `.claude/settings.json`:
every session in this repo POSTs normalized events to the board via the
claude adapter's `emit.py` (fails silently in under a second when the board isn't
running). A watcher thread also polls the stage directories every 2s, so moves
made by hand still show up. The browser gets everything over SSE — no refresh
needed. Hooks are snapshotted at session start, so a session already open when
the hooks were added won't report until restarted.
Agent prompts ship in `manager/core/prompts/` and can be overridden per
project by placing a file of the same name in `manager/local/prompts/`
(the override wins). They are plain
markdown with `{branch}` / `{stage}` / `{filename}` / `{body}` placeholders,
filled via `str.format` — so literal braces elsewhere in a prompt would break
it. They are read fresh on every agent launch; edits apply without restarting
the board.
## Agents working the board
Card actions appear on hover, taking over the status pill's slot (never
stacking on top of it) — at most two per state, only things you'd actually
do without opening the card: **▸ start work** on in-progress cards,
**‖ hold** while an agent runs, **↩ back** on cards waiting on you,
**↺ reopen** on done cards, and **◔ still true?** everywhere. Actions that
cost tokens or stop work arm on first click and fire on the second.
Each launched agent wears a short name for its lifetime (Wren, Juno,
Basil, …) — picked per launch, never shared by two running agents, shown as
`Wren · #09` on cards, in the sessions list and throughout the ticker. Names
are held in memory, so a restarted board falls back to plain "Agent" for
sessions that predate it.
**▸ start work** launches a headless `claude -p` on the task. It exists only
on `in-progress/` cards: moving a card to in-progress is the commitment, and
only then does work start — the server refuses launches from anywhere else.
1. The board creates a git worktree at `.worktrees/<task-stem>/` on a new
branch `task/<task-stem>` from current HEAD. (The agent is told not to
touch the task file — worktree moves would be invisible to the main
checkout anyway.)
2. The agent works in the worktree: implements, tests, commits. Its hook
events stream to the board like any session.
3. On clean exit the board moves the card to `review/`; on failure it stays
in `in-progress/` and the exit is narrated in the ticker. Stdout is kept in
`.agent/logs/`.
## Pull requests
A card entering `review/` with a `task/<stem>` branch gets a PR opened for
it automatically — mechanically, by the board, not by an agent: it pushes
the branch to the repo's remote and runs `gh pr create` with the task title
and the agent's closing summary as the body. The PR url is written into the
task file as a `**PR:** <url>` line, so the file stays the source of truth
and the card grows a `PR ↗` chip. Cards without a branch pass through
quietly. One guard is loud: if local `main` is ahead of the remote, the PR
would drag those commits into its diff, so the board refuses and tells you
to push main first (then move the card out and back, or wait for the next
entry into review/).
Review-stage cards with a PR carry two actions:
- **◔ review PR** — a read-only agent reads the full diff in context,
checks it against the task and CLAUDE.md, posts its verdict to GitHub
(`gh pr review --approve` / `--request-changes`) and appends a
`## PR review` section to the task file ending in
`PR REVIEW: APPROVE | REQUEST CHANGES`.
- **⚑ copilot** — requests a GitHub Copilot review via the API. Works iff
Copilot code review is enabled for the repo; the error is relayed to the
toast if not. The card tracks the whole arc with a `⚑` chip: `⚑ ◌` asked,
`⚑ ✓` approved, `⚑ ✕` changes requested, `⚑ ·` commented — "asked"
becomes a verdict when the pending request turns into an authored review.
Once any review is in (a verdict from either agent kind, Copilot, or a
human), the card's actions shift to the loop that matters then:
**↻ act on PR** replaces the copilot button — an agent re-enters the task's
worktree (recreated from the branch if it was cleaned up), reads every
review and line comment, addresses each point or says why not, commits,
pushes so the PR updates, and appends a `## PR update` section to the task
file. Then **◔ review PR** again, until it settles.
The board polls open PRs of review-stage cards (reviews + CI checks, every
60s — a plain thread in board.py, no agent involved, silent when review/ is
empty) and folds everything into one verdict — any changes-requested review
or failing check wins over any approval. Tool chips (CI, copilot, PR, drive)
are destinations, not statuses: they live in the card's footer row, never
squeezed into the author row — `CI ✓` (pine), `CI ✕` (terracotta), `◌`
while in flight, with hover actions staying in the status pill's slot. The card wears it in the design
system's state colours: approved → pine (`--calm`) border and an
`approved` pill; changes asked → terracotta (`--alarm`) and a
`changes asked` pill; otherwise it stays the neutral `waiting on you`.
Merging remains yours — the board never merges.
The agent's first duty is to judge whether the task is actionable. If the
task still has open questions — unresolved decisions only its author can
settle — the agent does no work and exits with a `NOT READY: <reason>`
marker. The board then moves the card back (to `to-do/`, or `backlog/` if it
started there), records the reason in the ticker, and deletes the untouched
worktree and branch so the task can be refined and relaunched cleanly.
**◔ still true?** (every stage, done included) fires a read-only relevance
agent instead: no worktree, edit tools disallowed, running in the main
checkout. It checks the task against the actual codebase — already done?
assumptions stale? still worth doing as written? — and its report is
appended to the task file under a `## Relevance review — <date>` heading,
with the verdict (`Still relevant | Partly done | Already done | Needs
rewrite`) in the ticker. The card does not move; deciding what to do with
the verdict is yours. One agent per task at a time applies across both
kinds.
Dragging a card with work attached (branch or PR) to `done/` opens a
three-way choice instead of just moving: **keep it where it is** (nothing
changes), **just move the card** (branch, PR and worktree stay), or
**merge & clean up** — park the drive if it is this task's, merge the
branch into main, push (which marks the PR merged) and delete the remote
branch, remove the worktree and local branch, then move the card. Every
step narrates in the ticker; a merge conflict aborts cleanly and the card
stays put. Cards without work move silently, and hand-moves on disk are
never intercepted — the board only asks when you act through it.
One agent per task at a time; a work agent's worktree must not already
exist when starting.
The flow is linear:
```
backlog → to-do → in-progress → review → done
```
## Stages
### backlog/
Where new tasks are written and where they wait. A backlog task may be rough,
incomplete, or fully specified — what it has in common with its neighbours is
that nobody is working on it. Most tasks live here for most of their life.
### to-do/
Picked up and queued to work on next. Moving a task from `backlog/` to `to-do/`
is a commitment to do it soon, so keep this directory short — a long `to-do/` is
just a second backlog.
### in-progress/
Actively being worked on right now. Anything here should have someone (or an
agent session) attached to it. If work stalls, move it back to `to-do/` or
`backlog/` rather than leaving it parked — a stale `in-progress/` makes the board
lie about what is happening.
Implementation plans (created via Claude Code's plan mode) are stored in `plans/`
and can be referenced from the task file.
### review/
The work is built and awaits judgment: tests written and passing, a PR open
(see "Pull requests"), behaviour checked in the running app, edge cases
probed. A task sitting here has code but not yet confidence. If review turns
up problems, move it back to `in-progress/`.
### done/
Finished and merged. Completed task files are kept as a record of what was built
and why — they are the closest thing we have to design history, so don't delete
or trim them.
### reference/ (beside tasks/, not a stage)
Supporting documents that tasks can link to — external specs, API documentation,
research notes, screenshots, competitive analysis, regulatory references, etc.
These don't move through the workflow; they're stable resources. Reference them
from task files using relative links — two levels up from a stage directory
(e.g. `[IVASS spec](../../reference/ivass-document-requirements.md)`).
## Moving a task
When moving a task between stages:
1. Update the **Status** field in the task file header
2. Move the file to the new directory
3. Add any notes about why it's moving (e.g. "approach agreed, starting build")
Moves are not always forward. Going back a stage is normal and expected —
verification failing, or an approach not surviving contact with the code, should
move the task backwards rather than being worked around in place.
## Task file format
Each task is a markdown file with a descriptive filename
(e.g. `01-document-handling-review.md`). Numbers are allocated in creation order
and stay with the file for life — they do not renumber when a task moves stage.
Start new tasks from `tasks/task-template.md` — copy it into `backlog/` and
fill it in. Its sections earn their keep: Context and What-to-build are what
a work agent gets as its brief, Acceptance is what reviews judge against,
and a non-empty Open-questions section makes an agent refuse the task
(`NOT READY`) rather than guess. The template itself is never listed on the
board (only stage directories are read).
The file should have at minimum:
```markdown
# Task title
**Status:** Backlog | To Do | In Progress | Review | Done
**Priority:** High | Medium | Low
```
Use those exact status values — nothing else (not "Not started", "WIP", etc.) —
and keep the status in step with the directory the file sits in. Priority may
carry a short justification after the level
(e.g. `Medium — foundational for any real environment`).
An optional **Type** line can record what kind of work the task is, when that
isn't obvious from the title:
```markdown
**Type:** Discovery | Bug | Feature | Refactor | Chore
```
Type is orthogonal to status. A discovery task — research, scoping, spiking an
approach — moves through the same five stages as everything else; "discovery"
describes the work, not where it sits on the board.
The rest of the file is freeform — description, research findings, approach,
open questions, whatever is relevant to the current stage.
+38
View File
@@ -0,0 +1,38 @@
# Bench
A live kanban for coding-agent work: task files in stage directories are
the only source of truth; a stdlib-only board narrates everything that
happens to them — agents working in git worktrees, PRs opening on review,
CI and Copilot state on the cards, drives of the app from a task's own
branch, and an archive that is never a delete.
## Install into a repo
```bash
git clone <this repo> .task-manager && rm -rf .task-manager/.git
./.task-manager/start.sh # wires the project (idempotent) and serves
```
Commit `.task-manager/` into the host repo — core is vendored on purpose,
so clones work offline and updates show up in the host's own diffs.
## Update
```bash
# in manager/local/.env: BENCH_SOURCE=<this repo's git url>
./.task-manager/update.sh # latest
BENCH_REF=v1 ./.task-manager/update.sh # a specific release tag
```
Updates replace `manager/core/` and the top-level scripts wholesale and
touch nothing else — tasks, plans, reference, and everything under
`manager/local/` (your driver, commands, prompt overrides, settings,
state) survive every update. Then `python3 .task-manager/install.py` and
restart the board.
## The three-layer law
Core knows about tasks, worktrees, PRs and events. It knows nothing about
any particular app (drivers do: `local/driver/start`), agent vendor
(adapters do: `core/adapters/`), or project (`local/` does). Full docs in
CLAUDE.md; the adapter contract in `manager/core/adapters/README.md`.
+52
View File
@@ -0,0 +1,52 @@
#!/usr/bin/env python3
"""Wire the task manager into the project it sits in.
python3 .task-manager/install.py # apply (idempotent)
python3 .task-manager/install.py --dry-run # report only, change nothing
Vendor-specific wiring belongs to the configured agent adapter: this script
resolves the adapter (BOARD_AGENT_ADAPTER in manager/local/.env, default
"claude"; local/adapters/ overrides core/adapters/) and runs its `wire`
executable against the project root. Safe to run any time — after dropping
.task-manager/ into a new repo, and after every update.sh.
"""
from __future__ import annotations
import os
import subprocess
import sys
from pathlib import Path
TM = Path(__file__).resolve().parent
PROJECT = TM.parent
LOCAL = TM / "manager" / "local"
CORE = TM / "manager" / "core"
def adapter_name() -> str:
if os.environ.get("BOARD_AGENT_ADAPTER"):
return os.environ["BOARD_AGENT_ADAPTER"]
env_file = LOCAL / ".env"
if env_file.is_file():
for line in env_file.read_text(encoding="utf-8").splitlines():
key, _, value = line.strip().partition("=")
if key.strip() == "BOARD_AGENT_ADAPTER" and value.strip():
return value.strip().strip("'\"")
return "claude"
def main() -> int:
name = adapter_name()
for base in (LOCAL / "adapters", CORE / "adapters"):
wire = base / name / "wire"
if wire.is_file():
return subprocess.call(
[sys.executable, str(wire), str(PROJECT), *sys.argv[1:]])
print(f"agent adapter '{name}' has no wire script — looked in "
f"{LOCAL / 'adapters' / name} and {CORE / 'adapters' / name}.")
return 1
if __name__ == "__main__":
sys.exit(main())
+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 1000030000 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
View File
View File
Executable
+90
View File
@@ -0,0 +1,90 @@
#!/usr/bin/env bash
# Start the task manager: wire the project, sort out the port, serve the board.
#
# ./.task-manager/start.sh # foreground; Ctrl-C stops the board
# ./.task-manager/start.sh --no-open # extra args pass through to board.py
#
# Port logic (BOARD_PORT from env, else manager/.env, else 26071):
# - our own board already answering there -> just open the browser
# - port free -> start on it
# - something else squatting on it -> take the next free port AND
# persist it to manager/.env, so the hooks and agents (which read the same
# file) follow the board to its new port instead of reporting into the void.
set -euo pipefail
TM="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
MANAGER="$TM/manager"
CORE="$MANAGER/core"
ENV_FILE="$MANAGER/local/.env"
port="${BOARD_PORT:-}"
if [ -z "$port" ] && [ -f "$ENV_FILE" ]; then
port="$(sed -n 's/^[[:space:]]*BOARD_PORT[[:space:]]*=[[:space:]]*//p' "$ENV_FILE" | tail -1 | tr -d "'\"")"
fi
port="${port:-26071}"
# Idempotent project wiring; a project without .claude/ still gets the board.
python3 "$TM/install.py" || true
echo
is_free() {
python3 - "$1" <<'PY'
import socket, sys
s = socket.socket()
try:
s.bind(("127.0.0.1", int(sys.argv[1])))
except OSError:
sys.exit(1)
finally:
s.close()
PY
}
is_our_board() {
python3 -c '
import json, sys, urllib.request
try:
with urllib.request.urlopen("http://127.0.0.1:%s/api/state" % sys.argv[1], timeout=2) as r:
data = json.load(r)
except Exception:
sys.exit(1)
sys.exit(0 if data.get("board", {}).get("root") == sys.argv[2] else 1)
' "$1" "$TM/tasks"
}
if is_our_board "$port"; then
echo "Board already running at http://127.0.0.1:$port/ — opening it."
python3 -m webbrowser -t "http://127.0.0.1:$port/" >/dev/null
exit 0
fi
if ! is_free "$port"; then
original=$port
for offset in $(seq 1 20); do
candidate=$((original + offset))
if is_free "$candidate"; then port=$candidate; break; fi
done
if [ "$port" = "$original" ]; then
echo "error: ports $original-$((original + 20)) all busy — set BOARD_PORT yourself." >&2
exit 1
fi
echo "Port $original is taken by something else — using $port instead."
echo "Persisting BOARD_PORT=$port to manager/.env so hooks and agents follow."
python3 - "$ENV_FILE" "$port" <<'PY'
import pathlib, sys
path, port = pathlib.Path(sys.argv[1]), sys.argv[2]
lines = path.read_text(encoding="utf-8").splitlines() if path.exists() else []
out, replaced = [], False
for line in lines:
if line.strip().startswith("BOARD_PORT"):
out.append(f"BOARD_PORT={port}")
replaced = True
else:
out.append(line)
if not replaced:
out.append(f"BOARD_PORT={port}")
path.write_text("\n".join(out) + "\n", encoding="utf-8")
PY
fi
exec python3 "$CORE/board.py" --port "$port" "$@"
Executable
+73
View File
@@ -0,0 +1,73 @@
#!/usr/bin/env bash
# Stop the task manager board.
#
# ./.task-manager/stop.sh # refuses while agents are running
# ./.task-manager/stop.sh --force # stop anyway (agents keep running,
# # but the board loses their endings:
# # no auto-move, no PR, no decline)
#
# Only ever stops OUR board: the process is identified by asking the port's
# /api/state for its tasks root — a foreign process on the port is left alone.
set -euo pipefail
TM="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
ENV_FILE="$TM/manager/local/.env"
FORCE="${1:-}"
port="${BOARD_PORT:-}"
if [ -z "$port" ] && [ -f "$ENV_FILE" ]; then
port="$(sed -n 's/^[[:space:]]*BOARD_PORT[[:space:]]*=[[:space:]]*//p' "$ENV_FILE" | tail -1 | tr -d "'\"")"
fi
port="${port:-26071}"
# One probe answers both questions: is this our board, and are agents running?
probe="$(python3 -c '
import json, sys, urllib.request
import urllib.error
try:
with urllib.request.urlopen("http://127.0.0.1:%s/api/state" % sys.argv[1], timeout=2) as r:
data = json.load(r)
except urllib.error.HTTPError:
print("foreign"); sys.exit(0) # something HTTP answered, but not our API
except Exception:
print("none"); sys.exit(0)
if data.get("board", {}).get("root") != sys.argv[2]:
print("foreign"); sys.exit(0)
running = [a for a in data.get("agents", []) if a.get("status") == "running"]
print("ours " + ",".join(
"%s on %s" % (a.get("name") or "an agent", a.get("task", "?")) for a in running))
' "$port" "$TM/tasks")"
case "$probe" in
none)
if lsof -ti tcp:"$port" >/dev/null 2>&1; then
echo "Port $port is occupied by something that isn't this project's board — leaving it alone." >&2
exit 1
fi
echo "Nothing answering on port $port — the board is not running."
exit 0;;
foreign)
echo "Port $port is serving something that is not this project's board — leaving it alone." >&2
exit 1;;
esac
agents="${probe#ours }"
if [ -n "$agents" ] && [ "$FORCE" != "--force" ]; then
echo "Agents are still working: $agents"
echo "Stopping now would lose their endings (auto-move, PR, decline handling)."
echo "Wait for them, hold them from the board, or run: ./.task-manager/stop.sh --force"
exit 1
fi
pids="$(lsof -ti tcp:"$port" 2>/dev/null || true)"
if [ -z "$pids" ]; then
echo "Board answered but no local process found on port $port — nothing to do."
exit 1
fi
kill $pids
for _ in 1 2 3 4 5 6 7 8 9 10; do
sleep 0.3
lsof -ti tcp:"$port" >/dev/null 2>&1 || { echo "Board stopped."; exit 0; }
done
echo "Still up after SIGTERM — you may need: kill -9 $pids" >&2
exit 1
View File
View File
View File
View File
View File
+53
View File
@@ -0,0 +1,53 @@
<!--
Copy this file into backlog/ as NN-short-kebab-title.md — numbers are
allocated in creation order and stay with the task for life. The board never
lists this template (it only reads the stage directories). The **PR:** line
is added by the board itself when the task reaches review/ with a branch.
-->
# NN — Imperative title: what changes when this is done
**Status:** Backlog
**Priority:** Medium — one clause on why it sits at this level
**Type:** Feature
One paragraph for someone — human or agent — who has the codebase but not
the conversation: what this task changes, and why it is worth doing.
## Context
What exists today and why it falls short. Point at real places rather than
describing from memory: packages and modules (`packages/domain/...`), prior
tasks (`../done/...`), reference documents (`../../reference/...`).
## What to build
The work itself, concrete enough to start on. Name the layers things belong
in — the repo CLAUDE.md's dependency rules decide where code goes, not
convenience.
- First piece
- Second piece
## Acceptance
Observable outcomes, not implementation steps. The repo's definition of done
(tests pass, `lint-imports` clean, new behaviour covered) applies on top.
- [ ] Something a reviewer can check without reading the diff
- [ ] Another one
## Open questions
Decisions only the task's author can settle. This section is load-bearing:
an agent told to start work while anything real sits here will refuse with
`NOT READY` and send the card back — that is the point. Empty it (or delete
it) when the task is ready to action.
- None.
## Notes
Freeform: research findings, links, decisions taken along the way. The
board's relevance checks and PR reviews append their reports below this
line as the task moves.
View File
Executable
+49
View File
@@ -0,0 +1,49 @@
#!/usr/bin/env bash
# Update the task manager from its distribution repo.
#
# ./.task-manager/update.sh
#
# Replaces manager/core/ WHOLESALE plus the top-level core-owned files
# (CLAUDE.md, install.py, start.sh, stop.sh, update.sh). Never touches
# tasks/, plans/, reference/, or manager/local/ — your project's tasks,
# driver, adapters, prompt overrides, .env and state survive every update.
#
# Source repo: BENCH_SOURCE in manager/local/.env (a git URL).
set -euo pipefail
TM="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
ENV_FILE="$TM/manager/local/.env"
src="${BENCH_SOURCE:-}"
if [ -z "$src" ] && [ -f "$ENV_FILE" ]; then
src="$(sed -n 's/^[[:space:]]*BENCH_SOURCE[[:space:]]*=[[:space:]]*//p' "$ENV_FILE" | tail -1 | tr -d "'\"")"
fi
if [ -z "$src" ]; then
echo "No source repo configured — set BENCH_SOURCE=<git url> in manager/local/.env" >&2
exit 1
fi
# A specific release: BENCH_REF=v3 ./update.sh (any tag or branch; default = latest main)
ref="${BENCH_REF:-}"
before="$(cat "$TM/manager/core/VERSION" 2>/dev/null || echo '?')"
tmp="$(mktemp -d)"
trap 'rm -rf "$tmp"' EXIT
echo "Fetching $src ${ref:+(ref $ref) }"
git clone --quiet --depth 1 ${ref:+--branch "$ref"} "$src" "$tmp/dist"
if [ ! -d "$tmp/dist/manager/core" ]; then
echo "That repo does not look like a task-manager distribution (no manager/core/)." >&2
exit 1
fi
rsync -a --delete "$tmp/dist/manager/core/" "$TM/manager/core/"
for f in CLAUDE.md README.md install.py start.sh stop.sh update.sh; do
[ -f "$tmp/dist/$f" ] && cp "$tmp/dist/$f" "$TM/$f"
done
chmod +x "$TM"/start.sh "$TM"/stop.sh "$TM"/update.sh 2>/dev/null || true
find "$TM/manager/core/adapters" -name run -o -name wire | xargs chmod +x 2>/dev/null || true
after="$(cat "$TM/manager/core/VERSION" 2>/dev/null || echo '?')"
echo "Updated core: version $before$after."
echo "Now run: python3 $TM/install.py (re-wires the project; idempotent)"
echo "Then restart the board: $TM/stop.sh && $TM/start.sh"