Walkthrough

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:

  • when routing (#134) — the first variant whose when = {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's model. This overrides the when routing — pin a specific bundle (e.g. force a sensitive fix onto local regardless 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 (the elif). 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.rmtree on a shared fpath), 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's External dependencies field 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 why hold is the real default.
  • The size guard (default off) — advisory only. With size_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 split no longer applies (splitting means authoring new briefs, and Do's output isn't one) — instead the message points at iterate-plan at 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 -u clobbers the bundle. A flow's results/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 later stash pop overwrites newer Check artifacts with older Do ones.
  • A branch switch or copier update rewrites src/pdca_harness/*.py under the running interpreter. The live process pinned the old modules in memory, but any python -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 →