diff --git a/protoagent.plugin.yaml b/protoagent.plugin.yaml index 6346b88..cc73e63 100644 --- a/protoagent.plugin.yaml +++ b/protoagent.plugin.yaml @@ -1,6 +1,6 @@ id: project_board name: Project Board (coding orchestration) -version: 0.30.0 +version: 0.31.0 description: >- A board-driven coding-orchestration plugin: a lean 6-state board (backlog → ready → in_progress → in_review → done, + a blocked flag) backed by **beads** (`br`), an diff --git a/pyproject.toml b/pyproject.toml index 41ad073..f121ead 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "project-board" -version = "0.30.0" +version = "0.31.0" description = "Board-driven coding-orchestration plugin for protoAgent (beads board + ACP spawn loop + planning layer + console view)." requires-python = ">=3.11" diff --git a/store.py b/store.py index 2b84995..9ed1986 100644 --- a/store.py +++ b/store.py @@ -36,6 +36,7 @@ import json import logging import os +import re import shutil import subprocess @@ -63,6 +64,18 @@ # feature went back). Both are inert when the review gate is off. LABEL_REVIEW_PENDING = "review-pending" LABEL_CHANGES_REQUESTED = "changes-requested" +# Pre-ready DESIGN state (plan M6, optional): a large/architectural feature parked +# while its design/due-diligence is worked out (`mark_designing`). Informational for +# the projection/console — the HARD gate is in `mark_ready` (a design referencing an +# ADR is required at that size before the feature can go ready). +LABEL_DESIGNING = "designing" + +# Difficulties whose blast radius demands a written design + an ADR reference before +# the feature may go ready (the M6 DESIGN gate in `mark_ready`). +DESIGN_GATED_DIFFICULTIES = ("large", "architectural") +# What counts as "references an ADR": `ADR 0076` / `ADR-76` / `adr/0076` / +# a `docs/adr/0076-…` path — case-insensitive, number required. +ADR_REF_RE = re.compile(r"(?i)\badr[\s/_-]{0,2}\d{1,4}\b|docs/adr/\d{4}-") # Cumulative generations `coder.solve()` has spent on this feature (ADR 0064 P2 board # seam) — `gens:`, replaced (not accumulated as separate labels) each time so a # single label always carries the running total for `portfolio_rollup` to read. @@ -239,7 +252,38 @@ def mark_ready(self, fid: str) -> dict: "Ready only with a spec, testable acceptance criteria, and the explicit files " "to create/modify (a junior — or a coding agent — could pick it up and finish)." ) - self._run("update", fid, "--add-label", LABEL_READY) + # DESIGN gate (plan M6): a large/architectural feature is a decision, not just + # a task — it may not go ready until its `design` field exists AND references + # the ADR that records the decision (run /due-diligence, write the ADR, cite + # it). Small/medium features are untouched. + if str(f.get("difficulty", "")).strip().lower() in DESIGN_GATED_DIFFICULTIES: + design = str(f.get("design", "")).strip() + if not design: + raise BoardError( + f"Design gate: feature {fid!r} is difficulty={f.get('difficulty')!r} but has no " + "`design` — at this blast radius the decision must be designed first (run the " + "due-diligence workflow, record the decision as an ADR, and put the design + " + "ADR reference in the feature's design field)." + ) + if not ADR_REF_RE.search(design): + raise BoardError( + f"Design gate: feature {fid!r} is difficulty={f.get('difficulty')!r} and has a " + "design, but the design references no ADR — record the decision as an ADR and " + "cite it (e.g. 'ADR 0077') so the rationale outlives this feature." + ) + self._run("update", fid, "--add-label", LABEL_READY, "--remove-label", LABEL_DESIGNING) + return self.get_feature(fid) + + def mark_designing(self, fid: str, note: str = "") -> dict: + """Park a pre-ready feature in the DESIGNING state (label) while its design/ + due-diligence is worked out — the optional waiting room in front of the M6 + design gate. Purely informational; `mark_ready` still enforces the gate.""" + f = self._require(fid) + if f["board_state"] not in ("backlog", "ready"): + raise BoardError(f"can't mark designing from {f['board_state']!r}") + self._run("update", fid, "--add-label", LABEL_DESIGNING, "--remove-label", LABEL_READY) + if note: + self._comment(fid, f"designing: {note}") return self.get_feature(fid) # ── the puller (Ready → In Progress) ────────────────────────────────────── diff --git a/tests/test_store.py b/tests/test_store.py index 52d7148..7227968 100644 --- a/tests/test_store.py +++ b/tests/test_store.py @@ -229,7 +229,8 @@ def test_mark_ready_adds_the_label_when_fully_specced(make_board, monkeypatch): } monkeypatch.setattr(b, "get_feature", lambda fid: ready_feature) b.mark_ready("bd-1") - assert ("update", "bd-1", "--add-label", "ready") in br.calls + # adds `ready` (and clears a `designing` parking label in the same update) + assert ("update", "bd-1", "--add-label", "ready", "--remove-label", "designing") in br.calls @pytest.mark.parametrize( @@ -257,6 +258,83 @@ def test_mark_ready_rejects_an_underspecced_feature(make_board, monkeypatch, mis assert br.cmds("update") == [] # nothing mutated on a rejected gate +# ── the DESIGN gate (plan M6): large/architectural needs design + ADR ref ─────── + + +def _design_feature(**over): + base = { + "id": "bd-9", + "board_state": "backlog", + "spec": "s", + "acceptance_criteria": "a", + "files_to_modify": ["a.py"], + "difficulty": "large", + "design": "", + } + base.update(over) + return base + + +def test_design_gate_rejects_large_feature_with_no_design(make_board, monkeypatch): + br = Br() + b = make_board(br) + monkeypatch.setattr(b, "get_feature", lambda fid: _design_feature()) + with pytest.raises(BoardError, match="Design gate.*no\\s+`design`"): + b.mark_ready("bd-9") + assert br.cmds("update") == [] + + +def test_design_gate_rejects_a_design_without_an_adr_reference(make_board, monkeypatch): + br = Br() + b = make_board(br) + monkeypatch.setattr( + b, "get_feature", lambda fid: _design_feature(difficulty="architectural", design="we will use a queue") + ) + with pytest.raises(BoardError, match="references no ADR"): + b.mark_ready("bd-9") + assert br.cmds("update") == [] + + +@pytest.mark.parametrize( + "design", + [ + "Per ADR 0077, findings gate the merge edge.", + "see adr-0064 for the ladder", + "decision recorded in docs/adr/0076-managed-git-acp-delegates.md", + "ADR/0055 isolation applies", + ], +) +def test_design_gate_accepts_designs_citing_an_adr(make_board, monkeypatch, design): + br = Br() + b = make_board(br) + monkeypatch.setattr(b, "get_feature", lambda fid: _design_feature(design=design)) + b.mark_ready("bd-9") + assert br.cmds("update") # gate passed → the ready label update ran + + +def test_design_gate_ignores_small_and_medium_features(make_board, monkeypatch): + br = Br() + b = make_board(br) + monkeypatch.setattr(b, "get_feature", lambda fid: _design_feature(difficulty="medium")) + b.mark_ready("bd-9") # no design, but medium → gate not applied + assert br.cmds("update") + + +def test_mark_designing_parks_and_mark_ready_unparks(make_board, monkeypatch): + br = Br() + b = make_board(br) + monkeypatch.setattr(b, "get_feature", lambda fid: _design_feature()) + b.mark_designing("bd-9", note="running due diligence") + assert ("update", "bd-9", "--add-label", "designing", "--remove-label", "ready") in br.calls + + +def test_mark_designing_rejects_in_flight_features(make_board, monkeypatch): + b = make_board(Br()) + monkeypatch.setattr(b, "get_feature", lambda fid: {"id": fid, "board_state": "in_progress"}) + with pytest.raises(BoardError, match="can't mark designing"): + b.mark_designing("bd-9") + + # ── cancel_feature: the second terminal edge (#47) ──────────────────────────────