Phases are the release, so AGENTS.md stops burying them. The six sections
that were `###`s inside "Task file format" — where they had split that
section's own prose in half, header table above and Assignee below —
become a `## Phases` section of their own, between "Pull requests" and
"Stages" because that is the order they are learned in. Nothing in them
changed: the diff is a move plus the section's opening paragraph.
Which gives the site three pages to cut rather than a tail nobody would
find at the bottom of "Task files": /concepts/phases/ (the model),
/concepts/running-a-phase/ (the branch, the beat, the halt, the ending)
and /concepts/phases-on-the-board/ (what the Board stops drawing, and the
Phases view that draws it instead). They sit after "PRs and review" in
the Concepts flow, which is the order AGENTS.md now reads in — the
manifest and the source cannot disagree about that without the build
saying so.
The landing page's third reason says it too, since "parallel work, zero
collisions" was only half of what the board now does, and the README's
opening paragraph gains the sentence it was missing.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
BOARD_AGENT_COMMANDS is the one setting a headless agent cannot work
around, and since the install stopped asking for it a project the
detector does not recognise starts with it empty — correctly, but
silently, until a run ended with an agent explaining it could not verify
its work.
Say it twice, in the two places it is learnable: a quiet `no agent
commands` chip in the header (`--idle`, like the drive's "no driver",
never `--alarm` — nothing is failing, something is unconfigured), and a
note appended to the ticker line of the launches that would have run
those commands, work and act-pr. Neither blocks anything: an agent that
only edits files is still useful.
What counts as empty is answered once, by `config.agent_commands()`,
which splits exactly as the adapters' own `split_commands()` does — so
whitespace and a lone comma are nothing configured on the board as well
as at the launch, and the page reads the server's boolean rather than
the raw setting.
`▸ run phase` guarded its own door and left the neighbour's open:
`/api/agent/start` accepted a phase card, cut `task/<stem>` and handed a
list of other cards to a work agent as a brief. It did that once, and the
agent implemented two cards at once in a worktree nobody was watching.
The refusal is a server rule, in `_validate` with the stage check — ahead
of the claim and well ahead of the worktree, so it costs nothing and
leaves nothing to clean up — and it names ▸ run phase rather than just
saying no. Which headless kinds a phase card may host is now decided kind
by kind where the guard lives: ▸ start work and ↻ act on PR refuse it
(both are work agents), while ◔ still true? and ◔ review PR are allowed —
the latter now told the phase's own branch, since its PR is from
`phase/<stem>` and `task/<stem>` was never cut. The guard is about
starting, so a card retyped under a running agent is left alone.
And the card says which state it is in: an `in-progress/` phase nobody has
started read exactly like one mid-run, the header chip being absent in
both cases. It now wears `not started` (or `held`) in the settled
register, with the line under it saying what ▸ run phase would do — never
the accent, the breathing mark or the caret, which mean work is happening.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A work agent ended its turn to wait for a four-minute test suite it had backgrounded, and the run ended with it: 314 lines of passing work staged and never committed, an empty branch, a halted phase. Nothing in the prompts said the run was a single non-interactive turn, so waiting looked like a sensible strategy.
All four core prompts now open with the same block: this is one turn, the process exits with your reply, and nothing you meant to finish afterwards survives. It names the trap directly — do not background a command and end the turn to wait for it, do not promise to come back — and each template follows it with what its own run loses: work.md commits around long-running commands, act-pr.md commits and pushes, review-pr.md posts its verdict inside the turn, review.md writes its report there.
The block sits before the task body and carries no braces, so str.format still renders it and the NOT READY / ADDRESSED / PR REVIEW / RELEVANCE REVIEW marker lines keep their place after it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Merge & clean up on a phase card now ends the phase: after the merge into
main has actually succeeded, every member the phase merged moves to done/
and its workspace is cleared the way completing an ordinary card clears
its own — worktree removed, local branch deleted, branch on the remote
deleted.
Only what the phase merged. A member is swept when its card settled into
review/ or done/ and its branch is contained in the phase branch (or
there was never a branch to bring) — the same pair the runner reads a
member as merged by. One that halted, was held or was walked back keeps
its card, its worktree and its branch: there is work in them.
Nothing uncommitted is thrown away: a member's worktree comes out without
--force, and a dirty one is reported in the ticker and kept with its
branch rather than forced.
One ending, told once. taskfiles.move_together moves the cards in a
single commit naming all of them (`board: 47, 52 → done with phase 53`,
so it publishes in team mode like any other board commit), and the moves
are marked quiet so the watcher does not also scroll five identical move
lines behind the one line the ending gets.
The other endings are untouched: "just move the card" and archiving the
phase card move no member, because neither puts anything in main.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Dragging a phase card between stages while one of its members has an
agent in it is a move nobody can mean: the card lands somewhere its
branch, its worktree and its live agent are not. The board refuses it,
and names the way through rather than the wall — which member is
working, and that ‖ hold stops the phase and the agent it has in flight
while leaving the branch, the merges and every worktree as they were.
- phases.assert_not_working() is the refusal: it reads the one file
first, so an ordinary card never reaches the question, then resolves
the phase's list and asks what is actually running.
- agents.working_on() answers that from the processes themselves, not
from the registry's status alone — the reaper flips that a moment
after a run ends, and a rule that only refuses must not hold a card
hostage to a run that has already died. stop_phase() now reads the
same helper.
- httpd asks it on /api/move, /api/archive (archiving is a move) and
/api/task/complete, so a stale page cannot get past it.
- The toast wraps, is bounded to the viewport and stays up for as long
as its text takes to read: a refusal you cannot finish reading is the
wall this was written against.
What still moves: a phase between members, a halted phase, a held one,
a member card, and every ordinary card.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
laneRank() lumped a held (stopped) phase in with unstarted phases at
the same rank, so an actionable "stopped — held by …" lane could sort
below phases nobody has started. The snapshot already carries
snap.stopped, so give a held run its own rank just below running,
keeping it near the top where a person can resume it. Addresses the
Copilot review point on PR #48.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Card 56 took a phase's members off the Board; this is where they went. A fourth view beside Board, Sessions and Focus, holding one lane per phase card — every phase there is, not only the running ones, because a phase whose cards are all merged and which is waiting on its own PR still owns them.
A lane is a head, five stage columns and the phase's log. The head carries the phase, its progress, the member in flight and its own controls — hold, the phase branch, the phase card — and nothing that ends a phase, which stays a board move on the phase card. Four columns are the board's stages; the fifth is 'Merged in', not done/, because a member merged into the phase branch is finished as far as the phase is concerned and is not in main yet. Which members the branch holds is the runner's last pass while a phase runs, and the phase log once there are no more passes, so a phase in review/ still draws the work where it landed. The cards are the cards: cardFor() builds them here exactly as it builds them for the Board, agent line, chips, position-in-the-run chip and hover actions intact. The phase log sits under the lane, the runner's decisions in the order it made them, read off the card's own '## Phase log' — the only thing that can tell "not reached yet" from "started and ended badly".
A halted lane says so at the top of itself, names the card it stopped on, and offers run again beneath the reason. That action left the Board's phase card for the same reason: clearing a halt should mean having read what caused it, so the card offers a way into the view instead, and hold, which needs nothing read. The crossing the other way is the switcher, which carries how many phases are running and an alarm mark while one has halted — on every view, alongside the toast and the ticker line that already fire.
Covered by tests/test_phases_view.py: collect() reads a throwaway tasks/ tree and the page's own lane rules run over that reading in node (ordering, placement, the last column, the log, the all-merged phase in review/), plus source-level wiring invariants for the DOM half. test_phase_watch, test_card_actions and test_scroll_kept updated where this moves what they pin. Full suite green: 939 tests.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The Board view stops drawing a phase's members: the phase card stands for them there, and the columns go back to counting what you can see. Nothing is deleted, moved or marked — a member keeps its stage, its file, its agent and its actions.
heldByPhase()/columnCards()/columnNote() in board.html: the count is the length of the list drawn, and '+2 in phases' beside it says where the rest went, only where members are actually hidden. Membership is the only thing that hides a card, so removing it is the un-hiding: an archived phase card and an edited '## Cards' list stop deriving it, and a phase in done/ holds nothing. A card wearing phase drift is never hidden. The phase card carries the summary it now owes: a '1 of 2 merged' chip opening its own sheet, the member in flight on its activity line, and a halt worn in --alarm. Only the Board hides; Sessions, Focus, the header's live chip and the tab title still see an agent working on a hidden member.
Covered by tests/test_phase_members_hidden.py — collect() reads a throwaway tasks/ tree and the page's own rules run over that reading in node, plus source-level wiring invariants. Full suite green.
Every card in backlog/, to-do/ and done/ gets a ⌸ chip at the end of its
footer row — the tray's own glyph — arming on the first click and
archiving on the second, through the routes and the ARCHIVE_FROM guard
that already existed. In-progress and review cards do not get one: the
board offers what it will do.
Committed by hand after the run that wrote it ended mid-turn without
committing. The work is the agent's; the suite passes on it (875 tests)
and it is unchanged from what the run left staged.
renderBoard() starts with `board.innerHTML = ''` and rebuilds every
column, so each column's scrolling .drop was a brand-new node on every
pass — and a new node's scrollTop is 0. render() runs on every SSE frame
and a working agent emits an event per tool use, so a column being read
snapped back to the top several times a minute: worst exactly when there
is most to read. The same wipe threw away #board's horizontal position,
and the same demolition costs the session timeline, the sessions rail,
the Focus view, the drawer, and the activity log's place whenever it was
not stuck to the bottom.
Rather than a reconciling render — the real cure, and a far larger change
to the way the page works — the position is paid for separately: note
where each scroller was before the wipe, put it back once the new nodes
are in, both halves inside the same synchronous render so nothing
flashes. Keys are stable names rather than nodes (the stage slug for a
column, the session id for a timeline), because the node is what does not
survive. Restoring clamps, so a column whose cards moved on lands at its
new bottom instead of past it, and one now shorter than its own viewport
lands at the top instead of at a negative offset.
A `v:` prefix marks a key belonging to a view, and switching views drops
those: coming back is a fresh look, not a stale offset from a previous
visit. The activity log spans every view, so its key carries no prefix
and outlives the switch. The two behaviours that were already right are
left alone and now have tests holding them there — the log still follows
the newest line when it was stuck to the bottom (that reading runs after
the restore, so it wins), and the drawer still opens at the top when you
select a different card, which falls out of keying the drawer per
document rather than being fought for.
The helpers run for real under node, as tests/test_drawer_markdown.py
already does with md(); the source-level invariants beside them hold the
mark/restore pairs in order around each wipe, and one of them fails if a
new scrolling element is added to the CSS without a renderer keeping its
place.
python3 -m unittest discover -s tests → 854 tests, ok
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>
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>
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>
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 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.
"You" was the else-branch of session_label: anything the board could not
attribute to an agent it attributed to the person. Every session read back
from disk was one of those, because the agent id lived only in the session
registry and never reached the persisted events — so past agent runs came
back wearing the human's label, carrying their own closing reports
underneath it.
Identity is now a small whole file beside each event log
(state/sessions/<id>.who.json): agent id, the agent's name, the model it
rode, and the task. A file rather than a key on the events, because the
logs are append-only JSONL whose first line every reader takes for an
event — and because the name and the model are nowhere in the stream, so
this is the only thing a restart can read them back from. It is rewritten
only when what the board knows changes, which also covers an agent id that
arrives on a later event.
load_disk_sessions() reads it back, and the label now has three registers
instead of two: the agent's name (persisted, so a restart no longer costs
it), "You" only for a session positively recorded as carrying no agent,
and a neutral "Session · <id>" for a log written before any of this was
recorded. Old logs are not retro-attributed in either direction.
agentFor() in board.html falls back to the persisted identity when this
board no longer holds the live record, so a replayed agent session wears
its model chip from what was written rather than from what happens to be
in memory. What depends on liveness (Hold, the worktree branch) finds
nothing there and stays silent, as before.
tests/test_session_identity.py drives the real ingest → persist → reload
path and the page's own chip functions in node.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The closing report was capped by keeping its last 3000 characters, so a
report longer than that lost exactly the sentence the prompt contract
puts first — the state of the work — and the task file began mid-word.
Card 32's record still shows it.
reports.py is now the one place a report is clipped: it keeps both ends
(the headline and the review-first pointer), cuts the middle on line
boundaries, and leaves one line of prose saying how many characters went
and naming the log under local/state/agent/logs/ that still holds all of
it. One documented cap, CAP = 12000, replaces 3000 in agents.py and 1500
in github.py, so the PR body and the task file carry the same text for
the same run. It sits left of both consumers in the module map and
depends on config alone.
The failure excerpt keeps its tail deliberately — for a crash the end is
the story — and now says so through reports.tail.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A running agent was visible on the board and nowhere else, so switching
tabs — the normal thing to do while one works for minutes — left bench
saying the same string whether three agents ran or none.
The count now leads the title (`2◌ · bench · bench`), ahead of even the
project: the reasoning that put the project first (truncation eats the
tail) applies harder to the one state you want from a tab you are not
looking at. The mark is the in-flight glyph the CI and copilot chips
already wear, not an emoji, and a quiet board keeps the plain title byte
for byte.
Both the tab and the header chip now count through one runningAgents(),
so they cannot disagree, and the title is written only when the string
actually changed — render() runs on every SSE frame.
Behaviour is covered in node (skipped where node is absent); the wiring
that keeps one filter and one writer is a source-level invariant, as
elsewhere for board.html.
md() split a block into physical lines and made each one a unit. Task
files are hard-wrapped at ~74 columns, so the second line of an item
became its own bullet, `- [ ]` rendered as a literal bracket pair,
nested lists flattened, and prose kept the author's ragged edge as
<br>. "Enough for these task files" was exactly what it was not.
Lists are now grouped into logical items before rendering: a new item
begins only at a marker, and a line without one is continuation text
joined with a space. Indentation is honoured — a marker past its level
opens a nested list, a shallower one closes back to the level that
fits — and one entry point serves both bullets and ordered lists, so
an <ol> nests under a <ul> the same way. Task-list items render as a
glyph in a span, never an <input>: the file is the source of truth and
the drawer is not an editor. A ticked box reads as settled (--calm);
an open one stays neutral. Paragraphs and blockquotes join their
source lines with a space, so prose reflows to the drawer's width.
Fences, tables, headings and rules are untouched, including the fence
state machine that spans blocks.
The tests lift esc() and md() out of the page and run them under node,
because the renderer is a pure function and its output is what to
assert on; node is not a bench dependency, so those checks skip when
it is absent and source-level invariants cover the shape of the fix.
One check renders every card on the board plus AGENTS.md and asserts
one bullet per source marker — the acceptance criterion applied to the
whole corpus.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A project could run bench for months without a manager/local/.env:
everything fell back to core/.env.example, so the two settings that
change what bench is — claim-on-move and syncing through origin/main —
were invisible to anyone who had not read that file.
install.py now writes it on a first run. It asks three questions no
default can be right about (solo or team, which agent adapter, the
project's test command) and writes core/.env.example with the answers
substituted into their lines, comments and all keys intact — so the
written file is where the project reads what else it can change.
- Runs after first_boot_clean: .env is one of the two things the
first-boot guard reads, so writing it earlier would skip the clean.
- Never asks without a terminal on stdin. install.py sits on the path
of start.sh, update.sh and every hook, so no TTY prints one line and
carries on rather than blocking a board start on an invisible prompt.
--dry-run reports the questions and writes nothing.
- An existing .env is never touched; --setup is the only way back to
the questions, pre-filling from the current file and rewriting it in
place, so start.sh's fallback BOARD_PORT line survives.
- Bare Enter takes every default (the result is the example verbatim,
i.e. today's behaviour exactly); Ctrl-D skips the rest.
start.sh needed no change — it already calls install.py before the port
dance, which is the right order. Tests drive the interactive runs over a
real pty and the non-interactive ones with /dev/null on stdin.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The MultiEdit deny rule was already dropped in the hotfix; this is task
10's follow-up half — the guard that catches the next fossil.
The claude adapter's review branch spells "cannot edit files" in the
vendor's own tool names, and that roster moves: a rule naming a tool the
installed CLI does not have is refused at startup, so the launch dies
before the agent speaks. Membership assertions could not see that
happening, so the stub-binary test now asserts each mode's flag list
literally (settings payload elided) — a deny name added or renamed shows
up as a diff a reviewer must re-verify against the installed CLI. The
run script carries the same warning where the list actually lives.
The helper drops a leaked AGENT_MODEL, since --model would otherwise
appear in an argv now compared whole; test_agent_model.py owns that flag.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
An agent that exits non-zero was the least-handled outcome on the board:
one ticker line that scrolled away, a card that looked exactly as it did
before the launch, and the log's contents — usually the whole story — left
on disk. Three launches died in an API outage and the board said nothing a
person would notice.
So the outcome is recorded on the run: exit code, ended-at, and the
cleaned tail of its log as the excerpt (a launch that died before the
agent spoke says so rather than showing blank). From that the card takes
the alarm border and a `run failed` pill, with the excerpt on hover and in
full in the card sheet; a toast fires, because failures are rare and
actionable; and the ticker line stays, now naming what the log ended on.
Every headless kind lands in _finish, so work, act-pr, PR review and the
relevance check are all covered. The state is scoped to the run and the
stage: the next launch supersedes it, and the watcher drops it when the
card moves, so nothing follows a card into review/.
The way is cleared for the relaunch too: a failed run with no commits has
its worktree and empty branch removed — the reasoning a decline already
uses — so ▸ start work works without a hand `git worktree remove`. A
failed run with commits keeps its worktree.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Task 12 recorded which model each launch rode, but it surfaced only at
the tail of the session-detail metadata line, where nobody's eye lands.
With work and review models diverging per intent, "which brain did this"
is a routine review question, so the answer now sits beside the name that
did it.
One component, modelChip(), rendered wherever a name identifies a run:
the sessions list row, the session-detail header, the Focus header and
the working card's agent line. It borrows the session-id hash's register
— small, mono, dim — and takes no colour, because a model is not a state.
The vendor prefix a name repeats is dropped for the chip (claude-opus-4-8
→ opus-4-8, anthropic/x → x) with the whole string on hover; an
unfamiliar name is shown as recorded rather than guessed at.
A launch that never knew its model wears no chip at all — an inherited
default, or a session replayed from disk after a restart. The two
metadata lines that used to carry the model keep only what the chip
cannot say: "model inherited", and only when it genuinely is.
Tests lift the two functions out of the page and run them in node (skipped
where node is absent) for the shortening, escaping and silence; placement
and register stay source-level invariants, as board.html has no frontend
test runner.
install.py shipped mode 644 in v0.1-alpha, so the README one-liner's
./install.py was permission-denied on every install. The repo modes and
update.sh's repair line were hotfixed already (all 14 shebang'd tracked
files are 100755; update.sh:165 names install.py) — this is the guard
that keeps them that way, and the patch release that heals the field.
- tests/test_release_artifact.py: the invariant, read from the tar
header rather than the repo — every member whose content starts `#!`
must carry the exec bit, failing by name. No exception list: there is
no shipped file that legitimately may not be run, and gaining one
means editing the test with a reason. Two tests keep it honest: the
guard is proven to bite by repacking the real artifact with
install.py's mode stripped, and an unpacked release must run
./install.py as a program, not via python3.
- tests/test_update_from_release.py: an install whose install.py is
mode 644 — the shape v0.1-alpha left in the field — is executable
again after any update. `cp` onto an existing file keeps the
destination's mode, so the chmod line is the only thing healing it;
removing that line fails this test.
- manager/core/VERSION → 0.1-alpha.1: cutting the patch is the honest
move over a release note telling users to work around it.
- manager/core/release-manifest: the invariant, stated where the
shipping list lives.
Verified: python3 -m unittest discover -s tests (267 tests, OK). Both
new assertions were watched failing first — a build-side `chmod -x` on
the staged install.py, and update.sh with install.py dropped from its
chmod list — then restored.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
AGENTS.md gets the "State syncs; reactions don't" section under Syncing
boards — the actor-only rule, the file-carried backstops behind it, and
the two consequences you can see (a half-done side effect is nobody's to
finish automatically; ownership gates work launches). The Pull requests
section says which board opens one and how ↑ open PR asks afterwards, the
card-actions list gains ▸ take over and ↑ open PR, "Claiming a card" stops
saying the claim refuses nothing, and the merge & clean up paragraph
documents the origin-side merge — including the constraint the local path
hid: whoever clicks needs merge rights on the repo, not just push rights.
.env.example: BOARD_COMMIT_MOVES documents the claim as a launch gate,
BOARD_SYNC the actor-only reactions and the origin-side merge.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Task 19 gave every board the same truth; this makes exactly one of them
react to it. State syncs; reactions don't.
- watch.py: attribution is now the trigger gate. _actor returns (who,
remote), and a move a pull applied — the arrivals sync files — renders
and narrates but opens no PR. A plain mv on this disk still acts: inert
means "happened elsewhere", not "unattributed".
- github.py: the file-carried gates behind that rule, so the rare double
is harmless rather than loud. The **PR:** line commits itself in team
mode (taskfiles.commit_edit, sharing the move's pathspec-scoped commit),
so it reaches the other boards instead of sitting in one working tree —
where it would also stall sync outright; and a `gh pr create` that races
anyway adopts the PR GitHub already holds.
- No board finishes the actor's half-done side effect on its own: the
startup reconcile stands down in team mode and a review card with a
branch and no PR carries ↑ open PR (POST /api/pr/open), which is a
person deciding rather than N boards guessing. _open_pr raises its
reasons now, so the automatic path narrates them and the explicit one
toasts them.
- agents.py: the claim gates work launches. A card someone else holds
refuses, naming them; ▸ take over is the deliberate second path (armed
like everything that costs tokens) and reassigns via taskfiles'
set_assignee; an unheld card claims itself on launch. Only in team mode
— with BOARD_COMMIT_MOVES off nothing writes an assignee, so nothing
reads one as a lock.
- github.complete_task: with BOARD_SYNC on, merge & clean up runs
`gh pr merge` and lets the beat deliver the result, so local main only
ever fast-forwards and no board makes a merge commit of its own. A
branch without a PR is refused with a pointer to ↑ open PR. Sync off
keeps the local merge path exactly as it was.
Verified with tests/test_actor_acts.py: two real clones of a real bare
upstream and a stub gh — the replica that only renders, the PR line that
travels, the double that adopts, the takeover that reassigns, and both
merge paths.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The design's answer to "what is the bench logo" is that there isn't a drawn
one: the word is the logo — "bench", lowercase, set in Zilla Slab SemiBold
and tracked -.015em, with the b lifted out of the same face as the icon.
The board still showed the old "Bench" in Plex Sans and a dot in the tab.
Bring both in line. The board may not fetch a font for a logo, so the
wordmark ships as outlines: one path per letter on a 1000-unit em, filled
with currentColor so Night and Daylight each get their own ink and the mark
never wears a colour that means state. --logo-h is the single number that
sizes it. The tab icon is the wordmark's own b on the accent tile it
already had, reusing the b's outline string character for character.
Nothing beside it moves: the mono path line still sits on the wordmark's
baseline, at the design's 11px, and the header's padding, switcher and chips
are untouched.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
AGENTS.md gets the "Syncing boards" section — what pushes, what pulls,
what a lost race looks like, and the two disciplines that make it safe:
local main advances only through the board and origin, and sync never
merges. The module map gains sync.py, the settings paragraph and the
Board view's description gain the sync chip and the author attribution,
and .env.example documents BOARD_SYNC / BOARD_SYNC_INTERVAL, including
the fetch-rate line for rate-limited remotes.
Team mode's second half. A board-made move already commits itself (18);
now that commit publishes, every board pulls on a beat, and a card two
boards move at once resolves the way git resolves everything else — the
push race is the concurrency control.
- core/sync.py: push is event-driven (a new state.COMMIT_HOOKS registry
fires it from taskfiles, so taskfiles stays left of everything that
reacts to it); pull is a beat that fast-forwards, or replays this
board's own commits on top when the two diverged. A replay that
conflicts on a task file drops the local move — origin is the
linearizer — and toasts who took the card.
- The piggyback guard stands in front of every push and every replay:
each local-ahead commit on main must be `board: `-prefixed, so a
human's unpushed work is never published as a side effect of a card
moving. Uncommitted changes, a checkout off main and an unreachable
origin all stall or degrade rather than risk anything, each narrated
once instead of once per beat.
- watch.py names the commit author instead of "disk" for moves a pull
brought, via the arrivals sync files for it; its narration moved out
of the loop into narrate() so it can be tested directly.
- The board grows a sync chip that appears only when sync stops
converging, and the SSE stream grows a toast type so the server can
say something to the person, not just to the ticker.
Gate off (the default) means no fetch, no push, no thread, no change.
Verified with tests/test_boards_sync.py: two real clones of a real bare
upstream race each other through every case above.
Copilot review on PR #15: the 'gate off' wording in AGENTS.md and
.env.example read as 'no assignee', but read_task() always parses the
Assignee line and the card face always renders it. The gate governs only
whether a move writes/clears the line and commits. Reword both to say so.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Every board tab read "Bench — task board", so the moment a second bench
existed the tab bar stopped saying which was which. The title now leads
with the project — "<project> · bench" — because tab truncation eats the
tail and the tail is the same in every bench tab.
The project is config.PROJECT: the repo directory's name, or BOARD_TITLE
from local/.env for people whose checkouts are all called "app". The
server renders it into the served page's <title>, so the tab is right on
first paint with no flicker from generic to named; /api/state carries it
too, and renderTitle() keeps it in step when the view switcher swaps the
tail (sessions, focus). The project stays the first word regardless, and
nothing else writes document.title.
Tests: tests/test_board_title.py covers the server half in fresh
interpreters (BOARD_TITLE resolution, the rendered title, escaping, the
rest of the page untouched) and the browser half as source invariants,
the same way the other board.html tests work.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
AGENTS.md gains the convention — claiming is moving, the assignee is who
launches agents on the card, hand-moves bypass the claim and should update
the line by hand, and git identities collide the way git's do — plus the
**Assignee:** header field beside Status/Priority/Type and the
BOARD_COMMIT_MOVES gate with its unpushed-main consequence.
.env.example documents the setting with its default (off).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Moving a card out of backlog/ or to-do/ now claims it. taskfiles.move_task
writes an **Assignee:** line from `git config user.name` — first claim only,
an existing assignee is never overwritten — and clears it when a card is
walked all the way back to backlog/. The same move then commits itself: one
commit, the move and the claim together, staged by pathspec so a developer's
unrelated staged work is neither committed nor unstaged, messaged
`board: <number> → <stage> (<name>)`. Hooks are skipped (bookkeeping, not
code) and nothing is pushed — that is task 19's job. A commit that fails is
narrated in the ticker; the card has already moved, and disk is the truth.
All of it sits behind BOARD_COMMIT_MOVES, off by default, so a
single-player board moves cards byte-identically to before.
The card face shows the owner instead of "nobody yet" in every stage — on
done/ cards the line reads as history — and the who row now escapes what
the file said.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>