retro-propose
The coxswain dispatches retro-propose once an epic closes: it reads the run records for every ticket in the epic and drafts what went well, what slowed the epic down, and what to change. What lands is a draft retro for a human to accept or edit, never a retro posted on the human's behalf.
retro-propose
A retro over a running system is not a model asked "how did we do" from memory — memory is exactly what the ledger exists to disbelieve. So the stats this graph reasons about are computed HERE, mechanically, as pure dict arithmetic over the rows the harness read: per (kind, subject) bucket, the outcome counts, the current consecutive-clean streak (a clean that took more than one build attempt is transparent, same rule the policy itself uses so a struggling loop cannot manufacture the record it stands on), the reversal count, and how many cleans were not first-try.
flowchart LR
n0["stats<br/>step · 1"]
n1["retro<br/>step · 2"]
n2["emit<br/>step · 3"]
n0 --> n1
n1 --> n2
style n0 fill:#f96,stroke:#333,stroke-width:2px
| Node | Step |
|---|---|
stats |
1 |
retro |
2 |
emit |
3 |
retro-propose — specification
The ledger reads back. A retro that reasoned from memory would be exactly the self-report the ledger exists to disbelieve, so this graph proposes nothing it cannot point at a row for.
| Node | Role | Tier | Notes |
|---|---|---|---|
stats |
— | — | deterministic bucket arithmetic over the ledger's rows |
retro |
retro |
deep | reasons only about buckets already established as fact |
emit |
— | — | doc_update proposals, cited and substantiated |
Args: run_id, date, cartridge (resolved, required, no fallback),
ledger_rows (the parsed ledger, required — may be an empty list), optional
max_proposals (default 5).
Returns: {run_id, date, observations[], proposals[], totals}
Ledger rows arrive as an argument, the same as every other graph's inputs. The
harness reads the file the ledger_rows need names and hands the graph parsed
rows; the graph never reads its own trust record — a proposal about the
ledger produced by code that also reads the ledger live is not an independent
check.
Why the stats are not asked of the model
stats runs before the model sees anything, per (kind, subject-or-None)
bucket: outcome counts, the current consecutive-clean streak, the reversal
count, and how many cleans took more than one build attempt. The streak
follows the exact rule core.policy uses to decide graduation — a clean that
took several attempts is transparent, proving the loop converged rather than
that the kind is trustworthy first-try — so retro's idea of "the current
streak" never disagrees with the policy that actually spends it.
The retro node then reasons only about numbers already established as fact.
Its answer is graded on the way out: every observation and every proposal
carries cites, naming the bucket key(s) ("<kind>|<subject-or-->") that
support it. A cite naming a bucket the stats do not contain is refused as a
fabricated citation. A proposal with zero cites is refused outright —
retro's whole discipline is that claims cite rows, and a claim that cites
nothing is a guess with formatting. The graph then builds each proposal's
evidence from the stats for the cited bucket(s) — the model cites, the
graph substantiates.
Zero ledger rows returns {observations: [], proposals: [], totals: {rows: 0, ...}}
with no node call. A retro over nothing has nothing to learn.
Requires the optional retro role. A team that has not bound it is told
so, rather than silently getting a graph that runs and finds nothing.
Caps: at most max_proposals proposals emit; the rest are counted in
totals.deferred_overflow, never dropped. Fabricated-citation and zero-cite
refusals happen before the cap is applied — a proposal has to be honest before
it gets to compete for a slot.
Scope limit, stated honestly
docs/GRAPH-CONTRACT.md's proposal shape requires a risk, read off the
cartridge's write-kind taxonomy. In the base taxonomy
(cartridges/base/cartridge.yaml), charter_proposal and skill_proposal
declare no risk — they are {ramp: never, apply_arm: pr} and nothing
else — and graphs._contract.proposal refuses to build a proposal for any
kind without one. That is precisely the finding retro would sometimes want to
make: "the charter got this wrong," or "a skill needs rewriting because of
what the last forty runs show."
Rather than inventing a risk at the node — the exact move the contract exists
to forbid — this graph emits doc_update proposals only in v1, and reports
any charter- or skill-level finding as an observations entry instead: cited
the same way a proposal is, but data, not a proposal, and not routed through
the gate. This is emission blocked on the taxonomy carrying a risk for
charter_proposal/skill_proposal, not a judgment that those findings
don't matter. The day the taxonomy grows a risk for them, observations
graduates the same finding shape into real proposals with no change to how
the node reasons — only to what emit is allowed to do with the answer.
Status: implemented in retro_propose.py.