tasks: phases get a room of their own — 56, 57, 58, 59

56 takes phase members off the Board view (counts describe what is
visible; membership is the only thing hiding them, so done, archived or
an edited list gives them back). 57 draws them instead: a Phases view,
one swimlane per phase, the phase log under it, run-again where the halt
is. 58 refuses to move a phase card while a member has a live agent —
'hold the phase first' — and covers archive, which is the path that
would really have hurt. 59 sweeps merged members to done/ when the phase
merges, so an ending reads as an ending.
This commit is contained in:
istos
2026-08-02 07:15:00 +02:00
parent b6091809fe
commit c256d80efc
4 changed files with 402 additions and 0 deletions
@@ -0,0 +1,103 @@
# 56 — Phase members leave the main board
**Status:** Backlog
**Priority:** High — the main board is the thing phases currently spoil,
and every other phase-UI card is downstream of this one
**Type:** Feature
A phase's members stop appearing on the Board view. The phase card stays —
one card, in whatever stage the phase is in — and its members are drawn
somewhere else (card 57). The Board goes back to meaning one thing: the
work you are personally holding, in five columns that fit.
## Context
- Membership is already derived, not stored: `taskfiles.weave_phases()`
(`manager/core/taskfiles.py:163`) resolves every phase card's `## Cards`
list against the board and gives each member a
`phase: {file, number, title, index, total}`. Everything this card
needs is on the task by the time `collect()` returns.
- `httpd.state_payload()` already ships `phases: phases.public_state()`
(`:42`) beside the board, and `board.html` reads it in three places, so
the client can tell a phase card from an ordinary one without asking
again.
- `renderBoard()` (`board.html:945`) builds each column from
`stage.tasks` and prints `stage.tasks.length` in the header, with a note
beside it (`STAGE_NOTE[stage.slug]`, or "N agents" when agents are
working there).
- The design exploration that led here, including the two mockups and the
controls split:
https://claude.ai/code/artifact/727a3b64-1354-4fb0-a73d-b700bdfc2b19
**Affected areas:** `manager/core/board.html` — the Board view's
rendering and its column headers. No server change: the state payload
already says everything needed.
## What to build
- **A member is not drawn on the Board view.** It keeps its stage, its
file, its agent and its actions — it is simply not in this view. The
card is not deleted, moved, or marked; the Board just stops listing it.
- **Counts describe what is visible.** A column's number is the number of
cards you can see in it. This is the rule that keeps the header honest:
the count is true by construction rather than true-with-a-footnote.
- **A note where the work went**, beside the count and in the register the
existing note uses — `+2 in phases`. It is a signpost to the other
view, not a correction to the number, and it only appears where members
are actually hidden.
- **Membership is the only thing that hides a card.** So when a phase
reaches `done/`, is archived, or has its `## Cards` list edited, its
former members reappear on the Board in whatever stage they are
genuinely in — with no sweep, no migration and no second rule. Removing
membership *is* the un-hiding.
- **The phase card carries the summary it now owes.** It is the only
thing standing for that work on this view, so it shows progress
(`1 of 2 merged`), the member in flight, and a way through to the
Phases view.
- **Only the Board view hides.** Sessions and Focus are about runs and
sessions, not stages, and a phase member's agent is an agent like any
other.
**Out of scope** — tempting neighbours left alone:
- Where the members are drawn instead — card 57. Until it lands they are
reachable through the phase card and through Sessions, which is thin
but not a dead end.
- Refusing to move a phase card — card 58.
- What happens to members' stages when the phase merges — card 59.
- Hiding anything that is not a phase member.
## Acceptance
- [ ] Given a phase with two members, when the Board view renders, then
the members are absent and the phase card is present.
- [ ] The column that held a member counts one fewer, and its header says
`+1 in phases`.
- [ ] A column with no hidden members shows no such note.
- [ ] Given the phase card reaches `done/`, when the board redraws, then
its former members appear in the columns matching their actual
stages.
- [ ] The same is true when the phase card is archived, and when a number
is removed from its `## Cards` list.
- [ ] The header's live-agents chip still counts an agent working on a
hidden member — hiding cards must not hide activity.
- [ ] Edge case: a card listed by a phase card that does not resolve
(`phaseDrift`) is **not** hidden — an authoring mistake must not
make a card vanish.
- [ ] Edge case: with no phases on the board at all, the Board view is
byte-for-byte what it is today.
## Notes
The risk this card takes is stated plainly and should stay stated: **a
board that does not show everything is a board that can surprise you**,
and "you can see everything the agents are doing" is most of what bench
is for. Three things pay that back — the count note, the phase card's own
progress line, and the halt reaching the header whether or not you are on
the Phases view (57). If those three are not in place, this card makes
the product worse rather than better.
Worth watching for once it lands: a phase with a member in `review/`
means `review/` says `0` and yet something genuinely is waiting on you.
The note covers it, but "your move" as a column note is now a claim about
visible cards only.
@@ -0,0 +1,107 @@
# 57 — The Phases view: a swimlane each
**Status:** Backlog
**Priority:** High — card 56 takes the members off the Board; this is
where they go
**Type:** Feature
**Depends on:** 56 — the members have to have left before they need a room
A fourth view beside Board, Sessions and Focus. One swimlane per phase,
each running the same five stages left to right, holding every card that
phase owns. It is the room where horizontal work gets horizontal space,
which the five columns could never spare.
## Context
- The exploration and both mockups, including the table of which control
belongs to which view:
https://claude.ai/code/artifact/727a3b64-1354-4fb0-a73d-b700bdfc2b19
- Views already exist as a switcher: `setView()` (`board.html:1005`),
`VIEW_TITLES` (`:860`), `S.view`, and the `#views button` elements. A
fourth is an addition to a pattern, not a new one.
- Everything to draw a lane is already in the state payload:
`phases.public_state()` gives the runner's last pass per phase card —
progress, the member in flight, whether it halted and why — and
`weave_phases` has already told each member its phase and position.
- The phase card keeps its own record in a `## Phase log` section, one
line per decision the runner made. It is the thing you read when
something went sideways, and it has nowhere to be shown today.
- `stop_phase()` (`phases.py:662`) holds the phase *and* the agent it has
in flight; `start_phase()` runs it again and clears a halt.
**Affected areas:** `manager/core/board.html` — a new view and its
rendering. The API is already sufficient.
## What to build
- **A Phases view** in the switcher, carrying a count of running phases
and, when one has halted, a mark that says so from the Board view
without switching to find out.
- **One lane per phase**, ordered so a halted phase is not below the fold.
A lane has a head — the phase, its progress, the member in flight — and
five stage columns holding its cards.
- **The cards are the cards.** Full fidelity: the live agent line, the CI
and PR chips, the ordinary hover actions. Nothing is shrunk to a token,
which is the advantage a whole view buys.
- **The last column is the phase's own**, not `done/`: a member merged
into the phase branch is finished as far as the phase is concerned and
is not in `main` yet. Name it for what it is.
- **The phase log under the lane**, the runner's decisions in order. It is
the only thing that can distinguish "not reached yet" from "started and
ended badly".
- **A halted lane says so at the top of itself** — the reason, the card it
stopped on — and offers **▸ run again**. That action lives here and not
on the Board, because clearing a halt should mean having read what
caused it.
- **Per-phase controls in the lane head**: hold, the phase branch, the
phase card. Per-member controls are the card's own and need no
special-casing.
- **Nothing here that ends the phase.** Merging is a board move on the
phase card, and there should be exactly one place where work leaves the
board.
**Out of scope** — tempting neighbours left alone:
- Starting a phase, which stays on the phase card in the Board view: the
commitment is the card reaching `in-progress/`.
- **Merge & clean up**, for the same reason.
- Editing a phase's list — reordering, adding, removing — which is the
file's business and card 51's action.
- A per-phase Focus view. Focus is a heads-up for one session; this is a
different thing wearing similar words.
## Acceptance
- [ ] With at least one phase in `in-progress/`, the switcher offers
Phases and the view draws one lane per phase.
- [ ] A lane shows every card the phase lists, in the stage each is
actually in, with its position in the run.
- [ ] A member's card in the lane offers the same actions it would offer
on the Board.
- [ ] A halted phase names the reason and the card, offers **▸ run
again**, and running it again clears the halt and continues.
- [ ] **‖ hold** in the lane head stops the phase and the agent it has in
flight, and unwinds nothing.
- [ ] The phase log is readable under the lane, in the order the runner
wrote it.
- [ ] Given a phase halts while you are on the Board view, you learn it
there — the switcher marks it, and the existing toast and ticker
still fire.
- [ ] With no phases at all, the view says so plainly rather than
rendering an empty grid.
- [ ] Edge case: a phase whose members are all merged, waiting on its own
PR, still draws a lane rather than disappearing before you have
merged it.
## Notes
The reason this is a view and not a panel: five columns cannot hold two
boards at once, and every attempt to make them — a rail, a band, a thread
— either fights the geometry or hides the order. Given a room of its own,
the swimlane is the obvious drawing, and it was the obvious drawing all
along.
The thing to get right is not the lane, it is the **crossing**: a person
on the Board must learn that a phase halted without being on this view,
and a person here must be able to get back to the card that owns it. Two
signposts, both cheap, and the feature is untrustworthy without either.
@@ -0,0 +1,93 @@
# 58 — A phase card will not move while its work is running
**Status:** Backlog
**Priority:** Medium — the hole is narrow but what falls through it is a
phase whose card and branch disagree about what is happening
**Type:** Feature
Dragging a phase card between stages while one of its members has an
agent in it is a move nobody can mean. Refuse it, name what to do — **
hold** stops the phase and the agent it has in flight — and let the move
through the moment nothing is running.
## Context
- Today a phase card is an ordinary card to `move_task()`. Nothing
consults the phase, so a phase card can be walked back to `to-do/` (or
archived, which is a move) while its second member is mid-run, leaving
a card in one place and a live agent, a worktree and a phase branch in
another.
- The stopping half already exists and does exactly the right thing.
`stop_phase()` (`manager/core/phases.py:662`): *"the agent the phase has
in flight is held exactly as its own card's hold would hold it"*, and
the branch, the merges and the worktrees are left as they were. So the
fix for a refusal is one action the person already has.
- A **halted** phase has nothing running by construction, so it moves
freely — which is exactly when you would want to walk it back.
- The board has almost no refusals on drag. `move_task` raises for a bad
stage or an existing file; the drag-to-`done/` sheet *intercepts* rather
than refuses. This adds the first real "no", which is why the wording
matters more than the check.
**Affected areas:** `manager/core/taskfiles.py` or
`manager/core/phases.py` for the check, `manager/core/httpd.py` where
`/api/move` and `/api/archive` are served, and `manager/core/board.html`
for how a refused drag looks.
## What to build
- **Refuse the move while any member has a live agent.** Not "while the
phase is running" — a phase between members has nothing to lose, and
refusing then would be a rule people learn to resent.
- **Say the whole thing in the refusal**: which member is working, and
that **‖ hold** stops the phase and its agent without unwinding
anything. A refusal that only says no makes the person guess, and the
guess is usually to force it.
- **Archiving is a move.** `archive_task()` takes the same guard, or the
rule has a hole shaped exactly like the tidiest thing to do with a
phase you have lost interest in.
- **The card returns to where it was**, cleanly, with the reason in the
toast — a drag that snaps back with no explanation is worse than one
that is not allowed to start.
- **Guard the server, not just the drag.** The board can be stale, and
`/api/move` is reachable regardless — the same lesson card 54 wrote
down about `▸ start work` on a phase card.
**Out of scope** — tempting neighbours left alone:
- Stopping members individually. `‖ hold` on the phase covers it, and a
per-member stop is card 57's lane doing ordinary card things.
- Refusing to move a *member* card. That is a person overriding the
runner deliberately, and the phase halting on it afterwards is the
honest outcome.
- Anything about what the move does once allowed.
## Acceptance
- [ ] Given a phase with an agent running on a member, when its card is
dragged to another stage, then the move is refused, the card stays,
and the message names the working member and **‖ hold**.
- [ ] The same phase card cannot be archived while that agent runs.
- [ ] Given the phase is held, the card moves — and archives — normally.
- [ ] Given the phase has halted, the card moves normally: nothing is
running.
- [ ] Given a phase between members, with nothing launched, the card
moves normally.
- [ ] `POST /api/move` on that card is refused with the same reason, from
a stale page or by hand.
- [ ] An ordinary card is unaffected in every case.
- [ ] Edge case: a member's agent that has died but not been reaped does
not lock the phase card forever — the check reads what is actually
running, not what was once started.
## Notes
The rule earns its keep on the archive path more than the drag: a phase
you have given up on is precisely the one you would archive, and doing it
mid-run would leave a branch, a worktree and a running agent belonging to
a card that is no longer on the board.
Worth phrasing the refusal as an instruction rather than a prohibition —
"hold the phase first, then move it" reads as help, and "cannot be moved
while running" reads as a wall. The board's other refusals already do
this: work starts from `in-progress/` *— move the card there first*.
@@ -0,0 +1,99 @@
# 59 — Finishing a phase finishes its cards
**Status:** Backlog
**Priority:** Medium — without it a phase ends by handing you a pile of
cards you have already judged
**Type:** Feature
When a phase's PR merges into `main`, its members' work is in `main` too —
but their cards are sitting in `review/`, where the board says they are
waiting on you. **Merge & clean up** on a phase card should move its
members to `done/` in the same operation that moves the phase.
## Context
- A member stops at `review/`. The runner merges its branch into the
phase branch and records it, and deliberately does not move the card:
`done/` has always meant *merged into `main`*, and merged into a phase
branch is not that. That reasoning is right and should survive this
card.
- What makes it wrong at the end is card 56: members are hidden from the
Board while the phase holds them, and reappear when it lets go. A phase
reaching `done/` therefore returns three or five cards to `review/` in
one redraw — all of them merged, none of them needing anything, and all
of them in the column whose note is "your move".
- The operation to extend already exists and already does careful
multi-step git work: `github.complete_task()` (`:387`) parks the drive,
merges, cleans up the worktree and branch, then calls `move_task` for
the one card. It narrates each step and aborts cleanly on a conflict.
- Board-made moves commit themselves under `BOARD_COMMIT_MOVES` and, in
team mode, push — so a sweep of five cards is five commits or one, and
which it is deserves a decision rather than an accident.
**Affected areas:** `manager/core/github.py` (`complete_task`), and
whatever narrates the result.
## What to build
- **The sweep, inside the merge.** When a phase card is completed, every
member the phase lists moves to `done/` as part of the same operation —
after the merge into `main` has actually succeeded, never before.
- **Only what the phase merged.** A member that never reached the phase
branch — halted, held, walked back — is not swept. It stays where it
is, and it is the reason a person will look at the phase afterwards.
- **Narrate it as one thing.** The ticker should say a phase finished and
how many cards went with it, not five separate moves scrolling past. A
person watching should see one event, because one thing happened.
- **Decide the commit shape deliberately.** One commit for the sweep
reads better in `git log` than five `board: NN → done` lines in a row,
but the message must still say what moved; and in team mode it has to
reach the other boards either way.
- **Abort together.** If the merge fails, nothing moves — the existing
behaviour, extended to the members. A half-swept phase is worse than an
unswept one.
- **The other endings do not sweep.** Archiving a phase card, or removing
a member from its list, releases the members to the board in whatever
stage they are actually in (card 56). Only *merging* means the work is
in `main`, and only merging may say `done/`.
**Out of scope** — tempting neighbours left alone:
- Moving members while the phase runs. They stop at `review/` on purpose.
- Sweeping on any path other than **merge & clean up** — including "just
move the card", which explicitly leaves the work alone.
- Closing the members' own PRs. Those were opened against the phase
branch and are closed by their own merges.
## Acceptance
- [ ] Given a phase whose members are all merged, when **merge & clean
up** succeeds, then the phase card and every merged member are in
`done/`.
- [ ] The ticker reports it as one ending, naming the phase and the
number of cards.
- [ ] Given the merge fails or conflicts, nothing moves — not the phase
card, not one member.
- [ ] Given a member that never reached the phase branch, it is left
exactly where it is.
- [ ] Given "just move the card" instead, no member moves.
- [ ] Given the phase card is archived rather than merged, its members
reappear on the board in their own stages and none of them is
marked done.
- [ ] With `BOARD_COMMIT_MOVES` on, the sweep is committed and, in team
mode, published — a sweep that never leaves one working tree is not
a sweep.
- [ ] Edge case: a member already in `done/` — moved by hand — is not
moved again and does not fail the operation.
## Notes
This is the card that makes the ending feel like an ending. Everything
else about phases is about the middle: running, halting, resuming. The
last thing a person does is drag one card to `done/`, and what should
happen is that the whole phase goes quiet — not that five cards they have
already reviewed reappear asking for attention.
The reason it is separate from card 56 rather than folded in: 56 is about
what the *Board view draws*, and this is about what the *board does*. One
is a rendering rule, the other moves files and commits them. Keeping them
apart also keeps 56 shippable on its own.