Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion protoagent.plugin.yaml
Original file line number Diff line number Diff line change
@@ -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
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -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"

Expand Down
46 changes: 45 additions & 1 deletion store.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@
import json
import logging
import os
import re
import shutil
import subprocess

Expand Down Expand Up @@ -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:<total>`, replaced (not accumulated as separate labels) each time so a
# single label always carries the running total for `portfolio_rollup` to read.
Expand Down Expand Up @@ -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) ──────────────────────────────────────
Expand Down
80 changes: 79 additions & 1 deletion tests/test_store.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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) ──────────────────────────────


Expand Down
Loading