What changed, and why
froot runs maintenance loops over GitHub repos. Until now the spine learned each loop's specifics by match-ing on a Loop enum: the candidate scan, the judge framing, the reconcile skip, the PR-title verb, the dashboard icon were all per-loop arms scattered across the codebase. Adding a loop meant editing all of them.
This change replaces that with an open registry the spine reads. A LoopSpec carries only the genuinely per-loop seams; the durable chassis — the scan/dispatch machinery, the merge gate, earned autonomy, namespacing, the dashboard — stays loop-agnostic. The three acting loops self-register, and activities, model_judge, and the dashboard look the loop up instead of branching on it.
The shape follows three rules worth holding while you read:
- The gate stays a spine stage, unreachable from any model-filled slot — a LoopSpec exposes only observe, never a merge primitive. - Admission is by placement, not by an LLM call — dead-code is a first-class loop with CI as its oracle and no changelog judge. - **disposition is a field**, not a second loop species — the advisory family folds in behind it next.
Cut #1 is the acting family (dependency-patch, security-patch, dead-code) and is zero behavior change: the existing scan/judge/reconcile/dashboard tests now route through the registry and pass.
The registry: one spec per loop
LoopSpec is a frozen dataclass of the per-loop seams and nothing else. The one callable is observe (signal → candidates); the rest is inert data — disposition, the changelog-judge judge_context (None when a loop does no changelog judging), reconciles, the PR-title title_prefix, the dashboard_icon. The workflow-id segment is deliberately not here — it stays a pure derivation in naming (see the next sections).
The disposition enum, and the spec — six declarative fields, one callable.
src/froot/loops/registry.py · 125 lines
⋯ 44 lines hidden (lines 1–44)
⋯ 26 lines hidden (lines 53–78)
⋯ 30 lines hidden (lines 96–125)
The registry populates lazily: get() calls _ensure(), which imports the loop modules once so they run their register() calls. That keeps the registry free of import-order constraints and lets each loop keep its heavy adapter imports inside observe, so importing the registry pulls nothing.
register / lazy _ensure / get — a passive dict, no orchestration.
src/froot/loops/registry.py · 125 lines
⋯ 95 lines hidden (lines 1–95)
⋯ 1 line hidden (lines 125–125)
A loop is a small module
Each loop is one file that defines its observe and registers its spec. dependency-patch is the whole pattern in miniature: read the available upgrades, pick the highest patch. The adapter import stays inside observe (lazy), and the spec is the loop's entire identity.
observe + the registered spec — the title verb and a default icon.
src/froot/loops/dependency_patch.py · 49 lines
⋯ 28 lines hidden (lines 1–28)
dead-code is the interesting one. Its judgment runs at the signal: the safe-to-remove judge vetoes each flagged removal inside observe, so a tool used without an import (pytest, eslint) is dropped before any workflow starts. A judge error drops that removal (fail-safe). Because it does no changelog judging, its spec carries judge_context=None and reconciles=False (a removal has no version to be overtaken).
The veto-at-signal, and a spec that declares it judges elsewhere and skips reconcile.
src/froot/loops/dead_code.py · 84 lines
⋯ 28 lines hidden (lines 1–28)
The spine reads the registry
The per-loop match statements collapse to lookups. Candidate selection — once a three-arm match plus two helpers — is now one line. The changelog-judge framing reads spec.judge_context. The reconcile activity reads spec.reconciles instead of naming dead-code.
_select_candidates: the one genuinely per-loop body now lives in the spec.
src/froot/workflow/activities.py · 825 lines
⋯ 118 lines hidden (lines 1–118)
⋯ 678 lines hidden (lines 148–825)
The changelog framing line, sourced from the loop's spec.
src/froot/adapters/model_judge.py · 218 lines
⋯ 93 lines hidden (lines 1–93)
⋯ 112 lines hidden (lines 107–218)
Reconcile keys on the trait, not the loop name.
src/froot/workflow/activities.py · 825 lines
⋯ 552 lines hidden (lines 1–552)
⋯ 265 lines hidden (lines 561–825)
The dashboard derives from the registry
The acting-loop tabs already iterated the configured loops; the last hard-coded per-loop bit was the icon map keyed by loop name in the renderer. Now the spec carries dashboard_icon, read_model threads it onto each LoopView, and the renderer just uses view.icon.
The icon is resolved from the spec (read_model is the dashboard server context, not a workflow).
src/froot/dashboard/read_model.py · 940 lines
⋯ 559 lines hidden (lines 1–559)
⋯ 373 lines hidden (lines 568–940)
No more {...}.get(view.loop, ...) — the renderer is loop-blind.
src/froot/dashboard/render.py · 980 lines
⋯ 887 lines hidden (lines 1–887)
⋯ 84 lines hidden (lines 897–980)
The PR-title verb lives in the spec
The title verb (deps / security / dead-code) is a per-loop label like the icon, so it belongs in the spec too. But the draft builder is pure policy — it can't read the registry. So the impure open-PR activity resolves spec.title_prefix and passes it in; compose stays a pure function of its inputs.
A required keyword-only title_prefix; compose no longer knows about loops.
src/froot/policy/compose.py · 191 lines
⋯ 85 lines hidden (lines 1–85)
⋯ 26 lines hidden (lines 94–119)
⋯ 64 lines hidden (lines 128–191)
The activity resolves the verb from the spec and hands it to the pure builder.
src/froot/workflow/activities.py · 825 lines
⋯ 92 lines hidden (lines 1–92)
⋯ 710 lines hidden (lines 116–825)
Tests: zero behavior change, single-sourced seams
Behavior equivalence is carried by the existing suite: the scan, judge, reconcile, and dashboard tests now run through the registry and still pass — including test_reconcile_skips_dead_code (the skip happens without re-scanning) and test_scan_candidates_dead_code_vetoes_unsafe_removals (the veto-at-signal). The new registry tests guard the seams the spec now owns.
Disposition, the title verb, judge-context placement, the reconcile trait.
tests/test_loop_registry.py · 54 lines
⋯ 19 lines hidden (lines 1–19)
The discriminator: a spec icon change flows to the rendered tab, no renderer edit.
tests/test_dashboard_render.py · 544 lines
⋯ 150 lines hidden (lines 1–150)
⋯ 376 lines hidden (lines 169–544)
The whole cut is green at every commit: ruff, mypy, the full test suite, and both the lexical and transitive determinism gates. Adding an acting loop now needs a Loop enum member and a loops/<name>.py spec — nothing else.