Cards 18-20: the multi-user arc — claim on move, sync through origin, replica etiquette

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>
This commit is contained in:
istos
2026-07-30 08:45:58 +02:00
co-authored by Claude Fable 5
parent 0aeba69b01
commit e12d5b1a85
3 changed files with 289 additions and 0 deletions
+87
View File
@@ -0,0 +1,87 @@
# 18 — Cards are claimed on move: assignee written, board commits the change
**Status:** Backlog
**Priority:** High — the ownership primitive every other multi-user piece builds on
**Type:** Feature
Cards have no owner: the face shows "nobody yet" and nothing records
who picked work up. For one person that's cosmetic; for a team it's
the missing primitive. Moving a card out of backlog claims it — the
board writes the assignee into the file and commits the move — so
ownership travels with the card to every clone, and attribution stops
being an in-memory courtesy.
## Context
- Task files carry Status/Priority/Type but no assignee; the card
face's "nobody yet" slot is the UI waiting for this value.
- Board moves today rename the file and rewrite Status
(`taskfiles.py`) but git never hears about it — commits of tasks/
happen by hand, so the shared history lags the board by hours.
- Identity: `git config user.name` — already present on every machine
that can commit, already what blame/history show, no new concept.
- The 2s disk watcher narrates hand-moves; nothing in this card
changes that — it gains attribution in task 19 when remote moves
arrive via sync.
**Affected areas:** `taskfiles.py` (the assignee line, the commit),
`board.html` (render assignee on the face), AGENTS.md (the convention).
## What to build
- Moving a card backlog → to-do or to-do → in-progress through the
board writes `**Assignee:** <git user.name>` into the header (first
claim only — an existing assignee is preserved, not overwritten).
Walking a card back to backlog clears it.
- Board-made task changes commit themselves: the move + claim in ONE
commit touching only that task file, message prefixed `board: ` with
the actor and transition (`board: 18 → in-progress (ronald)`).
Commit only — pushing is task 19's job. Gate the auto-commit behind
`BOARD_COMMIT_MOVES` (default off) so single-player behaviour is
unchanged until opted in.
- The card face replaces "nobody yet" with the assignee; done/archive
keep it as history.
- AGENTS.md documents the convention: claiming is moving; the assignee
launches agents on the card; hand-moves should update the line too.
**Out of scope** — tempting neighbours left alone:
- Pushing, pulling, or any cross-machine behaviour (task 19).
- Enforcing assignee-only launches (task 20).
- Multiple assignees, @-mentions, or any identity beyond git's.
## Acceptance
- [ ] Given an unclaimed backlog card, when it is dragged to to-do on
a board with `BOARD_COMMIT_MOVES=1`, then the file gains
`**Assignee:**` with the mover's git name and exactly one
`board: `-prefixed commit exists touching exactly that file.
- [ ] Given an already-assigned card, when someone else moves it
forward, then the assignee is unchanged (first claim sticks).
- [ ] Given a claimed card walked back to backlog, then the assignee
line is removed.
- [ ] With the gate off (default), moves behave byte-identically to
today: no commit, no assignee write unless configured.
- [ ] Edge case — a dirty index: the board's commit stages only the
task file's paths; a developer's unrelated staged changes are
neither committed nor unstaged.
## Open questions
- None.
## Notes
First card of the multi-user arc (18 claim → 19 sync → 20 etiquette),
from the 2026-07-30 design discussion: origin/main becomes the truth
and every checkout a replica, with git as the lock server. The claim
must be atomic with the move — same commit — because it is the
optimistic lock task 19's push races resolve.
**Risks**
- Hand-moves (plain `mv`) bypass the claim; the watcher still narrates
them but no assignee is written. Acceptable — AGENTS.md says to
update the line — but the gap should be stated, not hidden.
- `user.name` collisions ("ronald" on two machines) merge identities;
fine for teams that also share a git history, worth one doc line.
@@ -0,0 +1,99 @@
# 19 — Boards sync through origin/main: push on move, pull on a beat
**Status:** Backlog
**Priority:** High — this is the multi-user feature; 18 without it is bookkeeping
**Type:** Feature
**Depends on:** 18 — the claim commit is what gets pushed, and its
same-commit atomicity is what makes the races below resolve correctly
One machine's `tasks/` is the truth today; everyone else's is stale
until someone remembers to push and pull. Make origin/main the truth
and every board a converging replica: board-made moves push
immediately, every board pulls on a short beat, remote moves appear in
the ticker attributed to their author, and losing a same-card race is
a toast, not a mystery.
## Context
- `watch.py` polls the stage directories every 2s — remote changes
that arrive via pull are already noticed and narrated; today they
would read "disk", this card upgrades them to the commit author.
- Task 14 (landed) already points fresh agent branches at
origin/main; this card gives the *board state* the same treatment.
- The precondition that makes pulling safe is bench's own discipline:
code work lives in worktrees and PRs, so the main checkout stays
clean and fast-forwardable. Team mode assumes — and the docs must
say — that local main advances only through the board and origin.
- Push publishes every local-ahead commit, not just the board's —
the piggyback hazard below is the sharpest edge in this card.
**Affected areas:** a new small `sync.py` (or a sibling thread beside
`watch.py`), `config.py` (settings), `state.py`/ticker attribution,
AGENTS.md (team-mode discipline).
## What to build
- **Gate**: `BOARD_SYNC=1` (default off; implies `BOARD_COMMIT_MOVES`).
Off = today's behaviour exactly.
- **Push, event-driven**: after each board-made task commit, push. On
non-fast-forward: fetch, rebase the board commits, push again. If
the rebase conflicts on a task file, the local move loses: revert
it, re-read the remote version, and toast who took it
("07 claimed by elena — your move was undone").
- **Piggyback guard**: before any auto-push, every local-ahead commit
on main must be `board: `-prefixed. Anything else → no push, one
clear ticker warning naming the stray commit. Never publish a
human's unpushed work as a side effect.
- **Pull, periodic**: fetch + fast-forward-only merge every
`BOARD_SYNC_INTERVAL` (default ~30s) and once at startup. Never
pull into a non-clean tree or past a divergence — narrate and wait
for a human instead. The watcher then narrates arrived moves with
the commit author's name instead of "disk".
- **Offline**: unreachable origin degrades to local-only silently
sane — commits queue, a quiet ticker note says sync is behind,
next successful fetch catches up. No errors every 30s.
**Out of scope** — tempting neighbours left alone:
- Reacting to synced state with side effects — replicas render only
(task 20 owns the etiquette).
- Syncing `local/state/` — liveness stays per-board by design.
- Any transport other than git via origin; any branch other than main.
## Acceptance
- [ ] Given two clones with `BOARD_SYNC=1`, when A moves a card, then
within one beat B's board shows the move and B's ticker
attributes it to A's git name.
- [ ] Given both move the same card in one window, then exactly one
claim survives on origin, the loser's board reverts with the
take-over toast, and both converge to the same file bytes.
- [ ] Given a human's non-board commit sitting on local main, when
the board would push, then it refuses with the named warning
and the human's commit stays private.
- [ ] Edge case — origin unreachable: boards keep working locally,
one quiet ticker note, full convergence after connectivity
returns; no error spam.
- [ ] With the gate off, no fetch, no push, no behaviour change.
## Open questions
- None.
## Notes
Middle card of the multi-user arc (18 claim → 19 sync → 20 etiquette).
Git is the lock server and main the linearizer: push races are the
concurrency control, which is why the claim must ride in the move's
own commit (18) and why replicas must not react (20).
**Risks**
- ff-only pulls plus the piggyback guard mean a divergent main stalls
sync until a human resolves it — correct, but the stall must be
loudly visible or two halves of a team quietly stop converging.
- A 30s beat × N boards is N fetches/min against origin — fine for
GitHub, worth a doc line for rate-limited setups.
- Reverting a lost move while the user is mid-drag needs care in the
UI — the board re-reads disk on every render, which should make
this safe, but test the race deliberately.
@@ -0,0 +1,103 @@
# 20 — Replica etiquette: the actor's board acts, everyone else renders
**Status:** Backlog
**Priority:** High — without it, task 19 turns every board action into N duplicate side effects
**Type:** Feature
**Depends on:** 18, 19 — etiquette for a fleet that exists only once
claims and sync do
The board doesn't just render state, it reacts to it: a card entering
review opens a PR; entering in-progress arms launches. With N synced
boards watching one truth, a move must trigger its side effects on
exactly one of them — the actor's — or the team gets N PR-open
attempts, duplicate agents, and merge stampedes. And ownership must
mean something: launching work on someone else's claimed card should
be a deliberate act, not an accident.
## Context
- `github.py` opens a PR when a card enters review; today that fires
on the board that observed the move. Under task 19, every board
observes every move — the trigger must distinguish "I did this"
from "this arrived".
- The `**PR:**` line already gates re-opening — the idempotency
pattern to generalize, as the backstop behind actor-only triggers.
- One-agent-per-task lives in board memory (`state.py` registries) —
invisible to other machines. The card file's assignee (task 18) is
the cross-machine version.
- Merge & clean-up merges locally and pushes — in a synced team that
fights the "main advances only through origin" discipline task 19
documents.
**Affected areas:** `agents.py` (launch guard), `github.py` (actor-only
PR opening, merge via origin), `taskfiles.py`/`watch.py` (marking
remote-arrived moves), AGENTS.md.
## What to build
- **Remote moves are inert.** A move that arrives via sync (commit
author ≠ this board's identity, or applied by the pull rather than
the UI) renders and narrates but triggers nothing: no PR opening,
no launch arming, no worktree work. Side effects belong to the
board whose user made the move.
- **Idempotency as the backstop.** The actor-only rule prevents
duplication; file-carried gates (`**PR:**` line before `gh pr
create`; branch-exists checks before worktree creation) make the
rare double harmless. Both layers, deliberately.
- **Claims gate launches.** ▸ start work on a card assigned to
someone else refuses with who owns it; an explicit second path
(arm-style, or clearing the assignee first) allows deliberate
takeover — never accidental. Unassigned cards in team mode claim on
launch, reusing 18's write.
- **Merges go through origin.** With `BOARD_SYNC=1`, merge & clean-up
merges via `gh pr merge` (origin-side) instead of locally, then
lets the sync beat deliver the result — local main never diverges
by design. Single-player keeps the local merge path untouched.
**Out of scope** — tempting neighbours left alone:
- Shared liveness (a teammate's running agent stays a static
"in-progress, assigned to X" on your board).
- Cross-machine agent registries or any coordination service beyond
the files and origin.
- Permissions/roles — anyone can take over deliberately; git history
is the audit.
## Acceptance
- [ ] Given A moves a card to review, when the move syncs to B, then
exactly one PR exists and B's github poller adopts it read-only.
- [ ] Given a card assigned to A, when B clicks ▸ start work, then
the launch refuses naming A; the deliberate-takeover path works
and reassigns via 18's write.
- [ ] Given the rare double-fire anyway, then the file-carried gates
make the second attempt a no-op, not an error stampede.
- [ ] Given `BOARD_SYNC=1`, when merge & clean-up runs, then the
merge commit originates on origin and local main only ever
fast-forwards. With sync off, the local merge path is
byte-identical to today.
- [ ] Edge case — the actor's board dies mid-side-effect (moved the
card, crashed before PR opened): any board can complete it
explicitly (the review-stage card without a PR shows an "open
PR" action), but none does so automatically.
## Open questions
- None.
## Notes
Closing card of the multi-user arc (18 claim → 19 sync → 20
etiquette). The principle worth keeping even beyond this card:
**state syncs; reactions don't.** Every future automation hung off a
stage transition inherits the same question — "am I the actor?" —
and should answer it the same way.
**Risks**
- Actor detection must survive restarts (the board that moved a card
reboots before its PR opens) — hence the explicit-completion action
in the last acceptance item rather than a fragile in-memory flag.
- `gh pr merge` needs merge rights on origin for every team member —
a real-world constraint the docs must state, since the local-merge
path hid it.