Overview
Cut #1 gave froot a loop registry: the spine reads a LoopSpec per loop instead of branching on the Loop enum. It covered the three acting loops — dependency-patch, security-patch, dead-code — that propose a PR the spine gates and merges.
froot has a second family too: the advisory loops (determinism-review, a11y-review) that scan a repo's open PRs and leave one decaying comment, never a merge. This change makes the registry speak both families. It restructures LoopSpec into a discriminated union — a shared core plus a CommitTail or an AdvisoryTail — so a loop's disposition is derived from its tail's type, not stored beside inert fields of the other family. Then it registers the two advisory loops behind AdvisoryTail.
Blast radius: the registry, the three acting loop modules, their call sites, and the Loop enum. One unrelated fix rides along — a decay edge in the determinism reviewer's comment. The advisory specs are registered but nothing reads their AdvisoryTail yet; the dashboard derivation that consumes them is the next chained PR. So this PR's advisory registration is well-tested scaffolding, load-bearing next.
The discriminated union
LoopSpec is now a shared core (the loop key, the dashboard icon) plus a tail that is one of two frozen dataclasses. The tail's TYPE is the discriminant, so disposition is a derived property — there is no disposition field to fall out of sync, and no spec ever carries an observe beside a marker.
src/froot/loops/registry.py · 174 lines
⋯ 44 lines hidden (lines 1–44)
⋯ 52 lines hidden (lines 123–174)
Acting code paths read their per-loop seams through commit_tail(), which narrows the union and raises if the loop is advisory. An advisory loop that reaches an acting path fails loudly instead of returning a half-typed tail.
src/froot/loops/registry.py · 174 lines
⋯ 156 lines hidden (lines 1–156)
The acting loops move their seams into a CommitTail
Each acting loop's spec keeps loop and dashboard_icon in the core and nests the rest — observe, title_prefix, judge_context, reconciles — inside tail=CommitTail(...). dead-code is the instructive one: it judges at the signal (a safe-to-remove veto), so it carries no changelog judge_context, and a removal has no version to be overtaken, so it opts out of reconcile.
src/froot/loops/dead_code.py · 85 lines
⋯ 71 lines hidden (lines 1–71)
dependency-patch and security-patch follow the same shape, each with its own title_prefix and a judge_context that frames its in-loop changelog judge.
The call sites read through the narrowing guard
Every acting call site swapped registry.get(loop).<field> for registry.commit_tail(loop).<field>. Same fields, but now the read is type-checked against the acting family and guarded at runtime.
In activities: the PR-title verb in _draft_for, the signal→candidate observe in _select_candidates, and the reconcile trait in reconcile_open_prs.
src/froot/workflow/activities.py · 842 lines
⋯ 104 lines hidden (lines 1–104)
⋯ 37 lines hidden (lines 111–147)
⋯ 690 lines hidden (lines 153–842)
And the changelog-judge framing in the model adapter:
src/froot/adapters/model_judge.py · 218 lines
⋯ 93 lines hidden (lines 1–93)
⋯ 113 lines hidden (lines 106–218)
The Loop enum gains a second family
Two members join the enum, and its docstring now names both families. The enum stays a bare key; the family machinery lives in each loop's registered spec, behind the tail.
src/froot/domain/loop.py · 33 lines
The two advisory specs
Each new module registers an AdvisoryTail carrying the two things the advisory family single-sources: the HTML-comment marker that finds this loop's one per-PR comment (the upsert/decay key, also the dashboard's query key) and the dashboard panel title. The runtime — the per-repo and per-PR review workflows — is still bespoke; this spec just makes the loop a registry citizen so the dashboard and config can derive it uniformly next.
src/froot/loops/determinism_review.py · 25 lines
⋯ 15 lines hidden (lines 1–15)
src/froot/loops/a11y_review.py · 25 lines
⋯ 15 lines hidden (lines 1–15)
_ensure() imports both new modules so they self-register on first read, alongside the three acting loops.
src/froot/loops/registry.py · 174 lines
⋯ 133 lines hidden (lines 1–133)
⋯ 26 lines hidden (lines 149–174)
A decay edge in the reviewer's comment
The determinism reviewer upserts one comment per PR. The old render_review_comment returned None when a tick found no hazards, so a PR whose hazards were fixed kept its stale finding list forever. Now render always returns a body: with findings, the hazard list; with none, an explicit all-clear.
src/froot/policy/review_comment.py · 120 lines
⋯ 68 lines hidden (lines 1–68)
A new should_post is the decay rule: post when there is something to say OR a prior comment must be cleared. A clean PR that never had a comment stays silent.
post_review now reads the prior comment's existence first, then lets should_post decide. This mirrors the a11y reviewer, which already decayed this way.
src/froot/workflow/activities.py · 842 lines
⋯ 700 lines hidden (lines 1–700)
⋯ 105 lines hidden (lines 738–842)
The tests
The registry tests now route acting assertions through commit_tail(...) and add three for the union: the disposition is derived from the tail type, the advisory loops register as emit-signal, and commit_tail rejects an advisory loop.
tests/test_loop_registry.py · 90 lines
⋯ 58 lines hidden (lines 1–58)
The reviewer-comment tests swap the old none-when-empty case for an all-clear render plus a direct truth table of the decay rule.