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:
@@ -0,0 +1,3 @@
|
||||
manager/local/.env
|
||||
manager/local/state/
|
||||
__pycache__/
|
||||
@@ -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.
|
||||
@@ -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
@@ -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())
|
||||
@@ -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=
|
||||
@@ -0,0 +1 @@
|
||||
1
|
||||
@@ -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.
|
||||
Executable
+169
@@ -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]}],
|
||||
}}))
|
||||
Executable
+26
@@ -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
|
||||
Executable
+157
@@ -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())
|
||||
@@ -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
@@ -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()
|
||||
@@ -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"})
|
||||
@@ -0,0 +1,145 @@
|
||||
"""Paths, stages and launch configuration for the task manager.
|
||||
|
||||
Core knows about tasks, worktrees, PRs and events. It knows nothing about
|
||||
any particular app (drivers do), agent vendor (adapters do), or project
|
||||
(local/ does). Everything the other modules need to know about *where
|
||||
things are* lives here. No state, no behaviour.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
CORE = Path(__file__).resolve().parent # manager/core — replaceable
|
||||
MANAGER = CORE.parent # manager
|
||||
LOCAL = MANAGER / "local" # project-owned, never replaced
|
||||
TM_ROOT = MANAGER.parent # .task-manager
|
||||
TASKS = TM_ROOT / "tasks" # stage directories only
|
||||
|
||||
STATE = LOCAL / "state" # runtime data (gitignored)
|
||||
SESSIONS_DIR = STATE / "sessions" # per-session event logs, JSONL
|
||||
AGENT_DIR = STATE / "agent" # headless-agent stdout logs
|
||||
DRIVES_DIR = STATE / "drives" # driver stdout logs
|
||||
|
||||
# Ordered — this is the column order on the board.
|
||||
STAGES = [
|
||||
("backlog", "Backlog"),
|
||||
("to-do", "To Do"),
|
||||
("in-progress", "In Progress"),
|
||||
("review", "Review"),
|
||||
("done", "Done"),
|
||||
]
|
||||
STAGE_DIRS = {slug for slug, _ in STAGES}
|
||||
STAGE_LABELS = dict(STAGES)
|
||||
|
||||
|
||||
def _repo_root() -> Path:
|
||||
try:
|
||||
out = subprocess.check_output(
|
||||
["git", "-C", str(MANAGER), "rev-parse", "--show-toplevel"],
|
||||
text=True, stderr=subprocess.DEVNULL,
|
||||
).strip()
|
||||
return Path(out)
|
||||
except (subprocess.CalledProcessError, OSError):
|
||||
return TM_ROOT.parent
|
||||
|
||||
|
||||
REPO = _repo_root()
|
||||
|
||||
|
||||
def _load_env() -> dict[str, str]:
|
||||
"""local/.env, overridden by the process environment. Stdlib-only
|
||||
parser: KEY=VALUE lines, # comments, optional quotes around the value."""
|
||||
values: dict[str, str] = {}
|
||||
path = LOCAL / ".env"
|
||||
if path.is_file():
|
||||
for line in path.read_text(encoding="utf-8").splitlines():
|
||||
line = line.strip()
|
||||
if not line or line.startswith("#") or "=" not in line:
|
||||
continue
|
||||
key, _, value = line.partition("=")
|
||||
values[key.strip()] = value.strip().strip("'\"")
|
||||
values.update(os.environ)
|
||||
return values
|
||||
|
||||
|
||||
_ENV = _load_env()
|
||||
|
||||
|
||||
def setting(key: str, default: str) -> str:
|
||||
return _ENV.get(key, default)
|
||||
|
||||
|
||||
def child_env() -> dict[str, str]:
|
||||
"""Environment for adapter/driver child processes: the real environment
|
||||
with local/.env settings folded in (process env still wins), so
|
||||
BOARD_* settings reach the scripts that read them directly."""
|
||||
return dict(_ENV)
|
||||
|
||||
|
||||
# Pinned so the board is always at the same bookmarkable URL. Sits in the
|
||||
# ephemeral-safe 10000–30000 range, clear of the other local dev servers.
|
||||
PORT = int(setting("BOARD_PORT", "26071"))
|
||||
# One isolated checkout per running work agent, relative to the repo root.
|
||||
WORKTREES = REPO / setting("BOARD_WORKTREES", ".worktrees")
|
||||
|
||||
# Which agent adapter runs headless jobs. Resolution ladder: local wins.
|
||||
ADAPTER = setting("BOARD_AGENT_ADAPTER", "claude")
|
||||
|
||||
# GitHub plumbing: the gh CLI (stub-able for tests) and the git remote PRs
|
||||
# go to. Empty remote = auto-detect the first remote; no remote = no PRs.
|
||||
GH_BIN = setting("BOARD_GH_BIN", "gh")
|
||||
GIT_REMOTE = setting("BOARD_GIT_REMOTE", "")
|
||||
PR_POLL_INTERVAL = float(setting("BOARD_PR_POLL_INTERVAL", "60"))
|
||||
|
||||
WATCH_INTERVAL = float(setting("BOARD_WATCH_INTERVAL", "2"))
|
||||
EVENTS_CAP = int(setting("BOARD_EVENTS_CAP", "800"))
|
||||
BOARD_EVENTS_CAP = int(setting("BOARD_HISTORY_CAP", "300"))
|
||||
|
||||
|
||||
def prompt(name: str) -> str:
|
||||
"""Prompt templates: core ships defaults, local/prompts/ overrides win.
|
||||
Read fresh on every launch so edits apply without a restart."""
|
||||
override = LOCAL / "prompts" / name
|
||||
if override.is_file():
|
||||
return override.read_text(encoding="utf-8")
|
||||
return (CORE / "prompts" / name).read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def adapter_dir() -> Path | None:
|
||||
"""The configured agent adapter's directory — local overrides core."""
|
||||
for base in (LOCAL / "adapters", CORE / "adapters"):
|
||||
candidate = base / ADAPTER
|
||||
if (candidate / "run").is_file():
|
||||
return candidate
|
||||
return None
|
||||
|
||||
|
||||
def driver_path() -> Path | None:
|
||||
"""The project's app driver, if it has one."""
|
||||
candidate = LOCAL / "driver" / "start"
|
||||
return candidate if candidate.is_file() else None
|
||||
|
||||
|
||||
def commands() -> list[dict]:
|
||||
"""Project-owned commands: executables in local/commands/, surfaced as
|
||||
chips on cards and run against the task's worktree. A `# help:` line
|
||||
near the top becomes the tooltip."""
|
||||
directory = LOCAL / "commands"
|
||||
found = []
|
||||
if directory.is_dir():
|
||||
for path in sorted(directory.iterdir()):
|
||||
if not path.is_file() or path.name.startswith(".") or not os.access(path, os.X_OK):
|
||||
continue
|
||||
help_text = ""
|
||||
try:
|
||||
for line in path.read_text(encoding="utf-8").splitlines()[:8]:
|
||||
if line.startswith("# help:"):
|
||||
help_text = line[len("# help:"):].strip()
|
||||
break
|
||||
except OSError:
|
||||
pass
|
||||
found.append({"name": path.name, "help": help_text})
|
||||
return found
|
||||
@@ -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()
|
||||
Executable
+22
@@ -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
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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)})
|
||||
@@ -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).
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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"
|
||||
@@ -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)
|
||||
@@ -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"})
|
||||
@@ -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.
|
||||
@@ -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" "$@"
|
||||
@@ -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
|
||||
@@ -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.
|
||||
@@ -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"
|
||||
Reference in New Issue
Block a user