Skip to content

Commit d479f53

Browse files
mabry1985Josh Mabryclaude
authored
feat: wire the fusion rung into the board seam (ADR 0064 P3, v0.28.0) (#64)
coder.solve()'s ladder always supported fusion (rung 4: a richer generator, oracle-selected) but the board seam (P2) never wired it -- solve() was called with no fusion_generate at all, so no board dispatch could ever reach it, regardless of how many rungs failed. Fusion can't tool-call (a plain chat completion, e.g. protolabs/fusion -- not an ACP session), so its candidate generation is a different shape from the ACP rungs: _fusion_prompt hands it the task + the CURRENT content of the feature's declared files_to_modify (read from the base repo -- fusion has no tool access to look these up itself) and asks for the complete, final content of every file it creates or changes. _parse_fusion_files extracts {path: content} from the reply; generate_fusion writes those files into a fresh worktree (same throwaway-per-candidate discipline as the ACP rungs, same promote/reap bookkeeping) and hands the path to the SAME verify() -- real acceptance tests, no separate judge. Guards against a completion naming an absolute path or a ../ climb escaping the worktree. New config: coder_solve_fusion_delegate (an openai-type delegate name, resolved by _drive the same way coder/reviewer already are), coder_solve_fusion_k. Blank delegate (default) -> solve() gets fusion_generate=None, identical to before this rung existed -- honest degrade, unchanged. Tests: file-format parsing, prompt construction (existing-file content, missing-file note, feedback), generate_fusion (writes correctly, rejects path traversal, handles an empty/unparseable reply without crashing), dispatch() end-to-end reaching fusion after cheaper rungs fail, and the no-delegate honest-degrade path. 255 passed (was 244; +11 new, +9 existing _fake_solve signatures updated for the new fusion_generate/fusion_k kwargs). Co-authored-by: Josh Mabry <josh@protolabs.studio> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 5dc7139 commit d479f53

7 files changed

Lines changed: 431 additions & 22 deletions

File tree

README.md

Lines changed: 18 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -46,16 +46,23 @@ board to a PR — or fork it as a starting point.
4646
requires a spec, EARS acceptance criteria, and explicit `files_to_modify`.
4747
- **Escalation (opt-in)** — with a `coders` map of >1 distinct delegate, a capability
4848
failure climbs a model tier (`fast→smart→reasoning`) and blocks at the top.
49-
- **coder.solve() board seam (ADR 0064 P2)** — on a fresh build, when the
49+
- **coder.solve() board seam (ADR 0064 P2/P3)** — on a fresh build, when the
5050
[`coder`](https://github.com/protoLabsAI/protoAgent/tree/main/plugins/coder) plugin
5151
is enabled AND the feature has acceptance criteria AND `coder_solve_test_cmd` (or
5252
`local_gate_cmd`) is set, the loop dispatches through `coder.solve()`'s
53-
execution-grounded ladder (greedy → best-of-k → tree-search) instead of a single
54-
`delegate_to(acp)` shot — gated on the feature's acceptance tests actually PASSING
55-
in a real candidate worktree, never an LLM judge. Composes WITH the tier ladder
56-
above (solve() searches *within* a tier; a search that never passes escalates a
57-
tier, or blocks, exactly like a no-diff dispatch). Missing coder/acceptance/test
58-
command ⇒ honest degrade to the single shot — see `coder_seam.py`.
53+
execution-grounded ladder — greedy → best-of-k → tree-search → **fusion** — instead
54+
of a single `delegate_to(acp)` shot, gated on the feature's acceptance tests
55+
actually PASSING in a real candidate worktree, never an LLM judge. **Fusion** (rung
56+
4, opt-in via `coder_solve_fusion_delegate`) is a richer *generator* for the
57+
hardest features the cheaper rungs couldn't pass — it can't tool-call (a plain
58+
completion, e.g. `protolabs/fusion`, not an ACP session), so `coder_seam.py` hands
59+
it the current content of the feature's declared files and writes its reply's
60+
files into a fresh worktree itself; the SAME `verify()` oracle judges it. Composes
61+
WITH the tier ladder above (solve() searches *within* a tier; a search that never
62+
passes escalates a tier, or blocks, exactly like a no-diff dispatch). Missing
63+
coder/acceptance/test command ⇒ honest degrade to the single shot; missing
64+
`coder_solve_fusion_delegate` ⇒ the ladder simply stops at tree-search — see
65+
`coder_seam.py`.
5966
- **Planning layer** — two reasoning subagents (`decompose` + `antagonist`) driven by
6067
the `decompose-project` skill: idea → outline → MADR ADRs → epics › milestones ›
6168
features, hardened by an adversary, with a per-epic human gate.
@@ -114,6 +121,10 @@ project_board:
114121
# acceptance criteria + a test command — see "What it does").
115122
coder_solve_test_cmd: "pytest tests/ -q" # solve()'s verify() oracle; falls back to
116123
# local_gate_cmd if blank, else the seam honest-degrades.
124+
coder_solve_fusion_delegate: "" # rung 4 (ADR 0064 P3), opt-in: an `openai`-type
125+
# delegate name (e.g. protolabs/fusion) for the hardest
126+
# features. Blank (default) = ladder stops at tree-search.
127+
coder_solve_fusion_k: 2 # candidates fusion generates when reached
117128
# webhook_secret: "..." # set before exposing /webhook/pr publicly
118129
```
119130

coder_seam.py

Lines changed: 150 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
"""The P2 board seam (ADR 0064): dispatch a feature's build through the `coder`
22
plugin's execution-grounded ``solve()`` ladder instead of a single
3-
``delegate_to(acp)`` shot — greedy → best-of-k → tree-search, gated on the
4-
feature's acceptance tests actually PASSING in a real worktree, never an LLM judge.
3+
``delegate_to(acp)`` shot — greedy → best-of-k → tree-search → fusion, gated on
4+
the feature's acceptance tests actually PASSING in a real worktree, never an LLM
5+
judge.
56
67
**Composes** `plugins.coder.solve` (a separate, git-URL-installed plugin — imported
78
lazily/best-effort so this repo carries no hard dependency on it and no import-time
@@ -28,19 +29,96 @@
2829
the acceptance criteria as part of its definition of done; this module's ``verify``
2930
just RUNS whatever tests exist in a candidate's worktree via the configured command
3031
and gates on its exit code — real execution, no fabricated grounding.
31-
"""
32+
33+
**Rung 4 — fusion (ADR 0064 P3).** Fusion (e.g. ``protolabs/fusion``) is a strong
34+
*generator* but, per the ADR, it **can't tool-call** — unlike the ``acp`` coder
35+
(a real edit/verify session in the worktree), it can only return a plain chat
36+
completion. So its candidate generation is a DIFFERENT shape from the ACP rungs:
37+
``_fusion_prompt`` hands it the task + the CURRENT content of the feature's
38+
declared ``files_to_modify`` (read from the base repo — fusion has no tool access
39+
to look these up itself) and asks for the complete, final content of every file it
40+
creates or changes; ``_parse_fusion_files`` extracts ``{path: content}`` from the
41+
reply; ``_WorktreeSolveAdapter.generate_fusion`` writes those files into a fresh
42+
worktree (the same throwaway-per-candidate discipline as the ACP rungs) and hands
43+
the path to the SAME ``verify()`` — real acceptance tests, same oracle, no separate
44+
judge. Wholesale file replacement (not a unified diff) is deliberate: an LLM
45+
completion reliably reproduces a full file; a hand-rolled patch with drifted
46+
context lines is a common failure mode `git apply` doesn't forgive. Only reached
47+
when a ``fusion_delegate`` is configured (an ``openai``-type Delegate, already
48+
resolved by the caller) — absent that, ``solve()`` gets ``fusion_generate=None``
49+
and stops at tree-search exactly as before (honest degrade, unchanged)."""
3250

3351
from __future__ import annotations
3452

3553
import asyncio
3654
import importlib
3755
import logging
56+
import re
57+
from pathlib import Path
3858
from typing import Callable
3959

4060
from . import worktree
4161

4262
log = logging.getLogger("protoagent.plugins.project_board")
4363

64+
# ``### path/to/file.py`` header, then a fenced block (any/no language hint) holding
65+
# that file's COMPLETE new content. Deliberately simple/strict: a fusion completion
66+
# that doesn't follow the format parses to no files, which just fails verify() like
67+
# any other empty candidate — never a silent partial/mangled write.
68+
_FUSION_FILE_RE = re.compile(r"^###\s+(\S.+?)\s*$\n```[^\n]*\n(.*?)```", re.MULTILINE | re.DOTALL)
69+
70+
_FUSION_READ_MAX_CHARS = 20_000 # per-file context cap — fusion sees enough to edit, not a dump
71+
72+
73+
def _parse_fusion_files(reply: str) -> dict[str, str]:
74+
"""Extract ``{relative path: full file content}`` from a fusion completion. No
75+
match ⇒ empty dict — the caller writes nothing, and the untouched worktree just
76+
fails ``verify()`` like any other candidate that didn't address the task."""
77+
return {path.strip(): content for path, content in _FUSION_FILE_RE.findall(reply or "")}
78+
79+
80+
def _fusion_prompt(task: str, *, feedback: str | None, repo: str, files_to_modify: list[str]) -> str:
81+
"""Build fusion's prompt. Fusion can't read the repo itself (no tool-calling), so
82+
this hands it the CURRENT content of every file the feature declares — read from
83+
the base repo, best-effort (a listed-but-not-yet-created file is noted as new)."""
84+
file_blocks = []
85+
for rel in files_to_modify:
86+
p = Path(repo) / rel
87+
try:
88+
text = p.read_text(errors="replace")[:_FUSION_READ_MAX_CHARS]
89+
file_blocks.append(f"### {rel} (current content)\n```\n{text}\n```")
90+
except OSError:
91+
file_blocks.append(f"### {rel} (does not exist yet — you are creating it)")
92+
files_section = (
93+
"\n\n".join(file_blocks) if file_blocks else "(no existing files listed — create what the task needs)"
94+
)
95+
parts = [
96+
"Implement the task below. You have NO tool access — you cannot read or run "
97+
"anything else, so work only from what's given here.",
98+
"",
99+
"## Task",
100+
task.strip(),
101+
"",
102+
"## Current file contents",
103+
files_section,
104+
"",
105+
"## Your reply format — REQUIRED, exactly this shape per file",
106+
"For every file you create or change, return its COMPLETE, FINAL content "
107+
"(never a partial diff or `...` elisions) as:",
108+
"### relative/path/to/file.py",
109+
"```",
110+
"<the file's entire new content>",
111+
"```",
112+
"Only include files you're actually creating or changing. No prose outside the file blocks.",
113+
]
114+
if feedback:
115+
parts += [
116+
"",
117+
"## Your previous attempt FAILED the acceptance tests — fix exactly this",
118+
feedback.strip(),
119+
]
120+
return "\n".join(parts)
121+
44122

45123
class SolveExhausted(worktree.WorktreeError):
46124
"""``coder.solve()`` spent its whole generation budget against this feature's
@@ -118,6 +196,9 @@ def __init__(
118196
test_cmd: str,
119197
test_timeout: float,
120198
verdict_cls,
199+
fusion_delegate=None,
200+
files_to_modify: list[str] | None = None,
201+
_fusion_dispatch=None,
121202
):
122203
self.repo = repo
123204
self.base = base
@@ -128,22 +209,70 @@ def __init__(
128209
self.test_cmd = test_cmd
129210
self.test_timeout = test_timeout
130211
self.verdict_cls = verdict_cls # `plugins.coder.solve.Verdict` — passed in, never imported here
212+
self.fusion_delegate = fusion_delegate # a resolved `openai`-type Delegate, or None
213+
self.files_to_modify = files_to_modify or []
214+
# Test-injection seam (mirrors `_solve`/`_budget_cls`/`_verdict_cls` on
215+
# `dispatch()`): production never passes this — the real lazy
216+
# `ADAPTERS["openai"].dispatch` import happens in `generate_fusion` below.
217+
self._fusion_dispatch = _fusion_dispatch
131218
self.candidates: list[tuple[str, str]] = [] # (worktree_path, branch)
132219
# `git worktree add` against the SAME repo must not run concurrently (best-
133220
# of-k dispatches `generate()` via asyncio.gather) — serialize just that
134221
# step; the slow part (the coder dispatch) still runs in parallel.
135222
self._wt_lock = asyncio.Lock()
136223
self._n = 0
137224

138-
async def generate(self, task: str, *, feedback: str | None = None) -> str:
225+
async def _new_candidate_worktree(self) -> tuple[str, str]:
139226
self._n += 1
140227
cid = f"{self.fid}.g{self._n}"
141228
async with self._wt_lock:
142229
wt, branch = await worktree.create_worktree(self.repo, self.base, cid, self.root)
143230
self.candidates.append((wt, branch))
231+
return wt, branch
232+
233+
async def generate(self, task: str, *, feedback: str | None = None) -> str:
234+
wt, _branch = await self._new_candidate_worktree()
144235
await worktree.dispatch_coder(self.coder, wt, _augment_prompt(task, feedback), timeout=self.dispatch_timeout)
145236
return wt
146237

238+
async def generate_fusion(self, task: str, *, feedback: str | None = None) -> str:
239+
"""Rung 4 (ADR 0064 P3): fusion can't tool-call, so instead of dispatching an
240+
ACP session into the worktree, get a plain completion and write its files into
241+
one. Same candidate bookkeeping (``candidates``/``_wt_lock``) as ``generate``,
242+
so promote/reap treats a fusion winner identically to an ACP one."""
243+
if self._fusion_dispatch is not None:
244+
openai_dispatch = self._fusion_dispatch
245+
else:
246+
from plugins.delegates.adapters import ADAPTERS
247+
248+
openai_dispatch = ADAPTERS["openai"].dispatch
249+
250+
prompt = _fusion_prompt(task, feedback=feedback, repo=self.repo, files_to_modify=self.files_to_modify)
251+
reply = await openai_dispatch(self.fusion_delegate, prompt, timeout=self.dispatch_timeout)
252+
files = _parse_fusion_files(reply)
253+
wt, _branch = await self._new_candidate_worktree()
254+
wt_root = Path(wt).resolve()
255+
written = 0
256+
for rel, content in files.items():
257+
# `rel` comes from a model completion — an absolute path or a `../` climb
258+
# would otherwise write outside the worktree (Path.__truediv__ with an
259+
# absolute right-hand side even discards the left side entirely). Resolve
260+
# and require containment; skip (don't crash the candidate) on a miss.
261+
dest = (wt_root / rel).resolve()
262+
if wt_root not in dest.parents and dest != wt_root:
263+
log.warning(
264+
"[project_board] %s fusion tried to write outside its worktree: %r — skipped", self.fid, rel
265+
)
266+
continue
267+
dest.parent.mkdir(parents=True, exist_ok=True)
268+
dest.write_text(content)
269+
written += 1
270+
if not written:
271+
log.warning(
272+
"[project_board] %s fusion reply parsed to 0 writable files — candidate is unchanged base", self.fid
273+
)
274+
return wt
275+
147276
async def verify(self, candidate_wt: str):
148277
Verdict = self.verdict_cls
149278
try:
@@ -215,9 +344,13 @@ async def dispatch(
215344
k: int,
216345
tree_depth: int,
217346
record_gens: RecordGens | None = None,
347+
fusion_delegate=None,
348+
fusion_k: int = 2,
349+
files_to_modify: list[str] | None = None,
218350
_solve=None,
219351
_budget_cls=None,
220352
_verdict_cls=None,
353+
_fusion_dispatch=None,
221354
) -> tuple[str, str, str]:
222355
"""Run the execution-grounded ladder for one feature build.
223356
@@ -234,6 +367,14 @@ async def dispatch(
234367
import happens here, deferred so this module carries no hard dependency on the
235368
`coder` plugin).
236369
370+
``fusion_delegate`` (a resolved ``openai``-type Delegate, or ``None``) gates rung
371+
4 (ADR 0064 P3) — the caller resolves it (mirroring how ``coder`` itself is
372+
resolved), so this module never does delegate lookup. ``None`` (unconfigured) ⇒
373+
``solve()`` gets ``fusion_generate=None`` and stops at tree-search, unchanged from
374+
before this rung existed. ``files_to_modify`` feeds fusion's prompt (it can't read
375+
the repo itself, unlike the ACP rungs) — the same list the feature's Ready gate
376+
already required.
377+
237378
**``solve()`` itself can raise.** The ladder (`coder`'s own ``solve.py``) has no
238379
try/except around ``generate``/``verify`` — it assumes a candidate attempt never
239380
errors, only that it might fail its tests. A REAL dispatch can still raise
@@ -263,6 +404,9 @@ async def dispatch(
263404
test_cmd=test_cmd,
264405
test_timeout=test_timeout,
265406
verdict_cls=Verdict,
407+
fusion_delegate=fusion_delegate,
408+
files_to_modify=files_to_modify,
409+
_fusion_dispatch=_fusion_dispatch,
266410
)
267411
try:
268412
result = await solve(
@@ -272,6 +416,8 @@ async def dispatch(
272416
budget=Budget(budget),
273417
k=k,
274418
tree_depth=tree_depth,
419+
fusion_generate=adapter.generate_fusion if fusion_delegate is not None else None,
420+
fusion_k=fusion_k,
275421
)
276422
except Exception as exc:
277423
for wt, branch in adapter.candidates:

loop.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -234,6 +234,14 @@ def __init__(self, cfg: dict):
234234
self.coder_solve_budget = max(1, int(self.cfg.get("coder_solve_budget", 6)))
235235
self.coder_solve_k = max(1, int(self.cfg.get("coder_solve_k", 3)))
236236
self.coder_solve_tree_depth = max(0, int(self.cfg.get("coder_solve_tree_depth", 2)))
237+
# Rung 4 (ADR 0064 P3): a richer generator for the HARDEST features — reached
238+
# only after greedy AND best-of-k AND tree-search all fail their tests. Fusion
239+
# can't tool-call (it's a plain completion, not an ACP session), so it's an
240+
# `openai`-type delegate name, resolved per-dispatch in `_drive` (mirroring how
241+
# `coder`/`reviewer` are resolved) — never here, this is just config plumbing.
242+
# Blank ⇒ no fusion rung; the ladder stops at tree-search exactly as before.
243+
self.coder_solve_fusion_delegate = str(self.cfg.get("coder_solve_fusion_delegate", "")).strip()
244+
self.coder_solve_fusion_k = max(1, int(self.cfg.get("coder_solve_fusion_k", 2)))
237245
# KG lessons (the flywheel READ half): before dispatching a coder, query the
238246
# knowledge graph (via graph.sdk) for distilled lessons relevant to THIS feature
239247
# and inject them into the prompt — so the coder heeds this area's known failure
@@ -673,6 +681,11 @@ async def _drive(self, feature: dict):
673681
self._inflight[fid] = (repo, wt, branch)
674682
result = await worktree.dispatch_coder(coder, wt, prompt, timeout=self.coder_timeout or None)
675683
elif self._use_coder_solve(feature) and not self._ci_feedback.get(fid):
684+
fusion = (
685+
self._resolve_delegate(self.coder_solve_fusion_delegate, "openai")
686+
if self.coder_solve_fusion_delegate
687+
else None
688+
)
676689
wt, branch, result = await coder_seam.dispatch(
677690
task=prompt,
678691
coder=coder,
@@ -687,6 +700,9 @@ async def _drive(self, feature: dict):
687700
k=self.coder_solve_k,
688701
tree_depth=self.coder_solve_tree_depth,
689702
record_gens=lambda n: store.record_gens_spent(fid, n),
703+
fusion_delegate=fusion,
704+
fusion_k=self.coder_solve_fusion_k,
705+
files_to_modify=feature.get("files_to_modify") or [],
690706
)
691707
self._inflight[fid] = (repo, wt, branch)
692708
elif self.max_mode_n > 1 and not self._ci_feedback.get(fid):

protoagent.plugin.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
id: project_board
22
name: Project Board (coding orchestration)
3-
version: 0.27.0
3+
version: 0.28.0
44
description: >-
55
A board-driven coding-orchestration plugin: a lean 6-state board (backlog → ready
66
→ in_progress → in_review → done, + a blocked flag) backed by **beads** (`br`), an

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "project-board"
3-
version = "0.27.0"
3+
version = "0.28.0"
44
description = "Board-driven coding-orchestration plugin for protoAgent (beads board + ACP spawn loop + planning layer + console view)."
55
requires-python = ">=3.11"
66

0 commit comments

Comments
 (0)