From 29cbb11eb50c686dfd4b02a67fdf08b64e14d7b2 Mon Sep 17 00:00:00 2001 From: istos Date: Wed, 29 Jul 2026 17:57:07 +0200 Subject: [PATCH] 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 --- .gitignore | 3 + CLAUDE.md | 409 ++++ README.md | 38 + install.py | 52 + manager/core/.env.example | 38 + manager/core/VERSION | 1 + manager/core/adapters/README.md | 57 + manager/core/adapters/claude/emit.py | 169 ++ manager/core/adapters/claude/hook_settings.py | 17 + manager/core/adapters/claude/run | 26 + manager/core/adapters/claude/wire | 157 ++ manager/core/agents.py | 523 +++++ manager/core/board.html | 1746 +++++++++++++++++ manager/core/board.py | 77 + manager/core/commands.py | 107 + manager/core/config.py | 145 ++ manager/core/drive.py | 218 ++ manager/core/driver.example/start | 22 + manager/core/events.py | 142 ++ manager/core/github.py | 382 ++++ manager/core/httpd.py | 214 ++ manager/core/prompts/act-pr.md | 26 + manager/core/prompts/review-pr.md | 31 + manager/core/prompts/review.md | 23 + manager/core/prompts/work.md | 38 + manager/core/state.py | 75 + manager/core/taskfiles.py | 170 ++ manager/core/watch.py | 54 + manager/local/CLAUDE.md | 5 + manager/local/adapters/.gitkeep | 0 manager/local/commands/.gitkeep | 0 manager/local/driver/.gitkeep | 0 manager/local/prompts/.gitkeep | 0 plans/.gitkeep | 0 reference/.gitkeep | 0 start.sh | 90 + stop.sh | 73 + tasks/archive/.gitkeep | 0 tasks/backlog/.gitkeep | 0 tasks/done/.gitkeep | 0 tasks/in-progress/.gitkeep | 0 tasks/review/.gitkeep | 0 tasks/task-template.md | 53 + tasks/to-do/.gitkeep | 0 update.sh | 49 + 45 files changed, 5230 insertions(+) create mode 100644 .gitignore create mode 100644 CLAUDE.md create mode 100644 README.md create mode 100644 install.py create mode 100644 manager/core/.env.example create mode 100644 manager/core/VERSION create mode 100644 manager/core/adapters/README.md create mode 100755 manager/core/adapters/claude/emit.py create mode 100644 manager/core/adapters/claude/hook_settings.py create mode 100755 manager/core/adapters/claude/run create mode 100755 manager/core/adapters/claude/wire create mode 100644 manager/core/agents.py create mode 100644 manager/core/board.html create mode 100644 manager/core/board.py create mode 100644 manager/core/commands.py create mode 100644 manager/core/config.py create mode 100644 manager/core/drive.py create mode 100755 manager/core/driver.example/start create mode 100644 manager/core/events.py create mode 100644 manager/core/github.py create mode 100644 manager/core/httpd.py create mode 100644 manager/core/prompts/act-pr.md create mode 100644 manager/core/prompts/review-pr.md create mode 100644 manager/core/prompts/review.md create mode 100644 manager/core/prompts/work.md create mode 100644 manager/core/state.py create mode 100644 manager/core/taskfiles.py create mode 100644 manager/core/watch.py create mode 100644 manager/local/CLAUDE.md create mode 100644 manager/local/adapters/.gitkeep create mode 100644 manager/local/commands/.gitkeep create mode 100644 manager/local/driver/.gitkeep create mode 100644 manager/local/prompts/.gitkeep create mode 100644 plans/.gitkeep create mode 100644 reference/.gitkeep create mode 100755 start.sh create mode 100755 stop.sh create mode 100644 tasks/archive/.gitkeep create mode 100644 tasks/backlog/.gitkeep create mode 100644 tasks/done/.gitkeep create mode 100644 tasks/in-progress/.gitkeep create mode 100644 tasks/review/.gitkeep create mode 100644 tasks/task-template.md create mode 100644 tasks/to-do/.gitkeep create mode 100755 update.sh diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..bef2289 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +manager/local/.env +manager/local/state/ +__pycache__/ diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..444dc82 --- /dev/null +++ b/CLAUDE.md @@ -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: ` 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//` on a new + branch `task/` 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/` 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:** ` 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: ` +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 — ` 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. diff --git a/README.md b/README.md new file mode 100644 index 0000000..5f0b409 --- /dev/null +++ b/README.md @@ -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 .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= +./.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`. diff --git a/install.py b/install.py new file mode 100644 index 0000000..1124707 --- /dev/null +++ b/install.py @@ -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()) diff --git a/manager/core/.env.example b/manager/core/.env.example new file mode 100644 index 0000000..2936699 --- /dev/null +++ b/manager/core/.env.example @@ -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/, overridable +# in local/adapters/). 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= diff --git a/manager/core/VERSION b/manager/core/VERSION new file mode 100644 index 0000000..d00491f --- /dev/null +++ b/manager/core/VERSION @@ -0,0 +1 @@ +1 diff --git a/manager/core/adapters/README.md b/manager/core/adapters/README.md new file mode 100644 index 0000000..5d13aa2 --- /dev/null +++ b/manager/core/adapters/README.md @@ -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. diff --git a/manager/core/adapters/claude/emit.py b/manager/core/adapters/claude/emit.py new file mode 100755 index 0000000..e7046a0 --- /dev/null +++ b/manager/core/adapters/claude/emit.py @@ -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) diff --git a/manager/core/adapters/claude/hook_settings.py b/manager/core/adapters/claude/hook_settings.py new file mode 100644 index 0000000..bc17272 --- /dev/null +++ b/manager/core/adapters/claude/hook_settings.py @@ -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]}], +}})) diff --git a/manager/core/adapters/claude/run b/manager/core/adapters/claude/run new file mode 100755 index 0000000..5d550ea --- /dev/null +++ b/manager/core/adapters/claude/run @@ -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 diff --git a/manager/core/adapters/claude/wire b/manager/core/adapters/claude/wire new file mode 100755 index 0000000..44c150e --- /dev/null +++ b/manager/core/adapters/claude/wire @@ -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()) diff --git a/manager/core/agents.py b/manager/core/agents.py new file mode 100644 index 0000000..f929b46 --- /dev/null +++ b/manager/core/agents.py @@ -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} diff --git a/manager/core/board.html b/manager/core/board.html new file mode 100644 index 0000000..c3df5c1 --- /dev/null +++ b/manager/core/board.html @@ -0,0 +1,1746 @@ + + + + + +Bench — task board + + + + + + + +
+
Bench
+ + +
+ + +
+ +
+ +
+
+
+ + +
+ +
+
+
+
+
+
+ + +
+
+ +
+
+
+
+
+
+ +
+
+
+
+
+
+ + + +
+
+ +
+
+ + + + diff --git a/manager/core/board.py b/manager/core/board.py new file mode 100644 index 0000000..0adf01b --- /dev/null +++ b/manager/core/board.py @@ -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() diff --git a/manager/core/commands.py b/manager/core/commands.py new file mode 100644 index 0000000..7c34d93 --- /dev/null +++ b/manager/core/commands.py @@ -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/, 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"}) diff --git a/manager/core/config.py b/manager/core/config.py new file mode 100644 index 0000000..88b2ba8 --- /dev/null +++ b/manager/core/config.py @@ -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 diff --git a/manager/core/drive.py b/manager/core/drive.py new file mode 100644 index 0000000..3e2b374 --- /dev/null +++ b/manager/core/drive.py @@ -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: ` 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() diff --git a/manager/core/driver.example/start b/manager/core/driver.example/start new file mode 100755 index 0000000..72ade53 --- /dev/null +++ b/manager/core/driver.example/start @@ -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/) +# 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 diff --git a/manager/core/events.py b/manager/core/events.py new file mode 100644 index 0000000..35a5cea --- /dev/null +++ b/manager/core/events.py @@ -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 diff --git a/manager/core/github.py b/manager/core/github.py new file mode 100644 index 0000000..ad44e03 --- /dev/null +++ b/manager/core/github.py @@ -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:** `), 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) diff --git a/manager/core/httpd.py b/manager/core/httpd.py new file mode 100644 index 0000000..6ae88e3 --- /dev/null +++ b/manager/core/httpd.py @@ -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)}) diff --git a/manager/core/prompts/act-pr.md b/manager/core/prompts/act-pr.md new file mode 100644 index 0000000..b374260 --- /dev/null +++ b/manager/core/prompts/act-pr.md @@ -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//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: +followed by a bullet per review point: what was asked, and what you did +about it (or why you deliberately did not). diff --git a/manager/core/prompts/review-pr.md b/manager/core/prompts/review-pr.md new file mode 100644 index 0000000..6ddf44e --- /dev/null +++ b/manager/core/prompts/review-pr.md @@ -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 "" +- request changes: gh pr review {branch} --request-changes --body "" + +Then end your reply with a report whose FIRST line is exactly +PR REVIEW: +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. diff --git a/manager/core/prompts/review.md b/manager/core/prompts/review.md new file mode 100644 index 0000000..e6d0ff9 --- /dev/null +++ b/manager/core/prompts/review.md @@ -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: +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. diff --git a/manager/core/prompts/work.md b/manager/core/prompts/work.md new file mode 100644 index 0000000..f074dfb --- /dev/null +++ b/manager/core/prompts/work.md @@ -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: + +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. diff --git a/manager/core/state.py b/manager/core/state.py new file mode 100644 index 0000000..3eaf1fd --- /dev/null +++ b/manager/core/state.py @@ -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" diff --git a/manager/core/taskfiles.py b/manager/core/taskfiles.py new file mode 100644 index 0000000..5ca5866 --- /dev/null +++ b/manager/core/taskfiles.py @@ -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) diff --git a/manager/core/watch.py b/manager/core/watch.py new file mode 100644 index 0000000..0195ac1 --- /dev/null +++ b/manager/core/watch.py @@ -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"}) diff --git a/manager/local/CLAUDE.md b/manager/local/CLAUDE.md new file mode 100644 index 0000000..c7a4331 --- /dev/null +++ b/manager/local/CLAUDE.md @@ -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. diff --git a/manager/local/adapters/.gitkeep b/manager/local/adapters/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/manager/local/commands/.gitkeep b/manager/local/commands/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/manager/local/driver/.gitkeep b/manager/local/driver/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/manager/local/prompts/.gitkeep b/manager/local/prompts/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/plans/.gitkeep b/plans/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/reference/.gitkeep b/reference/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/start.sh b/start.sh new file mode 100755 index 0000000..dab6d9b --- /dev/null +++ b/start.sh @@ -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" "$@" diff --git a/stop.sh b/stop.sh new file mode 100755 index 0000000..08ac35b --- /dev/null +++ b/stop.sh @@ -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 diff --git a/tasks/archive/.gitkeep b/tasks/archive/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/tasks/backlog/.gitkeep b/tasks/backlog/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/tasks/done/.gitkeep b/tasks/done/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/tasks/in-progress/.gitkeep b/tasks/in-progress/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/tasks/review/.gitkeep b/tasks/review/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/tasks/task-template.md b/tasks/task-template.md new file mode 100644 index 0000000..4fb41c7 --- /dev/null +++ b/tasks/task-template.md @@ -0,0 +1,53 @@ + + +# 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. diff --git a/tasks/to-do/.gitkeep b/tasks/to-do/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/update.sh b/update.sh new file mode 100755 index 0000000..9d5f237 --- /dev/null +++ b/update.sh @@ -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= 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"