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
5 changes: 1 addition & 4 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -25,10 +25,7 @@ jobs:
run: pip install -r requirements-dev.txt ruff

- name: Lint
# Lint the whole repo; format-check only the test suite (the plugin's
# existing source predates a ruff-format pass — normalizing it is a
# separate cleanup, out of scope for the test add).
run: ruff check . && ruff format --check tests/
run: ruff check . && ruff format --check .

- name: Test
run: pytest -q
63 changes: 47 additions & 16 deletions __init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ def register(registry) -> None:
# (plugin_id, prefix) de-dupe keeps both.
try:
from .api import build_data_router, build_router

registry.register_router(build_router(cfg), prefix="/plugins/project_board")
registry.register_router(build_data_router(cfg), prefix="/api/plugins/project_board")
except Exception: # noqa: BLE001 — API is best-effort
Expand All @@ -48,6 +49,7 @@ def register(registry) -> None:
# Background orchestration loop (off unless project_board.loop_enabled).
try:
from .loop import BoardLoop

loop = BoardLoop(cfg)
registry.register_surface(loop.start, stop=loop.stop, name="project-board-loop")
except Exception: # noqa: BLE001 — loop is best-effort; the API still serves
Expand All @@ -61,21 +63,26 @@ def register(registry) -> None:
# skill that turns an idea into the docs tree + the board (per-epic human gate).
try:
from .subagents import ANTAGONIST_CONFIG, DECOMPOSE_CONFIG

registry.register_subagent(DECOMPOSE_CONFIG)
registry.register_subagent(ANTAGONIST_CONFIG)
registry.register_skill_dir("skills")
except Exception: # noqa: BLE001 — planning layer is best-effort
log.exception("[project_board] registering planning subagents/skill failed")

log.info("[project_board] registered board API + loop + tools + planning (coder=%s reviewer=%s)",
cfg.get("coder", "proto"), cfg.get("reviewer", "quinn"))
log.info(
"[project_board] registered board API + loop + tools + planning (coder=%s reviewer=%s)",
cfg.get("coder", "proto"),
cfg.get("reviewer", "quinn"),
)


def _board_tools(cfg: dict):
from .store import BoardError, get_store

store_kw = dict(db=cfg.get("db_path") or None, repo=cfg.get("repo", "."),
base_branch=cfg.get("base_branch", "main"))
store_kw = dict(
db=cfg.get("db_path") or None, repo=cfg.get("repo", "."), base_branch=cfg.get("base_branch", "main")
)

@tool
def board_create_epic(title: str, description: str = "") -> str:
Expand All @@ -87,9 +94,17 @@ def board_create_epic(title: str, description: str = "") -> str:
return f"Error: {exc}"

@tool
def board_create_feature(title: str, spec: str = "", acceptance_criteria: str = "",
files_to_modify: str = "", design: str = "", parent: str = "",
priority: int = 2, difficulty: str = "", depends_on: str = "") -> str:
def board_create_feature(
title: str,
spec: str = "",
acceptance_criteria: str = "",
files_to_modify: str = "",
design: str = "",
parent: str = "",
priority: int = 2,
difficulty: str = "",
depends_on: str = "",
) -> str:
"""Create a board feature (a bead; starts in `backlog`). To pass the Ready
gate a feature needs a self-sufficient `spec`, testable `acceptance_criteria`,
AND `files_to_modify` (comma-separated paths to create/modify — vague tasks
Expand All @@ -100,9 +115,16 @@ def board_create_feature(title: str, spec: str = "", acceptance_criteria: str =
deps = [d.strip() for d in depends_on.split(",") if d.strip()]
files = [p.strip() for p in files_to_modify.replace("\n", ",").split(",") if p.strip()]
f = get_store(**store_kw).create_feature(
title, spec=spec, acceptance_criteria=acceptance_criteria, design=design,
files_to_modify=files, parent=parent, priority=priority,
difficulty=difficulty, depends_on=deps)
title,
spec=spec,
acceptance_criteria=acceptance_criteria,
design=design,
files_to_modify=files,
parent=parent,
priority=priority,
difficulty=difficulty,
depends_on=deps,
)
return json.dumps({"id": f["id"], "state": f["board_state"], "title": f["title"]})
except BoardError as exc:
return f"Error: {exc}"
Expand All @@ -122,11 +144,20 @@ def board_list(state: str = "") -> str:
"""List board features, optionally filtered by board `state` (backlog/ready/
in_progress/in_review/done/blocked). Priority order."""
feats = get_store(**store_kw).list_features(state=state or None)
return json.dumps([
{"id": f["id"], "title": f["title"], "state": f["board_state"],
"blocked": f["blocked"], "dag_blocked": f.get("dag_blocked", False),
"pr_url": f["pr_url"], "priority": f["priority"], "difficulty": f["difficulty"]}
for f in feats
])
return json.dumps(
[
{
"id": f["id"],
"title": f["title"],
"state": f["board_state"],
"blocked": f["blocked"],
"dag_blocked": f.get("dag_blocked", False),
"pr_url": f["pr_url"],
"priority": f["priority"],
"difficulty": f["difficulty"],
}
for f in feats
]
)

return [board_create_epic, board_create_feature, board_mark_ready, board_list]
62 changes: 38 additions & 24 deletions api.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,8 @@
drivable here, headlessly.

The ``/webhook/pr`` endpoint is the SINGLE external Done edge: a merged-PR event
sets ``done`` and nothing else does (invariant #2). Poll is the fallback.

STUB note: webhook signature verification (GitHub HMAC) is TODO — wire it before
exposing the endpoint publicly. Review the payload→record_merge mapping below.
sets ``done`` and nothing else does (invariant #2). The raw body is HMAC-verified
against ``X-Hub-Signature-256`` whenever a ``webhook_secret`` is configured.
"""

from __future__ import annotations
Expand Down Expand Up @@ -46,14 +44,19 @@ def build_router(cfg: dict):
@router.get("/board", response_class=HTMLResponse)
async def _board():
return HTMLResponse(BOARD_PAGE)
store_kw = dict(db=(cfg or {}).get("db_path") or None, repo=(cfg or {}).get("repo", "."),
base_branch=(cfg or {}).get("base_branch", "main"))

store_kw = dict(
db=(cfg or {}).get("db_path") or None,
repo=(cfg or {}).get("repo", "."),
base_branch=(cfg or {}).get("base_branch", "main"),
)
escalate_on = escalation_enabled(cfg)
worktrees_root = (cfg or {}).get("worktrees_root", ".worktrees")
# GitHub webhook secret (HMAC-SHA256). From config or env; blank ⇒ verification
# disabled (dev only) — a warning fires per unsigned request.
webhook_secret = str((cfg or {}).get("webhook_secret") or
os.environ.get("PROJECT_BOARD_WEBHOOK_SECRET", "")).strip()
webhook_secret = str(
(cfg or {}).get("webhook_secret") or os.environ.get("PROJECT_BOARD_WEBHOOK_SECRET", "")
).strip()

def store():
return get_store(**store_kw)
Expand All @@ -69,8 +72,7 @@ def _guard(fn):
# PUBLIC-of-necessity surface: the /board page (an iframe page-load can't
# carry a bearer) and the CI-infra edges — /webhook/pr (GitHub signs with
# HMAC, not the operator bearer) and /features/{fid}/ci (posted by CI
# runners; carries bounded semantics, its own auth story tracked with the
# webhook-signature TODO above).
# runners; a CI-infra edge with bounded semantics).

@router.post("/features/{fid}/ci")
async def _ci(fid: str, body: dict = Body(...)):
Expand All @@ -88,14 +90,16 @@ async def _ci(fid: str, body: dict = Body(...)):
def _handle():
s = store()
if not escalate_on:
return {"requeued": False, "escalated": False,
"feature": s.bounce_ci_fail(fid, reason)}
return {"requeued": False, "escalated": False, "feature": s.bounce_ci_fail(fid, reason)}
nxt = s.escalate(fid, f"ci-fail: {reason}" if reason else "ci-fail")
if nxt is None:
return {"requeued": False, "escalated": True, "exhausted": True,
"feature": s.block_from_review(fid, f"ci-fail: {reason}")}
return {"requeued": True, "escalated": True, "next_tier": nxt,
"feature": s.requeue(fid)}
return {
"requeued": False,
"escalated": True,
"exhausted": True,
"feature": s.block_from_review(fid, f"ci-fail: {reason}"),
}
return {"requeued": True, "escalated": True, "next_tier": nxt, "feature": s.requeue(fid)}

return _guard(_handle)

Expand All @@ -113,8 +117,10 @@ async def _webhook_pr(request: Request):
if not hmac.compare_digest(expected, sig):
raise HTTPException(401, "invalid webhook signature")
else:
log.warning("[project_board] webhook signature NOT verified — set "
"project_board.webhook_secret (or PROJECT_BOARD_WEBHOOK_SECRET)")
log.warning(
"[project_board] webhook signature NOT verified — set "
"project_board.webhook_secret (or PROJECT_BOARD_WEBHOOK_SECRET)"
)
try:
body = json.loads(raw or b"{}")
except json.JSONDecodeError:
Expand All @@ -131,6 +137,7 @@ async def _webhook_pr(request: Request):
# Reap the feature's worktree now that it's merged → done (stop accumulation).
try:
from . import worktree

repo = store_kw["repo"]
wt = os.path.join(repo, worktrees_root, f"feat-{f['id']}")
await worktree.remove_worktree(repo, wt, f"feat/{f['id']}")
Expand All @@ -151,8 +158,11 @@ def build_data_router(cfg: dict):
from fastapi import APIRouter, Body, HTTPException

router = APIRouter()
store_kw = dict(db=(cfg or {}).get("db_path") or None, repo=(cfg or {}).get("repo", "."),
base_branch=(cfg or {}).get("base_branch", "main"))
store_kw = dict(
db=(cfg or {}).get("db_path") or None,
repo=(cfg or {}).get("repo", "."),
base_branch=(cfg or {}).get("base_branch", "main"),
)

def store():
return get_store(**store_kw)
Expand All @@ -170,8 +180,11 @@ async def _create_epic(body: dict = Body(...)):

@router.post("/milestones")
async def _create_milestone(body: dict = Body(...)):
return _guard(lambda: store().create_milestone(
body.get("title", ""), body.get("epic_id", ""), body.get("description", "")))
return _guard(
lambda: store().create_milestone(
body.get("title", ""), body.get("epic_id", ""), body.get("description", "")
)
)

# ── features ──────────────────────────────────────────────────────────────
@router.get("/features")
Expand All @@ -193,8 +206,9 @@ async def _create_feature(body: dict = Body(...)):
async def _dep(fid: str, body: dict = Body(...)):
"""Add a `blocks` edge: `fid` waits for `depends_on` to be merged→done.
(Foundation gating is just a blocks-edge on the foundation feature.)"""
return _guard(lambda: (store().add_dependency(fid, str(body.get("depends_on", ""))),
store().get_feature(fid))[1])
return _guard(
lambda: (store().add_dependency(fid, str(body.get("depends_on", ""))), store().get_feature(fid))[1]
)

# ── transitions ───────────────────────────────────────────────────────────
@router.post("/features/{fid}/ready")
Expand Down
2 changes: 1 addition & 1 deletion board_view.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
"""Board console view (ADR 0026, D5) — the deferred-until-now UI.
"""Board console view (ADR 0026, D5) — the Kanban/list board UI.

A self-contained page served at ``/plugins/project_board/board`` (by the API
router in api.py — see __init__.register) that renders the board two ways (Kanban
Expand Down
43 changes: 25 additions & 18 deletions loop.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,11 +13,9 @@
merge webhook ▼ CI fail ▼ any failure ▼
done in_progress (bounce) blocked (flag + reason)

STUBBED for review: CI status + merge arrive via webhook/poll (api.py), not here;
``open_pr`` is sketched (worktree.py). The loop's claim/dispatch/teardown/error
paths below are the thing to review before hardening. Concurrency is capped at 1
for the first cut (token + merge-integration cost); the cap is where parallelism
lands later.
CI status + merge arrive out-of-band via the board API (``api.py``), not here.
Concurrency is capped at 1 (token + merge-integration cost); the cap is where
parallelism lands later.
"""

from __future__ import annotations
Expand Down Expand Up @@ -51,9 +49,11 @@ def __init__(self, cfg: dict):
# and we never write redundant tier/attempt labels.
self.coders = {str(k): str(v) for k, v in (self.cfg.get("coders") or {}).items()}
self.escalation_on = escalation_enabled(self.cfg)
self._store_kw = dict(db=self.cfg.get("db_path") or None,
repo=self.cfg.get("repo", "."),
base_branch=self.cfg.get("base_branch", "main"))
self._store_kw = dict(
db=self.cfg.get("db_path") or None,
repo=self.cfg.get("repo", "."),
base_branch=self.cfg.get("base_branch", "main"),
)
self._task: asyncio.Task | None = None
self._stop = asyncio.Event()
# The in-flight build's worktree, so shutdown can reap it (a cancel mid-drive
Expand All @@ -70,8 +70,12 @@ def start(self):
log.info("[project_board] loop disabled (project_board.loop_enabled=false) — board API still serves")
return None
self._task = asyncio.create_task(self._run(), name="project-board-loop")
log.info("[project_board] loop started (coder=%s reviewer=%s every %ss)",
self.coder_name, self.reviewer_name, self.interval)
log.info(
"[project_board] loop started (coder=%s reviewer=%s every %ss)",
self.coder_name,
self.reviewer_name,
self.interval,
)
return self._task

async def stop(self):
Expand Down Expand Up @@ -139,16 +143,16 @@ async def _drive(self, feature: dict):
return
# Fresh worktree per attempt (a failed attempt may leave partial work).
wt, branch = await worktree.create_worktree(repo, base, fid, self.root)
self._active = (repo, wt, branch) # track for shutdown reaping
self._active = (repo, wt, branch) # track for shutdown reaping
try:
result = await worktree.dispatch_coder(coder, wt, prompt) # reaps subprocess
pr_url = await worktree.open_pr(wt, branch, base=base, title=title,
body=(result or "")[:4000])
pr_url = await worktree.open_pr(wt, branch, base=base, title=title, body=(result or "")[:4000])
except (worktree.NoChangesError, worktree.WorktreeError) as exc:
# Capability failure (coder errored / no diff) → escalate when a
# ladder exists; an infra failure (push/gh) is not escalable → block.
capability = isinstance(exc, worktree.NoChangesError) or \
str(exc).startswith("coder dispatch failed")
capability = isinstance(exc, worktree.NoChangesError) or str(exc).startswith(
"coder dispatch failed"
)
if self.escalation_on and capability:
nxt = store.escalate(fid, str(exc)[:200])
if nxt:
Expand All @@ -169,7 +173,7 @@ async def _drive(self, feature: dict):
await self._request_review(fid, pr_url)
# Keep the worktree (a CI-fail bounce re-dispatches); reaping happens
# on a terminal block above, and the coder subprocess is already reaped.
self._active = None # built OK — not an interrupted build to reap
self._active = None # built OK — not an interrupted build to reap
return
except BoardError as exc:
log.warning("[project_board] %s blocked (board): %s", fid, exc)
Expand All @@ -191,6 +195,7 @@ async def _request_review(self, fid: str, pr_url: str):
log.info("[project_board] no reviewer %r configured — skipping review dispatch", self.reviewer_name)
return
from plugins.delegates.adapters import ADAPTERS

try:
msg = f"Please review this PR for correctness and acceptance: {pr_url}"
await ADAPTERS["a2a"].dispatch(reviewer, msg)
Expand All @@ -206,6 +211,7 @@ def _resolve_delegate(self, name: str, expect_type: str):
try:
from plugins.delegates.registry import DelegateRegistry
from plugins.delegates.store import merged_delegates

d = DelegateRegistry(merged_delegates()).get(name)
except Exception: # noqa: BLE001 — delegates plugin may be disabled
return None
Expand All @@ -218,8 +224,9 @@ def _build_prompt(self, feature: dict) -> str:
passive 'implement this feature' + a vague spec makes a coder produce
nothing; naming the files + a direct 'make the edits now' makes it act."""
files = feature.get("files_to_modify") or []
files_block = "\n".join(f"- {f}" for f in files) if files else \
"(none listed — create the files the task requires)"
files_block = (
"\n".join(f"- {f}" for f in files) if files else "(none listed — create the files the task requires)"
)
design = feature.get("design", "")
design_block = f"\n## Design / context\n{design}\n" if design.strip() else ""
return (
Expand Down
Loading
Loading