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:
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.
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'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.
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 -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. 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 →