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>
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.
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>
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>
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 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>
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>
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>
Five fixed 296px columns overflowed 13" MacBooks (~1556px footprint vs
~1440 viewport), clipping the Done column. Columns now grow/shrink from
a zero basis, capped at --col (pixel-identical at >=1556px) and floored
at a new --col-min token (240px: five columns + gaps + board padding =
1276px, fitting 1280px viewports). Below the floor the existing
overflow-x:auto fallback is unchanged.
Sessions, Focus and the header are already fluid at 1280px — checked,
nothing to fix there.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
What counts as a definition-of-done check was the origin project's
stack (pytest / lint-imports / frontend) frozen into core: emit.py
classified against inline literals and the Focus panel carried three
fixed rows with duplicated regexes. Per the three-layer law that is
project knowledge, so it now lives in one file: core/checks ships the
old rows as the default, and a checks file in manager/local/ replaces
it wholesale — the same filename-wins resolution as prompts.
- core/checks: '<label>: <command regex>' per line, self-documenting;
read fresh on every use.
- emit.py classifies Bash commands against the resolved file (kind
'check', the label carried into the summary) and judges pass/fail
generically — counted results, broken totals, OK/FAILED verdict
lines — since the hook payload carries no exit status. The runtime
moved under a __main__ guard so the classifier is importable.
- config.checks() mirrors the parser (the bridge stays standalone) and
httpd serves it in /api/state; the Focus panel renders one row per
served entry, matching events with the same patterns — no fixed
rows, no duplicated regexes.
- manager/local/checks gives bench its real definition (unittest), so
the self-hosted board shows a check that can actually run.
- The default BOARD_AGENT_COMMANDS drops its pytest prefix: core no
longer names any stack outside the shipped checks default, and a
test walks manager/core to keep it that way.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The Focus 'Right now' well advertised a report with the › chevron but was
a static div. When the rendered event carries detail it is now a real
disclosure — a details.fold whose summary is the well itself, keyed into
S.openFolds like the Sessions timeline's folds so the open state survives
the SSE-driven re-renders, with the report in the fold's mono <pre>,
scroll-bounded when long.
Wells where clicking does nothing no longer wear the chevron: the board
cards' live line and the empty-focus well lead with · instead (the drive
wells' ✳ already meant something else). One glyph, one meaning.
tests/test_focus_well.py encodes the invariants as source-level checks:
› only inside the wellfold summary, detail rendered preformatted, fold
state keyed rather than DOM-only, Sessions folds untouched.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Everything core from cicero-pas's .task-manager, with instance data
reduced to skeleton: empty stage directories, the task template, empty
local/ scaffolding, and a README covering install (clone into
.task-manager/, vendored on purpose) and update (update.sh replaces core
wholesale; local/ and tasks/ survive).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>