Walkthrough

Step 07 — Cross-cutting: size, iteration, and parallel execution

← 06 Act · Index · next: 08 Command reference →

Everything through step 06 is one contribution moving linearly through Plan → Do → Check → Act, with sign-off and publish as Check's last two steps. Three mechanisms don't fit that line — they modify how the beats run rather than being a beat themselves, and each one touches more than one of them:

  • Size & split — estimates a brief's size before Do spends anything, and a deterministic path (owned by Plan, backstopped by the driver) to decompose one that's too big.
  • Iteration & carry-forward — what actually happens on disk when a bundle iterates, whether a human triggered it or the driver did.
  • Parallel lanes & housekeeping — running several cycles at once, and the maintenance that keeps a long-running instance from silting up.

This step gathers them in one place rather than splitting each across every beat it touches.


Size & split

Step 03 already covers the size guard — the pre-dispatch check that can warn before Do. This section is about the decomposition it warns you toward, and — the important part — splitting is owned by the Plan beat itself, not a maintenance task you run afterward. The planner leaf's own runtime prompt tells it: "If this slice turns out to be several slices, SPLIT IT IN THIS BEAT — a split produces briefs, and briefs are yours. You do not leave the session to file issues by hand." The planner runs pdca split and pdca split --accept itself, inline, with the human right there in the same interactive Plan session — the same session that's already open to co-author brief.md in the first place.

The process

flowchart TD subgraph ENTRY_P["Entry P — the default path: inside the Plan session itself"] P1["Planner + human co-author brief.md"] --> P2{"planner judges:<br/>one slice, or several?"} P2 -->|"one"| P3["Writes brief.md normally — Plan ends, bundle PLANNED"] P2 -->|"several"| P4["Planner runs pdca split &lt;id&gt; itself,<br/>inline, without leaving the session"] end subgraph ENTRY_A["Entry A — backstop: state PLANNED, before Do dispatches"] A1["brief.md exists, was never split"] --> A2["Size estimate: structural score<br/>+ a freshly-paid [leaves.sizer] verdict"] A2 --> A3{"combined band?"} A3 -->|"ok / watch"| A4["No action — Do dispatches"] A3 -->|"oversized"| A4b{"split child whose brief still<br/>declares SIBLING conflicts?"} A4b -->|"yes — the split's metadata"| A8["Remedy: driven by inherited/sibling fields<br/>— prefer building; Do dispatches anyway"] A4b -->|"no"| A5{"splittable?"} A5 -->|"no — patch-only, coherent"| A6["Warn: expect a large patch —<br/>Do dispatches anyway"] A5 -->|"yes"| A7["Remedy: consider pdca split first"] end subgraph ENTRY_B["Entry B — backstop: state BUILT, before Check dispatches"] B1["patch.diff exists"] --> B2["Size estimate: structural score<br/>+ the STORED sizer verdict (read free, not re-paid)"] B2 --> B3{"combined band?"} B3 -->|"ok / watch"| B4["No action — Check dispatches"] B3 -->|"oversized"| B5{"splittable?<br/>(no sibling fork — a patch exists,<br/>so the route back is iterate-plan either way)"} B5 -->|"no — patch-only, coherent"| B6["Warn: expect a large patch —<br/>Check dispatches anyway"] B5 -->|"yes"| B7["Remedy: iterate-plan at sign-off,<br/>re-plan lands back at Entry P"] end P4 --> S1["splitter leaf drafts split-proposal.md<br/>(starts from the sizer's proposed_seams, if any)"] A7 -.->|"human or planner runs it directly"| S1 B7 -.->|"iterate-plan archives the attempt first"| S1 S1 --> S2["Human reads the proposal with the planner —<br/>still the same session, for Entry P"] S2 --> S3["pdca split &lt;id&gt; --accept<br/>files one sub-issue per child, or reuses --ids;<br/>materializes child bundles"] S3 --> S4["Parent: close-disposition = split,<br/>routes straight to sign-off"] S3 --> S5["Children: Depends on / Conflicts with<br/>rewritten to real ids, scheduled into waves"]

Entry P is the default; Entries A and B are backstops, not the primary path. The pre-dispatch size guard from step 03 still runs — once at PLANNED before Do, once at BUILT before Check, on the same "splittable?" logic either way — but its job now is to catch a brief that reached the driver without having been split already: one seeded by hand (--from-brief), one planned by a minimal or stub planner, or a session where a human judged it fine at the time and turned out to be wrong. Both backstops feed the same pdca split / --accept mechanics Entry P uses — there's one decomposition path, just two ways of arriving at it.

Within any entry, the same question decides split-or-not: splittable is set membership over the readouts, not the combined band — only if the churn signal or the model's "independently shippable outcomes" verdict is itself oversized, or the patch-size signal isn't the sole thing that fired, is a split even offered. A brief that's oversized purely on predicted patch size — a large but coherent change — gets a different message entirely ("expect a large patch," not "split this"), because splitting a coherent change produces artificial seams, not a real decomposition. At Entry A one question comes first: if the estimate excluded any sibling conflicts — Conflicts with entries naming this bundle's own split siblings, the ordering metadata the splitter wrote — then the size that's left is what the split handed the child (the parent's Difficulty, its dependency tokens, its brief), and the message names that provenance — "scores large for a split child (child 601 of a split of #500, depth 1) — driven by inherited/sibling fields; prefer building over re-splitting" — instead of recommending the split its parent already had. The count is the test, never the presence of a lineage record: a child carries lineage forever, so keying on that would tell a child whose conflicts are all organic that its size was inherited — and the ordinary remedy comes back on its own once the siblings land and those entries leave the brief, with no paid sizer involved. Entry B doesn't ask: a bundle that already has a patch routes through iterate-plan either way, and the re-plan lands back at Entry A.

The two backstops differ because the beat does. At Entry A the bundle is still just a brief, so pdca split runs directly. At Entry B a patch already exists, and splitting means authoring new briefs — Plan's beat, not something you do to a bundle mid-build — so the route back is iterate-plan at sign-off: that archives the rejected attempt (Do's output isn't discarded, the same as any other iterate — below) and returns the bundle to PLANNED, landing right back at Entry P for the re-plan.

The estimate

pdca size [<ids…>] is read-only — it decides nothing, writes nothing, and is safe to run against a live queue at any time:

pdca size 13636        # one bundle
pdca size               # every briefed bundle

Each bundle prints a band (ok / watch / oversized) plus the reasons that fired — the reasons matter more than the band, because "3 conflicts declared" and "predicts a large patch" call for different responses, and a bare band hides which one it was.

The estimate has two independent signals, combined by deterministic code (never a model deciding alone):

  1. Structural — stdlib only, always available offline. A weighted score over five brief features, calibrated against 86 settled bundles of a real instance:

    Feature Weight Why
    conflicts_with 3 strongest churn signal (ρ 0.32)
    difficulty_high 3 Difficulty: high declared
    ext_deps 3 external dependency tokens — highest single-feature precision
    brief_bytes 3 brief size above a 12 KB cutoff
    is_plan_pointer −2 a brief pointing at a host planning artifact converges better — the one de-escalating term

    conflicts_with counts organic conflicts only. Every child of a split declares a Conflicts with entry for each of its siblings because the splitter put it there — those ordering fields between children are the point of a split — so scoring them counted the process's own scheduling metadata as churn, against a weight calibrated over organic bundles. Together with a Difficulty: high inherited from the parent and the parent's dependency tokens copied down, that banded every materialised child oversized (3+3+3 against a cutoff of 7) before anyone read its scope. Ids that are not this bundle's siblings score exactly as before, and a bundle with no split-lineage.json is unaffected. The excluded count is reported on the estimate as sibling_conflicts: not scored, but visible, because children that conflict pairwise are the splitter saying the split separated nothing.

    The score sorts into two separate readouts, because they answer different questions: churn (how many sign-off rounds this will take) and patch size (how big the diff will be). A large-but-coherent change scores high on patch size and low on churn — that's not a split candidate, it's just a big diff — so both bands are reported and kept distinct rather than collapsed into one number.

  2. Model — the optional [leaves.sizer] leaf answers the one question structure demonstrably cannot: how many independently shippable outcomes does this brief describe? Its verdict folds in through escalate-only combination — it can raise a band structure scored low, never lower one a structural signal raised. A model that could downgrade would be a single point of failure over a signal that at least fails predictably. Its prompt is explicit that it only proposes seams — proposed_seams in its JSON verdict — and does not cut them: "the split is authored in PLAN, by the human." When a split does happen, the splitter leaf reads this stored verdict as a starting point rather than re-paying a model to rediscover what the sizer already found — never re-invoked, just re-read.

Nothing here gates, and that's a calibrated decision, not an oversight: the best structural rule reaches 50% recall at 62% precision against ≥3 rounds — nearly one wrong flag for every right one. [driver].size_guard = "hold" is accepted but silently treated as "warn" for exactly this reason (see step 03). Retune the weights and cutoffs per-instance under [driver.sizing] in pdca.toml if your own corpus disagrees with the defaults above.

The thresholds stay honest by review, not by faith. A calibrated number that only moves when someone re-derives it by hand is wrong for a long time before anyone notices — so the loop is instrumented where cross-cycle patterns are already reviewed: pdca act index renders a sizing: line per frozen cycle, the a-priori estimate beside the measured outcome from the bundle's recorded size signal (a blank outcome means the bundle predates the signal — "not measured", never "measured small"). When the two visibly drift, re-run scripts/size-calibrate over the instance and walk its output back into [driver.sizing] — the step-by-step procedure lives in that table's comment block in pdca.toml. Its conflicts_with column is the same organic count the engine scores (both call one exclusion, and the excluded siblings are reported beside it as sibling_conflicts, correlated against nothing), so a retune fits the quantity the weight is actually applied to. The model half of the estimate is covered by the same review: model_weight (how much a sizer-leaf escalation adds to the score, 0 = band-only, today's behaviour) is a [driver.sizing] config value revisited at Act cadence rather than a constant baked into the engine. That revisit has a named blind spot: the index's sizing: line shows the structural estimate only (the stored sizer verdict is not joined in) and size-calibrate mines no model-verdict feature, so whether sizer escalations track real churn is not yet observable from either artifact. Until one of them grows that column, the evidenced Act-cadence outcome for model_weight is "stays 0, gap recorded" — not a retune.

The split

pdca split is the deterministic decomposition path — two verbs, because the second is meaningless without the first — and, per the process above, the planner leaf is normally the one typing both, from inside the Plan session:

pdca split 13636             # drafts split-proposal.md (the splitter leaf)
pdca split 13636 --accept    # files one tracker issue per child, then materializes them

The splitter leaf writes prose into split-proposal.md; everything after that is plain code, no model. Each child is delimited by an HTML comment — <!-- pdca:child child-1 --> … <!-- pdca:end child-1 --> — deliberately not a Markdown construct, because a child's body is a full draft brief and may legitimately contain ## headings, --- rules, or a - **Slug:** line that would collide with any Markdown-shaped delimiter.

--accept with no --ids now files the child issues itself — one gh issue create --parent per child, each a real tracker sub-issue of the parent — rather than making anyone leave the Plan session to file them by hand and come back with the numbers. --ids <id>[,<id>…] still exists for a human who's already filed them, and is required on a tracker pdca can't reach that way (a non-GitHub tracker fails closed with the exact fallback command printed, not a silent skip). Either way, acceptance is transactional: everything is validated before a single issue is filed or a file written — a tracker issue can't be un-created, so filing three of them for a proposal that then fails to parse is the one order that must never happen — and the bundle writes are staged, moved into place only once every child succeeds. The proposal's own Depends on / Conflicts with fields reference siblings by their proposal-local label (child-2); acceptance rewrites those to the real (possibly just-filed) ids, so the batch schedules into waves correctly from the first pdca flow on the children.

Before either shape does anything irreversible, acceptance also prints a convergence report — the one question the checks above never asked: does this split actually make the children smaller? Each child's own body is staged through the same structural estimate a materialised bundle gets (labels standing in for the tracker ids that don't exist yet, including in the lineage record the estimate reads), and the report names each child's band against the parent's and the feature carrying its score, saying so plainly when most children don't band lower. It isn't fooled by a proposal whose children conflict pairwise either: a Conflicts with edge between siblings is the splitter's own statement that those two children edit a shared resource, so a complete set of them is a split that separated nothing, reported as NOT converged even where the estimate excludes those declarations from the band printed beside them. It is strictly advisory — it never blocks and never prompts, exactly like the size guard it mirrors — and its own writes are guarded, so a stream that breaks part-way (pdca split 13636 --accept 2>&1 | head) changes neither the exit code nor which bundles are created.

The parent bundle doesn't just sit there afterward — it's marked via the same close-disposition fast path a duplicate or wontfix uses (close-disposition = split), so it routes straight to sign-off with a build-notes.md explaining the work moved to its children, rather than pretending it still needs a builder. Reopening it (iterate-to-Do) archives the split marker and re-enables the real Do+Check band, same as any other close disposition.

--accept also writes the split's lineage into every bundle it touches — split-lineage.json, the machine-readable inverse of the "Child slice of #N" breadcrumb that otherwise lives only in the filed tracker issue's body. Without it, a split child on disk is indistinguishable from a fresh oversized brief. Each child gets {"version": 1, "id", "parent", "siblings", "depth"} — its own id, the parent's, the other children of the same split, and the parent's own recorded depth + 1, so recursion depth is written down rather than recounted; the parent gets {"version": 1, "id", "children"} merged into whatever it already carried. The merge is the point: a parent that is itself a split child keeps its own parent / siblings / depth and simply gains children. One file, independent optional edges, no role discriminator — a bundle can legitimately be both a child and a parent, and one filename carrying one role could only ever record half of it. It's read back through one tolerant reader, split.read_lineage, which abstains rather than raising on anything it can't turn into a version-1 record: absent, unreadable, malformed JSON, a non-object payload and an unrecognised version all return None. The first three cover any way of failing to parse the file at all, down to bytes that aren't valid UTF-8; the last two parse cleanly and are turned away on shape and version. A depth that isn't a number is in neither group — {"depth": "one"} is valid JSON of the right version, so the reader hands the record straight back, and the depth arithmetic one layer down absorbs the value it can't compute with, counting it as unknown (0) so the child lands at depth 1. The parent's own record keeps "one" verbatim by a separate route: the merge copies an existing depth through rather than recomputing it. Provenance that can throw into a beat is worse than provenance that abstains, so a hand-edited record degrades the hint and never the run. The record sits deliberately outside DOWNSTREAM_OF_BRIEF — it describes the split, not an attempt's output, so an iterate-plan that archives a rejected attempt leaves it alone. It is covered by the same transactional guarantee as the rest: the children's records are staged and moved with their briefs, the parent's is written before the close marker, and a failed accept restores the parent's prior record byte-for-byte (a record that can't be read refuses the accept up front, since one that can't be read can't be restored).

A real split. Wyrd issue 736 — "the S3 gateway advertises no version" — went through four Do rounds as one slice before the planner split it. The proposal (results/issue_736/split-proposal.md) names the seam by where the blocking findings landed, not by byte count: every blocker of rounds 3 and 4 sat in deriving a build identity (a build script, the packaging path, a rerun-watch set), while stamping it on the wire "was never the problem: v4's adversary could not refute its red→green and it passed every rubric every round". Its wave sketch says why the children stack rather than run in parallel:

- `child-2` **depends on** `child-1` because its success criterion is defined *against*
  child-1's artifact: the header's remainder must be byte-equal to the `version` field of
  the `role started` log event that child-1 introduces. Built on a base without child-1
  there is no field to compare against and the header could only go green on a
  placeholder — the exact `wyrd/unknown` hole this ordering exists to close.

pdca split 736 --accept filed the children as tracker sub-issues #778 and #779, rewrote child-1 / child-2 to those ids, and left the parent as a split close-disposition with this build-notes.md:

# Build notes — NO PATCH (split)

This slice was decomposed rather than built. The work lives in the child bundles: issue_778, issue_779.

and this split-lineage.json on the parent ({"children": ["778", "779"], "id": "736", "version": 1}) and on each child ({"depth": 1, "id": "779", "parent": "736", "siblings": ["778"], "version": 1}). Step 03 picks up 779's brief from here, and step 05 shows the two PRs landing as a stack.

That record is what lets a split hand its children back to the run that is driving it. When a bundle in the drive set reaches close-disposition = split, the flow reads its children, drops the ones it can't drive (no brief, already terminal, already in this run), and splices the rest into the waves after the parent's — never the wave being driven, whose fold has a base about to move. From there they are ordinary members of the run: scheduled by their own Depends on / Conflicts with (waves), pointed at the same per-target integration branch, published and folded by the same code, and funded like every other wave from the run's pass pool — which is re-sized when a splice grows the schedule, so a wave the run acquired mid-flight is neither starved nor handed a second allowance. It is one implementation on the one drive path, so it applies to every shape that reaches it — pdca flow <id>, an explicit id list, and the CSV batch alike — and Entry B (iterate-plan at sign-off → re-plan → split) no longer ends in a restart.

The same machinery recovers a split an earlier run left behind. Name a parent that is already terminal on a split — pdca flow <parent-id>, after a crash, a ^C, or a split accepted in another session — and it is still skipped as finished (a terminal bundle has nothing to build, and the non-destructive hint still prints), but it is handed to the run as an adoption seed: its children are spliced in front of the schedule by the same code, under the same guards, with the same announcements. The walk goes through a generation that already closed, so a chain abandoned part-way down (500 → 601 → 701, with 601 itself split) hands over the descendants that are actually stranded. The parent keeps its own disposition in the results map; what a run cannot adopt is named, and stays the operator's pdca flow <child-ids>.

Adoption follows the lineage edge only: a flow drives the children of the bundles it is driving (or was asked to recover), transitively — an adopted child that splits again is adopted in its turn — and never widens into a sweep of results/. What bounds it is adoption itself: a bundle is adopted once, a candidate examined once, and every child is a bundle already on disk, so the walk consumes a finite set however the lineage is edited. The ids you name keep their strict contract — an id list with a cycle or an unresolvable Depends on is still refused up front — while a child that can't be scheduled is held with the reason and left in flight (the resume sweep's tolerance), out of the run's results rather than counted as work it did. That holds whenever the hold happens: a child adopted into a later wave that becomes unschedulable before its wave arrives is dropped back out of the run, and its adoption announcement retracted by name, so "held" always reads the same way. A parent marked split with no readable record is reported and skipped; both holds degrade to the old remedy, the pdca flow <child-ids> command --accept prints — but only when nothing can guarantee more. While a live run holds the split parent right now (driving it, or holding it as a recovery seed, from this session or another shell entirely) — or a live CSV batch has not yet swept its in-flight bundles, which reaches this parent too — --accept prints a condition instead: that a running flow drives the children if it reaches them, and names any it did not when it ends, with the same pdca flow <child-ids> command for use after that run has ended. It never says "will drive". Otherwise — standalone, once the run that held the parent has ended, or for a child a run already let go — the line is unchanged. And right before ANY run ends, it re-checks every split anywhere in its own drive set — including one accepted from another shell on a bundle the run has already walked away from un-terminal (_warn_abandoned above) — and names every child still IN FLIGHT there too, so a split's children are never left silently orphaned once the run that could have reached them is gone. In flight, not merely un-driven: a child an earlier run already carried to COMPLETE (or that was discontinued or resolved) is finished work, so it is passed over in silence rather than handed back with a pdca flow that would re-open something already done. A child that is itself a split is passed over too — but the walk continues THROUGH it, since a split parent is terminal by design and the generation below it may still be sitting where an earlier run left it.

One more thing changes at the reporting end, because adoption puts bundles you never typed into the run's results map and the exit code is derived from all of them: the single-id shape prints its state<TAB>path line for every bundle in that map, the named id first. A run that adopted nothing prints exactly the one line it always did, and one that adopted prints what it did to each child — so pdca flow 500 can no longer report COMPLETE on stdout while exiting 1 because of a bundle it never named, nor return 0 while a child it drove waits at AWAITING_SIGNOFF unmentioned.


Iteration & carry-forward

Step 05 covers the human decision — --iterate-do / --iterate-plan — and the state machine (step 00) shows ITERATE_DO / ITERATE_PLAN as transient states the driver resolves on its own. This section is the mechanics common to every iterate, however it's triggered.

What actually happens on disk

Two things happen, in order, whenever a bundle iterates:

  1. Carry-forward — before anything is archived, the driver appends a new ## Iteration <N> — carry-forward heading to the live brief.md, folding in whatever context is available: the §9 sign-off rationale, and every failing gate line — gating and advisory, since an iterate is often driven by an advisory red rather than a gating one — plus an explicit instruction not to re-attempt the rejected approach unchanged. This is best-effort and never breaks the transition: a bundle with no recorded rationale still iterates, just without extra context.
  2. Archive — the previous attempt's artifacts move into iteration-v<N>/ (never deleted): everything downstream of the brief (patch.diff, build-notes.md, gate results, SUMMARY.md, the rubric snapshot), the advisory review files and error-log tails, and any test file the brief shipped inside the bundle. iterate-to-Plan additionally archives brief.md itself (with the carry-forward note now baked into it) plus the Plan-advisory artifacts, which is what actually drops the bundle back to UNPLANNED for you to re-author.

One deliberate wrinkle ties this back to Size & split: the structural size estimate measures the brief above that carry-forward heading, never the file as it stands after several iterations. Measuring the raw file would leak the outcome into the predictor — an iterating bundle's brief is larger because it churned, not because it started large — so the same heading both mechanisms use is the one place they have to agree.

The iteration budget

[driver].max_passes (default 20) bounds how many build→sign-off passes one wave of a pdca flow run spends before it stops driving — not silently: the bundle is named on stderr with a pdca flow <id> resume hint, and its accepted siblings still publish. Raise it in pdca.toml rather than editing anything.

It also sizes the run's pool: that many passes per wave the schedule holds, read live and therefore re-sized whenever a split splices new waves in. Every wave is funded at the allowance you set and none gets a second one, so a run whose drive set grows neither multiplies your budget nor starves the work it just created — the earlier fixed pool, sized before those waves existed, could abandon a bundle the run had already scheduled, including an id you typed yourself. What bounds a chain of splits is that adoption is finite (a bundle is adopted once, a candidate examined once), not arithmetic that truncates the schedule; nothing gives back what the run has already spent. A run that does spend the pool stops there and says so, naming what it walked away from.

Auto-iterate: the driver deciding for itself

Every implementation defect the reviewer catches — a logic slip, a weak test, a red gate — parks a bundle at AWAITING_SIGNOFF and asks a human to press iterate-do, which is a decision the driver is often positioned to make itself. [driver].auto_iterate (default false — opt in) lets it: while Check's §6 has at least one mechanically-checkable ("IMPL") finding, the driver writes iterate-do and rebuilds, unattended.

The split rides on the same input | gate | judgment tag every §6 item already carries: the gate cells (C2 reproduction, C4 verification, T1–T4 conformance) are IMPL — a rebuild can plausibly fix them; the judgment cells (C5 causal adequacy, T5, the validation act) and the input cells (C1 spec, C3 change) stay HUMAN, as do a gate that couldn't run, an external dependency, an unmarked advisory finding, and anything it can't classify.

A HUMAN finding next to an IMPL one does not stop the rebuild — it's deferred to you. The driver records it in the bundle's deferred-findings.json and merges it back into §6 at every assembly, so it's waiting for you as an open - [ ] item when the loop hands the bundle over, even if no later round's reviewer raises it again. (A single HUMAN row used to veto the rebuild; auto-iterate then fired on only 31 of 230 attempts, and bundles you'd have called broken spent zero rounds.) A §6 with no IMPL item — HUMAN findings only, or nothing at all — halts straight away, since there's nothing for a rebuild to do. The reviewer's Validation — fitness-to-purpose row is neither: it's hard-coded to NEEDS-HUMAN on every cycle, so it's a constant that carries no signal. It isn't deferred, but it's still in every fresh §6 and you still clear it to accept.

One kind of HUMAN row isn't deferred either: a review or a gate that gave no verdict that round — the review missing or left as a placeholder, an advisory leaf's placeholder, a gate that couldn't run. That row is about the Check's own run, not the patch, and every Check runs the review and the gates again, so §6 shows it exactly while the problem lasts. Once a later round's review or gate comes back, the row is gone, and you never clear a failure that has already recovered. The same goes for the note a Check files when it can't pick an advisory reviewer from a different vendor than the builder (under mode = "vendor-complement"): it's about that Check's choice of reviewer, every Check makes that choice again, and §6 shows the note only for a round that wrote it.

The ledger is only emptied by you. If you tick a deferred finding in §6 and then choose iterate-do or iterate-plan for some other reason, that entry leaves the ledger before the attempt is archived; an entry you left unticked (or only edited) stays. So does one whose tick the driver can't pin on that finding alone: a tick on a finding this round's Check raised itself clears only that finding, and an edited row that could be either of two findings clears neither. A row you edited and left open also keeps every deferred finding it could be an edit of, even one you ticked; a row you left open exactly as the driver wrote it keeps only itself. Such an entry comes back unticked next round, so tick it again then. Only the §6 the driver assembled counts — a checkbox a reviewer quoted in §5 is not yours, for a tick here or for an accept. If the ledger file exists but can't be read, §6 says so as its own - [ ] item, and an accept is refused until you clear it.

Exactly two things stop the loop while there's still implementation work:

  • The size backstop — the early stop. At Check the driver measures what the patch actually came to and how many rounds this brief has already spent; past a threshold it raises a size backstop — item recommending iterate-plan. That's the one HUMAN item that stops the loop rather than riding along: it says further rebuilds are the wrong move, so it ends the loop, and the flow names it on stderr. By default it fires at 2 rounds, below the hard cap, on purpose — most bundles that reach a second round go on to a third, and a slice that needs splitting produces implementation-shaped findings every round. The thresholds live once, in pdca.toml's [driver.size_signal] block (set one to 0 to switch that rule off).
  • The hard cap — [driver].max_auto_iters (default 3) automatic rounds per bundle, tracked in auto-iterate.json. It's the ceiling that still holds when the backstop is off or hasn't fired.

There's no third, softer budget. Either way the bundle halts at AWAITING_SIGNOFF for you, with every deferred finding in §6 — never dropped.

Three guarantees hold by construction, worth knowing before you flip it on:

  • It only ever writes iterate-do. Never accept, never discontinue — those stay authored solely by a human's pdca signoff, going through the same C6-guarded decision path either way.
  • It never ticks a §6 box. An auto-iterate archives the whole SUMMARY, unticked, into iteration-v<N>/; the rebuild produces a fresh §6 from scratch, plus the deferred findings.
  • It never archives what it counts or holds. auto-iterate.json and deferred-findings.json are deliberately not archived, so the round count and the deferred findings survive across rebuilds instead of resetting every iterate.

Wyrd runs auto_iterate = true with max_auto_iters = 5, and has switched the size backstop's rounds rule off ([driver.size_signal] rounds = 0) after an Act review found that rule counting rounds lost to a stale host tool as churn (step 06). Step 05 shows a real auto-iterate round on issue 779: two implementation findings rebuilt unattended, the fitness-to-purpose question deferred to the human.


Parallel lanes & housekeeping

Lanes

[driver].lanes (default 1 — strictly serial) sizes a worker pool for the unattended Do+Check band only — Plan, sign-off, publish, and Act stay serial regardless. A pool of N runs N bundles concurrently in one workspace; each worker is pinned to a fixed slot 0..N-1, exposed to every gate command as $PDCA_LANE. Any gate that touches a shared mutable resource — a target checkout it applies/reverts, a container, a port — must namespace that resource by $PDCA_LANE (--name app-l$PDCA_LANE, a repo-lane$PDCA_LANE checkout) or two lanes collide. Override per-run without touching pdca.toml: PDCA_LANES=N or --lanes N on pdca flow. Several standalone pdca flow <id> processes can also share one workspace this way — each auto-claims a free lane via a lockfile, or is pinned explicitly with PDCA_LANE=k.

Those processes never drive the same bundle at once: one live driver per bundle. A pdca flow run claims each bundle before it drives it — the ids you name, each bundle its CSV sweep picks up, each child it adopts from a split — with a lock the OS drops when the run ends, however it ends (a return, an exception, SIGKILL). It keeps the claim for the rest of the run, except on a bundle it then decides not to drive (a named id it skips, a swept bundle held on an unresolved dependency, a child the reschedule holds), which it lets go at once. A second run whose named ids include a bundle a live run holds is refused before it touches anything, naming that bundle; a bundle it would only have reached implicitly (the sweep, an adopted child) is named and left to the run that holds it, and the rest of the run goes on. A claim the run cannot record at all — an unwritable process/, a filesystem without file locks — is treated the same way: the bundle is never driven unclaimed. The claim files live under process/.drive-claims/, outside every bundle, and are gitignored. It is also why a run that walks away from a bundle mid-flight says to resume it "once this run has ended": the command is right, its time is after the run that printed it.

The claim covers driving, not every write to a bundle, and there is one known gap in that: a CSV batch's Plan session runs before the batch claims anything, so the planner in it — or a pdca split --accept it runs — can still rewrite the brief of, or split, a bundle another live run holds. Single-step verbs (pdca run, signoff, publish) take no claim either.

Each cycle's Do+Check runs in a dedicated git worktree off the target's base ([driver].worktree, default true) so the primary checkout is never mutated in place — exposed as $PDCA_WORKTREE, and gate commands should target that, not the primary checkout. [driver].overflow (default 0) caps a pool of throwaway worktrees for the exceptional case: an out-of-cadence gate re-read against a lane another bundle currently owns spins up a fresh throwaway tree instead of clobbering that lane.

Before any lane spawns, the harness verifies the resources they need actually exist — the same per_lane [[doctor.checks]] rows from step 01 run automatically, or a lane_preflight command covers a resource that isn't expressible as a doctor row. Either way, a missing lane aborts the whole run before it produces a pile of false-red bundles, not partway through. Wyrd runs lanes = 3 and needs no per_lane rows: the driver's worktree isolation gives each lane its own checkout of the target (wyrd.pdca-wt-l0 … l2), and the engine's per-fix verifier and coverage runner scope their own worktrees by $PDCA_LANE the same way (wyrd-verify-l<N>, wyrd-cov-l<N>).

Bounding what a leaf may use

A leaf is a model subprocess doing real work in a real checkout, and lanes run several of them at once. [driver].leaf_memory_max (unset by default) caps how much memory each one may use: every leaf the driver spawns — planner, builder, reviewer, advisory, sign-off, publish, Act, headless and interactive alike — runs inside its own transient systemd scope bounded by that value, so a leaf that overruns is killed as itself. It exits non-zero, the bundle records that leaf's failure through the normal path, and the run survives to report it.

Unbounded, it does not fail that way. Two concurrent reviewer leaves doing the independent red→green re-verification the reviewer contract asks for wrote ~69 GB of cold build trees in thirteen minutes; systemd-oomd killed the whole terminal cgroup for memory pressure, taking the driver, both lanes and every bundle with it. Nothing in any gate log said why — oomd kills the cgroup, not the offending process, so the failure surfaced as the driver simply vanishing. This is the third resource the driver bounds, alongside a gate's wall clock ([gates].default_timeout_secs) and the workspace's disk footprint ([driver].sweep_worktrees below).

Processes are swept the same way memory is bounded — automatically, per child (issue #372). Every captured or wall-clock-bounded child runs in its own session, and whatever it leaves running in its process group when it exits — by any path: normal return, timeout, Ctrl-C — is terminated (SIGTERM, a short grace, SIGKILL), with one stderr note naming the command. proc.wait returning only proves the direct child exited; under shell=True — every gate — that child is just the shell, so surviving grandchildren are the rule, not the edge case (measured: a leaked test process burned a full core for 21 hours, and a straggler still holds ports, locks and fixtures when the next cycle's gates run in the same lane worktree). A child that exits clean sees no sweep and no note, and the interactive leaves are never sessionized — they keep the terminal exactly as before.

Two things it deliberately does not do:

  • Unset means unset. With no bound configured the spawn is byte-identical to what it was before the knob existed — no wrapper, no extra process. There is no portable number to default to, and a cap set too low is its own way to kill a run, so size it against your leaves' real peak (a reviewer building a cold tree is the hungriest) rather than optimistically.
  • A host that can't enforce it degrades, it doesn't fail. Where there is no usable systemd-run --user --scope — no systemd, no user manager, some containers, macOS — the bound is a documented no-op: one note on stderr and the leaf runs exactly as it does today. The harness probes the facility before it wraps anything, so a configured bound can never be the reason a leaf won't start. It probes once per run, not per spawn, so a run is either bounded or it is not — never half of each, which is the unattributable state the bound exists to remove.

Any [leaves.*] table takes memory_max to override the driver-level bound for one leaf, or memory_max = "off" to opt that leaf out entirely — the interactive leaves, which a human may sit in for an hour, are the usual candidates. That covers the array-form tables too: [[leaves.advisory]] and [[leaves.plan_advisory]] take their own memory_max (they are the pool a run fans out concurrently, so they are the usual place to spend a smaller cap), and [[leaves.builder_variant]] / [[leaves.builder_escalation]] / [[leaves.sizer_escalation]] inherit the leaf they are a variant of unless they set their own.

Waves in execution

Step 03 covers declaring the shape — Depends on / Conflicts with on the brief. This is what the driver does with that declaration once a batch actually runs: bundles in the same wave build in parallel; each wave's accepted result then becomes the base the next wave builds on, via [driver].wave_mode:

  • "stack" (default, fork-safe) — merges each wave's accepted, published PR branches onto a run-scoped integration branch the next wave builds on, and opens every PR against the real base. Push-only, so it works from a fork with no merge rights, and it keeps STOP discipline — the harness never merges anything into your target; you merge the PR stack bottom-up with a merge commit (never squash or rebase: they put a change on the base as new commits, not the ones its dependents carry). The fold puts every published branch of a target on its line, so a later wave's PR carries every earlier-wave branch on the line, not only its prerequisites', and shows their changes until they merge. Once they have all merged, its diff is its own change when the line is a plain chain: one branch per earlier wave, and no newer base taken in (your base did not move between the wave-0 publishes and the run's first fold, and no fold merged it in — see below). Otherwise a fold merge commit joined histories your base got separately, so the PR has several merge bases with it and can keep showing an already-merged change: use "Update branch" (merge the base into it) and its diff is its own change again. Each run has its own integration line, pdca-integration/<base>-r<key>, where the key comes from the ids the run was asked to drive: two runs on one base that drive different batches never share a line, and running the same ids again gets the same line back. Within a run the line only grows: the run's first fold starts it fresh from the base with a force-push (if your origin refuses force-pushes, delete the old pdca-integration/<base>-r<key> first, or allow force-pushes on pdca-integration/*), and later folds continue it without force — and stop the run if something else moved it. Commits pushed onto a stack PR's branch after a fold carried it reach the line at the next fold only when they extend it (a fast-forward; GitHub's merge-style "Update branch" is one). If the branch was rewritten instead (re-published with pdca publish, force-pushed, or updated with "Update with rebase"), the next fold stops the run before it merges or pushes anything. Restore the branch so it contains what the line took from it and re-issue the run, or let the run stop and re-issue it. A rewrite after the run's last fold read the branch (during that fold, or while the last wave builds, publishes, or Act runs) has no next fold to stop: the run checks every branch it carried once more at its end, names each rewritten one on stderr with the commit the line took from it and what to do, and exits 1. A branch that check cannot judge (say the fetch of its checkout fails) is named too, with that commit, for you to check by hand, and the run exits 1 as well; the other branches are still checked. A branch deleted by then is not judged there. Both checks fetch while no integration lock is held, so another git process in the same checkout (a held ref lock) can fail the fetch: it is tried twice, a second apart, before it counts as failed. If a PR merges and its branch is deleted, the fold looks up the commit the PR merged with: a line that has it already carries the PR; one that does not (say a fixup was pushed after the fold) merges your base in, after checking that your base has that commit. If it does not (the PR merged into another branch, or was squashed or rebased), the run stops rather than build the next wave without that work. It also stops, naming the bundle, when merging your base in would undo work a bundle put on the line (say your base reverted a merged PR, so a file the bundle added would disappear); a base that only edits such a file is merged in as usual. The fold makes the same check when it merges a bundle's own PR branch: a branch cut from your base after such a revert, or updated from it with "Update branch", carries the revert, so merging it would undo that work. The run stops and names the file, the bundle whose work it would remove, and the bundle whose branch brings the change (the pre-wave carry described below leaves just that bundle out instead). If two bundles' branches share the commit that made that work, git cannot tell which of them made it, and the message says so. A change the branch's own commits make is merged as usual, such as a dependent that deletes or edits that file on purpose. Both checks compare each file with how it was where the line leaves your base, so they miss a partial revert inside a file the line also changed in other ways, and a revert of work your base already has. Once a later wave's PR has merged, your base has the line's earlier waves too: that PR brings the line commits under it. Check for such reverts yourself. An accepted bundle whose publish pushed no branch holds only the bundles that depend on it or share a Conflicts with edge with it (declared on either side): the run ordered those after it to build on its work, which no line of the run now holds. The rest of the run goes on. When you publish it later (pdca publish <id>), its PR branch is cut from the line commit it was built on, so it carries no later wave's work; if a later run has replaced the line by then, publish refuses and pushes nothing — re-drive that bundle in a new run. Re-run a held Conflicts with partner only once that PR has merged into your base: the carry described below starts only from a plain Depends on, so an earlier re-run can build the partner without that work.

    Two bundles of one wave whose branches conflict (they change the same lines, and neither declares Conflicts with the other) do not stop the run either. The fold merges a wave's bundles in name order; a bundle whose branch merges cleanly onto the line as the wave found it, but not on top of the ones of its wave merged before it, is left out of the line for the rest of the run. The bundles that depend on it or share a Conflicts with edge with it are held, as above, and the rest of the run goes on. The run names the pair and the files when it happens and again at the end, with a step for you to confirm: declare Conflicts with: <the other id> in the held bundle's brief, then rebuild it on a base that holds the other one. Re-run a held partner only once that rebuilt bundle's PR has merged into your base. A branch that conflicts with work an earlier wave put on the line still stops the run, and so does one that conflicts only with a newer base the fold merged in during the wave (a PR of the wave merged and its branch was deleted meanwhile).

    A held bundle is still COMPLETE, so the results pdca flow prints list it as COMPLETE. The exit code tells the two runs apart: a run in which every bundle finished, but a fold held one or more of them out of the line for either reason above (it pushed no branch, or it conflicts with a bundle of its wave), exits 3, not 0. That holds with one id too (a run over a split parent folds between the waves of the children it adopts), where a bundle stopped for sign-off still counts as finished. A bundle that did not finish still exits 1, which wins over 3 (a held bundle's dependent is left unbuilt, so a hold with a dependent in the run exits 1). So 0 means every bundle finished and none was held out of a fold the run performed. An accepted bundle of the last wave that pushed no branch is not covered: no fold runs after it, so the run holds nothing.

    To recover a run that stopped part-way (an integrity stop, the pass budget, a crash, Ctrl-C), re-issue the same pdca flow <ids>. The ids the earlier run finished are skipped, and if a bundle the new run drives names one of them in its plain Depends on, the run first carries the finished prerequisites of that target onto the line, before wave 0, and points that target's bundles at it. It continues the line already on origin, without force, when every commit on it is accounted for by your base or a finished prerequisite's PR head, and starts the line when origin has none. Only a clean prerequisite is carried: its PR is open (or merged, with its head already on your base or the line), it merges onto the line without error, and, with [driver].regate_between_waves, the carried line's re-gate is not red. A finished prerequisite that is not clean holds the bundles whose plain Depends on names it, and is named on one line with the reason and what to do; the rest of the run goes on. One judged not clean before the fold, from its PR state and head, also holds the finished prerequisites built on it (their plain Depends on names it, directly or through others), since their branches carry its work: none of them is carried or accounts for a commit on the line, and the bundles that depend on them are held, each named on one line with the cause. A prerequisite whose merge fails during the fold (a conflict, a branch that is gone, or any other failed merge) also holds every finished prerequisite whose branch has any of its commits, with a Depends on edge or without, merged before the failed one or after, and in turn every one whose branch has a commit of one held this way. A prerequisite's commits are those on its branch or its PR head that neither your base nor the commit the fold started the line from has. Each one held is named on one line with the prerequisite whose commit it has as the cause, and the bundles that depend on it are held too. So is one whose own merge fails as well, when the failed one whose commit it has was asked for before it: a commit two failed ones share counts for both, so the one asked for first is the cause. The fold takes them off the line before its one push, so the carry adds none of their commits to the line. A prerequisite whose merge fails because its branch conflicts with the line (both change the same lines) is named with what it conflicts with, so a re-issue after a same-wave conflict hold gets the step the earlier run gave at its end. The run judges it once the fold is done, on the line it pushes. It first tries the branch on your base alone. If that conflicts, the line names your base and the files: update the branch from your base, resolving the conflict, or re-drive the bundle. Otherwise the run tries it on your base plus each finished prerequisite the fold carries, whether the fold merged it (before that one or after) or the line already had it, in whatever order you asked for them. Each one it conflicts with is named with the files, with the step for a same-wave conflict: declare Conflicts with: <those ids> in its brief, then rebuild it on a base that holds them. A prerequisite built on another is named only for its own change, not for the one it carries from the other: the branch is first merged onto what that prerequisite was built on, its side taken wherever the two clash (a file one of them deleted included), and the prerequisite is named, with the files, only when its own change then conflicts. One that is held as well is never named. If none of them conflicts with it and the branch still does not merge onto the line, the line names the files and says the conflict is with other work on the line, and proposes no Conflicts with. If the branch does merge onto that line, the line changed after it was tried. When prerequisites that were on the line then were taken back off it (ones with a failed one's commit, as above), each such one it conflicts with is named with the files and said not to be carried, with the same step for once a line carries it; when none of them alone accounts for the conflict, the line says to re-issue once they are carried or out of the batch. When none was taken off, a later merge changed the line, and the line says to re-issue: the carry tries it again there. Which of the two is carried does not change: one already on the line stays, else the one asked for first. If the line on origin holds a commit the run cannot account for, nothing is carried onto it and those bundles are held: delete that line on origin (unless the run's own fold already replaced it) and re-issue. A line the run did not carry onto is still replaced by its first fold, as above. Known limits, each of which holds the dependents (or the publish) until you act: a prerequisite squash- or rebase-merged with its branch deleted; a closed PR that a newer PR replaced; a prerequisite with no recorded PR URL; a rejected or force-pushed-over commit left on the old line; two finished prerequisites that conflict with each other (named, with the step, as above); an old line that still holds a prerequisite whose PR was closed since; and an accepted but unpublished bundle whose line a later run's fresh fold replaced (publish refuses it, as above). The hold for a failed merge can be wider than needed, and it leaves one gap: a prerequisite is held for a commit its branch shares with the failed one even when that commit is its own (say the failed one was built on it); one whose merge failed only because of a prerequisite the fold then took off the line stays held (named, as above); two that conflict with each other, one having the other's commit, can both be held; and on a line continued from an earlier run, the failed one's commits that run already put on the line stay there.

    A plain Depends on prerequisite that is not one of the ids the run was asked to drive (a pdca flow --from-csv sweep never includes a finished bundle; a pdca flow <ids> may not name it) is on no line of the run. Its dependent waits until that prerequisite's PR merges into the dependent's own target base, the repo and branch of its Repo + branch target. A PR that merged into another branch (an integration line, say), an Onto branch commit, or a PR whose state cannot be read (gh missing or failing) does not count. The dependent is named on one line with the prerequisite and what to do, and the rest of the run goes on: name the prerequisite in the same pdca flow <ids> command so the run carries it onto its line (as above), or wait for the merge and re-run. Nothing is held for a prerequisite with no patch or no usable target, for a dependent with no usable target of its own, with --no-publish, or in a dry-run. Depends on (merged) counts a merge the same way: a prerequisite the run does not drive must have merged into the dependent's base (a dependent with no usable target still takes a merge into any branch).

    Once the run has a line for the dependent's target (its first fold, or the carry above), the dependent is cut from that line, not from the base, and the line does not follow the base: a merge into the base after the line was started may be missing from it. For either edge the dependent then also waits until the line has the prerequisite's work: the commit its PR merge put on the base (gh pr view --json mergeCommit; a merge, squash or rebase merge all put one there), or the PR's head, which the carry puts on the line when it carries that prerequisite. A merge commit that cannot be read, or a checkout that cannot be fetched, holds it too. It is named on one line with the line's tip, and the rest of the run goes on. Re-running alone may not help, and that includes a re-run after waiting for a merge as above: a re-run whose carry finds the line on origin continues it, and holds the dependent this way. So once the run ends, delete that line on origin, then re-run: with no line there, the re-run starts from the base, which has the merge. A target with no line yet is judged as above.

  • "merge" (own-repo / continuous-delivery only) — actually gh pr merges each non-final wave's PRs, so the next wave builds on a genuinely merged base. Needs merge rights on the base remote and relaxes STOP discipline for the batch; fails closed if a PR turns out non-mergeable. [driver].merge_method picks the merge strategy ("merge" / "squash" / "rebase"). Before each merge the driver reads that PR's full check rollup itself — after the ready-mark, immediately before merging — and refuses (the run STOPs) on any failing check, any still-running one, and on a rollup with nothing in it at all; gh pr merge alone would only refuse what this host marks required in branch protection, so without that read a thin protection config lets the next wave build on a base that never went green. A green read is confirmed by a second read one poll interval later, and that read must list the same checks (each name as many times), so a rollup whose set of checks changed in between is not a confirmation. The rollup lists only the checks registered so far, so a slow job that has not registered yet cannot be seen: name such checks in [driver].expected_checks and a green read that lacks one counts as still running. With nothing declared (the default), a job that registers more than a poll interval late still gets through. A rollup only describes the head it ran on, so the driver first checks in git whether the PR's head is behind its base (an earlier PR of the same wave just merged): git merge-base --is-ancestor after a fetch. If it is, the driver updates the PR's branch with a merge commit of the base (gh pr update-branch, never a rebase), waits for the update to land and for the new head's rollup, and merges pinned to that head (--match-head-commit). A failed update, a red updated head, or a head or base that moved stops the run. Turn on "require branches to be up to date" in the base's branch protection: the host then also refuses a merge if the base moves after the driver's last read. [driver].merge_requires = "required" opts back into host-config-only semantics, and skips the behind check and the update too. Under either setting, when gh pr merge fails on a PR the host reports BLOCKED that has unresolved review threads or a review that requested changes, the run stops naming them, each thread with its file and line, and returns the PR to draft; because BLOCKED does not say which rule blocks the merge, the stop also shows the PR's reviewDecision, and merge-remarks.json in the bundle records that and the host's merge error beside the remarks (the bundle's next merge attempt removes the file first). Under either setting, before it readies a PR the driver reads the PR's base on the host and compares it with the bundle's own target base (the Repo + branch target publish used). If they differ, the driver retargets the PR (gh pr edit --base <target>) and reads the base again. It goes on only if the host now reports the target base; otherwise the run stops, naming both branches, and the PR is left draft or ready, as it was. Under "all" every later read of the base must still find the target base, so a base edited on the host while the checks are read stops the run too, with the ready-mark undone. A target base that another bundle of the batch produced stops the run before any retarget (see below). Merge mode does not support a Merges with group yet (an atomic merge group, step 03; issue #778): it merges each PR on its own, so one member could land on the base without the rest. So it builds no group member the run can see, meaning a bundle in the run whose brief declares Merges with, or that another brief in the run names there. That holds even when the whole group would sit in the final wave, which the driver does not merge: a later merge-mode run can still pick up its open PRs one at a time. pdca flow <ids> exits 2 before building anything, naming the group and the two ways out: set wave_mode = "stack", or fold the group into one bundle. The --from-csv sweep holds the group and its dependents and drives the rest. Bare pdca waves refuses (unschedulable:, exit 1) where the sweep holds, as it already does for an unresolved Depends on. Two gaps remain, both in issue #778: a group declared only in the brief of a bundle outside the run is not seen, and a split does not carry Merges with to its children. Declare it on every member.

In merge mode the merge is unattended, so publish refuses to open a PR against any branch this run produced — the work must land on a base that exists independently of the run, or the run stops and says so. Two shapes get refused, both naming the branch the PR would have targeted and the target base it should have used: a base that came from the auto-stacked chain (a Stacks on: prereq's fix branch or a recorded integration branch), and a brief whose Repo + branch target itself names a branch another bundle in the batch produced — the chained-brief practice that is right under "stack" and wrong here, where wave order already carries the dependency. Fix the brief's target (or go back to "stack") and re-publish. The same rule is checked again at merge time, for a PR that never went through this publish check (one an earlier run opened, or one whose base was edited on the host since): a target base another bundle of the batch produced stops the run, and a PR on any other base is retargeted to its bundle's target base before it is readied and merged. That is the one retarget the harness makes: of a PR it is about to merge in merge mode, to the target its brief names. It never changes a brief's target.

[driver].regate_between_waves (default false) optionally re-runs your repo-scoped gates over each folded integration tip before the next wave builds on it — catching a combination that's red even though every fix in it was green alone. Wyrd runs wave_mode = "stack" with merge_method = "merge" for when a human merges the stack — again since 2026-10-01, after two months in "merge" mode in which one red check on a non-final wave's PR stopped the whole run at that wave boundary (its docs/INTEGRATION.md §2 keeps the history). The 778 → 779 pair in step 05 ran this way: 779 was built on the run's integration line pdca-integration/main-r6da1b5689252 (recorded in the bundle's stack-base file), and its PR opened against main, stacked on #885.

Housekeeping: sweep & cleanup

Two maintenance commands, on two different axes — one is disk footprint, the other is tracker sync — and neither should run mid-flow (both can race a live session):

pdca sweep reclaims the harness's own worktree/build footprint — lane worktrees, integration worktrees, orphaned overflow trees — never bundle artifacts, never the primary checkout. It runs automatically at the publish/freeze boundary (once a run's waves complete) and on demand:

pdca sweep             # per [driver].sweep_worktrees
pdca sweep --dry-run    # report what would be reclaimed, touch nothing
pdca sweep --remove     # remove lane worktrees too, not just their build state

[driver].sweep_worktrees sets the automatic behaviour: "clean" (default — strip build state via git clean -fdxq + reset --hard, keep the checkouts warm; remove integration/overflow trees outright since they never get reused anyway), "remove" (lane worktrees go too — Do / pdca try recreate on demand), or "off" (never sweep automatically; pdca sweep still works explicitly). This exists because it's a real failure mode, not a hypothetical one: a long-running instance let its lane build dirs (target/, node_modules/, …) accumulate past 200 GB, and gating gates started false-redding with Disk quota exceeded — an environment fault that read, at first, like a real regression in the patch.

pdca cleanup reconciles bundle state against the issue tracker instead — a different drift entirely: an issue closed by decision in-thread while its bundle still sits pending, a bundle frozen COMPLETE while its issue stays open, a PR merged while sign-off is still outstanding. Dry-run by default; --apply executes:

pdca cleanup            # report every discrepancy, change nothing
pdca cleanup --apply    # execute the matched actions below
Local state Remote state Action
briefless (notes-only) issue CLOSED writes notes.json's resolved record → bundle reads RESOLVED (step 00)
AWAITING_SIGNOFF issue CLOSED records §9 discontinue (the same primitive as pdca signoff --discontinue)
mid-flight (PLANNED/BUILT/CHECKED/iterating) issue CLOSED report only — fabricating a §9 for in-flight work isn't auditable
not COMPLETE PR MERGED report only, always — never auto-writes an accept past the C6 guard
COMPLETE split parent (close-disposition = split) issue OPEN checked first, ahead of the PR rows: report only while any child issue is open or its state can't be read (or the lineage record's children list is missing, empty, or has an entry that is not an issue number — close it by hand); once every child issue is closed, comments (naming every child) + closes as completed if at least one child was completed, else not planned
COMPLETE with a merged PR issue OPEN comments + closes the issue
COMPLETE (close/no-fix) or DISCONTINUED issue OPEN comments + closes the issue
COMPLETE with an unmerged PR issue OPEN report only — stays open until the PR actually merges

This is where the RESOLVED state from step 00 actually gets written in practice — Plan never fabricates it, pdca cleanup is the one command that does, and only for a bundle that never entered a cycle. Every write here goes through the same three primitives regardless: the notes.json merge, signoff.record + a normal driver run, or a gh issue comment/close — nothing here invents a fourth path to a verdict. gh missing or unauthenticated aborts the whole command before any write.


You now have the full picture: the linear cycle from step 00 through step 06, plus the three mechanisms that cut across it. From here, render the harness into your own project if you haven't, or go back to the reference spec for the full reasoning behind any of it. Every command this guide mentions is listed, with its options and exit codes, in the command reference.

← 06 Act · Index · next: 08 Command reference →