From 22255f2842ace3cb380cae9f407b665e29e134e7 Mon Sep 17 00:00:00 2001 From: istos Date: Thu, 30 Jul 2026 09:05:48 +0200 Subject: [PATCH 1/3] Cards are claimed on move: the assignee written, the move committed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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: ()`. 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 --- manager/core/board.html | 7 +- manager/core/config.py | 12 ++ manager/core/taskfiles.py | 89 ++++++++++- tests/test_claim_on_move.py | 298 ++++++++++++++++++++++++++++++++++++ 4 files changed, 402 insertions(+), 4 deletions(-) create mode 100644 tests/test_claim_on_move.py 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 = `
${top.join('')}
` + `
${esc(task.title)}
` + - `
${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() From 3629116afadf492527ca52c50040e7cd279178f0 Mon Sep 17 00:00:00 2001 From: istos Date: Thu, 30 Jul 2026 09:06:00 +0200 Subject: [PATCH 2/3] Document the claim: a "Claiming a card" section and the Assignee field MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AGENTS.md gains the convention — claiming is moving, the assignee is who launches agents on the card, hand-moves bypass the claim and should update the line by hand, and git identities collide the way git's do — plus the **Assignee:** header field beside Status/Priority/Type and the BOARD_COMMIT_MOVES gate with its unpushed-main consequence. .env.example documents the setting with its default (off). Co-Authored-By: Claude Opus 5 --- AGENTS.md | 49 +++++++++++++++++++++++++++++++++++++-- manager/core/.env.example | 10 ++++++++ 2 files changed, 57 insertions(+), 2 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 5dd2df5..ecdc414 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -168,8 +168,9 @@ failing on a port clash. 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. @@ -397,6 +398,39 @@ 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:** ` 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: ()`, 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 writes no assignee and makes no commits, exactly as before, and +`tasks/` is committed by hand. + ## Task file format Each task is a markdown file with a descriptive filename @@ -435,6 +469,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: diff --git a/manager/core/.env.example b/manager/core/.env.example index e7a3012..67c71dc 100644 --- a/manager/core/.env.example +++ b/manager/core/.env.example @@ -57,6 +57,16 @@ 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:** 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: ()`. Nothing is pushed. Off (the +# default) means today's behaviour exactly: no assignee, no commit, tasks/ +# committed by hand. 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 From 2e84227407e12e17da31e67ee2836aee4bca9d7d Mon Sep 17 00:00:00 2001 From: istos Date: Thu, 30 Jul 2026 09:16:06 +0200 Subject: [PATCH 3/3] Clarify gate-off docs: an existing assignee is still read and shown Copilot review on PR #15: the 'gate off' wording in AGENTS.md and .env.example read as 'no assignee', but read_task() always parses the Assignee line and the card face always renders it. The gate governs only whether a move writes/clears the line and commits. Reword both to say so. Co-Authored-By: Claude Opus 4.8 --- AGENTS.md | 6 ++++-- manager/core/.env.example | 7 +++++-- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index ecdc414..ca1ae6f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -428,8 +428,10 @@ 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 writes no assignee and makes no commits, exactly as before, and -`tasks/` is committed by hand. +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 diff --git a/manager/core/.env.example b/manager/core/.env.example index 67c71dc..43c6872 100644 --- a/manager/core/.env.example +++ b/manager/core/.env.example @@ -63,8 +63,11 @@ BOARD_FETCH_TIMEOUT=10 # backlog/ clears it, and each move commits itself — the move and the claim # in one commit touching only that task file, messaged # `board: ()`. Nothing is pushed. Off (the -# default) means today's behaviour exactly: no assignee, no commit, tasks/ -# committed by hand. Anything but empty/0/false/no/off turns it on. +# 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.