Step 04 — Do: build the fix
← 03 Plan · Index · next: 05 Check →
Beat: Do. Fully automated — no human touch point. The builder leaf reads the
PLANNED bundle's brief.md (and nothing else) and produces the change. When
patch.diff lands, the bundle becomes BUILT.
Two parts follow: how to use it — the leaf config you write, the per-bundle model routing, and a real patch it produced — then how it works underneath: the worktree isolation, the artifacts it writes and why one is withheld from the reviewer, and the close-disposition fast path that skips Do entirely.
How to use
The builder leaf is headless (interactive = false) — it runs unattended
as part of pdca flow, so there's nothing to type. From gramps' pdca.toml:
[leaves.builder]
mode = "command"
interactive = false
argv = ["claude", "-p", "--agent", "builder", "--permission-mode", "acceptEdits",
"--allowedTools", "Read,Edit,Bash(git *),Bash(python3 *)"]
The narrow --allowedTools is deliberate: the builder may read, edit, and run
git/python — it cannot, say, open a PR. STOP discipline (step 03)
is enforced by what the leaf can't do, not just by instruction.
Choosing the Do model per bundle (issues #134/#167)
[leaves.builder] is the default executor, but the right backend often depends on the
bundle — a high-blast-radius fix wants a frontier model, a privacy-sensitive one must
stay on a local backend, an easy one can run cheap. Declare builder variants and the
driver (select_builder) picks one per bundle, from that bundle's brief:
[[leaves.builder_variant]] # routed by a brief field
model = "frontier"
when = { field = "difficulty", substring = "high" }
argv = ["claude", "-p", "--agent", "builder", "--model", "opus", "..."]
[[leaves.builder_variant]] # named, for an explicit pick
model = "local"
argv = ["local-build", "..."]
Two ways to select, both resolved per bundle:
whenrouting (#134) — the first variant whosewhen = {field, substring}matches a brief field wins (Difficulty: high→ the frontier variant). Default-open: an absent/non-matching field keeps the default, so a missing tag never silently reduces capability.- Explicit
Do model:(#167) — a brief can name a backend directly with- **Do model:** <name>, matched to a variant'smodel. This overrides thewhenrouting — pin a specific bundle (e.g. force a sensitive fix ontolocalregardless of difficulty).
Because the backend is resolved per bundle, different bundles in the same wave run on
different models concurrently — which also spreads load across providers' rate limits. A
variant's argv is the whole command, so it carries the model and any effort/reasoning
flags. On top of either pick, the escalation ladder ([[leaves.builder_escalation]],
#135) still overrides on iterate, so a bundle that keeps failing climbs to a stronger
backend regardless of its self-rating.
The real patch
Here's the heart of the actual results/issue_11589/patch.diff. The brief said
"delete only the selected plugin's own files when the directory is shared"; the
builder added two helpers and rerouted __uninstall:
--- a/PluginManager/PluginManager.py
+++ b/PluginManager/PluginManager.py
@@ -336,11 +336,52 @@ class PluginStatus(tool.Tool, ManagedWindow):
+ def __plugins_sharing_dir(self, pdata):
+ """Return the registered plugins, other than *pdata*, whose files live
+ in the same directory (``fpath``). ... (bug 11589)."""
+ shared = {}
+ for ptype in PTYPE_STR:
+ for other in self._preg.type_plugins(ptype):
+ if other.id != pdata.id and other.fpath == pdata.fpath:
+ shared[other.id] = other
+ return list(shared.values())
+
+ def __remove_plugin_files(self, pdata, siblings):
+ """Delete only the files belonging to *pdata* ... leaving the shared
+ directory and everything else in it intact."""
+ if pdata.fname in {other.fname for other in siblings}:
+ return
+ base = os.path.splitext(pdata.fname)[0]
+ for fname in (base + ".gpr.py", pdata.fname):
+ target = os.path.join(pdata.fpath, fname)
+ if os.path.isfile(target):
+ os.remove(target)
+
def __uninstall(self, pid, path):
"""Uninstall the plugin"""
pdata = self._pmgr.get_plugin(pid)
+ siblings = self.__plugins_sharing_dir(pdata)
try:
- if os.path.islink(pdata.fpath): # linux link
+ if siblings:
+ # The directory is shared by other registered plugins (e.g. the
+ # multi-rule FilterRules pack). Removing the whole directory
+ # would destroy those siblings ... so remove only this plugin's
+ # own files (bug 11589).
+ self.__remove_plugin_files(pdata, siblings)
+ elif os.path.islink(pdata.fpath): # linux link
Two things to note, both traceable straight back to the brief:
- The success criterion's two halves are both honoured. Shared directory →
remove only own files (new branch); sole occupant → fall through to the old
rmtree(theelif). The brief explicitly demanded the sole-occupant case "still remove the directory, preserving today's behaviour" — so the builder kept the old path instead of replacing it. - The fix targets the root cause named in the brief (
shutil.rmtreeon a sharedfpath), not the symptom. That's what Check's C5 "causal adequacy" will probe.
The companion test ships at exactly the path the brief named —
PluginManager/tests/test_uninstall_shared_dir.py — so the C4 gate has something
to run.
The bundle is now BUILT. The driver moves straight into Check — step
05 — with no pause.
How it works
The leaf, and the guards around it
Do has exactly one leaf — builder, covered above in
How to use including its per-bundle variants and escalation
ladder. Unlike Plan, Do has no leaves of its own beyond that one: no split, no
size judgment happens here — Do just builds what the brief says and stops.
Do doesn't add any gating logic of its own, but it's flanked on both sides by
the same pre-dispatch policy check — the mechanism itself, and the
[driver].dependency_guard / [driver].size_guard config, are defined once in
step 03. It fires twice:
once right before Do is allowed to dispatch, and again right after Do produces
a patch, before Check is allowed to. Step 07
has the full two-entry diagram; here's what each firing means for Do
specifically.
Before Do dispatches (bundle at PLANNED), two things about the brief are
checked:
- The dependency guard (default
hold) — blocking. Every backticked token in the brief'sExternal dependenciesfield is checked against the registered[[doctor.checks]]rows from step 01; an unmatched one holds the bundle right here, before a builder is ever spent. Set membership, not a heuristic, which is whyholdis the real default. - The size guard (default
off) — advisory only. Withsize_guard = "warn", an oversized brief prints the signal that fired and a remedy —pdca split— while Do dispatches anyway. This is the backstop case; the normal path is the planner splitting the brief itself, inside Plan, before it ever reaches here (step 07).
After Do produces a patch (bundle at BUILT), the same check runs a
second time, before Check dispatches — and it has to, because a bundle can
reach BUILT without passing back through a fresh Plan exit: a resumed run, or
a builder that wrote patch.diff and then crashed. Check is a real spend too
(a reviewer at xhigh, possibly an adversary), so it shouldn't run unpoliced on
a brief that was never actually cleared. Two things differ from the first
firing, both because Do has already happened:
- The dependency check is unchanged — still blocking, still reconciled fresh against whatever's registered now. Register the missing row after Do ran, and the very next beat proceeds; nothing about the hold survives past that.
- The size guard reads the stored sizer verdict instead of paying for a
fresh one — Plan already bought that opinion, and buying a second on work
that's already built would be paying twice for the same answer. The remedy
changes too:
pdca splitno longer applies (splitting means authoring new briefs, and Do's output isn't one) — instead the message points atiterate-planat sign-off, which archives this attempt and returns the bundle to Plan for the re-plan.
Both firings are evaluated fresh every time, never cached, so editing the brief or registering a missing row un-holds the bundle on the very next attempt, no re-plan required. The close-disposition fast path below is a different question from either of these — not "is this bundle allowed to proceed," but "does it need a real builder at all."
Isolated in a worktree (issue #94)
Do (and Check's gates) run against a dedicated git worktree off the target's
base, not the host's primary checkout — so a cycle never leaves the live checkout
dirty or collides with your own work there. The harness creates/resets it per cycle
(per lane slot) and exposes its path as $PDCA_WORKTREE: the builder is granted
access to it automatically, and bundle-scoped gate commands target it too. On by
default ([driver].worktree); best-effort — a target that isn't a worktree-capable
git checkout falls back to editing in place. (This isolation is what publish's
stash/restore worked around before; with it, serial cycles get the same clean-tree
guarantee lanes already had.)
The harness checkout is not isolated — don't do git maintenance under a live flow (issue #130)
Worktree isolation protects the target repo. It does not protect the
harness checkout — the repo that holds results/issue_<id>/ bundle state and
the pdca_harness code a running flow imports. While a pdca flow is live (e.g.
parked at the interactive sign-off leaf), ordinary git maintenance in that checkout
silently corrupts in-flight state:
git stash -uclobbers the bundle. A flow'sresults/issue_<id>/is long-lived untracked working-tree files (it isn't committed as it goes); a stash sweeps them into a stale snapshot while the flow keeps advancing the on-disk bundle — a laterstash popoverwrites newer Check artifacts with older Do ones.- A branch switch or
copier updaterewritessrc/pdca_harness/*.pyunder the running interpreter. The live process pinned the old modules in memory, but anypython -m pdca_harness …subprocess it spawns afterward picks up the new code — a mixed-version cycle.
The discipline is just #94's own principle turned on the harness repo: do
harness-repo maintenance (version bumps, copier update, bundle-recording commits)
in a separate git worktree of the harness repo — never branch-switch,
copier update, or git stash the live checkout while a flow runs. (An advisory
pdca flow-lock that refuses such maintenance while a flow is held is planned as a
follow-up; until then this is operator discipline.)
What Do produces
Three artifacts land in the bundle:
| Artifact | Purpose | Who sees it |
|---|---|---|
patch.diff |
The change itself | Check gates, reviewer, you |
| the test file | The red→green proof, shipped at the brief's Test file path | C4 gate runs it |
build-notes.md |
The builder's rationale — why this approach, trade-offs considered | You at sign-off — withheld from the reviewer |
That last withholding is a real design decision, not an accident: the Check
reviewer (step 05) sees only {patch.diff, test, brief.md, check-gates.json}. If the reviewer could read the builder's self-justification,
it would anchor on the builder's framing instead of judging the diff cold. The
human signing off does get build-notes.md, because the human is adjudicating,
not independently re-deriving.
The close-disposition fast path
Not every briefed bundle should be built. When a Plan concludes a close / no-fix outcome — a duplicate, a not-reproducible report, a by-design behaviour, an upstream issue, or a macOS-only manual check — there is nothing for the builder to write. The two model leaves (builder + reviewer) are the engine's only token spend, so authoring and judging a "nothing to build" rationale is pure waste.
The driver reads the brief's Disposition hint. If it matches a configured close
class ([driver].close_dispositions in pdca.toml — default: likely-close,
wontfix, by-design, duplicate, not-reproducible, manual-verification,
upstream, external), Do skips both leaves: instead of a patch.diff it writes a close-disposition
marker (the bundle's Do artifact) and a build-notes.md breadcrumb recording why
no patch exists. Check then records an N/A gate matrix (no patch to verify, no gate
command runs) and routes a confirm-the-close item into SUMMARY §6. The bundle
halts at AWAITING_SIGNOFF like any other — the human signs off on
the close, and the C6 guard blocks accept until they consciously confirm it.
It is a hint, not a gate. If the close was wrong, the human reopens to a fix path
(iterate-to-Do); that archives the close marker and the next pass runs the real
builder. A manual-verification close also seeds a MANUAL-VERIFICATION.md stub for
the human to fill in. This is what makes a mixed-disposition batch run safe: close
bundles drive themselves to sign-off cheaply alongside the ones that get built.
← 03 Plan · Index · next: 05 Check →