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.
The claim commit e31de61 was hand-made after the board (gate still off)
had already moved the file on disk: it added the in-progress copy but
missed staging the backlog deletion. Nothing was lost — same content,
both paths, now one.
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>
Rank rides in the card header so every reorder is a single-file write
that merges one card at a time — the shape the directory-is-truth law
and task 19's sync model both demand. Sparse integer ranks, midpoint
drops, one sanctioned multi-file renumber when gaps exhaust.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>