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 Wyrd's pdca.toml:

[leaves.builder]
mode = "command"
family = "claude"
interactive = false
agent = "builder"
effort = "medium"
argv = ["claude", "-p", "--agent", "builder", "--permission-mode", "acceptEdits",
        "--allowedTools", "Read,Edit,Write,Bash,Grep,Glob", "--model", "opus"]

Wyrd gives the builder the full tool set, Bash included — a Rust build needs cargo — and enforces STOP discipline mechanically rather than by trust: the rendered .claude/hooks/builder_guard.py PreToolUse hook (kept by ship_merge_guard = true at render) refuses any gh pr ready or gh pr merge the builder or publisher leaf attempts, and the role prompt ends at the commit. A project that can narrow --allowedTools further (say, Bash(git *) and Bash(python3 *) for a Python target) should — but the hook is what holds when the tool list can't. 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.

Wyrd uses the ladder and the named pins, and deliberately not the when routing: an Act review (step 06) found that Difficulty "is a size label the planner writes, not a statement about which invariants a slice touches", so it no longer buys a stronger first build. Its shape, trimmed:

[[leaves.builder_escalation]]
min_iteration = 2                    # attempts 2 to 4 → the same model at xhigh effort
effort = "xhigh"
argv = ["claude", "-p", "--agent", "builder", "--model", "opus", "..."]

[[leaves.builder_escalation]]
min_iteration = 5                    # attempt 5 on → a different model
effort = "xhigh"
argv = ["claude", "-p", "--agent", "builder", "--model", "fable", "..."]

[[leaves.builder_variant]]           # `Do model: sonnet` pins the first attempt
model = "sonnet"
effort = "xhigh"
argv = ["claude", "-p", "--agent", "builder", "...", "--model", "sonnet"]

[[leaves.builder_variant]]           # `Do model: codex` — a different vendor entirely
model = "codex"
family = "codex"
agent = "builder"
effort = "high"
argv = ["codex", "exec", "--sandbox", "workspace-write", "--skip-git-repo-check", "-m", "gpt-6-astra"]

Which backend ran each attempt is recorded in the bundle's loop-telemetry.json — the evidence Act reads to judge whether the ladder is worth its cost. Issue 779 took two attempts (step 05 shows why), and the file says exactly how the ladder treated them:

{
  "attempts": [
    {"n": 1, "builder": "claude", "family": "claude", "model": "opus", "effort": "medium"},
    {"n": 2, "builder": "claude", "family": "claude", "model": "opus", "effort": "xhigh"}
  ],
  "iterations_to_pass": 2
}

The real patch

Here's the heart of the actual results/issue_779/patch.diff — six files, 652 diff lines. The brief said "stamp Server: wyrd/<v> at the one point every response passes through, and let the composition root supply the value"; the builder added a config field to the gateway crate, stamped the header beside the request id, and set the field in the server binary:

--- a/crates/gateway-s3/src/lib.rs
+++ b/crates/gateway-s3/src/lib.rs
@@ -107,6 +107,67 @@ pub struct S3Config {
     pub service: String,
     /// The public S3 TLS identity, separate from internal mTLS (bound by #367).
     pub tls: Option<TlsIdentity>,
+    /// The build identity this front door advertises on every response it produces, as
+    /// `Server: wyrd/<server_version>` (#779). The composition root sets it to the
+    /// identity baked into the binary (`wyrd_server::version::BUILD_IDENTITY`); this crate
+    /// owns the header and its `wyrd/` shape, never the version. A caller that leaves it at
+    /// the [`S3Config::new`] default advertises [`UNKNOWN_SERVER_VERSION`], so the header
+    /// is still well-formed and makes no claim about a build it was not told.
+    pub server_version: String,
+}
...
@@ -1555,6 +1624,12 @@ where
     if let Ok(value) = HeaderValue::from_str(&request_id.to_string()) {
         response.headers_mut().insert(request_id::HEADER, value);
     }
+    // And name the build that produced it (#779). Same place, same reason: every response
+    // `dispatch` returns — success, 4xx, 5xx, and a streaming GET's head — passes through
+    // here, so no handler can forget it. Responses hyper produces before the service is
+    // entered (a malformed request line, an oversized header section) never reach `handle`
+    // and carry no `Server` header.
+    response.headers_mut().insert(header::SERVER, server);
     response
 }
--- a/crates/server/src/cli.rs
+++ b/crates/server/src/cli.rs
@@ -2654,6 +2654,10 @@ where
     gateway.recover().await?;
     let mut config = s3::S3Config::new(credentials);
     config.region = region;
+    // The build identity the front door advertises as `Server: wyrd/<v>` on every response
+    // it produces (#779) — the same constant the `role started` event records, so the wire
+    // and the log name one build. The composition root knows it; the wire crate does not.
+    config.server_version = crate::version::BUILD_IDENTITY.to_string();

Three things to note, each traceable straight back to the brief:

  • The invariant decided where the fix goes. The brief's Invariant to restore said a per-handler fix cannot satisfy "every response category", and named the one stamp point every request reaches. The builder put the header exactly there, with a comment that restates the honest boundary the brief drew (responses hyper sends before the service runs carry no header).
  • The scope decided who knows the version. Scope item (c) said the composition root supplies the value and nothing is threaded through the intermediate functions. The gateway crate owns the header's shape; the server binary sets one field. That is what Check's C5 "causal adequacy" will probe: cause removed at the seam, not a symptom patched per call site.
  • The falsifiability clause decided how the test is built. The companion test ships at exactly the path the brief named — crates/server/tests/s3_server_version_header.rs — and its doc-comment is the brief's reasoning turned into code: it drives the built binary over raw TCP and "names no symbol the fix introduces, so it compiles against a tree without the fix and fails there on the absent header." That is what lets the per-fix gate earn a real red leg instead of a compile error.

The builder also writes build-notes.md — its rationale, for the human. This is how the second attempt's notes open (the first attempt is in step 05):

# Build notes — issue 779 / s3-server-version-header-stamp (iteration 2)

Base: `getwyrd/wyrd@main` as integrated in the cycle worktree, `6032ff5` ("pdca-integrate:
issue_778"). #778 is on it: `wyrd_server::version::BUILD_IDENTITY`
(`crates/server/src/version.rs:48`) and the `version` field of the `role started` event, so
leg 5 can go green here. No STOP needed.

## What this iteration changed, per carry-forward item

The v1 production change (gateway stamp, config field, composition-root wiring) and the v1
test were not faulted by any reviewer and are carried over unchanged. Three items were
raised; this is what I did with each.

Note the base: not origin/main but the run's integration line with 778 folded onto it. 779 depends on 778, so the driver built it on a tree that already has its prerequisite — the wave mechanics in step 07.

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.

The guard catches what the brief declared. A dependency only the builder discovers — the tool was there, the version it needed wasn't — has its own exit path (issue #341, opt-in [driver].dependency_halt = true, which Wyrd runs): an honest builder declares it in build-notes.md (NEEDS-HUMAN external dependency: …, plus a proposed [[doctor.checks]] row), and the driver adjudicates the claim deterministically at BUILT — the named dependency must resolve to a doctor row whose detect command really does exit non-zero. Confirmed ⇒ the bundle skips the full Check (no reviewer spent adjudicating a patch already stated to be unverifiable) and parks at sign-off, resumable: install the dependency and answer iterate-do. Refuted or unresolvable ⇒ the full Check runs unchanged with the refutation recorded into §6 — a self-report can never skip review on its own say-so. Wyrd's Act log shows the pattern in use: when one security advisory in a dependency turned the base's cargo deny wall red for every bundle at once, the planners and builders settled on one standing doctor row id for it, so a bundle held on it names the same cause every time (step 06).

Both guard 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.) For Wyrd the worktrees sit beside the primary checkout as wyrd.pdca-wt-l0 … l2, one per lane, and engine/xtask.sh runs cargo xtask ci inside whichever one $PDCA_WORKTREE names — so the gate tests the same tree the builder edited.

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. A split parent takes the same path with close-disposition = split — that is how issue 736, the parent of 778 and 779, reached COMPLETE with no patch (step 07).

← 03 Plan · Index · next: 05 Check →