Merge origin/main into task/14: CLAUDE.md became the AGENTS.md pointer

Rename-vs-edit conflict: task 13 (landed) moved the workflow brief to
AGENTS.md leaving CLAUDE.md as a pointer, while this branch had edited
the brief's work-launch section in place. Resolution: CLAUDE.md keeps
main's pointer form; this branch's documentation of the
branch-from-origin behaviour moved verbatim into AGENTS.md where that
text now lives. agents.py and the rest auto-merged; 103 tests green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
istos
2026-07-30 07:46:26 +02:00
co-authored by Claude Fable 5
32 changed files with 1672 additions and 531 deletions
+435
View File
@@ -0,0 +1,435 @@
# 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/
├── AGENTS.md ← This file (core-owned; replaced by updates)
├── CLAUDE.md ← Compatibility pointer to AGENTS.md — nothing else
├── 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/ and
│ │ opencode/ ship; contract in README)
│ └── driver.example/
└── local/ ← This project's half. Updates never touch it.
├── .env ← Settings (gitignored; defaults in core/.env.example)
├── AGENTS.md ← Project-specific workflow notes — read it too
├── CLAUDE.md ← Compatibility pointer, as at the root
├── 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|act-pr|review + `AGENT_COMMANDS` in, stdout = the log, markers parsed
from it) and `wire` (idempotently give the host project live-session
visibility). Headless jobs answer no permission prompts, so each intent is
granted exactly the side effects its prompt demands — commit and test for
work, push for act-pr, posting PR verdicts for review — with the project's
own runnable commands coming from `BOARD_AGENT_COMMANDS` as neutral
prefixes each adapter renders in its vendor's rule syntax. Adapters
translate their vendor's events into the board's normalized schema at the
edge — core never sees vendor payloads. The full contract, including the
event schema, lives in `core/adapters/README.md`.
## Drives
The **⛭ drive** chip on a review card launches the app locally *from that
task's worktree*, so you can click around the actual feature before
merging. How an app starts is project knowledge, so it lives in the
project's driver — `local/driver/start`, an executable the board runs and
owns: refuse fast with a printed reason, print `DRIVE URL: <url>` when up,
run until parked (SIGTERM). No driver → the chip says so and the tooltip
explains what to create; `core/driver.example/` documents the contract.
One drive at a time; **park** takes it down.
## The activity bar and the archive
The bottom bar is one place: what happened, and where things go. **Activity**
expands the full event log (filters, the plans/ and reference/ listings, a
resize grip); collapsed, the latest event ticks along the bar. The
**Archive** tray anchors the right end — drag a card from `backlog/`,
`to-do/` or `done/` anywhere onto the bar and it moves to `tasks/archive/`:
out of every column, never deleted, Status set to `Archived`. The toast says
⌘Z brings it back, and it does — nothing in this system removes work
without an undo in the same breath. Cards in the working stages
(in-progress, review) cannot be archived; finish or walk them back first.
## Local commands
Projects grow chores that belong to a specific checkout — applying a
branch's DB migrations, reseeding, rebuilding assets. Those are
**local commands**: executables in `manager/local/commands/`, surfaced as
`$`-glyph chips on cards that have a branch (in progress and review) and
run against that task's worktree (recreated from the branch if needed).
The contract mirrors the driver's: env in (`CMD_WORKTREE`, `CMD_BRANCH`,
`CMD_TASK`, `CMD_REPO`), output to a log under `local/state/commands/`,
and the ticker narrates the ending either way with the log's last line.
A `# help:` line near the top of the script becomes the chip's tooltip.
Commands arm on first click and run on the second.
## Updating
`./.task-manager/update.sh` fetches the distribution repo (`BENCH_SOURCE`
in `local/.env`), replaces `manager/core/` wholesale plus the top-level
scripts, and touches nothing else — tasks, driver, prompt overrides, `.env`
and state all survive. Then re-run `install.py` (idempotent re-wire) and
restart the board.
## Installing into a project
```bash
python3 .task-manager/install.py # idempotent; --dry-run to preview
```
Checks that the containing directory is a `.claude`-initialised project and
delegates to the configured agent adapter's `wire` — for Claude that means
`.claude/settings.json`: `plansDirectory` and the five event hooks running
the adapter's `emit.py`. Fully present → reports "ok" and touches nothing;
partial, stale (old `.tasks/` paths) or duplicated → repaired in place. Other
hooks and settings are never touched, so it is safe to run any time — e.g.
after dropping `.task-manager/` into a new repo.
## Seeing the board
```bash
./.task-manager/start.sh # the usual way: install + port + board
python3 .task-manager/manager/board.py # or run the server directly
```
`start.sh` runs `install.py` (idempotent), then sorts out the port before
serving in the foreground (Ctrl-C stops it). Three cases:
- this project's board already answers on the port → just reopens the browser
- the port is free → starts on it
- something else occupies it → takes the next free port **and persists it to
`manager/.env`**, so the hooks and agents — which read the same file —
follow the board rather than reporting to a port it no longer serves.
Extra arguments pass through to `board.py` (e.g. `./start.sh --no-open`).
`stop.sh` is the counterpart. It identifies the board by asking the port's
API for its tasks root, so it never kills a foreign process squatting there.
While agents are running it refuses — stopping the board loses their endings
(auto-move, PR opening, decline handling) even though the agent processes
themselves survive — and names who is working on what; `--force` overrides.
Port **26071 is pinned** so the URL is always the same one to bookmark. Running
the command again while it is already up just reopens that tab rather than
failing on a port clash.
All settings live in `manager/core/.env.example` with their defaults documented —
the port, the binaries agents launch with, the commands agents may run,
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 project's configured definition-of-done checks (a
`checks` file in `manager/local/` overriding the shipped default in
`core/`), and per-file diff stats from its worktree.
Liveness comes from Claude Code hooks configured in `.claude/settings.json`:
every session in this repo POSTs normalized events to the board via the
claude adapter's `emit.py` (fails silently in under a second when the board isn't
running). A watcher thread also polls the stage directories every 2s, so moves
made by hand still show up. The browser gets everything over SSE — no refresh
needed. Hooks are snapshotted at session start, so a session already open when
the hooks were added won't report until restarted.
Agent prompts ship in `manager/core/prompts/` and can be overridden per
project by placing a file of the same name in `manager/local/prompts/`
(the override wins). They are plain
markdown with `{branch}` / `{stage}` / `{filename}` / `{body}` placeholders,
filled via `str.format` — so literal braces elsewhere in a prompt would break
it. They are read fresh on every agent launch; edits apply without restarting
the board.
## Agents working the board
Card actions appear on hover, taking over the status pill's slot (never
stacking on top of it) — at most two per state, only things you'd actually
do without opening the card: **▸ start work** on in-progress cards,
**‖ hold** while an agent runs, **↩ back** on cards waiting on you,
**↺ reopen** on done cards, and **◔ still true?** everywhere. Actions that
cost tokens or stop work arm on first click and fire on the second.
Each launched agent wears a short name for its lifetime (Wren, Juno,
Basil, …) — picked per launch, never shared by two running agents, shown as
`Wren · #09` on cards, in the sessions list and throughout the ticker. Names
are held in memory, so a restarted board falls back to plain "Agent" for
sessions that predate it.
**▸ start work** launches a headless `claude -p` on the task. It exists only
on `in-progress/` cards: moving a card to in-progress is the commitment, and
only then does work start — the server refuses launches from anywhere else.
1. The board creates a git worktree at `.worktrees/<task-stem>/` on a new
branch `task/<task-stem>` from the newest main it can see: with an
`origin` remote it fetches `origin/main` first (bounded by
`BOARD_FETCH_TIMEOUT`) and branches from that; no remote, a failed
fetch or a timeout fall back to current HEAD, so launching never
waits on the network. The main checkout itself is never touched, and
the ticker names the branch point whenever it isn't just 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 with commits on the branch the board moves the card to
`review/`; on failure it stays in `in-progress/` and the exit is narrated
in the ticker. A clean exit that committed *nothing* also stays in
`in-progress/` and is called out loudly — an empty branch reaching
review/ is how a broken launch hides. Stdout is kept in `.agent/logs/`.
## Pull requests
A card entering `review/` with a `task/<stem>` branch gets a PR opened for
it automatically — mechanically, by the board, not by an agent: it pushes
the branch to the repo's remote and runs `gh pr create` with the task title
and the agent's closing summary as the body. The PR url is written into the
task file as a `**PR:** <url>` line, so the file stays the source of truth
and the card grows a `PR ↗` chip. Cards without a branch pass through
quietly. One guard is loud: if local `main` is ahead of the remote, the PR
would drag those commits into its diff, so the board refuses and tells you
to push main first (then move the card out and back, or wait for the next
entry into review/).
Review-stage cards with a PR carry two actions:
- **◔ review PR** — a read-only agent reads the full diff in context,
checks it against the task and AGENTS.md, posts its verdict to GitHub
(`gh pr review --approve` / `--request-changes`) and appends a
`## PR review` section to the task file ending in
`PR REVIEW: APPROVE | REQUEST CHANGES`.
- **⚑ copilot** — requests a GitHub Copilot review via the API. Works iff
Copilot code review is enabled for the repo; the error is relayed to the
toast if not. The card tracks the whole arc with a `⚑` chip: `⚑ ◌` asked,
`⚑ ✓` approved, `⚑ ✕` changes requested, `⚑ ·` commented — "asked"
becomes a verdict when the pending request turns into an authored review.
Once any review is in (a verdict from either agent kind, Copilot, or a
human), the card's actions shift to the loop that matters then:
**↻ act on PR** replaces the copilot button — an agent re-enters the task's
worktree (recreated from the branch if it was cleaned up), reads every
review and line comment, addresses each point or says why not, commits,
pushes so the PR updates, and appends a `## PR update` section to the task
file. Then **◔ review PR** again, until it settles.
The board polls open PRs of review-stage cards (reviews + CI checks, every
60s — a plain thread in board.py, no agent involved, silent when review/ is
empty) and folds everything into one verdict — any changes-requested review
or failing check wins over any approval. Tool chips (CI, copilot, PR, drive)
are destinations, not statuses: they live in the card's footer row, never
squeezed into the author row — `CI ✓` (pine), `CI ✕` (terracotta), `◌`
while in flight, with hover actions staying in the status pill's slot. The card wears it in the design
system's state colours: approved → pine (`--calm`) border and an
`approved` pill; changes asked → terracotta (`--alarm`) and a
`changes asked` pill; otherwise it stays the neutral `waiting on you`.
Merging remains yours — the board never merges.
The agent's first duty is to judge whether the task is actionable. If the
task still has open questions — unresolved decisions only its author can
settle — the agent does no work and exits with a `NOT READY: <reason>`
marker. The board then moves the card back (to `to-do/`, or `backlog/` if it
started there), records the reason in the ticker, and deletes the untouched
worktree and branch so the task can be refined and relaunched cleanly.
**◔ still true?** (every stage, done included) fires a read-only relevance
agent instead: no worktree, edit tools disallowed, running in the main
checkout. It checks the task against the actual codebase — already done?
assumptions stale? still worth doing as written? — and its report is
appended to the task file under a `## Relevance review — <date>` heading,
with the verdict (`Still relevant | Partly done | Already done | Needs
rewrite`) in the ticker. The card does not move; deciding what to do with
the verdict is yours. One agent per task at a time applies across both
kinds.
Dragging a card with work attached (branch or PR) to `done/` opens a
three-way choice instead of just moving: **keep it where it is** (nothing
changes), **just move the card** (branch, PR and worktree stay), or
**merge & clean up** — park the drive if it is this task's, merge the
branch into main, push (which marks the PR merged) and delete the remote
branch, remove the worktree and local branch, then move the card. Every
step narrates in the ticker; a merge conflict aborts cleanly and the card
stays put. Cards without work move silently, and hand-moves on disk are
never intercepted — the board only asks when you act through it.
One agent per task at a time; a work agent's worktree must not already
exist when starting.
The flow is linear:
```
backlog → to-do → in-progress → review → done
```
## Stages
### backlog/
Where new tasks are written and where they wait. A backlog task may be rough,
incomplete, or fully specified — what it has in common with its neighbours is
that nobody is working on it. Most tasks live here for most of their life.
### to-do/
Picked up and queued to work on next. Moving a task from `backlog/` to `to-do/`
is a commitment to do it soon, so keep this directory short — a long `to-do/` is
just a second backlog.
### in-progress/
Actively being worked on right now. Anything here should have someone (or an
agent session) attached to it. If work stalls, move it back to `to-do/` or
`backlog/` rather than leaving it parked — a stale `in-progress/` makes the board
lie about what is happening.
Implementation plans (created via Claude Code's plan mode) are stored in `plans/`
and can be referenced from the task file.
### review/
The work is built and awaits judgment: tests written and passing, a PR open
(see "Pull requests"), behaviour checked in the running app, edge cases
probed. A task sitting here has code but not yet confidence. If review turns
up problems, move it back to `in-progress/`.
### done/
Finished and merged. Completed task files are kept as a record of what was built
and why — they are the closest thing we have to design history, so don't delete
or trim them.
### reference/ (beside tasks/, not a stage)
Supporting documents that tasks can link to — external specs, API documentation,
research notes, screenshots, competitive analysis, regulatory references, etc.
These don't move through the workflow; they're stable resources. Reference them
from task files using relative links — two levels up from a stage directory
(e.g. `[IVASS spec](../../reference/ivass-document-requirements.md)`).
## Moving a task
When moving a task between stages:
1. Update the **Status** field in the task file header
2. Move the file to the new directory
3. Add any notes about why it's moving (e.g. "approach agreed, starting build")
Moves are not always forward. Going back a stage is normal and expected —
verification failing, or an approach not surviving contact with the code, should
move the task backwards rather than being worked around in place.
## Task file format
Each task is a markdown file with a descriptive filename
(e.g. `01-document-handling-review.md`). Numbers are allocated in creation order
and stay with the file for life — they do not renumber when a task moves stage.
Start new tasks from `tasks/task-template.md` — copy it into `backlog/` and
fill it in. Its sections earn their keep: Context and What-to-build are what
a work agent gets as its brief, Acceptance is what reviews judge against,
and a non-empty Open-questions section makes an agent refuse the task
(`NOT READY`) rather than guess. The template itself is never listed on the
board (only stage directories are read).
The file should have at minimum:
```markdown
# Task title
**Status:** Backlog | To Do | In Progress | Review | Done
**Priority:** High | Medium | Low
```
Use those exact status values — nothing else (not "Not started", "WIP", etc.) —
and keep the status in step with the directory the file sits in. Priority may
carry a short justification after the level
(e.g. `Medium — foundational for any real environment`).
An optional **Type** line can record what kind of work the task is, when that
isn't obvious from the title:
```markdown
**Type:** Discovery | Bug | Feature | Refactor | Chore
```
Type is orthogonal to status. A discovery task — research, scoping, spiking an
approach — moves through the same five stages as everything else; "discovery"
describes the work, not where it sits on the board.
An optional **Depends on** line can name what must land first — task numbers
or external preconditions — so sequencing lives in the header instead of
prose asides:
```markdown
**Depends on:** 03, 05
```
The board does not enforce it; it informs whoever picks the next card.
The rest of the file is freeform — description, research findings, approach,
open questions, whatever is relevant to the current stage.
+4 -431
View File
@@ -1,432 +1,5 @@
# Task Workflow
<!-- Compatibility pointer, load-bearing: the workflow brief lives in AGENTS.md
(the vendor-neutral name all coding agents read); this file makes Claude Code
CLIs without native AGENTS.md support load it via the import below. -->
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/ and
│ │ opencode/ ship; contract in 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|act-pr|review + `AGENT_COMMANDS` in, stdout = the log, markers parsed
from it) and `wire` (idempotently give the host project live-session
visibility). Headless jobs answer no permission prompts, so each intent is
granted exactly the side effects its prompt demands — commit and test for
work, push for act-pr, posting PR verdicts for review — with the project's
own runnable commands coming from `BOARD_AGENT_COMMANDS` as neutral
prefixes each adapter renders in its vendor's rule syntax. Adapters
translate their vendor's events into the board's normalized schema at the
edge — core never sees vendor payloads. The full contract, including the
event schema, lives in `core/adapters/README.md`.
## Drives
The **⛭ drive** chip on a review card launches the app locally *from that
task's worktree*, so you can click around the actual feature before
merging. How an app starts is project knowledge, so it lives in the
project's driver — `local/driver/start`, an executable the board runs and
owns: refuse fast with a printed reason, print `DRIVE URL: <url>` when up,
run until parked (SIGTERM). No driver → the chip says so and the tooltip
explains what to create; `core/driver.example/` documents the contract.
One drive at a time; **park** takes it down.
## The activity bar and the archive
The bottom bar is one place: what happened, and where things go. **Activity**
expands the full event log (filters, the plans/ and reference/ listings, a
resize grip); collapsed, the latest event ticks along the bar. The
**Archive** tray anchors the right end — drag a card from `backlog/`,
`to-do/` or `done/` anywhere onto the bar and it moves to `tasks/archive/`:
out of every column, never deleted, Status set to `Archived`. The toast says
⌘Z brings it back, and it does — nothing in this system removes work
without an undo in the same breath. Cards in the working stages
(in-progress, review) cannot be archived; finish or walk them back first.
## Local commands
Projects grow chores that belong to a specific checkout — applying a
branch's DB migrations, reseeding, rebuilding assets. Those are
**local commands**: executables in `manager/local/commands/`, surfaced as
`$`-glyph chips on cards that have a branch (in progress and review) and
run against that task's worktree (recreated from the branch if needed).
The contract mirrors the driver's: env in (`CMD_WORKTREE`, `CMD_BRANCH`,
`CMD_TASK`, `CMD_REPO`), output to a log under `local/state/commands/`,
and the ticker narrates the ending either way with the log's last line.
A `# help:` line near the top of the script becomes the chip's tooltip.
Commands arm on first click and run on the second.
## Updating
`./.task-manager/update.sh` fetches the distribution repo (`BENCH_SOURCE`
in `local/.env`), replaces `manager/core/` wholesale plus the top-level
scripts, and touches nothing else — tasks, driver, prompt overrides, `.env`
and state all survive. Then re-run `install.py` (idempotent re-wire) and
restart the board.
## Installing into a project
```bash
python3 .task-manager/install.py # idempotent; --dry-run to preview
```
Checks that the containing directory is a `.claude`-initialised project and
delegates to the configured agent adapter's `wire` — for Claude that means
`.claude/settings.json`: `plansDirectory` and the five event hooks running
the adapter's `emit.py`. Fully present → reports "ok" and touches nothing;
partial, stale (old `.tasks/` paths) or duplicated → repaired in place. Other
hooks and settings are never touched, so it is safe to run any time — e.g.
after dropping `.task-manager/` into a new repo.
## Seeing the board
```bash
./.task-manager/start.sh # the usual way: install + port + board
python3 .task-manager/manager/board.py # or run the server directly
```
`start.sh` runs `install.py` (idempotent), then sorts out the port before
serving in the foreground (Ctrl-C stops it). Three cases:
- this project's board already answers on the port → just reopens the browser
- the port is free → starts on it
- something else occupies it → takes the next free port **and persists it to
`manager/.env`**, so the hooks and agents — which read the same file —
follow the board rather than reporting to a port it no longer serves.
Extra arguments pass through to `board.py` (e.g. `./start.sh --no-open`).
`stop.sh` is the counterpart. It identifies the board by asking the port's
API for its tasks root, so it never kills a foreign process squatting there.
While agents are running it refuses — stopping the board loses their endings
(auto-move, PR opening, decline handling) even though the agent processes
themselves survive — and names who is working on what; `--force` overrides.
Port **26071 is pinned** so the URL is always the same one to bookmark. Running
the command again while it is already up just reopens that tab rather than
failing on a port clash.
All settings live in `manager/core/.env.example` with their defaults documented —
the port, the binaries agents launch with, the commands agents may run,
the worktrees directory, the watch interval and the in-memory caps. Copy it
to `manager/local/.env` (gitignored) to override locally; real environment
variables beat `.env`, which beats the defaults. The hook bridge reads the
same `.env`, so changing `BOARD_PORT` moves the board, the agents and the
hooks together.
Stdlib only, no install. It reads the directories on every request, so refreshing
the page shows current disk state. Dragging a card between columns does both
steps of a move for you — it renames the file and rewrites its **Status:** line.
Cards whose Status line disagrees with the directory they sit in are flagged
`status drift`.
## Live view
The UI follows the **Bench** design system — cool sea neutrals, IBM Plex Sans
for anything a person wrote and Plex Mono for anything a machine produced,
and colour that only ever means state: `--accent` (surf) an agent alive,
`--calm` (pine) settled or passed, `--alarm` (terracotta) blocked, failed or
HIGH, `--idle` (driftwood) done. The one looping animation ("breathe") means
an agent is working; a blinking caret means output is still arriving. Night
theme by default; the header button switches to Daylight. Tokens live at the
top of `manager/board.html`.
The board has three views (header switcher):
- **Board** — the kanban, live. Active Claude Code sessions appear as chips in
the header; a card an agent is working on carries a live activity line; the
bottom ticker narrates the latest events and every move is attributed
(`you` / `agent` / `disk`).
- **Sessions** — a flight recorder per session: a chronological timeline of
reads, edits, test runs, commits and card moves, with filters and expandable
output. Sessions persist to `.sessions/*.jsonl`, so past ones can be replayed.
- **Focus** — a heads-up display for one session: the task it holds, its live
TodoWrite plan, the definition-of-done checks (pytest / lint-imports /
frontend), and per-file diff stats from its worktree.
Liveness comes from Claude Code hooks configured in `.claude/settings.json`:
every session in this repo POSTs normalized events to the board via the
claude adapter's `emit.py` (fails silently in under a second when the board isn't
running). A watcher thread also polls the stage directories every 2s, so moves
made by hand still show up. The browser gets everything over SSE — no refresh
needed. Hooks are snapshotted at session start, so a session already open when
the hooks were added won't report until restarted.
Agent prompts ship in `manager/core/prompts/` and can be overridden per
project by placing a file of the same name in `manager/local/prompts/`
(the override wins). They are plain
markdown with `{branch}` / `{stage}` / `{filename}` / `{body}` placeholders,
filled via `str.format` — so literal braces elsewhere in a prompt would break
it. They are read fresh on every agent launch; edits apply without restarting
the board.
## Agents working the board
Card actions appear on hover, taking over the status pill's slot (never
stacking on top of it) — at most two per state, only things you'd actually
do without opening the card: **▸ start work** on in-progress cards,
**‖ hold** while an agent runs, **↩ back** on cards waiting on you,
**↺ reopen** on done cards, and **◔ still true?** everywhere. Actions that
cost tokens or stop work arm on first click and fire on the second.
Each launched agent wears a short name for its lifetime (Wren, Juno,
Basil, …) — picked per launch, never shared by two running agents, shown as
`Wren · #09` on cards, in the sessions list and throughout the ticker. Names
are held in memory, so a restarted board falls back to plain "Agent" for
sessions that predate it.
**▸ start work** launches a headless `claude -p` on the task. It exists only
on `in-progress/` cards: moving a card to in-progress is the commitment, and
only then does work start — the server refuses launches from anywhere else.
1. The board creates a git worktree at `.worktrees/<task-stem>/` on a new
branch `task/<task-stem>` from the newest main it can see: with an
`origin` remote it fetches `origin/main` first (bounded by
`BOARD_FETCH_TIMEOUT`) and branches from that; no remote, a failed
fetch or a timeout fall back to current HEAD, so launching never
waits on the network. The main checkout itself is never touched, and
the ticker names the branch point whenever it isn't just 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 with commits on the branch the board moves the card to
`review/`; on failure it stays in `in-progress/` and the exit is narrated
in the ticker. A clean exit that committed *nothing* also stays in
`in-progress/` and is called out loudly — an empty branch reaching
review/ is how a broken launch hides. Stdout is kept in `.agent/logs/`.
## Pull requests
A card entering `review/` with a `task/<stem>` branch gets a PR opened for
it automatically — mechanically, by the board, not by an agent: it pushes
the branch to the repo's remote and runs `gh pr create` with the task title
and the agent's closing summary as the body. The PR url is written into the
task file as a `**PR:** <url>` line, so the file stays the source of truth
and the card grows a `PR ↗` chip. Cards without a branch pass through
quietly. One guard is loud: if local `main` is ahead of the remote, the PR
would drag those commits into its diff, so the board refuses and tells you
to push main first (then move the card out and back, or wait for the next
entry into review/).
Review-stage cards with a PR carry two actions:
- **◔ review PR** — a read-only agent reads the full diff in context,
checks it against the task and CLAUDE.md, posts its verdict to GitHub
(`gh pr review --approve` / `--request-changes`) and appends a
`## PR review` section to the task file ending in
`PR REVIEW: APPROVE | REQUEST CHANGES`.
- **⚑ copilot** — requests a GitHub Copilot review via the API. Works iff
Copilot code review is enabled for the repo; the error is relayed to the
toast if not. The card tracks the whole arc with a `⚑` chip: `⚑ ◌` asked,
`⚑ ✓` approved, `⚑ ✕` changes requested, `⚑ ·` commented — "asked"
becomes a verdict when the pending request turns into an authored review.
Once any review is in (a verdict from either agent kind, Copilot, or a
human), the card's actions shift to the loop that matters then:
**↻ act on PR** replaces the copilot button — an agent re-enters the task's
worktree (recreated from the branch if it was cleaned up), reads every
review and line comment, addresses each point or says why not, commits,
pushes so the PR updates, and appends a `## PR update` section to the task
file. Then **◔ review PR** again, until it settles.
The board polls open PRs of review-stage cards (reviews + CI checks, every
60s — a plain thread in board.py, no agent involved, silent when review/ is
empty) and folds everything into one verdict — any changes-requested review
or failing check wins over any approval. Tool chips (CI, copilot, PR, drive)
are destinations, not statuses: they live in the card's footer row, never
squeezed into the author row — `CI ✓` (pine), `CI ✕` (terracotta), `◌`
while in flight, with hover actions staying in the status pill's slot. The card wears it in the design
system's state colours: approved → pine (`--calm`) border and an
`approved` pill; changes asked → terracotta (`--alarm`) and a
`changes asked` pill; otherwise it stays the neutral `waiting on you`.
Merging remains yours — the board never merges.
The agent's first duty is to judge whether the task is actionable. If the
task still has open questions — unresolved decisions only its author can
settle — the agent does no work and exits with a `NOT READY: <reason>`
marker. The board then moves the card back (to `to-do/`, or `backlog/` if it
started there), records the reason in the ticker, and deletes the untouched
worktree and branch so the task can be refined and relaunched cleanly.
**◔ still true?** (every stage, done included) fires a read-only relevance
agent instead: no worktree, edit tools disallowed, running in the main
checkout. It checks the task against the actual codebase — already done?
assumptions stale? still worth doing as written? — and its report is
appended to the task file under a `## Relevance review — <date>` heading,
with the verdict (`Still relevant | Partly done | Already done | Needs
rewrite`) in the ticker. The card does not move; deciding what to do with
the verdict is yours. One agent per task at a time applies across both
kinds.
Dragging a card with work attached (branch or PR) to `done/` opens a
three-way choice instead of just moving: **keep it where it is** (nothing
changes), **just move the card** (branch, PR and worktree stay), or
**merge & clean up** — park the drive if it is this task's, merge the
branch into main, push (which marks the PR merged) and delete the remote
branch, remove the worktree and local branch, then move the card. Every
step narrates in the ticker; a merge conflict aborts cleanly and the card
stays put. Cards without work move silently, and hand-moves on disk are
never intercepted — the board only asks when you act through it.
One agent per task at a time; a work agent's worktree must not already
exist when starting.
The flow is linear:
```
backlog → to-do → in-progress → review → done
```
## Stages
### backlog/
Where new tasks are written and where they wait. A backlog task may be rough,
incomplete, or fully specified — what it has in common with its neighbours is
that nobody is working on it. Most tasks live here for most of their life.
### to-do/
Picked up and queued to work on next. Moving a task from `backlog/` to `to-do/`
is a commitment to do it soon, so keep this directory short — a long `to-do/` is
just a second backlog.
### in-progress/
Actively being worked on right now. Anything here should have someone (or an
agent session) attached to it. If work stalls, move it back to `to-do/` or
`backlog/` rather than leaving it parked — a stale `in-progress/` makes the board
lie about what is happening.
Implementation plans (created via Claude Code's plan mode) are stored in `plans/`
and can be referenced from the task file.
### review/
The work is built and awaits judgment: tests written and passing, a PR open
(see "Pull requests"), behaviour checked in the running app, edge cases
probed. A task sitting here has code but not yet confidence. If review turns
up problems, move it back to `in-progress/`.
### done/
Finished and merged. Completed task files are kept as a record of what was built
and why — they are the closest thing we have to design history, so don't delete
or trim them.
### reference/ (beside tasks/, not a stage)
Supporting documents that tasks can link to — external specs, API documentation,
research notes, screenshots, competitive analysis, regulatory references, etc.
These don't move through the workflow; they're stable resources. Reference them
from task files using relative links — two levels up from a stage directory
(e.g. `[IVASS spec](../../reference/ivass-document-requirements.md)`).
## Moving a task
When moving a task between stages:
1. Update the **Status** field in the task file header
2. Move the file to the new directory
3. Add any notes about why it's moving (e.g. "approach agreed, starting build")
Moves are not always forward. Going back a stage is normal and expected —
verification failing, or an approach not surviving contact with the code, should
move the task backwards rather than being worked around in place.
## Task file format
Each task is a markdown file with a descriptive filename
(e.g. `01-document-handling-review.md`). Numbers are allocated in creation order
and stay with the file for life — they do not renumber when a task moves stage.
Start new tasks from `tasks/task-template.md` — copy it into `backlog/` and
fill it in. Its sections earn their keep: Context and What-to-build are what
a work agent gets as its brief, Acceptance is what reviews judge against,
and a non-empty Open-questions section makes an agent refuse the task
(`NOT READY`) rather than guess. The template itself is never listed on the
board (only stage directories are read).
The file should have at minimum:
```markdown
# Task title
**Status:** Backlog | To Do | In Progress | Review | Done
**Priority:** High | Medium | Low
```
Use those exact status values — nothing else (not "Not started", "WIP", etc.) —
and keep the status in step with the directory the file sits in. Priority may
carry a short justification after the level
(e.g. `Medium — foundational for any real environment`).
An optional **Type** line can record what kind of work the task is, when that
isn't obvious from the title:
```markdown
**Type:** Discovery | Bug | Feature | Refactor | Chore
```
Type is orthogonal to status. A discovery task — research, scoping, spiking an
approach — moves through the same five stages as everything else; "discovery"
describes the work, not where it sits on the board.
An optional **Depends on** line can name what must land first — task numbers
or external preconditions — so sequencing lives in the header instead of
prose asides:
```markdown
**Depends on:** 03, 05
```
The board does not enforce it; it informs whoever picks the next card.
The rest of the file is freeform — description, research findings, approach,
open questions, whatever is relevant to the current stage.
@AGENTS.md
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 12vectors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+14 -1
View File
@@ -13,9 +13,18 @@ git clone <this repo> .task-manager && rm -rf .task-manager/.git
./.task-manager/start.sh # wires the project (idempotent) and serves
```
The first `start.sh` clears the distribution's own cards from `tasks/`,
`plans/` and `reference/` (printing each removal), so a fresh install
starts with an empty board.
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.
The workflow brief ships as `.task-manager/AGENTS.md` — the cross-vendor
name coding agents read natively — with `CLAUDE.md` beside it as a one-line
compatibility pointer. Both live inside `.task-manager/`, so a host repo's
own root `AGENTS.md` is never touched.
## Update
```bash
@@ -35,4 +44,8 @@ restart the board.
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`.
AGENTS.md; the adapter contract in `manager/core/adapters/README.md`.
## License
[MIT](LICENSE).
+65
View File
@@ -9,11 +9,19 @@ 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.
The distribution repo tracks its own development on its own board, so a
fresh clone arrives carrying those cards. The very first run in a host
project vendored, before manager/local/ has ever been populated
clears the stage directories, tasks/archive/, plans/ and reference/
(keeping task-template.md and .gitkeep files, printing every removal) and
then stamps manager/local/state/ so the guard is false on every later run.
"""
from __future__ import annotations
import os
import shutil
import subprocess
import sys
from pathlib import Path
@@ -22,6 +30,9 @@ TM = Path(__file__).resolve().parent
LOCAL = TM / "manager" / "local"
CORE = TM / "manager" / "core"
STAGE_DIRS = ["backlog", "to-do", "in-progress", "review", "done"]
KEEP = {".gitkeep", "task-template.md"}
def _project_root() -> Path:
"""The host project's root: the git toplevel seen from the manager's
@@ -44,6 +55,59 @@ def _project_root() -> Path:
PROJECT = _project_root()
def _content_dirs(tm: Path) -> list[Path]:
tasks = tm / "tasks"
return ([tasks / stage for stage in STAGE_DIRS]
+ [tasks / "archive", tm / "plans", tm / "reference"])
def first_boot(tm: Path, project: Path) -> bool:
"""True only on a vendored install's very first run — the one moment
anything in the stage directories can only be the distribution's own.
False in every other situation:
- self-hosted (the manager IS the repo): tasks/ is that repo's own
history, never distribution residue including a fresh dev clone;
- already wired (local/.env or local/state/ exists): anything in the
stage directories can only be the host project's own work."""
if project.resolve() == tm.resolve():
return False
local = tm / "manager" / "local"
return not (local / ".env").exists() and not (local / "state").exists()
def first_boot_leftovers(tm: Path) -> list[Path]:
"""The distribution's shipped cards, plans and reference documents —
the paths a first boot must clear."""
return [child
for d in _content_dirs(tm) if d.is_dir()
for child in sorted(d.iterdir()) if child.name not in KEEP]
def first_boot_clean(dry_run: bool) -> None:
"""First boot only: remove the distribution's shipped content and stamp
local/state/ so this never runs again even if the adapter wire fails
(a host without .claude/ still gets the board via start.sh) or the host
creates cards before the next run. Off first boot nothing is touched,
not even the stamp."""
if not first_boot(TM, PROJECT):
return
leftovers = first_boot_leftovers(TM)
if leftovers:
print("first boot — clearing the distribution's own cards:")
verb = "would remove" if dry_run else "removed"
for path in leftovers:
print(f" {verb} {path.relative_to(TM)}")
if not dry_run:
if path.is_dir() and not path.is_symlink():
shutil.rmtree(path)
else:
path.unlink()
print()
if not dry_run:
(LOCAL / "state").mkdir(parents=True, exist_ok=True)
def adapter_name() -> str:
if os.environ.get("BOARD_AGENT_ADAPTER"):
return os.environ["BOARD_AGENT_ADAPTER"]
@@ -57,6 +121,7 @@ def adapter_name() -> str:
def main() -> int:
first_boot_clean(dry_run="--dry-run" in sys.argv[1:])
name = adapter_name()
for base in (LOCAL / "adapters", CORE / "adapters"):
wire = base / name / "wire"
+19 -1
View File
@@ -15,13 +15,31 @@ BOARD_AGENT_ADAPTER=claude
BOARD_CLAUDE_BIN=claude
BOARD_OPENCODE_BIN=opencode
# The model headless agents run on — an opaque vendor-native name the
# adapter passes through untranslated (claude: a model name or alias for
# --model; opencode: the "provider/model-id" config key). Empty = inherit
# the vendor's own default, i.e. whatever the CLI on this machine would
# pick anyway. The per-intent settings beat the general one for their
# intent only; _REVIEW covers both PR reviews and relevance checks. Work
# agents write code; reviews just read and judge — they can ride a
# cheaper, faster model.
BOARD_AGENT_MODEL=
BOARD_AGENT_MODEL_WORK=
BOARD_AGENT_MODEL_ACT_PR=
BOARD_AGENT_MODEL_REVIEW=
# Command prefixes headless agents may run in their worktree — the
# project's test/check commands, comma-separated, in plain neutral form
# (each adapter renders them into its vendor's permission rules; the
# git/gh grants per launch intent are built in). Headless runs have no
# human at a permission prompt, so a test runner missing from this list
# is a test the work agent cannot run.
BOARD_AGENT_COMMANDS=python3 -m unittest,python3 -m pytest
BOARD_AGENT_COMMANDS=python3 -m unittest
# What counts as a definition-of-done check (the Focus view's CHECKS
# panel) is a file, not an env var: core/checks ships a generic default,
# and a `checks` file in manager/local/ replaces it wholesale. Format
# (`<label>: <command regex>`) is documented in the default file itself.
# Where work-agent worktrees are created, relative to the repo root.
BOARD_WORKTREES=.worktrees
+19 -3
View File
@@ -15,14 +15,19 @@ An adapter is a directory with two executables:
- env in: `AGENT_PROMPT` (the full prompt), `AGENT_MODE` (the launch
intent, below), `AGENT_COMMANDS` (the project's allowed command
prefixes, below), `AGENT_CWD`, and the `BOARD_*` passthrough
(`BOARD_AGENT_ID`, `BOARD_TASK`, `BOARD_PORT`) which your event bridge
must forward with every event.
prefixes, below), `AGENT_MODEL` (optional, below), `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.
- The workflow brief the prompts point agents at is `AGENTS.md` at the
repo root (`CLAUDE.md` beside it is only a compatibility pointer).
Vendors that read `AGENTS.md` from the working directory's tree
natively — opencode does, as does current Claude Code — pick it up in
every worktree with no adapter work; `run` never needs to inject it.
### Launch intents (`AGENT_MODE`)
@@ -57,6 +62,17 @@ prefix-pattern based, so the translation is mechanical:
"allow"}}` in a generated config, wildcard rules, last match wins
(`opencode/permission_config.py`)
### The model (`AGENT_MODEL`) — optional
Absent = the vendor's own default: launch without any model argument and
let your CLI resolve it however it normally would. When set, it is an
opaque vendor-native model name — a claude alias, an opencode
`provider/model-id` — that core never validates or interprets; pass it
through untranslated (claude → `--model "$AGENT_MODEL"`, opencode → the
generated config's `model` key). Never send your vendor an empty value:
the board only sets the variable when a model is actually configured
(`BOARD_AGENT_MODEL` and its per-intent overrides in `local/.env`).
### `wire` — wire live-session visibility into the host project
Called by `install.py` with the project root as argv[1] (plus `--dry-run`).
+93 -46
View File
@@ -25,6 +25,10 @@ from pathlib import Path
EDIT_TOOLS = {"Edit", "Write", "MultiEdit", "NotebookEdit"}
# manager/ — the same distance up from core/adapters/claude/ as from a
# local/adapters/claude/ override, so both copies resolve the same files.
MANAGER = Path(__file__).resolve().parents[3]
def _txt(value, cap=600):
return value[:cap] if isinstance(value, str) else ""
@@ -51,6 +55,56 @@ def _resp_text(resp):
return ""
def check_defs():
"""The project's definition-of-done checks: `<label>: <command regex>`
per line, local/checks replacing core/checks wholesale the same file
the board serves to the Focus panel, read here for classification so
the two never drift. (Core's config.py mirrors this parser; the bridge
stays standalone.) Read fresh per event; must never raise."""
for base in (MANAGER / "local", MANAGER / "core"):
try:
text = (base / "checks").read_text(encoding="utf-8")
except OSError:
continue
defs = []
for line in text.splitlines():
line = line.strip()
if not line or line.startswith("#"):
continue
label, sep, pattern = line.partition(":")
label, pattern = label.strip(), pattern.strip()
if not sep or not label or not pattern:
continue
try:
defs.append((label, re.compile(pattern)))
except re.error:
continue
return defs
return []
def judge(out):
"""Generic pass/fail from a check's output — the hook payload carries
no exit status (stdout/stderr/interrupted only), so judgment rests on
the summaries test tools print: counted results ('3 passed',
'1 failed', '2 errors'), lint-style broken totals, and OK/FAILED
verdict lines. Returns (ok, summary bit); (None, '') when the output
says nothing recognizable either way."""
failed = re.search(r"\b\d+ (?:failed|errors?)\b", out)
passed = re.search(r"\b\d+ passed\b", out)
if failed:
return False, failed.group(0) + (f", {passed.group(0)}" if passed else "")
if passed:
return True, passed.group(0)
broken = re.search(r"\b(\d+) broken\b", out)
if broken:
return broken.group(1) == "0", broken.group(0)
verdict = re.search(r"^(OK|FAILED)\b.*", out, re.M)
if verdict:
return verdict.group(1) == "OK", verdict.group(0)[:60]
return None, ""
def classify(hook, tool, tool_input, resp):
if hook == "SessionStart":
return {"kind": "session", "summary": "session started"}
@@ -88,31 +142,19 @@ def classify(hook, tool, tool_input, resp):
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, ok, label = "command", None, None
for name, pattern in check_defs():
if pattern.search(cmd):
kind, label = "check", name
break
if kind == "command" and 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]}"
ok, bits = judge(out)
summary = f"{label}{bits}" if bits else 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]}"
@@ -129,7 +171,7 @@ def board_port():
if port:
return port
try:
env_file = Path(__file__).resolve().parents[3] / "local" / ".env"
env_file = MANAGER / "local" / ".env"
for line in env_file.read_text().splitlines():
key, _, value = line.strip().partition("=")
if key.strip() == "BOARD_PORT":
@@ -139,31 +181,36 @@ def board_port():
return port or "26071"
try:
payload = json.load(sys.stdin)
except Exception:
payload = {}
def main():
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")),
}
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
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)
sys.exit(0)
if __name__ == "__main__":
main()
+11
View File
@@ -7,6 +7,9 @@
# (see core/adapters/README.md)
# AGENT_COMMANDS comma-separated neutral command prefixes the
# project lets agents run (tests/checks)
# AGENT_MODEL optional; a claude model name/alias passed
# through as --model. Absent = the CLI's own
# resolution (user settings), untouched.
# 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
@@ -25,12 +28,20 @@ BIN="${BOARD_CLAUDE_BIN:-claude}"
MODE="${AGENT_MODE:-work}"
SETTINGS="$(python3 "$HERE/hook_settings.py" "$MODE")"
# The ${arr[@]+...} expansion keeps set -u happy on bash 3.2 when unset.
MODEL_ARGS=()
if [ -n "${AGENT_MODEL:-}" ]; then
MODEL_ARGS=(--model "$AGENT_MODEL")
fi
if [ "$MODE" = "review" ]; then
exec "$BIN" -p "$AGENT_PROMPT" --settings "$SETTINGS" \
${MODEL_ARGS[@]+"${MODEL_ARGS[@]}"} \
--permission-mode default \
--disallowedTools Edit Write NotebookEdit
else
# work and act-pr both mutate the worktree; the allowlist differs.
exec "$BIN" -p "$AGENT_PROMPT" --settings "$SETTINGS" \
${MODEL_ARGS[@]+"${MODEL_ARGS[@]}"} \
--permission-mode acceptEdits
fi
@@ -1,6 +1,6 @@
#!/usr/bin/env python3
"""Print the opencode config JSON for one headless launch: the permission
rules for the launch's intent.
rules for the launch's intent, plus the model when the board configured one.
Usage: permission_config.py [work|act-pr|review]
@@ -21,6 +21,10 @@ and never a blanket allow: the worktree is isolated, the shell is not.
The project's test/check commands arrive in AGENT_COMMANDS as comma-
separated neutral command prefixes (set BOARD_AGENT_COMMANDS in
local/.env); here each becomes "<prefix>" and "<prefix> *" allow rules.
AGENT_MODEL, when set, becomes the config's top-level "model" key —
opencode's "provider/model-id" form (opencode.ai/docs/config), passed
through untranslated. Absent = no key, opencode's own resolution applies.
"""
import json
import os
@@ -56,17 +60,21 @@ def bash_rules(mode: str, commands: list[str]) -> dict:
return rules
def build_config(mode: str, commands: list[str]) -> dict:
return {
def build_config(mode: str, commands: list[str], model: str = "") -> dict:
config = {
"$schema": "https://opencode.ai/config.json",
"permission": {
"edit": "deny" if mode == "review" else "allow",
"bash": bash_rules(mode, commands),
},
}
if model:
config["model"] = model
return config
if __name__ == "__main__":
mode = sys.argv[1] if len(sys.argv) > 1 else "work"
commands = split_commands(os.environ.get("AGENT_COMMANDS", ""))
print(json.dumps(build_config(mode, commands)))
model = os.environ.get("AGENT_MODEL", "").strip()
print(json.dumps(build_config(mode, commands, model)))
+3
View File
@@ -7,6 +7,9 @@
# (see core/adapters/README.md)
# AGENT_COMMANDS comma-separated neutral command prefixes the
# project lets agents run (tests/checks)
# AGENT_MODEL optional; opencode's "provider/model-id" form,
# set as the generated config's model key.
# Absent = opencode's own resolution, untouched.
# 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; `opencode run` prints
+24 -12
View File
@@ -81,6 +81,9 @@ def _agent_public(record: dict) -> dict:
("id", "task", "branch", "worktree", "status", "rc", "started", "session")}
public["mode"] = record.get("mode", "work")
public["name"] = record.get("name")
# The model the launch was actually given; None = inherited the
# vendor's own default. Honesty for the Sessions/Focus views.
public["model"] = record.get("model")
return public
@@ -111,9 +114,11 @@ def _launch(mode: str, prompt: str, cwd: Path, agent_id: str, filename: str, log
The adapter contract: `run` gets AGENT_PROMPT, AGENT_MODE (the intent:
work = mutate and commit, act-pr = work + push, review = read-only +
post PR verdicts) and AGENT_COMMANDS (the project's runnable command
prefixes) plus the BOARD_* passthrough for its event bridge; its
stdout is the job log; exit 0 = completed.
post PR verdicts), AGENT_COMMANDS (the project's runnable command
prefixes) and AGENT_MODEL (the configured model, when there is one)
plus the BOARD_* passthrough for its event bridge; its stdout is the
job log; exit 0 = completed. Returns (proc, log_file, model) with
model = '' when the launch inherits the vendor default.
"""
adapter = config.adapter_dir()
if adapter is None:
@@ -130,6 +135,13 @@ def _launch(mode: str, prompt: str, cwd: Path, agent_id: str, filename: str, log
"BOARD_TASK": filename,
"BOARD_PORT": str(state.serve_port),
})
model = config.agent_model(mode)
if model:
env["AGENT_MODEL"] = model
else:
# Inherit = the variable is simply absent. Popping also stops a
# stray AGENT_MODEL in the board's own environment leaking through.
env.pop("AGENT_MODEL", None)
log_file = log_path.open("wb")
try:
proc = subprocess.Popen(
@@ -138,7 +150,7 @@ def _launch(mode: str, prompt: str, cwd: Path, agent_id: str, filename: str, log
except OSError as exc:
log_file.close()
raise ValueError(f"could not launch adapter {adapter}: {exc}")
return proc, log_file
return proc, log_file, model
def _fresh_branch_point() -> tuple[str | None, str | None]:
@@ -229,7 +241,7 @@ def start_agent(filename: str, stage: str) -> dict:
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)
proc, log_file, model = _launch("work", prompt, worktree, agent_id, filename, log_path)
name = _pick_name(stem)
record = {
@@ -237,7 +249,7 @@ def start_agent(filename: str, stage: str) -> dict:
"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,
"name": name, "model": model or None,
}
with state.LOCK:
state.AGENTS[agent_id] = record
@@ -266,14 +278,14 @@ def start_review(filename: str, stage: str) -> dict:
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)
proc, log_file, model = _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,
"origin": stage, "mode": "review", "name": name, "model": model or None,
}
with state.LOCK:
state.AGENTS[agent_id] = record
@@ -399,13 +411,13 @@ def start_pr_review(filename: str, stage: str) -> dict:
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)
proc, log_file, model = _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,
"origin": stage, "mode": "review", "name": name, "model": model or None,
}
with state.LOCK:
state.AGENTS[agent_id] = record
@@ -450,14 +462,14 @@ def start_pr_fix(filename: str, stage: str) -> dict:
prompt = config.prompt("act-pr.md").format(
filename=filename, branch=branch, pr=task["pr"], body=task["body"])
# act-pr is the one intent allowed to push: the PR must update.
proc, log_file = _launch("act-pr", prompt, worktree, agent_id, filename, log_path)
proc, log_file, model = _launch("act-pr", 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,
"name": name, "model": model or None,
}
with state.LOCK:
state.AGENTS[agent_id] = record
+24 -12
View File
@@ -20,7 +20,7 @@
--accent:#56c2d8; --calm:#a6c96f; --alarm:#e08a63; --idle:#5d757b;
--on-accent:#0a161b; --on-calm:#141a10;
--shadow:0 10px 24px -18px rgba(0,0,0,.9);
--pad:14px; --gap:10px; --radius:10px; --col:296px;
--pad:14px; --gap:10px; --radius:10px; --col:296px; --col-min:240px;
--sans:'IBM Plex Sans',system-ui,sans-serif;
--mono:'IBM Plex Mono',ui-monospace,SFMono-Regular,Menlo,monospace;
}
@@ -91,7 +91,7 @@
.view.on{display:flex}
#view-board{flex-direction:column}
#board{flex:1;display:flex;gap:var(--gap);align-items:stretch;padding:14px 18px;overflow-x:auto;min-height:0}
.kcol{flex:0 0 var(--col);width:var(--col);display:flex;flex-direction:column;gap:8px;min-height:0}
.kcol{flex:1 1 0;min-width:var(--col-min);max-width:var(--col);display:flex;flex-direction:column;gap:8px;min-height:0}
.kcol > h2{
margin:0;display:flex;align-items:center;gap:8px;padding:0 4px 2px;
font-size:11px;font-weight:600;letter-spacing:.09em;text-transform:uppercase;color:var(--muted);
@@ -1295,17 +1295,20 @@ function renderFlight() {
}
const events = S.events[sid] || [];
const files = new Set(events.filter(e => e.file && e.kind === 'edit').map(e => e.file));
const tests = events.filter(e => e.kind === 'test').length;
const checks = events.filter(e => e.kind === 'test' || e.kind === 'check').length;
const agent = agentFor(sid);
const stopBtn = agent && agent.status === 'running'
? `<button id="stopagent" class="stopbtn" data-aid="${esc(agent.id)}">Hold</button>` : '';
const branch = agent && agent.branch ? ` · <span class="mono">${esc(agent.branch)}</span>` : '';
// Honesty about what the run actually rode: the configured model, or
// the vendor default it inherited. Interactive sessions say nothing.
const model = agent ? ` · <span class="mono">${agent.model ? esc(agent.model) : 'model inherited'}</span>` : '';
$('#fsession').innerHTML =
`<div><div class="s-title">${esc((meta.label || sid).split(' · ')[0])}` +
`<span class="sid">${esc(sid.slice(0, 8))}</span></div>` +
`<div class="s-line">${meta.task ? 'on ' + esc(meta.task) + ' · ' : ''}` +
`started ${fmtShort(meta.started)} · ${meta.count || 0} events · ` +
`${files.size} files edited · ${tests} test runs${branch}</div></div>` +
`${files.size} files edited · ${checks} check runs${branch}${model}</div></div>` +
stopBtn + spark(events, meta);
const stop = $('#stopagent');
if (stop) stop.addEventListener('click', () => stopAgent(stop.dataset.aid));
@@ -1490,6 +1493,7 @@ function renderFocus() {
refBits.push(task.number ? '#' + esc(task.number) : esc(task.file));
refBits.push(`<span class="acc">${esc((meta.label || '').split(' · ')[0])}</span>`);
if (agent && agent.branch) refBits.push('worktree ' + esc(agent.branch));
if (agent) refBits.push(agent.model ? esc(agent.model) : 'model inherited');
refBits.push(esc(task.stage) + '/' + esc(task.file));
} else {
refBits.push(esc(sid.slice(0, 8)), 'no task attached');
@@ -1504,19 +1508,27 @@ function renderFocus() {
steps + act + `</div>`;
const rev = [...events].reverse();
const lastTest = rev.find(e => e.kind === 'test' && !e.running);
const lastLint = rev.find(e => e.kind === 'check' && (e.cmd || '').includes('lint-imports'));
const lastFront = rev.find(e => e.kind === 'check' && /type-check|vitest|npm/.test(e.cmd || ''));
const checkRow = (name, ev) => {
if (!ev) return `<div class="check-row none"><span class="glyph"></span><span class="name">${name}</span><span class="state">not run</span></div>`;
if (!ev) return `<div class="check-row none"><span class="glyph"></span><span class="name">${esc(name)}</span><span class="state">not run</span></div>`;
const cls = ev.ok === false ? 'fail' : ev.ok === true ? 'pass' : 'none';
const glyph = ev.ok === false ? '✕' : ev.ok === true ? '✓' : '·';
const state = esc(ev.summary.replace(/^pytest — /, '').replace(/^ran: /, '')) + ' · ' + fmtShort(ev.ts);
return `<div class="check-row ${cls}"><span class="glyph">${glyph}</span><span class="name">${name}</span><span class="state">${state}</span></div>`;
const text = ev.summary.startsWith(name + ' ')
? ev.summary.slice(name.length + 3) : ev.summary.replace(/^ran: /, '');
const state = esc(text) + ' · ' + fmtShort(ev.ts);
return `<div class="check-row ${cls}"><span class="glyph">${glyph}</span><span class="name">${esc(name)}</span><span class="state">${state}</span></div>`;
};
// One row per project-defined check — labels and command patterns come
// from the served checks definition (local/checks over core/checks),
// the same file the adapter classifies against.
const checkRows = (S.state?.checks || []).map(c => {
let re = null;
try { re = new RegExp(c.pattern); } catch { /* skip unparseable */ }
const ev = re && rev.find(e => !e.running && e.cmd && re.test(e.cmd));
return checkRow(c.label, ev);
}).join('');
const checksPanel = `<div class="panel"><div class="phead"><span class="label">Checks</span></div>` +
checkRow('pytest', lastTest) + checkRow('lint-imports', lastLint) +
checkRow('frontend', lastFront) + `</div>`;
(checkRows || `<div class="check-row none"><span class="glyph"></span><span class="name">no checks defined</span><span class="state"></span></div>`) +
`</div>`;
let fileRows = '', fileHead = '';
const diff = agent && S.diffCache[agent.id];
+1 -1
View File
@@ -6,7 +6,7 @@
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:
../AGENTS.md for the workflow and the module map:
config.py paths, stages, launch configuration
state.py shared registries, event persistence, SSE fan-out
+18
View File
@@ -0,0 +1,18 @@
# Definition-of-done checks — the project half of the Focus view's
# CHECKS panel. One check per line:
#
# <label>: <regex matched against the commands agents run>
#
# The board never runs anything: the agent adapter classifies each
# command an agent runs against these patterns, and Focus shows the last
# matching run per row, judged pass/fail from the command's own output.
#
# Core ships this default; a file named `checks` in manager/local/
# replaces it WHOLESALE (same resolution as prompts — the local file
# wins by filename). The adapter and the browser read the same file, so
# a label edited here flows to classification and rendering alike; keep
# patterns in the regex dialect Python and JavaScript share. Read fresh
# on every event and request — edits apply without a restart.
pytest: \bpytest\b
lint-imports: \blint-imports\b
frontend: type-check|vue-tsc|\bnpm (run )?test\b|\bvitest\b
+50 -2
View File
@@ -9,6 +9,7 @@ things are* lives here. No state, no behaviour.
from __future__ import annotations
import os
import re
import subprocess
from pathlib import Path
@@ -92,8 +93,25 @@ ADAPTER = setting("BOARD_AGENT_ADAPTER", "claude")
# test/check commands) — neutral, comma-separated; each adapter renders
# them in its own permission-rule syntax. The universal git/gh grants are
# the adapter's own knowledge; this list is the project's half.
AGENT_COMMANDS = setting("BOARD_AGENT_COMMANDS",
"python3 -m unittest,python3 -m pytest")
AGENT_COMMANDS = setting("BOARD_AGENT_COMMANDS", "python3 -m unittest")
# Model per launch intent — an opaque vendor-native name core passes to the
# adapter untranslated (what names mean anything is vendor knowledge). Empty
# = inherit the vendor's own default, exactly today's behaviour. A per-intent
# setting beats the general one; review covers PR reviews and relevance
# checks (they share the review intent).
AGENT_MODEL = setting("BOARD_AGENT_MODEL", "")
AGENT_MODELS = {
"work": setting("BOARD_AGENT_MODEL_WORK", ""),
"act-pr": setting("BOARD_AGENT_MODEL_ACT_PR", ""),
"review": setting("BOARD_AGENT_MODEL_REVIEW", ""),
}
def agent_model(mode: str) -> str:
"""The model one launch intent rides — '' means inherit."""
return AGENT_MODELS.get(mode, "") or AGENT_MODEL
# 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.
@@ -120,6 +138,36 @@ def prompt(name: str) -> str:
return (CORE / "prompts" / name).read_text(encoding="utf-8")
def checks() -> list[dict]:
"""Definition-of-done checks for the Focus panel: core ships a default
(core/checks); a local/checks replaces it wholesale, like prompts. Each
line is `<label>: <command regex>`; invalid regexes are skipped. The
agent adapter reads the same file to classify commands (the claude
adapter's emit.py is standalone, so the parser is mirrored there), and
the browser matches with the served patterns keep them in the regex
dialect Python and JavaScript share. Read fresh on every request."""
for base in (LOCAL, CORE):
path = base / "checks"
if not path.is_file():
continue
entries = []
for line in path.read_text(encoding="utf-8").splitlines():
line = line.strip()
if not line or line.startswith("#"):
continue
label, sep, pattern = line.partition(":")
label, pattern = label.strip(), pattern.strip()
if not sep or not label or not pattern:
continue
try:
re.compile(pattern)
except re.error:
continue
entries.append({"label": label, "pattern": pattern})
return entries
return []
def adapter_dir() -> Path | None:
"""The configured agent adapter's directory — local overrides core."""
for base in (LOCAL / "adapters", CORE / "adapters"):
+1
View File
@@ -35,6 +35,7 @@ def state_payload() -> dict:
"branches": github.task_branches(),
"commands": config.commands(),
"commandRuns": commands.public(),
"checks": config.checks(),
"archivedCount": taskfiles.archived_count(),
"boardEvents": board_events,
"now": time.time(),
+1 -1
View File
@@ -14,7 +14,7 @@ Do this properly:
repos/{{owner}}/{{repo}}/pulls/<number>/comments`.
- Address each point in the code. If you disagree with a point, do not
silently ignore it — leave it unchanged and say why in your summary.
- Follow repo CLAUDE.md: layering rules, definition of done. Run the tests
- Follow repo AGENTS.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.
+1 -1
View File
@@ -14,7 +14,7 @@ Review the PR properly:
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
- Check it against AGENTS.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
+1 -1
View File
@@ -14,7 +14,7 @@ written? Specifically:
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
You are read-only: make NO edits, NO commits, move nothing. Read AGENTS.md
and the code; run read-only commands (grep, git log) as needed.
End with a report whose FIRST line is exactly
+2 -2
View File
@@ -4,7 +4,7 @@ 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
Read AGENTS.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:
@@ -24,7 +24,7 @@ NOT READY: <one-line reason>
followed by a bullet list of the specific questions that block the task.
The board treats that marker as "send the task back for refinement". Only
questions that change what should be built count — implementation details
you can decide yourself by reading the codebase and CLAUDE.md do not.
you can decide yourself by reading the codebase and AGENTS.md do not.
Rules:
- Do NOT move, rename or edit the task file itself — the board manages its
+1 -1
View File
@@ -1,6 +1,6 @@
"""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
The directory a task file sits in *is* its status (see ../AGENTS.md). Nothing
here knows about agents or HTTP; it is the same folder kanban you could drive
by hand with mv.
"""
+9
View File
@@ -0,0 +1,9 @@
# 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.
Bench's definition of done is `python3 -m unittest` and nothing else; the
`checks` file beside this one is what the Focus view's CHECKS panel shows
for this project.
+4 -4
View File
@@ -1,5 +1,5 @@
# Project-specific workflow notes
<!-- Compatibility pointer, load-bearing: the project notes live in AGENTS.md
(the vendor-neutral name all coding agents read); this file makes Claude Code
CLIs without native AGENTS.md support load it via the import below. -->
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.
@AGENTS.md
+4
View File
@@ -0,0 +1,4 @@
# Bench's own definition of done: the stdlib test suite, nothing else —
# no lint step, no frontend build. Replaces core/checks wholesale; format
# documented there.
unittest: \bunittest\b
+6 -6
View File
@@ -8,7 +8,7 @@ Anything below marked *optional* is deletable, and deleting beats leaving
it hollow — an empty boilerplate section reads as thinking that never
happened. Board process (review, PR, CI, merge) and the project-wide
definition of done stay off the card: the board does the former
mechanically and the repo CLAUDE.md owns the latter.
mechanically and the repo AGENTS.md owns the latter.
This template lives in tasks/, which updates never touch — improvements to
it ship only with fresh installs, so local edits are yours to keep.
@@ -33,13 +33,13 @@ tasks (`../done/...`), plan files (`../../plans/...`) and reference
documents (`../../reference/...`) — a link outlives a summary.
**Affected areas:** the modules or layers this touches, one line in the
repo CLAUDE.md's module-map vocabulary — telling reviewers where to look
repo AGENTS.md's module-map vocabulary — telling reviewers where to look
and agents where to stop. Optional: delete when the title already says it.
## 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
in — the repo AGENTS.md's dependency rules decide where code goes, not
convenience.
- First piece
@@ -55,9 +55,9 @@ tempting neighbours — delete rather than leave empty.
## Acceptance
Observable outcomes, not implementation steps — review agents judge the
diff against exactly this list. The repo's definition of done (tests pass,
`lint-imports` clean, new behaviour covered) applies on top; don't restate
it. Given/When/Then phrasing is welcome where it sharpens a criterion, and
diff against exactly this list. The repo's definition of done (the
project's configured checks pass, new behaviour covered) applies on top;
don't restate it. Given/When/Then phrasing is welcome where it sharpens a criterion, and
edge cases belong here too — boundaries, empty inputs, failure paths.
- [ ] Something a reviewer can check without reading the diff
+239
View File
@@ -0,0 +1,239 @@
"""Choosing agent models per launch intent (task 12): the BOARD_AGENT_MODEL
settings resolve intent model in config, travel to the adapter as
AGENT_MODEL (absent when empty never an empty flag value), and each
adapter renders the opaque name natively. With nothing set, launches are
byte-identical to the inherit-everything behaviour.
The `run` scripts are exercised end-to-end against stub binaries
(BOARD_CLAUDE_BIN / BOARD_OPENCODE_BIN), the same seam a live board uses.
Run with: python3 -m unittest discover -s tests
"""
import importlib.util
import json
import os
import stat
import subprocess
import sys
import tempfile
import unittest
from pathlib import Path
REPO = Path(__file__).resolve().parents[1]
CORE = REPO / "manager" / "core"
CLAUDE = CORE / "adapters" / "claude"
OPENCODE = CORE / "adapters" / "opencode"
sys.path.insert(0, str(CORE))
import agents # noqa: E402
import config # noqa: E402
def _load(name, path):
spec = importlib.util.spec_from_file_location(name, path)
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module
permission_config = _load("permission_config", OPENCODE / "permission_config.py")
# Neutralize any local/.env or shell leakage: process env beats .env, and
# an empty value is exactly "nothing configured".
UNSET = {"BOARD_AGENT_MODEL": "", "BOARD_AGENT_MODEL_WORK": "",
"BOARD_AGENT_MODEL_ACT_PR": "", "BOARD_AGENT_MODEL_REVIEW": ""}
def _resolve(settings: dict) -> dict:
"""config.agent_model per intent, in a fresh interpreter so the given
settings are what config reads at import."""
env = dict(os.environ)
env.update(UNSET)
env.update(settings)
out = subprocess.check_output(
[sys.executable, "-c",
"import sys; sys.path.insert(0, sys.argv[1]); import config, json; "
"print(json.dumps({m: config.agent_model(m) "
"for m in ('work', 'act-pr', 'review')}))",
str(CORE)],
env=env, text=True)
return json.loads(out)
class ModelResolution(unittest.TestCase):
def test_nothing_set_means_inherit_for_every_intent(self):
self.assertEqual(_resolve({}),
{"work": "", "act-pr": "", "review": ""})
def test_the_general_setting_covers_all_intents(self):
self.assertEqual(_resolve({"BOARD_AGENT_MODEL": "vendor-x"}),
{"work": "vendor-x", "act-pr": "vendor-x",
"review": "vendor-x"})
def test_a_per_intent_setting_beats_the_general_one_for_that_intent_only(self):
resolved = _resolve({"BOARD_AGENT_MODEL": "big",
"BOARD_AGENT_MODEL_REVIEW": "cheap"})
self.assertEqual(resolved,
{"work": "big", "act-pr": "big", "review": "cheap"})
def test_per_intent_alone_leaves_the_others_inheriting(self):
resolved = _resolve({"BOARD_AGENT_MODEL_ACT_PR": "pusher"})
self.assertEqual(resolved,
{"work": "", "act-pr": "pusher", "review": ""})
class LaunchEnv(unittest.TestCase):
"""_launch's half of the contract: AGENT_MODEL set iff a model is
configured absent means absent, even against a leaky environment."""
def setUp(self):
self._tmp = tempfile.TemporaryDirectory()
tmp = Path(self._tmp.name)
self.capture = tmp / "env.json"
adapter = tmp / "stub-adapter"
adapter.mkdir()
run = adapter / "run"
run.write_text("#!/usr/bin/env python3\n"
"import json, os\n"
f"open({str(self.capture)!r}, 'w').write(json.dumps(dict(os.environ)))\n")
run.chmod(run.stat().st_mode | stat.S_IXUSR)
self._saved = (config.AGENT_MODEL, config.AGENT_MODELS,
config.adapter_dir, config.child_env)
config.adapter_dir = lambda: adapter
# A stray AGENT_MODEL inherited by the board process must not leak.
config.child_env = lambda: {"PATH": os.environ.get("PATH", ""),
"AGENT_MODEL": "stray-from-the-shell"}
def tearDown(self):
(config.AGENT_MODEL, config.AGENT_MODELS,
config.adapter_dir, config.child_env) = self._saved
self._tmp.cleanup()
def _launch_env(self, mode: str) -> tuple[dict, str]:
log = Path(self._tmp.name) / "job.log"
proc, log_file, model = agents._launch(
mode, "do the task", Path(self._tmp.name), "id-1", "t.md", log)
proc.wait()
log_file.close()
return json.loads(self.capture.read_text()), model
def test_no_model_configured_means_no_variable_at_all(self):
config.AGENT_MODEL = ""
config.AGENT_MODELS = {"work": "", "act-pr": "", "review": ""}
env, model = self._launch_env("work")
self.assertNotIn("AGENT_MODEL", env)
self.assertEqual(model, "")
def test_the_resolved_model_arrives_as_agent_model(self):
config.AGENT_MODEL = "big"
config.AGENT_MODELS = {"work": "", "act-pr": "", "review": "cheap"}
env, model = self._launch_env("review")
self.assertEqual(env["AGENT_MODEL"], "cheap")
self.assertEqual(model, "cheap")
env, model = self._launch_env("work")
self.assertEqual(env["AGENT_MODEL"], "big")
self.assertEqual(model, "big")
class AgentRecord(unittest.TestCase):
def test_public_record_carries_the_model_none_means_inherited(self):
base = {"id": "a", "task": "t.md", "branch": None, "worktree": None,
"status": "running", "rc": None, "started": 0.0,
"session": None, "mode": "review", "name": "Wren"}
self.assertIsNone(agents._agent_public(base)["model"])
self.assertEqual(
agents._agent_public({**base, "model": "cheap"})["model"], "cheap")
def _write_stub(directory: Path, name: str, script: str) -> Path:
stub = directory / name
stub.write_text(script, encoding="utf-8")
stub.chmod(stub.stat().st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH)
return stub
class ClaudeRunModel(unittest.TestCase):
"""AGENT_MODEL reaches the claude launch as --model; absent (or empty,
which must never happen but costs nothing to survive) = no flag."""
def _run(self, model: str | None) -> list[str]:
with tempfile.TemporaryDirectory() as tmp:
capture = Path(tmp) / "args.json"
stub = _write_stub(Path(tmp), "claude-stub",
"#!/usr/bin/env python3\n"
"import json, sys\n"
f"open({str(capture)!r}, 'w').write(json.dumps(sys.argv[1:]))\n")
wrapper = _write_stub(Path(tmp), "bin",
f"#!/usr/bin/env bash\nexec python3 {stub} \"$@\"\n")
env = dict(os.environ)
env.pop("AGENT_MODEL", None)
env.update({"BOARD_CLAUDE_BIN": str(wrapper),
"AGENT_PROMPT": "do the task", "AGENT_MODE": "work",
"AGENT_COMMANDS": "python3 -m unittest"})
if model is not None:
env["AGENT_MODEL"] = model
result = subprocess.run(["bash", str(CLAUDE / "run")], env=env,
capture_output=True, text=True)
self.assertEqual(result.returncode, 0, result.stderr)
return json.loads(capture.read_text())
def test_unset_launches_byte_identical_to_today(self):
args = self._run(None)
self.assertNotIn("--model", args)
self.assertEqual(args, self._run("")) # empty behaves like absent
def test_set_appends_model_and_changes_nothing_else(self):
args = self._run("claude-model-x")
i = args.index("--model")
self.assertEqual(args[i + 1], "claude-model-x")
self.assertEqual(args[:i] + args[i + 2:], self._run(None))
class OpencodeRunModel(unittest.TestCase):
"""AGENT_MODEL reaches the opencode launch as the generated config's
model key; absent = no key, config byte-identical to today."""
def _run(self, model: str | None) -> dict:
with tempfile.TemporaryDirectory() as tmp:
capture = Path(tmp) / "capture.json"
stub = _write_stub(Path(tmp), "opencode-stub",
"#!/usr/bin/env python3\n"
"import json, os\n"
f"open({str(capture)!r}, 'w').write("
"json.dumps(json.load(open(os.environ['OPENCODE_CONFIG']))))\n")
wrapper = _write_stub(Path(tmp), "bin",
f"#!/usr/bin/env bash\nexec python3 {stub} \"$@\"\n")
env = dict(os.environ)
env.pop("AGENT_MODEL", None)
env.update({"BOARD_OPENCODE_BIN": str(wrapper),
"AGENT_PROMPT": "do the task", "AGENT_MODE": "review",
"AGENT_COMMANDS": "python3 -m unittest"})
if model is not None:
env["AGENT_MODEL"] = model
result = subprocess.run(["bash", str(OPENCODE / "run")], env=env,
capture_output=True, text=True)
self.assertEqual(result.returncode, 0, result.stderr)
return json.loads(capture.read_text())
def test_unset_launches_byte_identical_to_today(self):
cfg = self._run(None)
self.assertNotIn("model", cfg)
self.assertEqual(cfg, permission_config.build_config(
"review", ["python3 -m unittest"]))
def test_set_lands_as_the_config_model_key_untranslated(self):
cfg = self._run("anthropic/model-x")
self.assertEqual(cfg["model"], "anthropic/model-x")
del cfg["model"]
self.assertEqual(cfg, self._run(None))
def test_build_config_only_grows_the_key_when_given_a_model(self):
commands = ["python3 -m unittest"]
self.assertNotIn("model", permission_config.build_config("work", commands))
self.assertEqual(
permission_config.build_config("work", commands, "p/m")["model"], "p/m")
if __name__ == "__main__":
unittest.main()
+263
View File
@@ -0,0 +1,263 @@
"""Project-owned definition-of-done checks (task 03).
What counts as a check is project knowledge: core ships a default
definition (core/checks), a same-named file in local/ replaces it
wholesale, the claude adapter classifies agent commands against the
resolved file, and the Focus panel renders one row per entry from the
same definition served over /api/state. These tests pin the resolution
order, the two parsers' agreement, the generic pass/fail judgment that
replaced per-tool output parsing, and the absence of the origin
project's stack anywhere else in core.
python3 -m unittest discover -s tests -v
"""
from __future__ import annotations
import re
import sys
import tempfile
import unittest
from pathlib import Path
REPO = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(REPO / "manager" / "core"))
sys.path.insert(0, str(REPO / "manager" / "core" / "adapters" / "claude"))
import config # noqa: E402
import emit # noqa: E402
CORE = REPO / "manager" / "core"
class ShippedDefault(unittest.TestCase):
"""A project defining nothing sees today's rows — as the default."""
def test_default_rows_and_patterns(self):
original = config.LOCAL
try:
with tempfile.TemporaryDirectory() as tmp:
config.LOCAL = Path(tmp) # no local/checks
checks = config.checks()
finally:
config.LOCAL = original
self.assertEqual([c["label"] for c in checks],
["pytest", "lint-imports", "frontend"])
by_label = {c["label"]: c["pattern"] for c in checks}
self.assertTrue(re.search(by_label["pytest"], "python3 -m pytest -q"))
self.assertTrue(re.search(by_label["lint-imports"], "lint-imports"))
for cmd in ("npm run test", "vue-tsc --noEmit", "npx vitest run"):
self.assertTrue(re.search(by_label["frontend"], cmd), cmd)
self.assertFalse(re.search(by_label["pytest"], "python3 -m unittest"))
class LocalOverride(unittest.TestCase):
"""local/checks beats core/checks wholesale, like prompts."""
def _with_local(self, text):
original = config.LOCAL
try:
with tempfile.TemporaryDirectory() as tmp:
config.LOCAL = Path(tmp)
(Path(tmp) / "checks").write_text(text, encoding="utf-8")
return config.checks()
finally:
config.LOCAL = original
def test_local_definition_wins(self):
checks = self._with_local("# ours\nsmoke: \\bmake smoke\\b\n"
"types: \\bmypy\\b\n")
self.assertEqual([c["label"] for c in checks], ["smoke", "types"])
def test_replacement_is_wholesale_even_when_empty(self):
self.assertEqual(self._with_local("# nothing to check\n"), [])
def test_malformed_lines_are_skipped(self):
checks = self._with_local("no separator here\n"
": pattern without label\n"
"label without pattern:\n"
"bad-regex: [unclosed\n"
"good: \\bok\\b\n")
self.assertEqual([c["label"] for c in checks], ["good"])
def test_bench_defines_its_own(self):
"""Bench's real local/checks: the stdlib suite, so the self-hosted
Focus view shows a check that can actually run here."""
checks = config.checks()
self.assertEqual([c["label"] for c in checks], ["unittest"])
self.assertTrue(re.search(checks[0]["pattern"], "python3 -m unittest"))
class AdapterReadsTheSameFile(unittest.TestCase):
"""emit.py resolves and parses the identical definition, so a label
edit flows to classification and rendering alike."""
def test_parsers_agree_on_the_shipped_default(self):
original = emit.MANAGER
try:
emit.MANAGER = Path(tempfile.mkdtemp()) # no local, no core
(emit.MANAGER / "core").mkdir()
(emit.MANAGER / "core" / "checks").write_text(
(CORE / "checks").read_text(encoding="utf-8"), encoding="utf-8")
adapter_view = [(label, pattern.pattern)
for label, pattern in emit.check_defs()]
finally:
emit.MANAGER = original
core_original = config.LOCAL
try:
with tempfile.TemporaryDirectory() as tmp:
config.LOCAL = Path(tmp)
board_view = [(c["label"], c["pattern"]) for c in config.checks()]
finally:
config.LOCAL = core_original
self.assertEqual(adapter_view, board_view)
def test_local_wins_in_the_adapter_too(self):
original = emit.MANAGER
try:
with tempfile.TemporaryDirectory() as tmp:
emit.MANAGER = Path(tmp)
(Path(tmp) / "core").mkdir()
(Path(tmp) / "core" / "checks").write_text(
"core-only: \\bx\\b\n", encoding="utf-8")
(Path(tmp) / "local").mkdir()
(Path(tmp) / "local" / "checks").write_text(
"ours: \\bmake check\\b\n", encoding="utf-8")
defs = emit.check_defs()
finally:
emit.MANAGER = original
self.assertEqual([label for label, _ in defs], ["ours"])
def test_missing_files_mean_no_checks_not_a_crash(self):
original = emit.MANAGER
try:
with tempfile.TemporaryDirectory() as tmp:
emit.MANAGER = Path(tmp)
self.assertEqual(emit.check_defs(), [])
finally:
emit.MANAGER = original
class Classification(unittest.TestCase):
"""Bash commands classify against the resolved definitions; the label
carries into the summary; judgment is generic, not per-tool."""
def setUp(self):
self._original = emit.MANAGER
self._tmp = tempfile.TemporaryDirectory()
emit.MANAGER = Path(self._tmp.name)
(emit.MANAGER / "core").mkdir()
(emit.MANAGER / "core" / "checks").write_text(
"suite: \\bunittest\\b\nlint: \\bmake lint\\b\n", encoding="utf-8")
def tearDown(self):
emit.MANAGER = self._original
self._tmp.cleanup()
def _bash(self, cmd, out="", hook="PostToolUse"):
return emit.classify(hook, "Bash", {"command": cmd},
{"stdout": out, "stderr": ""})
def test_matching_command_becomes_a_check_with_its_label(self):
ev = self._bash("python3 -m unittest discover -s tests",
"Ran 7 tests in 0.1s\n\nOK")
self.assertEqual(ev["kind"], "check")
self.assertTrue(ev["ok"])
self.assertTrue(ev["summary"].startswith("suite — "))
def test_label_edits_flow_to_the_event(self):
(emit.MANAGER / "core" / "checks").write_text(
"renamed: \\bunittest\\b\n", encoding="utf-8")
ev = self._bash("python3 -m unittest", "OK")
self.assertTrue(ev["summary"].startswith("renamed — "))
def test_counted_failures_fail(self):
ev = self._bash("python3 -m unittest", "2 failed, 5 passed in 1.2s")
self.assertEqual(ev["kind"], "check")
self.assertFalse(ev["ok"])
self.assertIn("2 failed", ev["summary"])
self.assertIn("5 passed", ev["summary"])
def test_unjudgeable_output_stays_neutral(self):
ev = self._bash("make lint", "some chatter")
self.assertEqual(ev["kind"], "check")
self.assertIsNone(ev["ok"])
def test_unmatched_commands_stay_commands(self):
self.assertEqual(self._bash("ls -la")["kind"], "command")
def test_git_classification_survives(self):
ev = self._bash('git commit -m "a message"')
self.assertEqual(ev["kind"], "git")
def test_running_events_keep_the_check_kind(self):
ev = self._bash("python3 -m unittest", hook="PreToolUse")
self.assertEqual(ev["kind"], "check")
self.assertTrue(ev["running"])
class GenericJudgment(unittest.TestCase):
"""No tool names: counts, broken totals and OK/FAILED verdict lines."""
def test_ladder(self):
cases = [
("3 passed in 0.5s", True),
("1 failed, 2 passed", False),
("2 errors", False),
("Ran 7 tests in 0.1s\n\nOK", True),
("Ran 7 tests in 0.1s\n\nFAILED (failures=1)", False),
("0 broken contracts", True),
("4 broken contracts", False),
("nothing recognizable", None),
("", None),
]
for out, expected in cases:
ok, _ = emit.judge(out)
self.assertEqual(ok, expected, f"judge({out!r})")
class ServedToTheBrowser(unittest.TestCase):
"""The state API carries the definition; Focus renders from it with
no fixed rows and no duplicated patterns."""
@classmethod
def setUpClass(cls):
cls.html = (CORE / "board.html").read_text(encoding="utf-8")
def test_state_payload_includes_checks(self):
import httpd
payload = httpd.state_payload()
self.assertIn("checks", payload)
self.assertEqual(payload["checks"], config.checks())
def test_focus_renders_from_served_definition(self):
self.assertIn("S.state?.checks", self.html)
self.assertIn("new RegExp(c.pattern)", self.html)
self.assertIn("no checks defined", self.html)
def test_no_fixed_rows_or_duplicated_patterns(self):
for fossil in ("lint-imports", "vitest", "vue-tsc", "pytest"):
self.assertNotIn(fossil, self.html, f"board.html still hardcodes {fossil}")
class NoFossilsInCore(unittest.TestCase):
"""Nothing in manager/core/ names the origin project's stack outside
the shipped default checks definition."""
def test_core_is_clean(self):
allowed = CORE / "checks"
pattern = re.compile(r"pytest|lint-imports|vue-tsc|vitest")
for path in sorted(CORE.rglob("*")):
if not path.is_file() or path == allowed:
continue
try:
text = path.read_text(encoding="utf-8")
except UnicodeDecodeError:
continue
self.assertIsNone(pattern.search(text),
f"{path.relative_to(REPO)} names a stack "
"that belongs in the checks definition")
if __name__ == "__main__":
unittest.main()
+82
View File
@@ -0,0 +1,82 @@
"""The kanban's columns yield on small laptops (task 09).
board.html is a single file with inline JS and no frontend test runner, so
these are source-level invariants: the ones that, if broken, would bring
back the rigid 296px columns that clipped the Done column on a 13" MacBook,
or let the floor drift above what a 1280px viewport can hold.
python3 -m unittest discover -s tests -v
"""
from __future__ import annotations
import re
import unittest
from pathlib import Path
BOARD = Path(__file__).resolve().parents[1] / "manager" / "core" / "board.html"
COLUMNS = 5 # backlog → to-do → in-progress → review → done
class ColumnFlexTests(unittest.TestCase):
@classmethod
def setUpClass(cls):
cls.html = BOARD.read_text(encoding="utf-8")
m = re.search(r"\.kcol\{([^}]*)\}", cls.html)
assert m, "board.html lost its .kcol rule"
cls.kcol = m.group(1)
def token(self, name: str) -> int:
m = re.search(rf"{name}:\s*(\d+)px", self.html)
self.assertIsNotNone(m, f"{name} must be a px token at the top of board.html")
return int(m.group(1))
def test_floor_is_a_design_token(self):
"""The floor lives beside --col with the other layout tokens, not as
a magic number buried in the rule."""
self.token("--col-min")
self.assertIn("min-width:var(--col-min)", self.kcol.replace(" ", ""),
".kcol must take its floor from the --col-min token")
def test_columns_yield_between_floor_and_cap(self):
"""flex:1 1 0 with max-width:var(--col): wide screens cap at today's
296px (pixel-identical), narrower viewports shrink all five evenly."""
flat = self.kcol.replace(" ", "")
self.assertIn("flex:110", flat,
".kcol must grow and shrink from a zero basis")
self.assertIn("max-width:var(--col)", flat,
"wide screens must still cap columns at --col")
self.assertNotIn("width:var(--col);", flat.replace("max-width", ""),
"a fixed width would undo the flex")
self.assertEqual(self.token("--col"), 296,
"the cap is today's column width; changing it is a "
"redesign, not this fix")
def test_five_columns_fit_a_1280px_viewport(self):
"""At the floor, 5 columns + 4 gaps + the board's own padding must
not exceed 1280 CSS px the smallest laptop this board honours."""
floor = self.token("--col-min")
gap = self.token("--gap")
board = re.search(r"#board\{([^}]*)\}", self.html).group(1)
pad = re.search(r"padding:\s*\d+px\s+(\d+)px", board)
self.assertIsNotNone(pad, "#board lost its horizontal padding")
footprint = COLUMNS * floor + (COLUMNS - 1) * gap + 2 * int(pad.group(1))
self.assertLessEqual(footprint, 1280,
f"five columns at the floor need {footprint}px; "
"a 1280px viewport would scroll")
def test_cards_stay_legible_at_the_floor(self):
"""Shrinking must stop somewhere: a floor under ~240px would start
crushing card internals instead of ellipsising them."""
self.assertGreaterEqual(self.token("--col-min"), 240)
def test_overflow_fallback_survives(self):
"""Below the floor the old behaviour is the fallback: #board still
scrolls horizontally rather than clipping."""
board = re.search(r"#board\{([^}]*)\}", self.html).group(1)
self.assertIn("overflow-x:auto", board.replace(" ", ""))
if __name__ == "__main__":
unittest.main()
+143
View File
@@ -0,0 +1,143 @@
"""install.py's first-boot cleaning: a vendored clone's very first run
clears the distribution's own cards so a new host starts with a pristine
board, and no later run ever touches the host's own. Run with:
python3 -m unittest discover -s tests
install.py is exercised end-to-end as a subprocess against scratch host
layouts the same entry point start.sh uses so what is asserted is
what a real first boot does to disk.
"""
import os
import shutil
import subprocess
import sys
import tempfile
import unittest
from pathlib import Path
REPO = Path(__file__).resolve().parents[1]
STAGES = ["backlog", "to-do", "in-progress", "review", "done"]
KEEP = {".gitkeep", "task-template.md"}
def make_host(root: Path) -> Path:
"""A host project with a freshly vendored .task-manager: the real
install.py and claude adapter, plus the distribution's shipped cards."""
host = root / "host"
(host / ".claude").mkdir(parents=True)
tm = host / ".task-manager"
tm.mkdir()
shutil.copy(REPO / "install.py", tm / "install.py")
shutil.copytree(REPO / "manager" / "core" / "adapters" / "claude",
tm / "manager" / "core" / "adapters" / "claude")
for stage in STAGES + ["archive"]:
d = tm / "tasks" / stage
d.mkdir(parents=True)
(d / ".gitkeep").touch()
(d / "00-shipped-card.md").write_text("# Shipped\n", encoding="utf-8")
(tm / "tasks" / "task-template.md").write_text("# Template\n", encoding="utf-8")
for extra in ["plans", "reference"]:
d = tm / extra
d.mkdir()
(d / ".gitkeep").touch()
(d / "shipped.md").write_text("shipped\n", encoding="utf-8")
(tm / "reference" / "shots").mkdir()
(tm / "reference" / "shots" / "board.png").write_bytes(b"png")
return tm
def run_install(tm: Path, *args: str) -> subprocess.CompletedProcess:
env = {k: v for k, v in os.environ.items() if not k.startswith("BOARD_")}
return subprocess.run(
[sys.executable, str(tm / "install.py"), *args],
capture_output=True, text=True, cwd=tm.parent, env=env)
def shipped_files(tm: Path) -> list[Path]:
"""Every file under the cleaned directories that first boot should
have removed empty means the board is pristine."""
return [p
for top in [tm / "tasks", tm / "plans", tm / "reference"]
for p in top.rglob("*")
if p.is_file() and p.name not in KEEP]
class FirstBoot(unittest.TestCase):
def setUp(self):
self.scratch = Path(tempfile.mkdtemp()).resolve()
self.addCleanup(shutil.rmtree, self.scratch, True)
self.tm = make_host(self.scratch)
def test_first_run_clears_shipped_content_and_prints_each_removal(self):
result = run_install(self.tm)
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
self.assertEqual(shipped_files(self.tm), [])
for stage in STAGES + ["archive"]:
self.assertTrue((self.tm / "tasks" / stage / ".gitkeep").is_file())
self.assertTrue((self.tm / "tasks" / "task-template.md").is_file())
self.assertTrue((self.tm / "plans" / ".gitkeep").is_file())
self.assertTrue((self.tm / "reference" / ".gitkeep").is_file())
for line in ["tasks/backlog/00-shipped-card.md",
"tasks/archive/00-shipped-card.md",
"plans/shipped.md", "reference/shots"]:
self.assertIn(f"removed {line}", result.stdout)
self.assertTrue((self.tm / "manager" / "local" / "state").is_dir())
def test_second_run_removes_nothing_and_host_cards_survive(self):
run_install(self.tm)
card = self.tm / "tasks" / "backlog" / "20-host-card.md"
card.write_text("# The host's own\n", encoding="utf-8")
result = run_install(self.tm)
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
self.assertNotIn("removed", result.stdout)
self.assertTrue(card.is_file())
self.assertIn("ok", result.stdout)
def test_dry_run_lists_without_removing(self):
result = run_install(self.tm, "--dry-run")
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
self.assertIn("would remove tasks/backlog/00-shipped-card.md",
result.stdout)
self.assertNotIn("removed ", result.stdout)
self.assertNotEqual(shipped_files(self.tm), [])
self.assertFalse((self.tm / "manager" / "local" / "state").exists())
def test_existing_env_file_disarms_the_guard(self):
local = self.tm / "manager" / "local"
local.mkdir(parents=True)
(local / ".env").write_text("BOARD_PORT=26071\n", encoding="utf-8")
result = run_install(self.tm)
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
self.assertNotIn("removed", result.stdout)
self.assertNotEqual(shipped_files(self.tm), [])
self.assertFalse((local / "state").exists())
def test_symlinked_leftover_is_unlinked_not_followed(self):
"""A symlink among the leftovers is removed as a link — the
directory it points to survives untouched."""
outside = self.scratch / "outside"
outside.mkdir()
(outside / "precious.md").write_text("keep me\n", encoding="utf-8")
link = self.tm / "tasks" / "backlog" / "10-linked"
link.symlink_to(outside)
result = run_install(self.tm)
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
self.assertFalse(link.is_symlink())
self.assertFalse(link.exists())
self.assertTrue((outside / "precious.md").is_file())
def test_self_hosted_repo_is_never_cleaned(self):
"""When the manager IS the repo (bench itself, or a dev clone of
it), tasks/ is that repo's history — even unwired, never touched."""
subprocess.run(["git", "init", "-q", str(self.tm)], check=True,
capture_output=True)
(self.tm / ".claude").mkdir()
result = run_install(self.tm)
self.assertNotIn("removed", result.stdout)
self.assertNotEqual(shipped_files(self.tm), [])
self.assertFalse((self.tm / "manager" / "local" / "state").exists())
if __name__ == "__main__":
unittest.main()
+97
View File
@@ -0,0 +1,97 @@
"""update.sh's brief rename round-trip: a project installed on the old
layout (a full vendor-named CLAUDE.md, no AGENTS.md) updates to a core
that ships AGENTS.md as the brief plus a pointer CLAUDE.md the brief
must arrive, the pointer must replace the old full copy, and nothing a
project owns may move. Run with: python3 -m unittest discover -s tests
update.sh is exercised end-to-end as a subprocess against a scratch
install and a scratch distribution repo built from this repo's real
top-level files, so what is asserted is what a real update does to disk.
"""
import shutil
import subprocess
import tempfile
import unittest
from pathlib import Path
REPO = Path(__file__).resolve().parents[1]
TOP_FILES = ["AGENTS.md", "CLAUDE.md", "README.md", "install.py",
"start.sh", "stop.sh", "update.sh"]
OLD_BRIEF = "# Task Workflow\n\nThe old full vendor-named brief.\n"
OLD_LOCAL_NOTES = "# Project notes\n\nThe project's own, old-style.\n"
def make_dist(root: Path) -> Path:
"""A distribution repo carrying this repo's real top-level files and a
minimal manager/core/, committed so update.sh can clone it."""
dist = root / "dist"
(dist / "manager" / "core" / "adapters").mkdir(parents=True)
# A different length than the installed "old" — rsync's quick check
# (size+mtime) must see a change, as any real version bump would.
(dist / "manager" / "core" / "VERSION").write_text("new-version\n",
encoding="utf-8")
for f in TOP_FILES:
shutil.copy(REPO / f, dist / f)
for cmd in (["git", "init", "-q"], ["git", "add", "-A"],
["git", "-c", "user.name=t", "-c", "user.email=t@t",
"commit", "-qm", "dist"]):
subprocess.run(cmd, cwd=dist, check=True, capture_output=True)
return dist
def make_old_install(root: Path) -> Path:
"""An installed .task-manager on the pre-rename layout: the full brief
under the vendor name, no AGENTS.md anywhere."""
tm = root / "host" / ".task-manager"
(tm / "manager" / "core").mkdir(parents=True)
(tm / "manager" / "local").mkdir()
(tm / "tasks" / "backlog").mkdir(parents=True)
(tm / "manager" / "core" / "VERSION").write_text("old\n", encoding="utf-8")
(tm / "CLAUDE.md").write_text(OLD_BRIEF, encoding="utf-8")
(tm / "manager" / "local" / "CLAUDE.md").write_text(
OLD_LOCAL_NOTES, encoding="utf-8")
(tm / "tasks" / "backlog" / "01-card.md").write_text(
"# Card\n\n**Status:** Backlog\n", encoding="utf-8")
shutil.copy(REPO / "update.sh", tm / "update.sh")
(tm / "update.sh").chmod(0o755)
return tm
class UpdateRoundTrip(unittest.TestCase):
def setUp(self):
self.tmp = Path(tempfile.mkdtemp())
self.addCleanup(shutil.rmtree, self.tmp, True)
self.dist = make_dist(self.tmp)
self.tm = make_old_install(self.tmp)
result = subprocess.run(
["bash", str(self.tm / "update.sh")],
env={"PATH": "/usr/bin:/bin:/usr/local/bin",
"BENCH_SOURCE": self.dist.as_uri()},
capture_output=True, text=True)
self.assertEqual(result.returncode, 0, result.stderr)
def test_brief_arrives_under_the_cross_vendor_name(self):
agents = (self.tm / "AGENTS.md").read_text(encoding="utf-8")
self.assertEqual(agents,
(REPO / "AGENTS.md").read_text(encoding="utf-8"))
self.assertIn("# Task Workflow", agents)
def test_pointer_replaces_the_old_full_copy(self):
pointer = (self.tm / "CLAUDE.md").read_text(encoding="utf-8")
self.assertIn("@AGENTS.md", pointer)
self.assertNotEqual(pointer, OLD_BRIEF)
def test_core_updated_but_project_halves_untouched(self):
self.assertEqual(
(self.tm / "manager" / "core" / "VERSION").read_text(),
"new-version\n")
self.assertEqual(
(self.tm / "manager" / "local" / "CLAUDE.md").read_text(
encoding="utf-8"), OLD_LOCAL_NOTES)
self.assertTrue((self.tm / "tasks" / "backlog" / "01-card.md").exists())
if __name__ == "__main__":
unittest.main()
+5 -2
View File
@@ -4,7 +4,8 @@
# ./.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
# (AGENTS.md, its pointer 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.
#
@@ -37,7 +38,9 @@ if [ ! -d "$tmp/dist/manager/core" ]; then
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
# AGENTS.md is the workflow brief; CLAUDE.md its compatibility pointer —
# copying both means an old full CLAUDE.md is replaced, never resurrected.
for f in AGENTS.md CLAUDE.md README.md LICENSE 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