tasks: phases — 48 the model, 49 the runner, 50 the UI, 51 the action

A phase is a card with Type: Phase and a ## Cards section listing its
members in run order; members carry Depends on, which guards rather than
orders. It runs on its own branch, each member branched from the phase
tip so card two sees card one's work, merged back on green, one PR into
main at the end — the human gate moves from every card to the phase
boundary, and 'the board never merges' stays true of main.
This commit is contained in:
istos
2026-08-01 08:46:09 +02:00
parent de4e7dc762
commit 0224f3a73e
4 changed files with 462 additions and 0 deletions
@@ -0,0 +1,110 @@
# 48 — A phase is a card that lists its cards
**Status:** Backlog
**Priority:** High — everything else about phases reads this; get the
shape wrong and three cards inherit it
**Type:** Feature
A phase is a group of related tasks meant to run one after another. Rather
than a new directory, a new stage or a registry, a phase is **a task card
like any other** — `**Type:** Phase`, with a `## Cards` section naming its
members in the order they run. This card is the model and its rendering:
after it, the board knows what a phase is and shows it. Nothing runs yet.
## Context
- `tasks/` holds only state and has to keep working as a plain folder
kanban even if `manager/` is deleted. A phase that lived anywhere else
would be a second source of truth; as a card it is diffable,
greppable, reviewable and syncs like everything else.
- **`Type` already exists and is already read.** `TYPE_RE`
(`manager/core/taskfiles.py:27`) matches any value and `read_task`
surfaces it (`:70`), so `**Type:** Phase` needs no parser change to be
stored — only somewhere to mean something.
- **`Depends on` is documented but parsed nowhere.** `AGENTS.md`
describes it as informational — "the board does not enforce it; it
informs whoever picks the next card" — and a grep of `manager/core/`
finds no reader. This card gives it one.
- Ordering *within* a lane is task `../backlog/25-order-within-a-lane.md`
and is not needed here: a phase's order comes from its own list, not
from where its cards happen to sit.
**Affected areas:** `manager/core/taskfiles.py` (reading the section and
the dependency line), and `manager/core/board.html` (the chip on a member
card).
## What to build
- **`**Type:** Phase`** marks a card as a phase. The name of the phase is
the card itself — its number and title — so nothing new has to be
named, spelled twice or kept in step.
- **A `## Cards` section** listing members in run order, one per line,
each beginning with a task number:
```markdown
## Cards
- 31 — Stand up site/ and its build
- 32 — Serve it from a Cloudflare Worker
- 33 — The landing page
```
Document order is run order. The number is what is parsed; the title
after it is for the reader and is never matched against anything.
- **One direction only.** The phase card lists its members; member cards
say nothing about phases. Membership therefore cannot disagree with
itself, and there is exactly one place to edit when it changes.
- **`Depends on` is parsed at last, and it guards rather than orders.**
The list says what runs next; a member's dependencies say whether it
*may* — a card whose dependency is not finished is not startable even
if the list reached it. Expose it on the task; do not act on it here.
- **Membership is derived for the member card.** The board reads every
task file already, so a member's phase and its position are computed
from the phase cards rather than stored twice.
- **A `⟶ <phase> 3/5` chip** in the member card's footer row, in the dim
register beside `CI` and `PR ↗` — a destination, not a status — opening
the phase card.
- **Drift is flagged, not swallowed**, in the same spirit as `status
drift`: a listed number with no card, the same card listed by two
phases, a card listed twice by one phase. Each is an authoring mistake
that would otherwise surface much later as a runner behaving oddly.
**Out of scope** — tempting neighbours left alone:
- Running anything. No branch, no launches, no advancing — card 49.
- The header chip and the run/hold actions — card 50.
- The board action that adds a card to a phase — card 51.
- Nesting. A phase card is not a member of another phase.
## Acceptance
- [ ] Given a card with `**Type:** Phase` and a `## Cards` section, when
the board reads it, then its members and their order are available
in the order the section lists them.
- [ ] A member card shows `⟶ <phase> <n>/<total>` in its footer row, and
clicking it opens the phase card.
- [ ] A card in no phase shows no chip and is unchanged in every way.
- [ ] Given a `## Cards` line naming a number no card has, the board
flags it rather than silently skipping it.
- [ ] Given the same card listed by two phase cards, both are flagged.
- [ ] `Depends on` is parsed into a list of numbers and exposed on the
task; nothing yet acts on it.
- [ ] Edge case: a `## Cards` section that is empty, or a phase card with
no such section, reads as a phase with no members rather than
failing.
- [ ] Edge case: a number in the list written as `#31` or `31 —` or bare
`31` all resolve to the same card.
## Notes
The phase card earns its keep beyond membership: it is where the phase's
own brief lives (what this group of work is for), it is what the board
will later hang a branch and a PR on, and it is what makes the whole
feature reviewable — a phase arrives in the repo as a file you can read
in a diff before anything runs.
Most phase cards will be written whole, with their members already
listed and each member's `Depends on` already filled in, rather than
assembled on the board. The authoring path (51) is the convenience, not
the primary one — which is a good reason for the file format to be
pleasant to write by hand.
@@ -0,0 +1,146 @@
# 49 — A phase runs itself, on a branch of its own
**Status:** Backlog
**Priority:** High — the point of the whole thing: work that continues
without you until something is genuinely wrong
**Type:** Feature
**Depends on:** 48 — the board has to know what a phase is first
Give a phase its own integration branch and work the list into it: each
card branched from the phase's tip, run headless, merged back when its
checks are green, and the next one started. One PR at the end, into
`main`, for a human. The runner is a beat on the board, not an agent —
everything it decides is already structured state, and an agent paid to
poll would be the wrong tool at the wrong price.
## Context
The problem this exists to solve, precisely:
- `_fresh_branch_point()` (`manager/core/agents.py:167`) branches every
new task worktree from `origin/main`. For *related* tasks — the whole
premise of a phase — card two branched from main cannot see card one's
work while card one sits unmerged in `review/`. It will conflict, or
quietly build the same thing twice.
- Gating on a merge into `main` would fix the branch point and destroy
the point: `main` is merged by a person, so the phase would stall on
every card.
So the phase gets **its own branch**, and the human gate moves from every
card to the phase boundary. The promise bench makes — *nothing merges
without you* — is about `main`, and it survives intact: the board merges
into a branch it created, inside a scope you opened, and `main` still
waits for your click.
What already exists and should be used rather than rebuilt:
- The board reacts to state without an agent: a card entering `review/`
opens a PR "mechanically, by the board", and the PR poller is a plain
thread checking reviews, CI and mergeable state every 60s.
- `github.public_state()` (`:378`) exposes `{verdict, ci, copilot,
conflicts, url}` per card — the advance condition is readable, not
judged.
- `watch.py` answers *am I the actor?*, and `AGENTS.md` says every future
automation hung off a stage transition inherits it.
- Every failure mode a card can have is already a state it wears:
`NOT READY`, `run failed`, a clean exit that committed nothing.
- `complete_task()` (`manager/core/github.py:387`) is the model for a
careful multi-step git operation that narrates and aborts cleanly.
**Affected areas:** a new `manager/core/phases.py` — right of `agents` in
the module map, since it needs `taskfiles`, `github` and `agents` — plus
the branch-point change in `agents.py` and a beat wired in `board.py`.
## What to build
- **A phase branch.** Starting a phase cuts `phase/<task-stem>` from the
newest `origin/main` it can see, by the same rule and the same timeout
a task branch uses.
- **Members branch from the phase tip**, not from main. This is the one
change inside `agents.py`: where a launch is part of a phase, the
branch point is the phase branch rather than `origin/main`, and the
ticker names it as it already names an unusual branch point.
- **A stateless beat.** On each pass, recompute: which members are
finished, which is first unfinished, what does it need. Hold no
registry — a board restart then resumes a phase by looking, and the
same logic answers "what now?" whether the last event was a launch, a
merge or a crash.
- **Advance on green.** A member is finished when its card is in
`review/` and its CI has passed. Then merge its branch into the phase
branch — additively, never rebasing, never force-pushing — and start
the next member whose `Depends on` are all finished.
- **Halt, never skip.** The five conditions, each already a visible state
on the card: `NOT READY`; a non-zero exit; a clean exit with no
commits; CI red; a merge into the phase branch that is not mechanical.
A phase that steps over a failed card builds the rest on a foundation
that never landed.
- **Keep the phase branch fresh.** Merge `main` into it on the beat,
additively, so a phase that runs for hours does not drift into one
enormous conflict at the end. A conflict there halts the phase like any
other.
- **Finish into a PR.** When every member is finished, push the phase
branch, open one PR into `main` with the member list as its body, write
the `**PR:**` line into the phase card, and move the phase card to
`review/`. From there the existing apparatus applies unchanged — CI
chip, `◔ review PR`, `⚑ copilot`, and drag-to-`done/` for **merge &
clean up**.
- **One board runs it.** The actor rule decides; a replica renders the
phase and advances nothing.
**Out of scope** — tempting neighbours left alone:
- Running members in parallel where dependencies allow. Sequential
first; parallelism is a second card once the sequencing is trusted.
- Any UI. The header chip, the run and hold actions and the narration
are card 50 — this card is reachable through the API and the ticker.
- Auto-merging anything into `main`, under any condition.
- Nested phases, and a member belonging to two phases at once (48 flags
that as drift; here it simply must not run twice).
## Acceptance
- [ ] Given a phase whose members are unstarted, when it is run, then
`phase/<stem>` exists at the newest `origin/main`, and the first
member's worktree is branched from it.
- [ ] Given member one is in `review/` with CI green, when the beat runs,
then its branch is merged into the phase branch and member two is
launched from the new tip — and member two's worktree contains
member one's work.
- [ ] Given a member whose `Depends on` names an unfinished card, it is
not launched even when the list reaches it.
- [ ] Given a member that exits `NOT READY`, the phase halts, the card
walks back as it does today, and no further member starts.
- [ ] Given a member whose run fails, or exits clean with no commits, or
whose CI is red, the phase halts in each case.
- [ ] When every member is finished, one PR is opened from the phase
branch into `main`, the phase card carries its `**PR:**` line, and
the card is in `review/`.
- [ ] Restarting the board mid-phase resumes it without repeating a
launch or a merge.
- [ ] With `BOARD_SYNC` on, a second board shows the phase advancing and
launches nothing itself.
- [ ] Edge case: a phase whose list is empty goes straight to `review/`
with nothing to merge, or refuses to start — either, but not a
branch left behind and a card stuck in `in-progress/`.
- [ ] Edge case: a member card moved by hand while the phase is running
does not cause a second launch on the same task.
## Notes
**`AGENTS.md` will need a sentence it does not have.** "Merging remains
yours — the board never merges" is quoted as an absolute. After this it
needs to say what it always meant: the board never merges into `main`. A
phase branch is the board's own, and merging into it is bookkeeping in
the same family as committing a move.
A lever worth considering once this works: run `◔ review PR` on each
member and require `APPROVE` as well as CI green, with one automatic
`↻ act on PR` round before halting. That would put an agent's review
inside the phase and keep the human's for the boundary — more autonomy,
more tokens, and a bigger change than this card should carry.
**Risks** — this is the third place in the codebase that merges git
branches, after `complete_task` and `↻ act on PR`'s conflict resolution.
It must behave like both: abort cleanly, leave no half-merged branch, and
narrate every step. A phase branch that ends up in a broken state is
worse than a phase that refuses to start.
@@ -0,0 +1,105 @@
# 50 — The board shows a phase running, and shows it stopping
**Status:** Backlog
**Priority:** High — a phase that runs unattended is only trustworthy if
its halt is impossible to miss
**Type:** Feature
**Depends on:** 49 — there must be something to watch
Give the phase the three pieces of interface it does not already inherit:
an action to start and stop it, a header chip while it runs, and the same
chip in `--alarm` when it halts. Everything else — the PR chip, CI, the
review actions, the merge-and-clean-up sheet — the phase card already
gets by being a card.
## Context
- The phase card moves through the stages like any other card, and each
stage already means the right thing: `to-do/` queued, `in-progress/`
running, `review/` all members landed and a PR open, `done/` merged.
- Card actions live in the status pill's slot on hover, at most two per
state, and anything costing tokens or stopping work arms on the first
click and fires on the second (`board.html:1191` onwards).
`▸ start work` sits in that slot on an in-progress card, and `‖ hold`
is already the word for stopping while an agent runs.
- The header carries what is happening *across* the board: the live
agents chip, and the sync chip which appears only when sync stops
converging and then holds until a human settles it. A running phase is
the same kind of fact.
- The design system's colour law: `--accent` an agent alive, `--alarm`
blocked or failed, and one looping animation ("breathe") meaning work
is happening.
- A failed run is deliberately told three times at three altitudes — a
state the card wears, a toast, and a line in the ticker — because it is
the outcome a person must not miss.
**Affected areas:** `manager/core/board.html` only, plus whatever
`/api/state` must carry to describe a running phase.
## What to build
- **`▸ run phase`** on an `in-progress/` phase card, in the slot
`▸ start work` occupies on an ordinary one, arming and firing like
every other launch. Moving the card to `in-progress/` stays the
commitment; this is the second half of it.
- **`‖ hold`** while it runs, meaning what it means everywhere else:
stop, without unwinding what has already landed on the phase branch.
- **A header chip while a phase runs**, beside the agents chip, breathing
in `--accent`: the phase, its progress and the card it is on —
`⟶ auth-rework · 3/5 · on #33`. Present only while a phase is running,
the way the sync chip is present only when there is something to say.
- **The halted state, in `--alarm`, holding.** `⟶ auth-rework · halted at
#35 — not ready`, staying until the phase is resumed or stopped rather
than scrolling away. With a toast, because a halt is rare and
actionable, and a ticker line that survives in the log. The member card
wears its own failure independently — that duplication is the point.
- **Narration of each advance** in the ticker: the member that finished,
the merge into the phase branch, the member starting next. A phase that
advances silently is a phase nobody can debug afterwards.
- **The phase card's sheet lists its members in order** with each one's
current stage, so the card answers "where is this up to" without
hunting across five columns.
**Out of scope** — tempting neighbours left alone:
- Filtering the board to a phase's cards, or dimming everything else.
Useful, bigger, and better judged once the chip exists.
- A Focus view for a phase. Focus is a heads-up display for one session;
one for a phase is a real idea and a separate build.
- Anything that changes what the runner does. This card watches.
- The `⟶` member chip, which is card 48's.
## Acceptance
- [ ] Given a phase card in `in-progress/`, hovering it offers
**▸ run phase**, which arms on the first click and starts on the
second.
- [ ] While a phase runs, the header carries a breathing chip naming the
phase, its progress and the member in flight.
- [ ] Given a member that fails, declines, commits nothing or goes red,
the chip turns `--alarm` and names the reason and the card, a toast
fires, and the chip holds until the phase is resumed or stopped.
- [ ] **‖ hold** stops the phase and leaves the phase branch and every
landed member exactly as they were.
- [ ] With no phase running, the header is exactly as it is today — no
empty chip, no placeholder.
- [ ] The ticker names every advance: what finished, what merged, what
started.
- [ ] Opening a phase card lists its members in run order with each
one's stage.
- [ ] Edge case: two phases could in principle run on one board — either
the chip handles more than one, or starting a second is refused
with a reason. Not a chip that silently shows one of them.
## Notes
The reason the halted state gets this much attention is that the feature
is asking for trust: you start a phase and stop watching. Everything in
bench that runs unattended already pays that back the same way — the
failed run wears its state, sync says when it stalls — and a phase that
halts quietly at 2am would undo the whole argument for having one.
Worth checking while building: the header is getting crowded. Agents
chip, sync chip, phase chip, theme, refresh. If they collide, the phase
chip is the one that can fold into the agents chip — both answer "what
is happening without me" — rather than shrinking the ones already there.
@@ -0,0 +1,101 @@
# 51 — Add a card to a phase without opening the file
**Status:** Backlog
**Priority:** Low — the convenience path; phase cards mostly arrive with
their lists already written
**Type:** Feature
**Depends on:** 48 — the list this appends to has to exist and be read
A `⟶ phase` action on a backlog or to-do card, offering the phase cards
currently in `to-do/` and appending the card to the one you pick. It is
the small path: most phases are written whole, members and dependencies
already in place, before anything reaches the board. This is for the card
you decide belongs after all.
## Context
- The phase card's `## Cards` section is the single place membership
lives (48), so adding a card means appending one line to *that* file —
not editing the card being added.
- The board already writes into task files where the file must stay the
source of truth: the `**PR:**` line goes in through `commit_edit()`
(`manager/core/taskfiles.py:242`), which commits under the same gate a
move does and, in team mode, reaches the other boards.
- A choice between several options has a pattern too: the drag-to-`done/`
sheet (`completeSheet`, `board.html:1362`) puts a short list of named
outcomes in front of you rather than guessing.
- Card actions arm before they fire, and the status pill's slot holds at
most two per state. On backlog and to-do cards that slot is close to
free — `◔ still true?` is often the only one there.
- Task `../backlog/47-an-archive-button-on-the-card.md` puts a chip in
the footer row for a similar reason; the two should not fight for the
same corner.
**Affected areas:** `manager/core/board.html` (the action and its sheet),
`manager/core/httpd.py` (a route), `manager/core/taskfiles.py` (the
append).
## What to build
- **A `⟶ phase` action** on cards in `backlog/` and `to-do/` that are not
already in a phase, and not themselves phase cards.
- **A sheet listing the phase cards in `to-do/`**, each with its number
and title and how many cards it already holds. Picking one appends the
card to the end of that phase's `## Cards` section and closes the
sheet.
- **`to-do/` only.** A phase in `in-progress/` is running: its branch
exists, its members are being worked in the order the list had when it
started, and appending mid-flight is a different feature with different
questions. Offer it and someone will find out the hard way.
- **The append goes through the board's own write path**, so it commits
itself under `BOARD_COMMIT_MOVES` and syncs like every other board-made
change to a task file. An addition that never leaves one working tree
is not an addition the phase will run.
- **Written the way a person writes it** — `- 33 — <title>` — so the
section stays something you would have typed. A machine-shaped line in
a file people author by hand is how a format stops being pleasant.
- **Nothing else moves.** The card stays in its stage; joining a phase is
not a commitment to start it, and the phase decides when it runs.
- **No phases in `to-do/` → the action is absent**, not present and
empty. The board offers what it can do.
**Out of scope** — tempting neighbours left alone:
- Removing a card from a phase, and reordering the list. Both are file
edits for now; both are better judged once phases have run a few
times.
- Adding to a running phase.
- Creating a phase card from the board. Phase cards are written, and
writing them is where their brief comes from.
- Multi-select — adding several cards in one gesture — which needs a
selection model the board does not have.
## Acceptance
- [ ] Given a card in `backlog/` or `to-do/` and at least one phase card
in `to-do/`, hovering offers `⟶ phase`; choosing a phase appends
the card to that phase's `## Cards` and the member chip from 48
appears on it.
- [ ] The appended line names the card's number and title, at the end of
the section, and the rest of the phase card is untouched.
- [ ] With `BOARD_COMMIT_MOVES` on, the append is committed on its own,
messaged like the board's other bookkeeping; with it off, nothing
is committed and the file is simply edited.
- [ ] Phase cards in `in-progress/`, `review/` or `done/` are not
offered.
- [ ] A card already in a phase does not offer the action.
- [ ] With no phase in `to-do/`, the action does not appear.
- [ ] Edge case: a phase card with no `## Cards` section yet gets one,
rather than the line being appended to the end of the file.
- [ ] Edge case: two boards adding to the same phase produce two lines,
not a lost one — the second append reads the file as it is on disk
rather than as it was rendered.
## Notes
The reason this is Low and not Medium: the intended way a phase arrives
is fully formed — a card written with its members listed and each
member's `Depends on` filled in — because that is a thing you can read in
a diff and reason about before any of it runs. This action is for the
afterthought, and it should stay small enough not to become the way
phases are assembled.