diff --git a/manager/core/board.html b/manager/core/board.html
index 04c347f..af818f1 100644
--- a/manager/core/board.html
+++ b/manager/core/board.html
@@ -875,7 +875,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';
@@ -976,7 +979,7 @@ function cardFor(task) {
el.innerHTML =
`
${initial}${who}` +
+ `
${esc(initial)}${esc(who)}` +
`${esc(meta)}${extras.length ? ' · ' + extras.join(' · ') : ''}
` +
chipRow + driveWell + liveLine;
el.querySelectorAll('a.chip2').forEach(a =>
diff --git a/manager/core/config.py b/manager/core/config.py
index e23d103..14f1385 100644
--- a/manager/core/config.py
+++ b/manager/core/config.py
@@ -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
@@ -124,6 +129,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"))
diff --git a/manager/core/taskfiles.py b/manager/core/taskfiles.py
index 0a1dd29..6ad70b2 100644
--- a/manager/core/taskfiles.py
+++ b/manager/core/taskfiles.py
@@ -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
diff --git a/tests/test_claim_on_move.py b/tests/test_claim_on_move.py
new file mode 100644
index 0000000..6561b32
--- /dev/null
+++ b/tests/test_claim_on_move.py
@@ -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('
${esc(who)}', self.html)
+
+
+if __name__ == "__main__":
+ unittest.main()