Merge branch 'task/18-cards-claimed-on-move'

This commit is contained in:
istos
2026-07-30 09:17:19 +02:00
6 changed files with 464 additions and 6 deletions
+49 -2
View File
@@ -175,8 +175,9 @@ view swaps only the tail (`<project> · sessions`).
All settings live in `manager/core/.env.example` with their defaults documented —
the port, the binaries agents launch with, the commands agents may run,
the worktrees directory, the watch interval and the in-memory caps. Copy it
to `manager/local/.env` (gitignored) to override locally; real environment
the worktrees directory, whether moves claim and commit themselves, the
watch interval and the in-memory caps. Copy it to `manager/local/.env`
(gitignored) to override locally; real environment
variables beat `.env`, which beats the defaults. The hook bridge reads the
same `.env`, so changing `BOARD_PORT` moves the board, the agents and the
hooks together.
@@ -404,6 +405,41 @@ Moves are not always forward. Going back a stage is normal and expected —
verification failing, or an approach not surviving contact with the code, should
move the task backwards rather than being worked around in place.
## Claiming a card
**Claiming is moving.** Taking a card out of `backlog/` or `to-do/` towards
work is the commitment, so that is where ownership is recorded: the board
writes an `**Assignee:** <name>` line into the header, taken from this
checkout's `git config user.name` — the identity git history already shows,
no new concept. The first claim sticks: a card that already names an
assignee keeps it when someone else moves it on. Walking a card all the way
back to `backlog/` clears the line — nobody holds it again.
The assignee is who launches agents on the card and whose judgment the
review waits for. It is a convention, not a lock: the board does not (yet)
refuse anyone else's actions.
Two consequences worth knowing:
- **Hand-moves bypass the claim.** A plain `mv` between stage directories
is still a first-class move (the watcher narrates it), but nothing writes
the assignee — update the line yourself in the same edit as **Status**.
- **Identity is git's, so it collides like git's.** Two machines both
configured `user.name = ronald` are one person as far as the board is
concerned. Teams that share a git history already share that assumption.
With `BOARD_COMMIT_MOVES` on, board-made moves also **commit themselves**:
the move and the claim land in one commit touching only that task file,
messaged `board: <number> → <stage> (<name>)`, staged by pathspec so
unrelated staged work is neither committed nor unstaged (hooks are skipped —
this is bookkeeping, not code). Pushing is not part of it: those commits sit
on your local `main` until you push it, which the PR guard above will tell
you about if you forget. The setting is off by default: a single-player
board neither writes nor clears the assignee and makes no commits, exactly
as before, and `tasks/` is committed by hand. The gate governs only whether
a *move* writes the line — an **Assignee:** added to a file by hand is still
read and shown on the card whether the gate is on or off.
## Task file format
Each task is a markdown file with a descriptive filename
@@ -442,6 +478,17 @@ Type is orthogonal to status. A discovery task — research, scoping, spiking an
approach — moves through the same five stages as everything else; "discovery"
describes the work, not where it sits on the board.
An optional **Assignee** line records who holds the card:
```markdown
**Assignee:** ronald
```
The board writes it when a move claims the card (see "Claiming a card") and
removes it when the card is walked back to `backlog/`; on `done/` cards it
stays as history. Editing it by hand is fine — it is a plain header field,
and a hand-move should update it alongside **Status**.
An optional **Depends on** line can name what must land first — task numbers
or external preconditions — so sequencing lives in the header instead of
prose asides:
+13
View File
@@ -63,6 +63,19 @@ BOARD_PR_POLL_INTERVAL=60
# means today's behaviour, never a blocked launch.
BOARD_FETCH_TIMEOUT=10
# Claim on move, and commit it. On: a board-made move out of backlog/ or
# to-do/ writes **Assignee:** <git user.name> into the task file (first
# claim only — an existing assignee is preserved), walking a card back to
# backlog/ clears it, and each move commits itself — the move and the claim
# in one commit touching only that task file, messaged
# `board: <number> → <stage> (<name>)`. Nothing is pushed. Off (the
# default) means moves neither write nor clear the assignee and never
# commit — today's behaviour exactly, tasks/ committed by hand. An
# assignee added to a file by hand is still read and shown either way;
# the gate only governs whether a move writes it. Anything but
# empty/0/false/no/off turns it on.
BOARD_COMMIT_MOVES=
# Seconds between disk polls of the stage directories.
BOARD_WATCH_INTERVAL=2
+5 -2
View File
@@ -888,7 +888,10 @@ function cardFor(task) {
meta = elapsed(agent.started) + (agent.branch ? ' · ' + agent.branch : '');
} else {
initial = '·';
if (task.stage === 'backlog' || task.stage === 'to-do') who = 'nobody yet';
// a claimed card names its owner in every stage — in done/ the line is
// history: who did this. Unclaimed cards keep the old stage vocabulary.
if (task.assignee) { who = task.assignee; initial = task.assignee.slice(0, 1); }
else if (task.stage === 'backlog' || task.stage === 'to-do') who = 'nobody yet';
else if (task.stage === 'in-progress') who = 'unattended';
else if (task.stage === 'review') who = 'needs your eyes';
else who = 'merged';
@@ -989,7 +992,7 @@ function cardFor(task) {
el.innerHTML =
`<div class="toprow">${top.join('')}</div>` +
`<div class="title">${esc(task.title)}</div>` +
`<div class="whorow"><span class="initial">${initial}</span><span class="who">${who}</span>` +
`<div class="whorow"><span class="initial">${esc(initial)}</span><span class="who">${esc(who)}</span>` +
`<span class="meta">${esc(meta)}${extras.length ? ' · ' + extras.join(' · ') : ''}</span></div>` +
chipRow + driveWell + liveLine;
el.querySelectorAll('a.chip2').forEach(a =>
+12
View File
@@ -73,6 +73,11 @@ def setting(key: str, default: str) -> str:
return _ENV.get(key, default)
def flag(key: str, default: str = "") -> bool:
"""A boolean setting. Anything but empty/0/false/no/off is on."""
return setting(key, default).strip().lower() not in ("", "0", "false", "no", "off")
def child_env() -> dict[str, str]:
"""Environment for adapter/driver child processes: the real environment
with local/.env settings folded in (process env still wins), so
@@ -129,6 +134,13 @@ PR_POLL_INTERVAL = float(setting("BOARD_PR_POLL_INTERVAL", "60"))
# network weather; this bounds the whole delay.
FETCH_TIMEOUT = float(setting("BOARD_FETCH_TIMEOUT", "10"))
# Team mode's first half: a board-made move claims the card (writing
# **Assignee:** from git's own user.name) and commits itself, so ownership
# and stage travel with the file to every clone. Off by default — a
# single-player board moves cards exactly as it always did, and committing
# tasks/ stays a hand job.
COMMIT_MOVES = flag("BOARD_COMMIT_MOVES")
WATCH_INTERVAL = float(setting("BOARD_WATCH_INTERVAL", "2"))
EVENTS_CAP = int(setting("BOARD_EVENTS_CAP", "800"))
BOARD_EVENTS_CAP = int(setting("BOARD_HISTORY_CAP", "300"))
+87 -2
View File
@@ -9,6 +9,7 @@ from __future__ import annotations
import re
import shutil
import subprocess
from pathlib import Path
import config
@@ -18,10 +19,15 @@ TITLE_RE = re.compile(r"^#\s+(.+?)\s*$", re.MULTILINE)
STATUS_RE = re.compile(r"^\*\*Status:\*\*\s*(.+?)\s*$", re.MULTILINE)
PRIORITY_RE = re.compile(r"^\*\*Priority:\*\*\s*(.+?)\s*$", re.MULTILINE)
TYPE_RE = re.compile(r"^\*\*Type:\*\*\s*(.+?)\s*$", re.MULTILINE)
ASSIGNEE_RE = re.compile(r"^\*\*Assignee:\*\*\s*(.+?)\s*$", re.MULTILINE)
ASSIGNEE_LINE_RE = re.compile(r"^\*\*Assignee:\*\*[^\n]*\n?", re.MULTILINE)
PR_RE = re.compile(r"^\*\*PR:\*\*\s*(\S+)\s*$", re.MULTILINE)
PR_VERDICT_RE = re.compile(r"^PR REVIEW:\s*(APPROVE|REQUEST CHANGES)", re.MULTILINE)
NUMBER_RE = re.compile(r"^(\d+)[-_]")
STAGE_ORDER = {slug: index for index, (slug, _) in enumerate(config.STAGES)}
CLAIM_FROM = {"backlog", "to-do"} # the unstarted stages: leaving one claims
def _first(pattern: re.Pattern[str], text: str) -> str | None:
match = pattern.search(text)
@@ -45,6 +51,8 @@ def read_task(path: Path, stage: str) -> dict:
verdicts = PR_VERDICT_RE.findall(text)
return {
"pr": _first(PR_RE, text),
# who holds the card — written by the board when a move claims it
"assignee": _first(ASSIGNEE_RE, text),
"prVerdict": {"APPROVE": "green", "REQUEST CHANGES": "red"}.get(
verdicts[-1] if verdicts else None),
"file": path.name,
@@ -142,8 +150,74 @@ def archived_count() -> int:
return len(list(directory.glob("*.md"))) if directory.is_dir() else 0
def _git(*args: str, timeout: int = 30) -> subprocess.CompletedProcess:
return subprocess.run(["git", "-C", str(config.REPO), *args],
capture_output=True, text=True, timeout=timeout)
def actor_name() -> str:
"""Who this checkout is: `git config user.name`, the identity git history
already shows. Empty when git has no name then nothing is claimed."""
try:
result = _git("config", "user.name", timeout=10)
except (subprocess.SubprocessError, OSError):
return ""
return result.stdout.strip() if result.returncode == 0 else ""
def claims(source: str, target: str) -> bool:
"""Claiming is moving: taking a card out of one of the unstarted stages
towards work is the commitment, and the commitment names its owner."""
return source in CLAIM_FROM and STAGE_ORDER[target] > STAGE_ORDER[source]
def _set_assignee(text: str, name: str) -> str:
"""First claim only — an existing assignee is never overwritten. The line
joins the other header fields, right under Status."""
if ASSIGNEE_RE.search(text):
return text
if STATUS_RE.search(text):
return STATUS_RE.sub(lambda m: f"{m.group(0)}\n**Assignee:** {name}", text, count=1)
return TITLE_RE.sub(lambda m: f"{m.group(0)}\n\n**Assignee:** {name}", text, count=1)
def _commit_move(filename: str, target: str, src: Path, dst: Path, who: str,
number: str | None) -> None:
"""The move and the claim in one commit touching only this task file.
Staging is scoped to the file's own paths (`git add` then a pathspec
commit), so a developer's unrelated staged changes are neither committed
nor unstaged. Hooks are skipped: this is the board's bookkeeping, not a
code change. Anything going wrong is narrated the card has already
moved on disk, which is the source of truth.
"""
message = f"board: {number or filename[:-3]}{target} ({who or 'board'})"
try:
spec = [str(dst)]
tracked = _git("ls-files", "--", str(src))
if tracked.returncode == 0 and tracked.stdout.strip():
spec.insert(0, str(src)) # git knew the old path: record its removal
result = _git("add", "-A", "--", *spec)
if result.returncode == 0:
result = _git("commit", "--no-verify", "-m", message, "--", *spec, timeout=60)
if result.returncode == 0:
return
lines = (result.stderr or result.stdout).strip().splitlines()
detail = lines[-1] if lines else f"git exited {result.returncode}"
except (subprocess.SubprocessError, OSError) as exc:
detail = str(exc)
state.record_board_event({
"kind": "agent", "actor": "board", "file": filename,
"summary": f"{filename} moved, but committing it failed: {detail[:140]}"})
def move_task(filename: str, source: str, target: str, actor: str = "you") -> dict:
"""Move a task file between stage directories and fix its Status line."""
"""Move a task file between stage directories and fix its Status line.
With `BOARD_COMMIT_MOVES` on, the move also claims the card (an
**Assignee:** line, this checkout's git name) or releases it when the
card is walked back to backlog, and commits the whole change.
"""
if source not in config.STAGE_DIRS or target not in config.STAGE_DIRS:
raise ValueError("unknown stage")
if Path(filename).name != filename or not filename.endswith(".md"):
@@ -163,8 +237,19 @@ def move_task(filename: str, source: str, target: str, actor: str = "you") -> di
else: # no Status line to keep in step — insert one under the title
text = TITLE_RE.sub(lambda m: f"{m.group(0)}\n\n**Status:** {label}", text, count=1)
name = ""
if config.COMMIT_MOVES:
name = actor_name()
if target == "backlog": # walked all the way back: unclaimed again
text = ASSIGNEE_LINE_RE.sub("", text, count=1)
elif name and claims(source, target):
text = _set_assignee(text, name)
state.expect_move(filename, target, actor)
dst.parent.mkdir(parents=True, exist_ok=True)
src.write_text(text, encoding="utf-8")
shutil.move(str(src), str(dst))
return read_task(dst, target)
task = read_task(dst, target)
if config.COMMIT_MOVES:
_commit_move(filename, target, src, dst, name, task["number"])
return task
+298
View File
@@ -0,0 +1,298 @@
"""Claiming is moving (task 18): a board-made move writes the assignee and
commits itself and with the gate off, changes nothing about today.
Every case runs against a throwaway git repo standing in for the project, so
the commit behaviour is checked against real git rather than a mock.
python3 -m unittest discover -s tests -v
"""
from __future__ import annotations
import shutil
import subprocess
import sys
import tempfile
import unittest
from pathlib import Path
REPO = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(REPO / "manager" / "core"))
import config # noqa: E402
import state # noqa: E402
import taskfiles # noqa: E402
CARD = ("# 18 — A card that gets claimed\n\n"
"**Status:** Backlog\n"
"**Priority:** High\n"
"**Type:** Feature\n\n"
"Body text nobody should touch.\n")
FILENAME = "18-a-card.md"
MOVER = "Mover One"
def git(cwd: Path, *args: str) -> subprocess.CompletedProcess:
return subprocess.run(["git", "-C", str(cwd), *args],
capture_output=True, text=True)
class ClaimOnMove(unittest.TestCase):
def setUp(self):
# resolve(): macOS tempdirs sit behind the /var → /private/var
# symlink and git reports the resolved path, so absolute pathspecs
# only match if we resolve too.
tmp = Path(tempfile.mkdtemp(prefix="bench-claim-")).resolve()
self.addCleanup(shutil.rmtree, tmp, True)
self.repo = tmp / "repo"
for slug in config.STAGE_DIRS:
(self.repo / "tasks" / slug).mkdir(parents=True)
subprocess.run(["git", "init", "-q", "-b", "main", str(self.repo)],
check=True, capture_output=True)
git(self.repo, "config", "user.name", MOVER)
git(self.repo, "config", "user.email", "mover@example.com")
self.patch(TASKS=self.repo / "tasks", REPO=self.repo,
SESSIONS_DIR=tmp / "sessions", COMMIT_MOVES=True)
state.BOARD_EVENTS.clear()
(self.repo / "unrelated.txt").write_text("one\n", encoding="utf-8")
self.write("backlog", CARD)
git(self.repo, "add", "-A")
git(self.repo, "commit", "-q", "-m", "root")
self.baseline = self.commit_count()
def patch(self, **values) -> None:
for attr, value in values.items():
self.addCleanup(setattr, config, attr, getattr(config, attr))
setattr(config, attr, value)
# — the repo under test —
def path(self, stage: str) -> Path:
return self.repo / "tasks" / stage / FILENAME
def write(self, stage: str, text: str) -> None:
self.path(stage).write_text(text, encoding="utf-8")
def read(self, stage: str) -> str:
return self.path(stage).read_text(encoding="utf-8")
def commit_count(self) -> int:
return int(git(self.repo, "rev-list", "--count", "HEAD").stdout.strip())
def head_message(self) -> str:
return git(self.repo, "log", "-1", "--pretty=%s").stdout.strip()
def head_files(self) -> list[str]:
# --no-renames: the point is which paths the commit touched, not how
# git chooses to describe the pair.
out = git(self.repo, "show", "--name-only", "--no-renames",
"--pretty=format:", "HEAD").stdout
return sorted(line for line in out.splitlines() if line.strip())
def hand_move(self, source: str, target: str, text: str) -> None:
"""A teammate's plain mv, committed — the starting point for cases
that need the card somewhere other than backlog/."""
shutil.move(str(self.path(source)), str(self.path(target)))
self.write(target, text)
git(self.repo, "add", "-A")
git(self.repo, "commit", "-q", "-m", f"hand move to {target}")
self.baseline = self.commit_count()
def porcelain(self) -> str:
return git(self.repo, "status", "--porcelain").stdout
# — claiming —
def test_a_forward_move_claims_the_card_and_commits_once(self):
task = taskfiles.move_task(FILENAME, "backlog", "to-do")
self.assertEqual(task["assignee"], MOVER)
self.assertIn(f"**Assignee:** {MOVER}", self.read("to-do"))
self.assertEqual(self.commit_count(), self.baseline + 1)
self.assertEqual(self.head_message(), f"board: 18 → to-do ({MOVER})")
self.assertEqual(self.head_files(),
["tasks/backlog/" + FILENAME, "tasks/to-do/" + FILENAME])
self.assertEqual(self.porcelain(), "",
"the move and the claim leave nothing behind uncommitted")
def test_the_claim_joins_the_header_and_leaves_the_rest_alone(self):
taskfiles.move_task(FILENAME, "backlog", "to-do")
self.assertEqual(self.read("to-do"), CARD
.replace("**Status:** Backlog", "**Status:** To Do")
.replace("**Status:** To Do\n",
f"**Status:** To Do\n**Assignee:** {MOVER}\n"))
def test_to_do_to_in_progress_claims_too(self):
self.hand_move("backlog", "to-do", CARD.replace("Backlog", "To Do"))
task = taskfiles.move_task(FILENAME, "to-do", "in-progress")
self.assertEqual(task["assignee"], MOVER)
self.assertEqual(self.head_message(), f"board: 18 → in-progress ({MOVER})")
def test_first_claim_sticks_when_someone_else_moves_it_on(self):
self.hand_move("backlog", "to-do",
CARD.replace("**Status:** Backlog",
"**Status:** To Do\n**Assignee:** ada"))
git(self.repo, "config", "user.name", "Mover Two")
task = taskfiles.move_task(FILENAME, "to-do", "in-progress")
self.assertEqual(task["assignee"], "ada", "the first claim owns the card")
self.assertEqual(self.read("in-progress").count("**Assignee:**"), 1)
self.assertEqual(self.head_message(), "board: 18 → in-progress (Mover Two)",
"the commit names who acted, not who holds it")
def test_a_move_that_is_not_a_claim_writes_no_assignee(self):
self.hand_move("backlog", "in-progress",
CARD.replace("Backlog", "In Progress"))
task = taskfiles.move_task(FILENAME, "in-progress", "review")
self.assertIsNone(task["assignee"])
self.assertNotIn("**Assignee:**", self.read("review"))
self.assertEqual(self.commit_count(), self.baseline + 1,
"the move itself still commits")
def test_walking_back_to_backlog_clears_the_claim(self):
self.hand_move("backlog", "in-progress",
CARD.replace("**Status:** Backlog",
"**Status:** In Progress\n**Assignee:** ada"))
task = taskfiles.move_task(FILENAME, "in-progress", "backlog")
self.assertIsNone(task["assignee"])
self.assertEqual(self.read("backlog"), CARD, "back to the unclaimed card")
def test_no_git_identity_claims_nothing(self):
"""A checkout git cannot name claims nothing — there is no identity
to write. (git itself then refuses the commit, which is narrated.)"""
self.addCleanup(setattr, taskfiles, "actor_name", taskfiles.actor_name)
taskfiles.actor_name = lambda: ""
task = taskfiles.move_task(FILENAME, "backlog", "to-do")
self.assertIsNone(task["assignee"])
self.assertNotIn("**Assignee:**", self.read("to-do"))
# — the gate —
def test_gate_off_moves_exactly_as_before(self):
self.patch(COMMIT_MOVES=False)
task = taskfiles.move_task(FILENAME, "backlog", "to-do")
self.assertIsNone(task["assignee"])
self.assertEqual(self.read("to-do"),
CARD.replace("**Status:** Backlog", "**Status:** To Do"))
self.assertEqual(self.commit_count(), self.baseline)
self.assertEqual(self.porcelain(),
" D tasks/backlog/18-a-card.md\n?? tasks/to-do/\n",
"the move stays for a human to commit, index untouched")
def test_gate_off_leaves_a_claimed_card_claimed(self):
self.patch(COMMIT_MOVES=False)
self.hand_move("backlog", "in-progress",
CARD.replace("**Status:** Backlog",
"**Status:** In Progress\n**Assignee:** ada"))
task = taskfiles.move_task(FILENAME, "in-progress", "backlog")
self.assertEqual(task["assignee"], "ada",
"with the gate off the board rewrites Status and nothing else")
# — the commit —
def test_unrelated_staged_work_is_neither_committed_nor_unstaged(self):
(self.repo / "unrelated.txt").write_text("two\n", encoding="utf-8")
git(self.repo, "add", "--", "unrelated.txt")
taskfiles.move_task(FILENAME, "backlog", "to-do")
self.assertEqual(self.head_files(),
["tasks/backlog/" + FILENAME, "tasks/to-do/" + FILENAME])
self.assertEqual(self.porcelain(), "M unrelated.txt\n",
"the developer's staged change is still staged")
self.assertIn("+two", git(self.repo, "diff", "--cached", "HEAD",
"--", "unrelated.txt").stdout)
def test_unrelated_unstaged_work_is_left_alone(self):
(self.repo / "unrelated.txt").write_text("two\n", encoding="utf-8")
taskfiles.move_task(FILENAME, "backlog", "to-do")
self.assertEqual(self.porcelain(), " M unrelated.txt\n")
def test_a_failing_commit_still_moves_the_card_and_says_so(self):
self.patch(REPO=self.repo.parent / "not-a-repo")
(self.repo.parent / "not-a-repo").mkdir()
task = taskfiles.move_task(FILENAME, "backlog", "to-do")
self.assertEqual(task["stage"], "to-do")
self.assertTrue(self.path("to-do").is_file())
event = state.BOARD_EVENTS[-1]
self.assertIn("committing it failed", event["summary"])
self.assertNotEqual(event["kind"], "move",
"a move-kind event is rendered from its from/to "
"fields, which this one has none of")
class ClaimPredicate(unittest.TestCase):
"""Which transitions claim: leaving an unstarted stage, forwards only."""
def test_claiming_transitions(self):
for source, target in (("backlog", "to-do"), ("backlog", "in-progress"),
("to-do", "in-progress"), ("to-do", "review")):
self.assertTrue(taskfiles.claims(source, target), f"{source}{target}")
def test_non_claiming_transitions(self):
for source, target in (("to-do", "backlog"), ("in-progress", "review"),
("review", "done"), ("done", "to-do"),
("in-progress", "backlog")):
self.assertFalse(taskfiles.claims(source, target), f"{source}{target}")
class AssigneeParsing(unittest.TestCase):
def test_read_task_exposes_the_assignee(self):
tmp = Path(tempfile.mkdtemp(prefix="bench-claim-read-")).resolve()
self.addCleanup(shutil.rmtree, tmp, True)
path = tmp / FILENAME
path.write_text(CARD.replace("**Status:** Backlog",
"**Status:** Backlog\n**Assignee:** ada lovelace"),
encoding="utf-8")
self.assertEqual(taskfiles.read_task(path, "backlog")["assignee"], "ada lovelace")
def test_an_unclaimed_card_has_no_assignee(self):
tmp = Path(tempfile.mkdtemp(prefix="bench-claim-read-")).resolve()
self.addCleanup(shutil.rmtree, tmp, True)
path = tmp / FILENAME
path.write_text(CARD, encoding="utf-8")
self.assertIsNone(taskfiles.read_task(path, "backlog")["assignee"])
class CardFace(unittest.TestCase):
"""board.html is a single file with no frontend runner — these are the
source-level invariants of the face showing an owner."""
@classmethod
def setUpClass(cls):
cls.html = (REPO / "manager" / "core" / "board.html").read_text(encoding="utf-8")
def test_the_assignee_replaces_nobody_yet(self):
self.assertIn("if (task.assignee) { who = task.assignee;", self.html)
index = self.html.index("if (task.assignee) { who = task.assignee;")
self.assertLess(index, self.html.index("who = 'nobody yet'"),
"the claim must be checked before the stage fallbacks")
def test_the_who_row_escapes_what_the_file_said(self):
self.assertIn('<span class="who">${esc(who)}</span>', self.html)
if __name__ == "__main__":
unittest.main()