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>
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>
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.
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>
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>
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>
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>