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>
First field bug of v0.1-alpha: install.py was committed 100644, so
every install shipped it permission-denied for direct ./install.py use
(start.sh's python3 invocation masked it). Exec bit set on all four
shebang'd files (install.py, board.py, hook_settings.py,
permission_config.py) and install.py added to update.sh's post-update
chmod line so already-broken installs heal on their next update. The
artifact-side invariant test — every shipped #! file executable — is
card 21.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
From the 2026-07-30 design discussion: origin/main becomes the truth
and every checkout a replica. 18 makes moving a card claim it (assignee
written and committed atomically); 19 pushes board moves and pulls on a
beat, with git as the lock server and take-over toasts for lost races;
20 keeps side effects on the actor's board only, gates launches on
claims, and routes team-mode merges through origin. All gated off by
default; single-player bench is untouched until opted in.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Ubuntu runner, Python 3.11 and 3.13 (stdlib-only project: oldest
supported and newest current). Git identity and init.defaultBranch=main
configured for the tests that build scratch repos; everything else the
suite needs (bash, tar, rsync, stub binaries) is on the runner already.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The grip's handlers write #logbody's inline height, but the CSS said
flex:1 — inside the column-flex #logpanel the flex algorithm sized the
element from basis 0% + grow and never consulted the height property,
so dragging did nothing. Worse, mouseup persisted a re-read
offsetHeight (the flex-computed value), overwriting the remembered
size with the status quo on every attempt.
Re-couple the two: #logbody becomes flex:none so the written height is
authoritative again, with max-height:60vh guarding overflow. The
handlers track the clamped height they compute and mouseup persists
that value; the load-time restore runs through the same clamp and
writes the same property.
The drawer grip writes width on the position:fixed #drawer itself, so
no flex competes there — covered by a non-regression test alongside
the source-level invariants in tests/test_log_resize.py.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The first full cycle closes: all seventeen cards from two days of
self-hosted dogfooding are done, and the earlier done pile moved to
tasks/archive/ — out of every column, never deleted. The board starts
its next cycle empty.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Resolutions honouring both sides:
- CLAUDE.md: main's pointer form wins; this branch's release-era wording
(tree diagram line, Updating section) ported into AGENTS.md where the
brief now lives.
- update.sh: this branch's release-based rewrite wins; main's additions
to the old file list (AGENTS.md from task 13, LICENSE) are carried by
the release-manifest instead: added copy AGENTS.md and copy LICENSE.
- Starter local/: manifest and release.sh now seed the split pair
(AGENTS.md notes + pointer CLAUDE.md) mirroring task 13's layout;
artifact test updated to match.
- tests/test_update_round_trip.py (main's, aimed at the removed
git-clone mechanism) retired; its two 13-specific intents ported into
test_update_from_release.py as
test_agents_brief_replaces_an_old_vendor_named_copy, its third already
covered there. 147 tests green.
- release.sh's lost executable bit restored.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The hover actions were easy to miss and mute when hit: a ~20px target
that slid in on hover and reshaped when arming swapped the label, then
zero feedback between the second click and the SSE redraw seconds later.
board.html, one state machine for every action button (hover actions and
$-command chips alike), its truth in S.acts so SSE re-renders mid-window
redraw the same picture:
- Stable geometry: rest/confirm/busy labels stack in one grid cell so the
button is born at its widest state's width; the slot fades in place
(opacity only); buttons are 24px tall and the toprow reserves that
height, so nothing moves or grows under the pointer.
- Visible armed window: alarm colour plus a draining underline over the
disarm timer, lengthened to 5s; a rebuilt button rejoins the drain
partway via a negative animation delay.
- Instant busy state: firing disables the button, breathes the glyph and
shows a participle (starting…, holding…) until the redraw replaces the
card, the runner reports failure, or a 15s timeout restores it with a
toast naming the action — never a silent revert.
- Misses fall harmlessly: the slot pads its hitbox (padding + negative
margin) and swallows clicks itself, so gap clicks neither fire an
action nor open the card sheet.
tests/test_card_actions.py holds the source-level invariants; the full
suite (119 tests) passes.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Refuse assets whose tar members or manifest copy paths reach outside
their root, checked before anything is replaced; create parent dirs for
future nested copy entries. Test both refusals hermetically. Replace
setUpClass bare asserts with explicit checks that survive python -O.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Both adapters' act-pr stances gain git fetch and git merge so a
conflicted PR can be resolved by merging main into the branch. History
rewriting is denied outright rather than merely unlisted — the plain
'git push' allow would otherwise cover the force spellings — via deny
rules in the claude settings and last-match-wins deny globs in the
opencode config. git rebase stays absent and denied in both.
act-pr.md teaches the flow: merge origin/main, honour both sides'
intent, tests green, the resolution in its own commit naming files and
choices, covered explicitly in the report — and refuse semantic
conflicts (main made the branch's premise false) by aborting the merge
and naming the collision for a human. The adapters README records the
widened contract.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The poller now reads GitHub's mergeable field alongside reviews and
checks. A CONFLICTING PR drops any approved-green verdict (as
changes-needed-by-you, not a CI failure), wears an alarm-coloured
conflicts chip in the card's footer row, and narrates the flip in the
ticker. GitHub computes mergeability lazily, so UNKNOWN keeps the
previous reading instead of flapping the chip.
The poll fold is now a pure function (_fold), so the verdict logic is
testable without gh.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Both stops precede any gh call, so they run hermetically in a scratch
git repo carrying only release.sh and a VERSION.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
README's first instruction becomes the tokenless curl | tar against
the stable latest-release URL; the clone path moves to a "working on
bench itself" note. CLAUDE.md's Updating section and .env.example's
BENCH_SOURCE comment describe release-based updates: the source is
stamped at build time, BENCH_SOURCE is an override rather than a
prerequisite.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The repo is two things at once — bench-the-project and bench-the-
distribution — and install/update conflated them by cloning the repo
and subtracting what should not have come along. Invert it: one
curated artifact, correct by construction.
- manager/core/release-manifest: the whole shipping list in one place
(copy/tree/once/keep/seed classes). update.sh's hardcoded top-level
file list, promoted.
- release.sh: builds bench.tar.gz from the manifest (contents at the
tarball root, stable asset name — the tokenless latest-release URL
depends on both), stamps the source repo into the shipped update.sh,
refuses on dirty tree or existing tag, tags v<VERSION> and publishes
via gh release create. Never ships in the artifact.
- update.sh: downloads the latest release (BENCH_REF pins a tag) via
gh with an anonymous curl fallback; refuses when the asset's VERSION
disagrees with its tag; replaces manager/core/ wholesale plus the
artifact manifest's `copy` files; touches nothing else. No release
published -> says so and changes nothing; no silent git fallback.
- tests: the tarball equals exactly the manifest (no cards, no local/
content beyond the generated starter, no state/tests/.claude), the
artifact installs pristine and boots the board, and update.sh's
replace/survive/refuse paths run hermetically against PATH-stubbed
gh and curl.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Rename-vs-edit conflict: task 13 (landed) moved the workflow brief to
AGENTS.md leaving CLAUDE.md as a pointer, while this branch had edited
the brief's work-launch section in place. Resolution: CLAUDE.md keeps
main's pointer form; this branch's documentation of the
branch-from-origin behaviour moved verbatim into AGENTS.md where that
text now lives. agents.py and the rest auto-merged; 103 tests green.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Ships with the distribution: update.sh's core-owned file list carries
LICENSE so installed projects receive it, and the release manifest
(task 15) inherits the list.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
BOARD_AGENT_MODEL picks one model for every headless launch;
BOARD_AGENT_MODEL_WORK / _ACT_PR / _REVIEW beat it per intent (review
covers PR reviews and relevance checks). Empty — the default — means
the variable never reaches the adapter and the vendor's own resolution
applies, exactly today's behaviour; a stray AGENT_MODEL in the board's
own environment is stripped rather than inherited silently.
Core carries the name as an opaque string: config resolves intent →
model, _launch passes it as AGENT_MODEL, and each adapter renders it
natively — claude appends --model, opencode sets the generated
config's model key ("provider/model-id", per its docs). The resolved
model (or its absence) is recorded on the agent record, so the
Sessions and Focus views state what a run actually rode instead of
leaving it to whoever's machine the board happens to run on.
Stub-binary tests pin the byte-identical-when-unset guarantee, the
per-intent resolution, the launch-env seam and the record.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The ticker note says the branch point is N ahead of this checkout, but
the count compared origin/main to the local main branch — misleading
whenever the board runs from another branch or detached HEAD. HEAD is
the fallback base, so HEAD..origin/main is exactly what launching from
it would have missed.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>