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:
@@ -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.
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
@@ -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
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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`).
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)))
|
||||
|
||||
@@ -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
@@ -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
@@ -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];
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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"):
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,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.
|
||||
"""
|
||||
|
||||
@@ -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.
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user