Say what "the board never merges" always meant

It meant main. A phase branch is the board's own, and merging into it
is bookkeeping in the same family as committing a move — so the
absolute in AGENTS.md, and the same claim on the landing page, both
become "never merges into main". Nothing about the human gate changes;
the sentence just stops overstating it.

The rest is the section the runner needs: how a phase is started, why
the branch exists at all, what makes the beat stateless, what green
means and what halts, and who runs it when there are replicas.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
istos
2026-08-01 09:44:02 +02:00
co-authored by Claude Opus 5
parent da8984d0e6
commit 35c83f5f70
2 changed files with 92 additions and 5 deletions
+90 -3
View File
@@ -44,7 +44,7 @@ Drivers know apps, adapters know vendors, `local/` knows this project.
Module map for `manager/core/` (dependencies flow strictly left to right):
```
config → state / reports → taskfiles → events / github / drive / sync → agents → watch / httpd → board.py
config → state / reports → taskfiles → events / github / drive / sync → agents → phases → watch / httpd → board.py
```
- `config.py` — paths, stages, settings, prompt/adapter/driver resolution
@@ -57,6 +57,8 @@ config → state / reports → taskfiles → events / github / drive / sync →
- `drive.py` — runs the project driver, tracks the one live drive
- `sync.py` — origin/main as the shared board: push on move, pull on a beat
- `agents.py` — headless work/review jobs, launched through the adapter
- `phases.py` — a phase run: its own branch, its cards merged into it one
at a time. A beat, not an agent; it holds no registry of where a phase is
- `watch.py` — 2s disk poller narrating moves made outside the API, and
the gate that keeps a move a pull applied from triggering anything
- `httpd.py` — HTTP routes, the SSE stream, serving the page
@@ -468,6 +470,12 @@ resolution commit, and refuses semantic ones, naming the collision for a
human to settle. GitHub computes mergeability lazily, so an UNKNOWN
reading keeps the chip's last state rather than flapping.
A phase member's PR is opened against **its phase's branch**, not `main`:
its branch was cut from there, so that is the only base whose diff is the
member's own work — and a PR into `main` carrying a whole phase is exactly
the merge this design refuses to make. Every other card, the phase card
itself included, opens into `main` as it always did.
The card wears that verdict 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
@@ -475,7 +483,10 @@ neutral `waiting on you`. 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.
Merging remains yours — the board never merges.
Merging into `main` remains yours — the board never merges into `main`. It
does merge into a branch of its own: a phase's integration branch is the
board's, and merging into it is bookkeeping in the same family as
committing a move (see "A phase runs itself, on a branch of its own").
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
@@ -793,7 +804,83 @@ that would otherwise surface later as a runner behaving oddly.
`**Depends on:**` is the other half, and it guards rather than orders: the
list says what runs next, a member's dependencies say whether it *may*. The
board parses the numbers out of the line and shows them; acting on them
belongs to whatever runs a phase.
belongs to the runner below.
### A phase runs itself, on a branch of its own
Running a phase works its list into a single integration branch. Starting
one cuts `phase/<task-stem>` from the newest `origin/main` it can see — the
same rule and the same timeout a task branch is cut by — and gives it a
worktree beside the task worktrees. From there each member is branched
**from the phase's tip**, run headless exactly as **▸ start work** runs any
card, and merged back into the phase branch when its checks are green;
then the next one starts. At the end one PR, from the phase branch into
`main`, for a human.
That is why the branch exists. Members of a phase are related by
definition, so card two branched from `main` could not see card one's work
while card one sat unmerged in `review/` — it would conflict, or quietly
build the same thing twice. Gating on a merge into `main` would fix the
branch point and destroy the point, because `main` is merged by a person
and the phase would stall on every card. So the human gate moves from every
card to the phase boundary, and the promise survives intact: the board
merges into a branch it created, inside a scope you opened, and `main`
still waits for your click.
**The runner is a beat, not an agent.** Everything it decides is already
structured state — a card's stage, a PR's CI verdict, whether one branch is
contained in another — so an agent paid to poll would be the wrong tool at
the wrong price. It is a plain thread (`BOARD_PHASE_INTERVAL`, 30s), silent
when no phase is running.
**The beat is stateless.** Each pass recomputes which members are finished,
which is first unfinished and what that one needs; it holds no registry of
where a phase *is*. Two durable things carry the memory instead, and both
are things the board already writes: **git**, where a member is finished
when its branch is contained in the phase branch, and **the phase card**,
which grows a `## Phase log` section the runner adds one line to per
decision — a run started, a member started, a member merged, a halt. The
log is the record a person reads, and the only thing that can tell "this
member has run and it ended badly" from "the phase has not reached it yet";
without it a restarted board would relaunch a run that died. So a restart
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 reaches `review/`
and its checks are not against it. Green is read from the same PR poll the
board already runs: red halts the phase, running holds it, and a member
with no checks at all advances — a project without CI must not deadlock
every phase it runs. A member with no branch at all that is already in
`review/` or `done/` is simply finished; there is nothing to bring.
**Halt, never skip.** Five conditions stop a phase, each already a visible
state on the card: a member that exits `NOT READY`, a run that exits
non-zero, a clean exit that committed nothing, CI red, and a merge into the
phase branch that is not mechanical. A phase that stepped over a failed
card would build the rest on a foundation that never landed. The halt is
written into the log and then held — said once, not once a beat — and
nothing retries by itself. Running the phase again is a person's decision,
and it is what appends the line that clears the halt. A member whose
`**Depends on:**` names something unfinished is a *wait*, not a halt: the
phase idles until the dependency lands (merged, for a card inside the
phase; `done/`, for one outside).
**Merges are additive, always.** Nothing here rebases and nothing
force-pushes. `main` is merged into the phase branch on every beat, 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, aborted cleanly, with
the colliding files named. When every member is in, the branch is pushed, a
PR into `main` is opened with the member list as its body, the `**PR:**`
line is written into the phase card and the card moves to `review/` — where
the existing apparatus applies unchanged: the CI chip, **◔ review PR**,
**⚑ copilot**, and drag-to-`done/` for **merge & clean up**.
**One board runs it.** State syncs; reactions don't, so the phase card's
**Assignee** is where "who runs it" is written down — the same claim that
gates starting work. A replica renders the phase and advances nothing.
Members run one at a time. Running independent members in parallel, and
any UI beyond the API and the ticker, are separate cards.
An optional **Assignee** line records who holds the card:
+2 -2
View File
@@ -165,8 +165,8 @@ Task board for ~/your-repo/.task-manager/tasks
<div class="why-reason">
<span class="why-lead">Nothing merges without you</span>
<span class="why-text">Finished work arrives as a PR with CI on the
card. bench never merges — the button stays yours, and you stop
being the thing everything queues behind.</span>
card. bench never merges into main — the button stays yours, and
you stop being the thing everything queues behind.</span>
</div>
</div>
</section>