lifecycle-propose
The coxswain dispatches lifecycle-propose whenever a ticket needs to move state: open to in-progress, in-progress to review, review to done. It proposes the transition and the evidence that justifies it; the board's single writer applies it. What lands is a proposed state change, never a direct write to the tracker.
lifecycle-propose
Takes one task, produces reviewed work and proposals. Nothing is pushed, opened, or merged. The build node returns a patch; applying it is the shell's job, inside a worktree the shell owns.
flowchart LR
n0["scope<br/>step · 1"]
n1["plan<br/>step · 2"]
n2["plan_alternative<br/>step · 3"]
n3["plan_arbitrate<br/>step · 4"]
n4["plan_adversary<br/>step · 5"]
n5["build (worktree)<br/>step · 6"]
n6["handoff<br/>step · 7"]
n7["review<br/>step · 8"]
n8["adversary<br/>step · 9"]
n9["arbitrate<br/>step · 10"]
n10["emit<br/>step · 11"]
n0 --> n1
n1 --> n2
n2 --> n3
n3 --> n4
n4 --> n5
n5 --> n6
n6 --> n7
n7 --> n8
n8 --> n9
n9 --> n10
style n0 fill:#f96,stroke:#333,stroke-width:2px
| Node | Step |
|---|---|
scope |
1 |
plan |
2 |
plan_alternative |
3 |
plan_arbitrate |
4 |
plan_adversary |
5 |
build (worktree) |
6 |
handoff |
7 |
review |
8 |
adversary |
9 |
arbitrate |
10 |
emit |
11 |
lifecycle-propose — specification
The development loop. Takes one ticket, produces reviewed work and proposals. Nothing is pushed, opened, or merged.
Nodes (v0 cut — keep every node that reads the cartridge, drop every node that only reads other nodes):
| Node | Role | Tier | Writes |
|---|---|---|---|
scope |
scope_epic |
standard | none (read-only, optional role) |
plan |
plan |
standard | none (read-only) |
plan_alternative |
plan_alternative |
standard | none (optional; runs only with plan_arbitrate) |
plan_arbitrate |
plan_arbitrate |
deep | none (optional; picks a plan or merges, names the price) |
plan_adversary |
plan_adversary |
deep | none (optional; attacks the chosen plan's claims; one revision at most) |
build |
build |
standard | its own worktree only |
review |
review_charter |
standard | none |
emit |
— | — | proposals as data |
scope runs first and only if the team bound scope_epic — unbound means absent,
which is what an optional role means. It applies the cartridge's epic_threshold
to decide epic / parent-with-subtasks / single ticket, routes the result through
work_routing, and emits a item_create proposal carrying both decisions as
evidence. Scoping is a separate act from filing, and it runs before planning
because it decides whether this is even one ticket.
The table says which node writes what. What it cannot show is where the write actually happens — the build node produces a patch and applies nothing, and the shell applies it, on the far side of the gate:
flowchart TB
TICKET["ticket, an argument"] --> SCOPE
subgraph GRAPH["the graph: pure, no disk, no clock"]
SCOPE["scope<br/>role: scope_epic, standard<br/>epic_threshold and work_routing"]
PLAN["plan<br/>role: plan, standard"]
ALT["plan_alternative<br/>role: plan_alternative, standard<br/>told to differ"]
PARB["plan_arbitrate<br/>role: plan_arbitrate, deep<br/>first / second / merged"]
PADV["plan_adversary<br/>role: plan_adversary, deep<br/>attacks the plan's claims"]
BUILD["build<br/>role: build, standard"]
FACTS["change_facts<br/>counted from the patch,<br/>never asked of the model"]
HANDOFF{"handoff<br/>does build's output contain<br/>what review needs?"}
REVIEW["review<br/>role: review_charter, standard"]
ADV["adversary<br/>role: review_adversary, standard"]
ARB["arbitrate<br/>role: arbitrate, deep"]
EMIT["emit"]
STOP(["graph stops"])
SCOPE --> PLAN
PLAN -- "both roles bound" --> ALT
ALT --> PARB
PARB -- "the chosen plan, verbatim" --> PADV
PARB -- "adversary unbound" --> BUILD
PLAN -- "unbound" --> PADV
PADV -- "proceed, or revised once<br/>by the plan's author" --> BUILD
BUILD -- "unified diff, returned not applied" --> FACTS
FACTS --> HANDOFF
HANDOFF -- "incomplete" --> STOP
HANDOFF -- "complete, with a small brief" --> REVIEW
REVIEW -- "review_tier 0" --> EMIT
REVIEW -- "review_tier 1 or more" --> ADV
ADV -- "agreed, tier 1" --> EMIT
ADV -- "disagreed, or tier 2" --> ARB
ARB --> EMIT
SCOPE -. "item_create proposal" .-> EMIT
end
EMIT -- "proposals" --> POLICY{"autonomy_policy<br/>has this kind graduated?"}
POLICY -- "propose" --> GATE{{"human gate"}}
POLICY -- "auto" --> ARM["apply arm, a role"]
subgraph HARNESS["harness: the only side effects"]
APPLY["git apply, in a worktree<br/>the harness created"]
RECORD["build_manifest, then record_run"]
end
GATE -- "approved" --> APPLY
ARM --> APPLY
GATE -- "every decision" --> RECORD
APPLY -. "never" .-> PUSH["push, open a PR, merge"]
style PUSH stroke-dasharray: 5 5
Two things the node table cannot show. The dashed edge is the point of the
graph: a draft PR is emitted as a proposal and never executed, and nothing is
pushed or merged by any path. And handoff has an edge that leaves the graph
entirely — an incomplete handoff stops the run rather than letting review form a
confident opinion about a half-finished change.
How many reviewers a change gets is review_tier's decision, not the author's:
tier 0 is reviewed once, tier 1 gets an adversary, and tier 2 arbitrates whether
or not the two agreed. No path skips review.
Solutions compete before build
The reviewers after build judge one diff. They can say ship, revise or
reject, and nothing else; a different design only appears if the one planner
thought of it. The three optional roles between plan and build are where
alternatives get a hearing, and they sit there for a reason of cost: a plan
node finishes in about ten turns for a tenth of a build, and comparing two
short plans is a small document where comparing two diffs is not.
plan_alternativewrites a second plan, shown the first only so it can avoid repeating it. It is told to differ, not to critique, and it works on a thread of its own, never the first planner's — independence is the whole value.plan_arbitratepicksfirst,secondormerged, and names the price. A pick hands the source plan to the builder VERBATIM; what was compared is what gets built, and theplanfield is not required on a pick. Only a merge is the arbiter's own plan, and amergedthat names no plan stops the graph: neither planner's plan is built under the arbiter's name. The arbiter, too, keeps a thread of its own. The competition runs only when both roles are bound: an alternative nobody judges is a budget spent on a plan nobody builds.plan_adversaryattacks the chosen plan's claims — a file it assumes, a signature it assumes, a step that cannot be checked without doing the next one — while an objection still costs one more plan instead of a rebuild. It gets ONE revision, by the plan's AUTHOR on the author's own thread, with the objections verbatim: the first planner when its plan won or no competition ran, the second planner when the arbiter chosesecond, and the arbiter itself when the plan is a merge, since neither planner wrote that one. A planner is never handed another's plan and told it is its own. The revision is not attacked again: the review round after build judges it, and a loop here would be a second fix loop with none of the first one's accounting.
The record keeps the loser, the choice and its price under
plan_competition, and the attack and whether it revised under
plan_attack; the draft-PR proposal carries both as evidence rows, and only
when they ran.
The fix loop is bounded, and it counts
A change the reviewers sent back goes back to the builder with the critique attached — the charter findings, the adversary's objections, the arbitrator's reasoning — and the instruction that every standing objection must actually fall. The retry is then reviewed under exactly the same rules as the first try: facts recounted from the new patch, handoff re-checked if it is bound, tier recomputed, the same reviewers at the same tier. A cheaper second pass would be a way of grinding a change past its reviewers, which is the thing this loop must not become.
It is bounded three separate ways, and each stop is recorded by name:
| Stop | When | Why it is a stop |
|---|---|---|
no_progress |
successive patches are ≥ 0.98 similar (difflib.SequenceMatcher) |
Re-submitting the same diff is not a fix; it is shopping for a verdict, and eventually one reviewer says yes. The near-identical patch is never reviewed. |
objection_standing |
a retry's adversary raises a claim already standing (matched case-insensitively, stripped) | Re-litigating an objection is not progress. A retry that instead ends in approve means the reviewers, shown the standing objections, judged them fallen. |
attempts_exhausted |
fix_attempts additional attempts (default 2) produced no approval |
A cap that can be argued with is not a cap. |
budget |
a retry build's RunnerError carries error_max_budget_usd |
The attempt is spent but returned no patch. build and review keep describing the last patch actually reviewed rather than lose it to an exception. |
The similarity check is difflib — pure, no disk, no clock — so it stays inside
the graph rather than becoming another thing the shell has to do.
And the loop refuses to hide the count. fix_loop.attempts is on every run;
a proposal that took more than one attempt carries attempts and an evidence
row saying which attempt approved it. A task that passed on the third try is not
the same evidence as one that passed clean, and the difference has to survive
the trip downstream: the policy in the substrate is what refuses to let a
repeated-attempt pass extend a streak, and it can only refuse what it can see.
This graph's job is not to enforce that rule — it is to never quietly make the
record look better than the run was.
Args: run_id, date, cartridge (resolved, required, no fallback),
ticket, optional fix_attempts (default 2; 0 disables retries), optional
worktree_root (else cartridge.landing_areas.worktree_root).
Returns: {run_id, date, ticket, plan, plan_competition, plan_attack, build,
review, change_facts, fix_loop, proposals[]} — plan/build/review hold the
final round's values; plan_competition and plan_attack are None when the
roles are unbound.
Formerly deferred, now landed elsewhere: the adversarial reviewer pair and
arbitration live in this graph; verification became the harness's check arm
(--repo applies the patch in a real worktree and runs landing_areas.checks,
attaching machine evidence before the gate); retro is its own graph
(graphs/ops/retro-propose.md); and intake is a queue directory the
coxswain driver drains (graphs/ops/coxswain.md). The ticket still
arrives as an argument — a graph that read a queue could not be replayed.
Staging a draft PR is emitted as a draft_pr_create proposal and never
executed by this graph.
Epic-threshold scoping was on that list and is now implemented — epic_threshold
and work_routing were declared in the base cartridge and read by no code,
which is exactly the drift the cartridge seam exists to prevent.
Status: implemented in lifecycle_propose.py. The
build node returns a unified diff and applies nothing; shell.py applies it in
a worktree it owns, and only after the gate approved the work.