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.
Card 47's agent ended its turn with 'the suite is still running; I'll
wait for the monitor', which in a one-shot claude -p run is the end of
the run — 314 lines of finished, passing work staged and never
committed. The prompt never states that the run is a single turn or that
uncommitted work dies with it.
/api/phase/run guards itself (phases._phase_card refuses a non-phase);
/api/agent/start has no phase check at all, so a stale tab launched an
ordinary work agent on card 53 and it implemented both members itself.
The gate that was missing is on the older feature — the usual shape:
a new capability guards its own front door and leaves the neighbour's
open.
Runs 52 (scroll position survives a redraw) then 47 (an archive chip on
the card). Neither depends on the other, so neither carries a Depends on
line — they are phased because they both edit board.html, and two agents
branched from main would collide in it. Sequencing gives the second one
the first one's work as its branch point.
Phases are meant to arrive whole — members listed, dependencies filled in, readable in a diff before any of it runs. This is the other case: the card you decide belongs after all.
⟶ phase sits on backlog/ and to-do/ cards that are not already in a phase and are not phase cards themselves, and opens a sheet naming the phase cards waiting in to-do/ with what each already holds. Picking one appends `- <n> — <title>` to the end of that phase's ## Cards — the way a person writes it, because the section is authored and read by hand and a machine-shaped line is how a format stops being pleasant.
Only to-do/. A phase in in-progress/ is running: its branch exists and its members are being worked in the order the list had when it started, so appending mid-flight is a different feature with different questions. Offer it and someone finds that out the hard way. No phase waiting there and the action is absent rather than present and empty.
Nothing else moves. One line into the phase card, nothing at all into the card added — membership runs one direction and joining a phase is not a commitment to start it. The append goes out through append_to_section, the same door the phase log uses, so it commits itself under BOARD_COMMIT_MOVES, reaches the other boards, and reads the phase card off the disk rather than off a render: two boards adding to one phase produce two lines, not a lost one.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A phase card inherits the PR chip, CI, the review actions and the
merge-and-clean-up sheet by being a card. Three things it does not
inherit, and this is them.
The runner learns to stop. `stopped — held by <name>` joins `halted` and
`run started` in the phase log, and one reader (`run_state`) answers
where a phase is from the last line that says: running, halted, stopped
or never run. `stop_phase` writes that line — so the next beat stands
down — and holds the member agent in flight, because a phase that said
it had stopped while its agent kept working would be lying about the one
thing it was asked. Nothing is unwound: the branch, every card merged
into it and every worktree are left exactly as they were. A halted phase
can be held too, which is the other half of the halt's promise that it
holds until the phase is run again or stopped.
The halt is now told three times, as a dead run is: the state, the
ticker line it already had, and a toast — rare, actionable, and the
whole argument for starting a phase and walking away. Every advance is
narrated: the member that came up green, the merge, the next start.
The page: run phase (take over on someone else's) in the slot start work
occupies, hold while it runs, and a header chip beside the agents chip —
breathing accent with the phase, its progress and the card in flight,
alarm and holding when it halts, absent entirely when there is nothing
to say. One chip per phase, because two phases could in principle run at
once and a chip that showed one of them silently would be worse than
none. Opening a phase card lists its members in run order with each
one's stage.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Three points from the PR #41 Copilot review, each a robustness gap on a path
the happy case never takes:
- phases._mine() gated the beat on the assignee even when this checkout has
no git name — but agents.claim_for_launch() cannot gate a launch there and
lets it through, so a phase could start (branch cut, run recorded) and then
advance nowhere. _mine() now treats "no local identity" as the lone actor,
matching the launch it mirrors.
- phases._record() ignored whether the log line landed. The log is the
durable memory a restart reads to tell "already started" from "not reached
yet"; a launch or merge with no line behind it is what a restart repeats.
_record() now raises _Halt when the write fails — before the action it was
meant to precede — split from a best-effort _write_log() the halt path and
_start() use so recording a halt can never itself raise.
- github._pr_base() switched a member PR's base to the phase branch only when
it existed locally. A board that did not run the phase knows it only through
the remote (sync fetches origin/main and nothing else), so _pr_base() now
also honours a phase branch the remote carries, and _open_pr() only pushes
the base when it is a local branch.
Four new tests in tests/test_phase_runs.py cover each. Full suite green (740).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
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>
Starting a phase cuts phase/<stem> from the newest main it can see and
works the list into it: each member branched from the phase's tip, run
headless, merged back when its checks are green, the next one started.
At the end one PR into main, for a human. The human gate moves from
every card to the phase boundary, and the promise survives: the board
merges into a branch it created, inside a scope you opened.
The runner is a beat, 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. It holds no registry of where a phase is. Two durable
things carry the memory, and the board already writes both: git, where
a member is finished when its branch is contained in the phase branch,
and the card, which grows a ## Phase log the runner adds one line to
per decision. The log is what tells "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.
Containment alone is not enough to call a member merged: a clean exit
that committed nothing leaves an empty branch that is contained. The
card has to have settled into review/ too, or a broken launch would
hide exactly where it always tries to.
Five conditions halt, each already a visible state on the card, and a
halt is written once and then held. Running the phase again is the
person's decision and is what clears it — the run is scoped to its own
log line, so a member whose run died is launchable again. A dependency
that has not landed is a wait, not a halt.
Merges are additive throughout: main into the phase branch on every
beat so a long run does not drift into one enormous conflict, members
into it as they go green, nothing rebased and nothing force-pushed. A
conflict aborts, leaves the branch as it was, and halts naming the
files that collided.
The actor rule decides who runs it, written where it already lives:
the phase card's assignee. A replica renders the phase and advances
nothing. Reachable through /api/phase/run and the ticker; the header
chip and the card actions are a separate card.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
github asked one question in four places by writing task/<stem> out
each time. It asks branch_of() now, which answers phase/<stem> for a
phase card — so from review/ onwards a phase is an ordinary card: its
PR opens, its worktree is driven, and merge & clean up finds the
branch it is meant to take apart.
A phase member's PR is opened against its phase's branch rather than
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 would invite exactly the merge this design refuses to make. The
base is published first, so the remote has something to open against;
the main-is-ahead guard still stands in front of every PR into main.
A phase's own PR says what is in it: the member list, in run order.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two pieces the runner needs, in the modules that own them.
taskfiles grows a third door: append_to_section, one line under one
heading. The phase log is a running record, and append_to_task would
scatter its lines through the file as other sections landed between
them — the record would stop being readable in the one place a person
looks. It commits like every other board-made write.
agents learns where a phase member starts. That is the whole reason a
phase has a branch: related cards run one after another, 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. A card in no phase, or one whose phase has not been
started, takes the ordinary fresh branch point, and the ticker names
the unusual base as it already does.
claim_for_launch loses its underscore: a phase run claims its card the
same way starting work on one does, from another module.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`**Type:** Phase` plus a `## Cards` section is the whole model. The phase
card is the only authority on membership: it lists its members in run
order, and a member's phase and position are derived from that list
rather than stored on the member — one direction, one place to edit.
- taskfiles.py reads the section (numbers only; `31`, `#31` and `031` are
one card, indented lines are a member's own continuation) and parses
`Depends on` at last, into the numbers it names. Nothing acts on it:
the list says what runs next, the line says whether it may.
- collect() weaves the board: members resolved in document order, each
member given its phase and 3/5 position. What will not resolve is
flagged rather than skipped — a number no card has, a card two phases
both list (both flagged), a card listed twice, a line naming no number,
a phase listed by a phase.
- board.html gives a member card a `⟶ <phase> 3/5` chip in the footer
row beside CI and PR ↗, opening the phase card, and wears phase drift
as a pill next to status drift.
Nothing runs yet — no branch, no launches, no advancing.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
renderBoard() wipes #board and rebuilds every column, so each .drop is a
new node with scrollTop 0 — and render() runs on every SSE frame, so the
board resets under you continuously while agents work. The log already
solves this for itself with S.logStick; the columns never got it.
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.
The card warned that archiving does not commit and suggested sequencing
behind card 44. That landed before 0.3-alpha: archive_task goes out
through _relocate, attributed and committed under the same gate a move
is. Nothing blocks 47.
The tray works but the gesture is a drag across the whole board. Puts
the same glyph on the card's footer row, on backlog/to-do/done only,
arming on the first click as the other two-step actions do.
REMOTE/UPSTREAM were module constants, so a team-mode board whose remote
is named anything but origin synced nothing at all — silently, with a
healthy header. The remote is now resolved per use (config's answer, the
one PRs already honour) and threaded through fetch, publish, replay and
integrate; every message names whatever was resolved.
The silence is the other half. Where _converge()/push_now() returned a
bare 'no-origin' that both callers discarded, _remote() now _note()s at
stalled level first: no remote at all names both fixes (add one, or set
BOARD_GIT_REMOTE), and a BOARD_GIT_REMOTE naming a remote this checkout
does not have stalls naming it rather than quietly using another. It
clears with a recovery line like the offline path, and install() asks at
startup so the chip is there from first paint, not the second beat.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
github.remote() decided it for PRs; sync assumed 'origin'. The answer
moves to config, which already owns BOARD_GIT_REMOTE, resolved on demand
so config still shells out nothing at import. github.remote() becomes a
thin call to it, and git_remotes() lets a caller check whether the name
resolves to a remote that exists rather than falling back silently.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The free-port probe bound without SO_REUSEADDR while the board's own
ThreadingHTTPServer sets it, so the socket a just-stopped board left in
TIME_WAIT read as "taken by something else": a routine stop/start walked
the board to the next port and wrote that over the user's BOARD_PORT pin.
The probe now binds exactly as the server does, which is the whole race.
Behind it, a held port gets a few seconds (BOARD_PORT_WAIT, 5s) to clear
before the walk, re-asking is_our_board each beat — a restart races its
own predecessor far more often than a stranger takes the port. Walking
off a pinned port still persists, since the hooks and agents read
BOARD_PORT and must reach the live board, but it now says so in full:
the right file (manager/local/.env, not manager/.env), old → new, and
how to reclaim the pin.
Tested end to end against a scratch host with a stub board.py, over real
sockets: a genuine TIME_WAIT remnant, a listener that lets go mid-wait,
a listener that does not, and our own board answering.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A link to bench.12vectors.com under the opening paragraph, and the board
screenshot beneath it — the fastest way to say what this is.
The image is an absolute raw.githubusercontent URL rather than a repo
path, for two reasons. README.md ships in the release artifact and
site/ deliberately does not, so a relative link would break in every
vendored install. And the site's own copy is served under an immutable
year-long cache, so linking that would pin readers to whatever
screenshot was current when they first loaded the page.
The three-layer-law paragraph gains a clause and loses one. That section
is sliced into /concepts/three-layer-law/, so "or read them at
bench.12vectors.com" rendered as a page on that site telling you to go
to that site. It now says AGENTS.md is the file the docs site is cut
from, which is true in both places — the rule this repo sets for its own
prose.
And un-pin the landing page's version test: it named 0.2-alpha as the
string to replace in VERSION, so the first bump after it was written
failed the suite. It reads the file now, which is what the test is about
in the first place.
The page is now three things: what bench is, what installing costs, and
why to bother. The header nav is the way into the docs.
Removing them takes their CSS with them (.doors/.door* in the base sheet
and in all three breakpoints) and two tests that existed only for them —
the six-are-distinct check here and TheDoorsOpenOntoArticles in
test_site_pages. The hero-buttons test survives its class, renamed to say
what it now guards: the landing page's only links into the docs.
One test needed retargeting rather than deleting.
test_it_fails_before_anything_is_written removed /concepts/team-mode/
from the manifest to prove a dead internal link stops the build before
anything is written — but the link it relied on was a door. It now aims
at /guides/install/, which the hero button still hard-links.
Side effect worth having: the two reasons in "Why bench?" that nearly
repeated doors 03 and 04 no longer sit above them.
Two changes to the landing page and the reference layout.
The three problem one-liners become "Why bench?" — four reasons now,
led by the one no hosted tool can answer: bench lives in your codebase
and is built to be extended, so the workflow it runs is exactly yours.
It stays unboxed on purpose; the doors below are the page's one grid of
cards and repeating that shape would make the eye read it twice.
The reference pages split the room right of the nav three fifths prose,
two fifths console. At the old fixed 208px every BOARD_AGENT_MODEL_* key
was cut mid-word, which makes a console of settings useless for the one
thing it is there for. Articles keep the narrow gutter — a list of
headings needs no more.
The narrow-width overrides are repeated at .page-reference .shell
specificity in both media blocks. Without that the base override (0-2-0)
outranks the bare .shell inside the query (0-1-0), the third track
survives, and with the gutter hidden it becomes two fifths of empty grid
squeezing the prose on every phone.
Against a real repo with a real branch: a real merge, a real conflict,
the wrong-branch refusal, a crash, and a second complete arriving from
inside the first one's merge — which is refused, starts no second merge,
and does not release the run it lost to. Plus the registry's own rules
and the card-face invariants in board.html.
The card takes the accent border and a breathing `completing` pill in
the slot the status pill already owns — the working vocabulary, because
that is what is happening — and carries the latest narrated step on its
activity line, so "parking the drive" and "cleaned up: worktree and
local branch removed" land on the card and not only in the ticker.
While it is claimed the card offers nothing: no hover actions, no drive
or command chips (both run against the worktree being removed), and not
draggable, so the sheet cannot be reopened behind the run. Suppressed,
not ignored on click — an action that looks available and does nothing
is the same lie in a different place.
An archive renamed the file on disk and stopped there, leaving an
uncommitted deletion of a tracked file — exactly what sync._clean()
refuses to run over, so one drag onto the tray stalled every later board
commit, kept the archive off the other boards, and left the card in a
single working tree. Appended agent reports had the same gap.
taskfiles now has one door out of a stage directory, _relocate(): it
registers the expectation, writes, moves, and commits under the same
COMMIT_MOVES gate a move uses — so archive, unarchive and move all commit
by construction rather than by each caller remembering. The messages read
like the others (`board: <n> -> archived (<name>)`, and the stage slug on
the way back) and carry the `board: ` prefix sync's piggyback guard
requires; routing through _commit means state.task_committed() fires, so
the event-driven push publishes it.
The second door is append_to_task(), which writes into a card where it
stands and commits via commit_edit(); agents._file_report() uses it
instead of touching config.TASKS itself, so a run's closing report is
recorded rather than left modified.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Merge & clean up is a minute of destructive work behind one click, and
nothing recorded that it was running: /api/task/complete took a second
request as readily as the first.
state.py grows COMPLETING — claimed before the first step, released in a
finally after the last one, on success, conflict and crash alike. The
steps are already narrated as board events against the file, so
record_board_event folds the latest summary into the claim rather than
asking complete_task to report twice. It is memory, not disk: a board
killed mid-completion leaves no card stuck busy.
/api/state carries the registry and every change publishes it, so the
board renders the busy card from the server's truth rather than from
what one tab happened to click.
The landing page was carrying two large visuals stacked — a full
terminal in the hero and the board beneath it — which is what made it
read as busy. Now the hero is one third words and two thirds board: the
claim this page makes is a board, so the board is what it shows first
and shows large.
The terminal moves into a 50/50 section below, beside the copy that
explains what installing costs: one script, port 26071, two questions,
and the settings file it writes. The six doors are unchanged.
Smaller changes that follow from it: the headline takes a narrower step
(--t-hero-narrow, 40px) because it no longer has half a row; it loses
its hard <br> and wraps on its own; the CTA drops the time claim now
that a whole section makes it; and the caption under the shot becomes
one plain line rather than a tagged row.
The third setup question wanted an answer about a repo the person may
have just cloned, thirty seconds in, before anything had explained why
the board needed one. The file that names a project's ecosystem usually
names its runner too, so it is read off the project: package.json → npm
test, Cargo.toml → cargo test, go.mod → go test ./..., a
pyproject/setup.py/tests/ → python3 -m unittest.
Nothing recognisable writes BOARD_AGENT_COMMANDS empty rather than the
example's Python default, which would be quietly wrong in most repos —
and a wrong prefix denies an agent exactly the way an empty one does, so
absent beats wrong here.
Two details the first attempt got wrong, both now covered:
- Detection runs *before* the questions. Placed after them, a Ctrl-D
part-way through skipped it and left the example's default.
- "Keep what the project already said" reads the existing .env, not the
example-seeded defaults dict — which already carries the example's
answer, so every first run looked like it had one and detection never
ran.
The landing page's terminal, README.md and AGENTS.md follow: two
questions now, and AGENTS.md documents the detection table.