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
src/froot/loops/registry.py174 lines · Python
⋯ 44 lines hidden (lines 1–44)
1"""The loop registry: froot's open loop catalog, keyed by :class:`Loop`.
2 
3The chassis schedules, dispatches, verifies, and gates; a :class:`LoopSpec` is a
4shared core (the loop key, the dashboard icon) plus a disposition-tagged
5*tail* — a :class:`CommitTail` (an acting loop's signal→candidate ``observe``,
6PR-title verb, changelog framing, reconcile trait) or an :class:`AdvisoryTail`
7(an advisory loop's comment marker and dashboard title). The tail's TYPE is the
8discriminant, so :attr:`LoopSpec.disposition` is derived, never stored beside
9inert fields of the other family. Everything else (the scan/dispatch chassis,
10the merge gate, earned autonomy, the dashboard, the workflow-id namespace) is
11loop-agnostic and stays in the spine.
12 
13This cut registers the three acting (CommitTail) loops; the advisory loops fold
14in behind :class:`AdvisoryTail` next.
15 
16Loops self-register at import. The registry is populated lazily on first read
17(see :func:`_ensure`) so importing a consumer never forces a particular import
18order, and the per-loop adapter imports stay inside each ``observe`` body.
19"""
20 
21from __future__ import annotations
22 
23from dataclasses import dataclass
24from enum import StrEnum
25from typing import TYPE_CHECKING
26 
27if TYPE_CHECKING:
28 from collections.abc import Awaitable, Callable
29 from pathlib import Path
30 
31 from froot.domain.loop import Loop
32 from froot.domain.repo import TargetRepo
33 from froot.domain.work import WorkItem
34 from froot.ports.protocols import PackageManager
35 
36 # Check out a repo's manifest and select this loop's work items, returning
37 # (considered, items) — ``considered`` is the upstream signal size, so the
38 # scan tick can show how much was seen versus kept.
39 ObserveFn = Callable[
40 [TargetRepo, PackageManager, Path],
41 Awaitable[tuple[int, tuple[WorkItem, ...]]],
42 ]
43 
44 
45class Disposition(StrEnum):
46 """How a loop's work item terminates (the commit-vs-emit fork)."""
47 
48 # Propose a PR; the spine gates the merge (the acting loops).
49 COMMIT_OR_REVERT = "commit-or-revert"
50 # Upsert a decaying advisory comment/label; no merge, no gate (advisory).
51 EMIT_SIGNAL = "emit-signal"
52 
53 
54@dataclass(frozen=True)
55class CommitTail:
56 """The COMMIT_OR_REVERT per-loop seams (an acting loop that opens a PR).
57 
58 Attributes:
59 observe: The signal→candidate seam — the one genuinely per-loop body.
60 title_prefix: The PR-title verb (``deps`` / ``security`` /
61 ``dead-code``) — a per-loop label, not derivable from the loop name.
62 judge_context: The framing line for the in-loop changelog judge, or
63 ``None`` when the loop does no changelog judging (e.g. dead-code,
64 whose judgment is a safe-to-remove veto *at the signal*).
65 reconciles: Whether version-supersession reconcile applies — ``True``
66 for bump loops, ``False`` for a removal (no version to overtake).
67 """
68 
69 observe: ObserveFn
70 title_prefix: str
71 judge_context: str | None = None
72 reconciles: bool = True
73 
74 
75@dataclass(frozen=True)
76class AdvisoryTail:
77 """The EMIT_SIGNAL per-loop seams (an advisory loop that comments on PRs).
78 
79 An advisory loop scans a repo's open PRs and upserts one decaying comment
80 per PR — no candidate, no PR of its own, no gate, no merge.
81 
82 Attributes:
83 marker: The HTML-comment marker that finds this loop's single per-PR
84 comment (the upsert/decay key, also the dashboard's query key).
85 panel_title: The dashboard tab/panel title (e.g. "Determinism review").
86 """
87 
88 marker: str
89 panel_title: str
90 
91 
92@dataclass(frozen=True)
93class LoopSpec:
94 """One loop's entry — a shared core plus a disposition-tagged tail.
95 
96 The tail's TYPE is the discriminant: a :class:`CommitTail` is an acting
97 (commit-or-revert) loop, an :class:`AdvisoryTail` is an emit-signal loop. No
98 spec ever carries an inert ``observe`` beside an inert ``marker`` — the
99 family machinery lives behind the tail, not beside it.
100 
101 Attributes:
102 loop: The loop this spec specialises (the registry key).
103 dashboard_icon: The icon key for this loop's dashboard tab (one of the
104 renderer's ``_ICONS``) — shared by both families.
105 tail: The per-loop seams for this loop's family (commit vs advisory).
106 
107 The workflow-id/branch namespace segment is NOT carried here: it is a pure
108 derivation in :mod:`froot.policy.naming`.
109 """
110 
111 loop: Loop
112 dashboard_icon: str
113 tail: CommitTail | AdvisoryTail
114 
115 @property
116 def disposition(self) -> Disposition:
117 """The family, derived from the tail's type (one conceptual field)."""
118 return (
119 Disposition.COMMIT_OR_REVERT
120 if isinstance(self.tail, CommitTail)
121 else Disposition.EMIT_SIGNAL
122 )
⋯ 52 lines hidden (lines 123–174)
123 
124 
125_LOOPS: dict[Loop, LoopSpec] = {}
126_REGISTERED = False
127 
128 
129def register(spec: LoopSpec) -> None:
130 """Register a loop spec (idempotent on re-import; last write wins)."""
131 _LOOPS[spec.loop] = spec
132 
133 
134def _ensure() -> None:
135 """Import the loop modules once so they self-register (lazy, idempotent)."""
136 global _REGISTERED
137 if _REGISTERED:
138 return
139 _REGISTERED = True
140 # Importing each module runs its module-level register(...) call. Kept here,
141 # not at package import, so the registry has no import-order constraints.
142 from froot.loops import ( # noqa: F401
143 a11y_review,
144 dead_code,
145 dependency_patch,
146 determinism_review,
147 security_patch,
148 )
149 
150 
151def get(loop: Loop) -> LoopSpec:
152 """The registered spec for a loop (raises ``KeyError`` if unregistered)."""
153 _ensure()
154 return _LOOPS[loop]
155 
156 
157def commit_tail(loop: Loop) -> CommitTail:
158 """The :class:`CommitTail` of an acting loop (asserts it is one).
159 
160 The acting spine reads its per-loop seams (observe / title / judge /
161 reconcile) through this, so a non-commit loop reaching an acting code path
162 fails loudly instead of returning a half-typed tail.
163 """
164 tail = get(loop).tail
165 if not isinstance(tail, CommitTail):
166 msg = f"{loop} is not a commit-or-revert loop"
167 raise TypeError(msg)
168 return tail
169 
170 
171def all_specs() -> tuple[LoopSpec, ...]:
172 """Every registered loop spec, in registration order."""
173 _ensure()
174 return tuple(_LOOPS.values())

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
src/froot/loops/registry.py174 lines · Python
⋯ 156 lines hidden (lines 1–156)
1"""The loop registry: froot's open loop catalog, keyed by :class:`Loop`.
2 
3The chassis schedules, dispatches, verifies, and gates; a :class:`LoopSpec` is a
4shared core (the loop key, the dashboard icon) plus a disposition-tagged
5*tail* — a :class:`CommitTail` (an acting loop's signal→candidate ``observe``,
6PR-title verb, changelog framing, reconcile trait) or an :class:`AdvisoryTail`
7(an advisory loop's comment marker and dashboard title). The tail's TYPE is the
8discriminant, so :attr:`LoopSpec.disposition` is derived, never stored beside
9inert fields of the other family. Everything else (the scan/dispatch chassis,
10the merge gate, earned autonomy, the dashboard, the workflow-id namespace) is
11loop-agnostic and stays in the spine.
12 
13This cut registers the three acting (CommitTail) loops; the advisory loops fold
14in behind :class:`AdvisoryTail` next.
15 
16Loops self-register at import. The registry is populated lazily on first read
17(see :func:`_ensure`) so importing a consumer never forces a particular import
18order, and the per-loop adapter imports stay inside each ``observe`` body.
19"""
20 
21from __future__ import annotations
22 
23from dataclasses import dataclass
24from enum import StrEnum
25from typing import TYPE_CHECKING
26 
27if TYPE_CHECKING:
28 from collections.abc import Awaitable, Callable
29 from pathlib import Path
30 
31 from froot.domain.loop import Loop
32 from froot.domain.repo import TargetRepo
33 from froot.domain.work import WorkItem
34 from froot.ports.protocols import PackageManager
35 
36 # Check out a repo's manifest and select this loop's work items, returning
37 # (considered, items) — ``considered`` is the upstream signal size, so the
38 # scan tick can show how much was seen versus kept.
39 ObserveFn = Callable[
40 [TargetRepo, PackageManager, Path],
41 Awaitable[tuple[int, tuple[WorkItem, ...]]],
42 ]
43 
44 
45class Disposition(StrEnum):
46 """How a loop's work item terminates (the commit-vs-emit fork)."""
47 
48 # Propose a PR; the spine gates the merge (the acting loops).
49 COMMIT_OR_REVERT = "commit-or-revert"
50 # Upsert a decaying advisory comment/label; no merge, no gate (advisory).
51 EMIT_SIGNAL = "emit-signal"
52 
53 
54@dataclass(frozen=True)
55class CommitTail:
56 """The COMMIT_OR_REVERT per-loop seams (an acting loop that opens a PR).
57 
58 Attributes:
59 observe: The signal→candidate seam — the one genuinely per-loop body.
60 title_prefix: The PR-title verb (``deps`` / ``security`` /
61 ``dead-code``) — a per-loop label, not derivable from the loop name.
62 judge_context: The framing line for the in-loop changelog judge, or
63 ``None`` when the loop does no changelog judging (e.g. dead-code,
64 whose judgment is a safe-to-remove veto *at the signal*).
65 reconciles: Whether version-supersession reconcile applies — ``True``
66 for bump loops, ``False`` for a removal (no version to overtake).
67 """
68 
69 observe: ObserveFn
70 title_prefix: str
71 judge_context: str | None = None
72 reconciles: bool = True
73 
74 
75@dataclass(frozen=True)
76class AdvisoryTail:
77 """The EMIT_SIGNAL per-loop seams (an advisory loop that comments on PRs).
78 
79 An advisory loop scans a repo's open PRs and upserts one decaying comment
80 per PR — no candidate, no PR of its own, no gate, no merge.
81 
82 Attributes:
83 marker: The HTML-comment marker that finds this loop's single per-PR
84 comment (the upsert/decay key, also the dashboard's query key).
85 panel_title: The dashboard tab/panel title (e.g. "Determinism review").
86 """
87 
88 marker: str
89 panel_title: str
90 
91 
92@dataclass(frozen=True)
93class LoopSpec:
94 """One loop's entry — a shared core plus a disposition-tagged tail.
95 
96 The tail's TYPE is the discriminant: a :class:`CommitTail` is an acting
97 (commit-or-revert) loop, an :class:`AdvisoryTail` is an emit-signal loop. No
98 spec ever carries an inert ``observe`` beside an inert ``marker`` — the
99 family machinery lives behind the tail, not beside it.
100 
101 Attributes:
102 loop: The loop this spec specialises (the registry key).
103 dashboard_icon: The icon key for this loop's dashboard tab (one of the
104 renderer's ``_ICONS``) — shared by both families.
105 tail: The per-loop seams for this loop's family (commit vs advisory).
106 
107 The workflow-id/branch namespace segment is NOT carried here: it is a pure
108 derivation in :mod:`froot.policy.naming`.
109 """
110 
111 loop: Loop
112 dashboard_icon: str
113 tail: CommitTail | AdvisoryTail
114 
115 @property
116 def disposition(self) -> Disposition:
117 """The family, derived from the tail's type (one conceptual field)."""
118 return (
119 Disposition.COMMIT_OR_REVERT
120 if isinstance(self.tail, CommitTail)
121 else Disposition.EMIT_SIGNAL
122 )
123 
124 
125_LOOPS: dict[Loop, LoopSpec] = {}
126_REGISTERED = False
127 
128 
129def register(spec: LoopSpec) -> None:
130 """Register a loop spec (idempotent on re-import; last write wins)."""
131 _LOOPS[spec.loop] = spec
132 
133 
134def _ensure() -> None:
135 """Import the loop modules once so they self-register (lazy, idempotent)."""
136 global _REGISTERED
137 if _REGISTERED:
138 return
139 _REGISTERED = True
140 # Importing each module runs its module-level register(...) call. Kept here,
141 # not at package import, so the registry has no import-order constraints.
142 from froot.loops import ( # noqa: F401
143 a11y_review,
144 dead_code,
145 dependency_patch,
146 determinism_review,
147 security_patch,
148 )
149 
150 
151def get(loop: Loop) -> LoopSpec:
152 """The registered spec for a loop (raises ``KeyError`` if unregistered)."""
153 _ensure()
154 return _LOOPS[loop]
155 
156 
157def commit_tail(loop: Loop) -> CommitTail:
158 """The :class:`CommitTail` of an acting loop (asserts it is one).
159 
160 The acting spine reads its per-loop seams (observe / title / judge /
161 reconcile) through this, so a non-commit loop reaching an acting code path
162 fails loudly instead of returning a half-typed tail.
163 """
164 tail = get(loop).tail
165 if not isinstance(tail, CommitTail):
166 msg = f"{loop} is not a commit-or-revert loop"
167 raise TypeError(msg)
168 return tail
⋯ 6 lines hidden (lines 169–174)
169 
170 
171def all_specs() -> tuple[LoopSpec, ...]:
172 """Every registered loop spec, in registration order."""
173 _ensure()
174 return tuple(_LOOPS.values())

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
src/froot/loops/dead_code.py85 lines · Python
⋯ 71 lines hidden (lines 1–71)
1"""The dead-code loop: remove unused dependencies.
2 
3Signal: the unused direct dependencies a static analyzer flags. Judgment: the
4safe-to-remove judge vetoes each *at the signal* (a tool used without an import
5— pytest, eslint — is dropped before a workflow ever starts), so this loop's one
6thin model call lives inside ``observe``, not as an in-loop changelog judge
7(``judge_context`` is therefore ``None``). Candidate: the surviving removals.
8Disposition: commit — the spine opens a PR and gates the merge (CI is oracle).
9"""
10 
11from __future__ import annotations
12 
13from typing import TYPE_CHECKING
14 
15from temporalio import activity
16 
17from froot.domain.loop import Loop
18from froot.loops.registry import CommitTail, LoopSpec, register
19 
20if TYPE_CHECKING:
21 from pathlib import Path
22 
23 from froot.domain.removal import Removal
24 from froot.domain.repo import TargetRepo
25 from froot.domain.work import WorkItem
26 from froot.ports.protocols import PackageManager
27 
28 
29async def observe(
30 target: TargetRepo,
31 package_manager: PackageManager,
32 manifest_dir: Path,
33) -> tuple[int, tuple[WorkItem, ...]]:
34 """Unused deps flagged, then vetoed safe-to-remove (the veto is the judge).
35 
36 The static analyzer flags every unused direct dependency; the safe-to-remove
37 judge then vetoes each — only a ``clean`` survives to become a PR. A judge
38 error drops that removal (fail-safe: never propose what was not vetted).
39 ``considered`` is the flagged count; the kept count is the survivors, so the
40 scan tick shows how much the veto filtered.
41 """
42 from froot.adapters.model_judge import PydanticAiJudge
43 
44 flagged = await package_manager.list_unused(target, manifest_dir)
45 if not flagged:
46 return 0, ()
47 judge = PydanticAiJudge()
48 kept: list[Removal] = []
49 for removal in flagged:
50 try:
51 verdict = await judge.judge_removal(removal)
52 except Exception as exc:
53 activity.logger.warning(
54 "safe-to-remove judge unavailable for %s; skipping: %r",
55 removal.package,
56 exc,
57 )
58 continue
59 if verdict.kind != "clean":
60 continue
61 # Carry the judge's reasoning into the work item so the PR body explains
62 # why the removal is safe, beside the detector note.
63 enriched = (
64 f"{removal.justification}; {verdict.rationale}"
65 if removal.justification
66 else verdict.rationale
67 )
68 kept.append(removal.model_copy(update={"justification": enriched}))
69 return len(flagged), tuple(kept)
70 
71 
72register(
73 LoopSpec(
74 loop=Loop.DEAD_CODE,
75 dashboard_icon="scissors",
76 tail=CommitTail(
77 observe=observe,
78 title_prefix="dead-code",
79 # Dead-code judges at the signal (the veto), not the changelog.
80 judge_context=None,
81 # A removal has no version to be overtaken — nothing to reconcile.
82 reconciles=False,
83 ),
84 )

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
src/froot/workflow/activities.py842 lines · Python
⋯ 104 lines hidden (lines 1–104)
1"""Activities: the effect interpreters that wrap the adapters.
2 
3Each activity is the impure boundary for one effect. Adapter imports are LAZY
4(inside the bodies) so the model and HTTP stacks never enter a workflow sandbox;
5the pure policy and domain imports stay at module level. Activities return
6domain values — the workflow wraps them into events and feeds the pure state
7machine. The activity signatures' types are evaluated at runtime by Temporal's
8data converter, so the domain imports here are deliberately not deferred.
9"""
10 
11from __future__ import annotations
12 
13import json
14import logging
15import tempfile
16from pathlib import Path
17from typing import TYPE_CHECKING, assert_never
18 
19from temporalio import activity
20 
21from froot.domain.a11y import A11yAnalysis, A11yVerdict
22from froot.domain.candidate import Candidate
23from froot.domain.changelog import (
24 ChangelogVerdict,
25 CleanVerdict,
26 UnknownVerdict,
28from froot.domain.ci import CIStatus
29from froot.domain.determinism import AnalysisResult, FrontierVerdict
30from froot.domain.pull_request import PullRequestDraft, PullRequestRef
31from froot.domain.removal import Removal
32from froot.domain.repo import TargetRepo
33from froot.domain.work import WorkItem
34from froot.policy.a11y_comment import (
35 A11Y_MARKER,
36 render_a11y_comment,
37 should_post,
39from froot.policy.a11y_scan import scan_sources
40from froot.policy.compose import (
41 pr_labels,
42 pull_request_draft,
43 removal_pull_request_draft,
45from froot.policy.determinism import analyze_workflow_surface
46from froot.policy.naming import (
47 branch_name,
48 bump_workflow_id,
49 pr_a11y_review_workflow_id,
50 pr_review_workflow_id,
52from froot.policy.review_comment import (
53 REVIEW_MARKER,
54 render_review_comment,
56from froot.policy.review_comment import (
57 should_post as should_post_review,
59from froot.workflow.types import (
60 AdjudicateA11yInput,
61 AdjudicateInput,
62 AutoMergeInput,
63 CiCheckInput,
64 CloseInput,
65 DispatchA11yInput,
66 DispatchInput,
67 DispatchReviewInput,
68 GateReviewInput,
69 GateSelfTestInput,
70 JudgeInput,
71 MergeInput,
72 OpenPrInput,
73 PostA11yInput,
74 PostReviewInput,
75 PrA11yReviewParams,
76 PrReviewParams,
77 ReconcileInput,
78 RecordInput,
79 ScanCandidatesInput,
81 
82if TYPE_CHECKING:
83 from froot.domain.loop import Loop
84 from froot.ports.protocols import PackageManager
85 
86_log = logging.getLogger("froot.outcome")
87_review_log = logging.getLogger("froot.review")
88_a11y_log = logging.getLogger("froot.a11y")
89_reconcile_log = logging.getLogger("froot.reconcile")
90_scan_log = logging.getLogger("froot.scan")
91_gate_log = logging.getLogger("froot.gate")
92 
93 
94def _manifest_dir(target: TargetRepo, workspace: Path) -> Path:
95 """The directory the manifest lives in (a monorepo subdir, or the root)."""
96 return workspace / target.manifest_dir if target.manifest_dir else workspace
97 
98 
99def _draft_for(params: OpenPrInput) -> PullRequestDraft:
100 """Build the PR draft for the work item — bump vs removal compose.
101 
102 The PR-title verb is the loop's, resolved from its registered spec here (the
103 impure boundary) and passed into the pure draft builders.
104 """
105 from froot.loops import registry
106 
107 item = params.candidate
108 title_prefix = registry.commit_tail(params.loop).title_prefix
109 match item:
110 case Candidate():
⋯ 37 lines hidden (lines 111–147)
111 return pull_request_draft(
112 params.target,
113 item,
114 params.verdict,
115 params.loop,
116 title_prefix=title_prefix,
117 )
118 case Removal():
119 return removal_pull_request_draft(
120 params.target, item, params.loop, title_prefix=title_prefix
121 )
122 assert_never(item)
123 
124 
125async def _select_candidates(
126 loop: Loop,
127 target: TargetRepo,
128 package_manager: PackageManager,
129 manifest_dir: Path,
130) -> tuple[int, tuple[WorkItem, ...]]:
131 """Gather this loop's signal from the checkout and select its work items.
132 
133 The one genuinely per-loop seam: dependency-patch reads the available
134 upgrades and picks the highest patch; security-patch reads the installed,
135 asks OSV for advisories, and picks the lowest version clearing each;
136 dead-code reads the unused dependencies a static analyzer flags and vetoes
137 each with the safe-to-remove judge. The impure sources are lazy-imported per
138 arm so none drags another's stack into a sandbox. Each feeds a pure (or, for
139 dead-code, model-vetoed) selection.
140 
141 Returns ``(considered, items)`` — ``considered`` is the size of the upstream
142 signal (available upgrades / advisories found / unused deps flagged) so the
143 scan can make its selectivity legible (how much was seen versus kept).
144 
145 The one genuinely per-loop body now lives in each loop's spec ``observe``
146 (see :mod:`froot.loops`); the spine looks the loop up and runs it, so this
147 selection seam needs no per-loop arm.
148 """
149 from froot.loops import registry
150 
151 return await registry.commit_tail(loop).observe(
152 target, package_manager, manifest_dir
⋯ 690 lines hidden (lines 153–842)
153 )
154 
155 
156@activity.defn
157async def scan_candidates(
158 params: ScanCandidatesInput,
159) -> tuple[WorkItem, ...]:
160 """Check out the repo and select this loop's candidates.
161 
162 Emits the tick's selectivity — how much upstream signal was considered
163 versus how much was kept — as span attributes (when ``FROOT_OTEL`` is on)
164 and as a structured ``scan_tick`` log, so the signal stage is legible in the
165 run ledger even on a tick that proposes nothing.
166 """
167 from froot.adapters.github import GitHubForge
168 from froot.adapters.registry import package_manager_for
169 from froot.adapters.telemetry import set_span_attributes
170 
171 forge = GitHubForge()
172 package_manager = package_manager_for(params.target.ecosystem)
173 with tempfile.TemporaryDirectory() as tmp:
174 workspace = Path(tmp)
175 await forge.checkout(params.target, workspace)
176 considered, candidates = await _select_candidates(
177 params.loop,
178 params.target,
179 package_manager,
180 _manifest_dir(params.target, workspace),
181 )
182 selected = len(candidates)
183 dropped = max(considered - selected, 0)
184 set_span_attributes(
185 scan_loop=params.loop.value,
186 scan_repo=params.target.repo.slug,
187 scan_considered=considered,
188 scan_selected=selected,
189 scan_dropped=dropped,
190 )
191 _scan_log.info(
192 json.dumps(
193 {
194 "event": "scan_tick",
195 "loop": params.loop.value,
196 "repo": params.target.repo.slug,
197 "considered": considered,
198 "selected": selected,
199 "dropped": dropped,
200 }
201 )
202 )
203 return candidates
204 
205 
206@activity.defn
207async def judge_changelog(params: JudgeInput) -> ChangelogVerdict:
208 """Fetch the candidate's changelog and get the model's typed verdict.
209 
210 The model is froot's one thin, non-load-bearing judgment: a clean verdict
211 never *gates* a PR (CI is the oracle), so a model that is down, slow, or
212 erroring must not stall the spine. A judge failure degrades to
213 ``UnknownVerdict`` — the bump proceeds, the human still gets the PR, and the
214 dashboard records the verdict as unknown — rather than failing (and then
215 retrying) the activity. Only the model call is guarded; the fetch is already
216 best-effort (returns ``None``, not an exception). The loop selects what the
217 model is asked (clean-patch vs breaking-change-on-a-security-bump).
218 """
219 from froot.adapters.changelog_http import HttpChangelogSource
220 from froot.adapters.model_judge import PydanticAiJudge
221 
222 candidate = params.candidate
223 if isinstance(candidate, Removal):
224 # A removal already cleared the safe-to-remove veto at scan; there is no
225 # changelog to read, so carry that conclusion straight into the PR
226 # framing (the rationale the veto recorded on the work item).
227 return CleanVerdict(
228 rationale=candidate.justification or "unused; safe to remove"
229 )
230 changelog = await HttpChangelogSource().fetch(candidate)
231 if changelog is None:
232 return UnknownVerdict(rationale="No changelog could be fetched.")
233 try:
234 return await PydanticAiJudge().judge(changelog, params.loop)
235 except Exception as exc:
236 activity.logger.warning(
237 "changelog judge unavailable for %s; degrading to unknown: %r",
238 params.candidate.package,
239 exc,
240 )
241 return UnknownVerdict(
242 rationale=f"Changelog judge unavailable ({type(exc).__name__})."
243 )
244 
245 
246@activity.defn
247async def gate_review(params: GateReviewInput) -> ChangelogVerdict:
248 """Independently deep-review a bump at the gate; fail-closed to a hold.
249 
250 The fourth trust leg (§3.7): a second, adversarial model pass over the
251 changelog, run only when a bump is about to auto-merge. ``clean`` approves
252 the merge; ``risky``/``unknown`` hold the PR for the human. Fail-CLOSED — a
253 missing changelog or a model error returns a non-clean verdict, so an
254 unreviewable bump never merges unattended. This is the opposite disposition
255 from :func:`judge_changelog` (which degrades-to-proceed): here the safe
256 direction is to hold, and a non-clean verdict already means hold.
257 """
258 from froot.adapters.changelog_http import HttpChangelogSource
259 from froot.adapters.model_judge import PydanticAiJudge
260 
261 candidate = params.candidate
262 verdict: ChangelogVerdict
263 if isinstance(candidate, Removal):
264 # The fourth leg for a removal: independently re-judge that it is safe
265 # to remove (the same check the scan veto ran). No changelog to read;
266 # fail-CLOSED to a hold on a model error, like the bump path.
267 try:
268 verdict = await PydanticAiJudge().judge_removal(candidate)
269 except Exception as exc:
270 activity.logger.warning(
271 "safe-to-remove gate review unavailable for %s; holding: %r",
272 candidate.package,
273 exc,
274 )
275 verdict = UnknownVerdict(
276 rationale=(
277 f"Removal reviewer unavailable ({type(exc).__name__})."
278 )
279 )
280 else:
281 changelog = await HttpChangelogSource().fetch(candidate)
282 if changelog is None:
283 verdict = UnknownVerdict(
284 rationale="No changelog to review; holding (fail-closed)."
285 )
286 else:
287 try:
288 verdict = await PydanticAiJudge().gate_review(
289 changelog, params.loop
290 )
291 except Exception as exc:
292 activity.logger.warning(
293 "gate reviewer unavailable for %s; holding: %r",
294 candidate.package,
295 exc,
296 )
297 verdict = UnknownVerdict(
298 rationale=(
299 f"Gate reviewer unavailable ({type(exc).__name__})."
300 )
301 )
302 _gate_log.info(
303 json.dumps(
304 {
305 "event": "gate_review",
306 "loop": params.loop.value,
307 "pr": params.pr.number,
308 "pr_url": params.pr.url,
309 "package": params.candidate.package,
310 "verdict": verdict.kind,
311 "approved": verdict.kind == "clean",
312 }
313 )
314 )
315 return verdict
316 
317 
318@activity.defn
319async def open_pull_request(params: OpenPrInput) -> PullRequestRef:
320 """Rewrite the manifest+lockfile and open (idempotently) the work item's PR.
321 
322 The one place the action differs by work-item kind: a bump regenerates the
323 lockfile at the target version, a removal deletes the dependency. Both are
324 lockfile-only (no install, no scripts); everything around them — the dedup
325 branch, the checkout, the push, the open — is the same chassis.
326 """
327 from froot.adapters.github import GitHubForge
328 from froot.adapters.registry import package_manager_for
329 
330 forge = GitHubForge()
331 package_manager = package_manager_for(params.target.ecosystem)
332 branch = branch_name(params.candidate, params.loop)
333 existing = await forge.find_open_pull_request(params.target, branch)
334 if existing is not None:
335 return existing
336 draft = _draft_for(params)
337 item = params.candidate
338 with tempfile.TemporaryDirectory() as tmp:
339 workspace = Path(tmp)
340 await forge.checkout(params.target, workspace)
341 manifest_dir = _manifest_dir(params.target, workspace)
342 match item:
343 case Candidate():
344 await package_manager.apply_patch_bump(item, manifest_dir)
345 case Removal():
346 await package_manager.remove_dependency(item, manifest_dir)
347 await forge.push_branch(workspace, branch, draft.title)
348 return await forge.open_pull_request(params.target, draft)
349 
350 
351@activity.defn
352async def check_ci(params: CiCheckInput) -> CIStatus:
353 """Read the repo's CI status for the PR's head commit (the oracle)."""
354 from froot.adapters.github import GitHubForge
355 
356 return await GitHubForge().ci_status(params.target, params.head_sha)
357 
358 
359@activity.defn
360async def record_outcome(params: RecordInput) -> None:
361 """Label the PR and log the run telemetry — the signal-update.
362 
363 The labels carry the loop *and* the judgment environment (the judge model)
364 the PR was opened under, so the gate can count only the track record earned
365 under the current environment and reset it when the model changes (§3.7).
366 """
367 from froot.adapters.github import GitHubForge
368 from froot.config.settings import ModelSettings
369 from froot.policy.environment import env_label
370 
371 outcome = params.outcome
372 item = outcome.candidate
373 labels = (
374 *pr_labels(params.loop),
375 env_label(ModelSettings().ollama_model),
376 )
377 await GitHubForge().add_labels(params.target, outcome.pr.number, labels)
378 record = {
379 "event": "loop_outcome",
380 "loop": params.loop.value,
381 "repo": params.target.repo.slug,
382 "package": item.package,
383 "changelog": outcome.verdict.kind,
384 "ci": outcome.ci.kind,
385 "ci_passed": outcome.ci_passed,
386 "pr": outcome.pr.number,
387 "pr_url": outcome.pr.url,
388 }
389 match item:
390 case Candidate():
391 record |= {
392 "action": "bump",
393 "from": str(item.current),
394 "to": str(item.target),
395 }
396 case Removal():
397 record |= {"action": "remove", "dev": item.dev}
398 _log.info(json.dumps(record))
399 
400 
401@activity.defn
402async def dispatch_bump(params: DispatchInput) -> None:
403 """Start the bump loop for a candidate (idempotent per bump identity).
404 
405 Reads the close-on-red toggle here, at the impure boundary, and pins it onto
406 the bump's params — so the running workflow never reads config itself and an
407 in-flight bump keeps the value it was dispatched with.
408 """
409 from temporalio.common import WorkflowIDReusePolicy
410 from temporalio.exceptions import WorkflowAlreadyStartedError
411 
412 from froot.config.settings import BehaviorSettings
413 from froot.workflow.bump_workflow import BumpWorkflow
414 from froot.workflow.temporal_client import client, task_queue
415 from froot.workflow.types import BumpParams
416 
417 temporal = await client()
418 try:
419 await temporal.start_workflow(
420 BumpWorkflow.run,
421 BumpParams(
422 target=params.target,
423 candidate=params.candidate,
424 close_on_red=BehaviorSettings().close_on_red,
425 loop=params.loop,
426 ),
427 id=bump_workflow_id(params.target, params.candidate, params.loop),
428 task_queue=task_queue(),
429 id_reuse_policy=WorkflowIDReusePolicy.REJECT_DUPLICATE,
430 )
431 except WorkflowAlreadyStartedError:
432 # This bump already has a loop (running or completed) — a no-op, so
433 # re-scanning never opens a second PR for the same bump.
434 return
435 
436 
437@activity.defn
438async def auto_merge_eligible(params: AutoMergeInput) -> bool:
439 """Whether this (repo, loop) class has earned the auto-merge grant.
440 
441 Short-circuits to ``False`` for any repo a steward has not allowlisted (the
442 default), so the common case costs nothing. Otherwise it re-derives the
443 class's standing from the live GitHub history — the same triangulated,
444 windowed, environment-scoped computation the dashboard's shadow gate shows
445 (``read_model.earned_now``) — so the acting gate and the advisory panel can
446 never disagree. Best-effort: any read failure degrades to ``False`` (hold),
447 never an auto-merge.
448 """
449 from datetime import UTC, datetime
450 
451 from froot.config.settings import AutonomySettings, ModelSettings
452 from froot.dashboard import github_source, read_model
453 from froot.policy.environment import environment_slug
454 
455 policy = AutonomySettings().policy()
456 repo = params.target.repo.slug
457 if repo not in policy.allowlisted_repos:
458 return False
459 now = datetime.now(UTC)
460 prs, prs_error = await github_source.fetch((repo,))
461 if prs_error is not None:
462 return False # can't confirm the record -> hold, never merge blind
463 outcomes, _ = await github_source.fetch_outcomes(
464 (repo,), prs, now=now, window_days=policy.window_days
465 )
466 return read_model.earned_now(
467 now,
468 prs,
469 outcomes,
470 repo,
471 params.loop,
472 policy,
473 environment_slug(ModelSettings().ollama_model),
474 )
475 
476 
477@activity.defn
478async def merge_pull_request(params: MergeInput) -> None:
479 """Auto-merge an earned, clean+green bump's PR (the acting gate's write).
480 
481 Reached only after the pure machine confirmed clean+green and the class
482 earned the grant on an allowlisted repo. Passes the head SHA so GitHub
483 refuses the merge if the head moved since the gate decided.
484 """
485 from froot.adapters.github import GitHubForge
486 
487 await GitHubForge().merge_pull_request(
488 params.target, params.pr.number, head_sha=params.pr.head_sha
489 )
490 _log.info(
491 json.dumps(
492 {
493 "event": "pr_merged",
494 "loop": params.loop.value,
495 "reason": "auto_merge",
496 "repo": params.target.repo.slug,
497 "pr": params.pr.number,
498 "pr_url": params.pr.url,
499 }
500 )
501 )
502 
503 
504@activity.defn
505async def close_pull_request(params: CloseInput) -> None:
506 """Comment why, then close a red bump's PR and delete its branch.
507 
508 The note goes through the idempotent ``upsert_issue_comment`` and the close
509 itself is idempotent, so a retried close edits its comment in place and
510 never double-posts. The bump's record step still runs after this, so the red
511 outcome is logged either way.
512 """
513 from froot.adapters.github import GitHubForge
514 from froot.policy.compose import CLOSE_MARKER, closed_on_red_comment
515 
516 forge = GitHubForge()
517 body = closed_on_red_comment(params.failing)
518 await forge.upsert_issue_comment(
519 params.target, params.pr.number, CLOSE_MARKER, body
520 )
521 await forge.close_pull_request(
522 params.target, params.pr.number, params.pr.branch
523 )
524 _log.info(
525 json.dumps(
526 {
527 "event": "pr_closed",
528 "loop": params.loop.value,
529 "reason": "ci_red",
530 "repo": params.target.repo.slug,
531 "pr": params.pr.number,
532 "pr_url": params.pr.url,
533 "failing": list(params.failing),
534 }
535 )
536 )
537 
538 
539@activity.defn
540async def reconcile_open_prs(params: ReconcileInput) -> int:
541 """Close this loop's PRs that a newer candidate or the base has overtaken.
542 
543 Self-contained: lists the repo's open PRs, re-derives this loop's live
544 candidates (a fresh checkout + the loop's signal, derive-never-store), asks
545 the pure :func:`~froot.policy.reconcile.reconciliations` policy: which of
546 loop's PRs to close, and closes each (deleting its branch). Scoped to the
547 loop's own branch namespace, so the two loops never reconcile each other's
548 PRs. A no-op when reconcile is off (``FROOT_RECONCILE``). Returns the count.
549 """
550 from froot.adapters.github import GitHubForge
551 from froot.adapters.registry import package_manager_for
552 from froot.config.settings import BehaviorSettings
553 from froot.policy.compose import CLOSE_MARKER
554 from froot.policy.reconcile import reconciliations
555 
556 if not BehaviorSettings().reconcile:
557 return 0
558 # Reconcile is version-supersession cleanup, which only bump loops have: a
559 # removal carries no version to be overtaken. A loop that does not reconcile
560 # (dead-code) is skipped rather than re-running its signal (knip + the veto
561 # judge) every tick only to close nothing. (A removal-specific reconcile —
562 # close when no longer unused — is future work.) The trait is on the spec.
563 from froot.loops import registry
564 
565 if not registry.commit_tail(params.loop).reconciles:
566 return 0
567 
568 target, loop = params.target, params.loop
569 forge = GitHubForge()
570 package_manager = package_manager_for(target.ecosystem)
571 open_prs = await forge.list_open_pull_requests(target)
572 with tempfile.TemporaryDirectory() as tmp:
573 workspace = Path(tmp)
574 await forge.checkout(target, workspace)
575 _considered, candidates = await _select_candidates(
576 loop, target, package_manager, _manifest_dir(target, workspace)
577 )
578 closures = reconciliations(open_prs, candidates, loop)
579 for closure in closures:
580 await forge.upsert_issue_comment(
581 target, closure.pr.number, CLOSE_MARKER, closure.comment
582 )
583 await forge.close_pull_request(
584 target, closure.pr.number, closure.pr.branch
585 )
586 if closures:
587 _reconcile_log.info(
588 json.dumps(
589 {
590 "event": "reconcile",
591 "loop": loop.value,
592 "repo": target.repo.slug,
593 "closed": len(closures),
594 "prs": [closure.pr.number for closure in closures],
595 }
596 )
597 )
598 return len(closures)
599 
600 
601@activity.defn
602async def gate_selftest(params: GateSelfTestInput) -> tuple[str, ...]:
603 """Run the adversarial gate probe against the live policy; alarm on escape.
604 
605 The §2.11 deliberate disturbance for the acting gate: a battery of synthetic
606 known-bad class histories a healthy gate must refuse, scored against the
607 policy froot is *actually running* (config and all). Any escape — a bad
608 class the live gate would grant — is logged at ERROR (the alarm) so it
609 surfaces in telemetry the moment config drifts; a clean pass logs a
610 heartbeat at INFO. Returns the escaped scenario names. Pure compute,
611 repo-independent (the ``target``/``loop`` are only the log's context).
612 """
613 from froot.config.settings import AutonomySettings
614 from froot.policy.gate_probe import gate_escapes
615 
616 escaped = gate_escapes(AutonomySettings().policy())
617 record = json.dumps(
618 {
619 "event": "gate_selftest",
620 "loop": params.loop.value,
621 "repo": params.target.repo.slug,
622 "healthy": not escaped,
623 "escaped": list(escaped),
624 }
625 )
626 if escaped:
627 _gate_log.error(record) # an alarm: the gate would trust a bad class
628 else:
629 _gate_log.info(record)
630 return escaped
631 
632 
633# ── The determinism reviewer (the transitive ring) ──────────────────────────
634@activity.defn
635async def list_review_prs(target: TargetRepo) -> tuple[PullRequestRef, ...]:
636 """List the repo's open PRs for the determinism reviewer to consider."""
637 from froot.adapters.github import GitHubForge
638 
639 return await GitHubForge().list_open_pull_requests(target)
640 
641 
642@activity.defn
643async def dispatch_pr_review(params: DispatchReviewInput) -> None:
644 """Start a PR's determinism review (idempotent per PR + head SHA)."""
645 from temporalio.common import WorkflowIDReusePolicy
646 from temporalio.exceptions import WorkflowAlreadyStartedError
647 
648 from froot.workflow.pr_review_workflow import PrReviewWorkflow
649 from froot.workflow.temporal_client import client, task_queue
650 
651 temporal = await client()
652 try:
653 await temporal.start_workflow(
654 PrReviewWorkflow.run,
655 PrReviewParams(target=params.target, pr=params.pr),
656 id=pr_review_workflow_id(
657 params.target, params.pr.number, params.pr.head_sha
658 ),
659 task_queue=task_queue(),
660 id_reuse_policy=WorkflowIDReusePolicy.REJECT_DUPLICATE,
661 )
662 except WorkflowAlreadyStartedError:
663 # This (PR, head SHA) already has a review — a no-op, so re-polling
664 # never double-reviews the same commit.
665 return
666 
667 
668@activity.defn
669async def analyze_pr(params: PrReviewParams) -> AnalysisResult:
670 """Check out the PR head and analyze the workflow surface for hazards."""
671 from froot.adapters.github import GitHubForge
672 from froot.adapters.source_tree import load_modules
673 from froot.config.settings import ReviewSettings
674 
675 forge = GitHubForge()
676 with tempfile.TemporaryDirectory() as tmp:
677 workspace = Path(tmp)
678 await forge.checkout_pull_request(
679 params.target, workspace, params.pr.number
680 )
681 # The ASTs and source lines are read into memory here, so the analysis
682 # below is unaffected by the workspace being cleaned up.
683 modules = load_modules(workspace)
684 return analyze_workflow_surface(modules, max_depth=ReviewSettings().depth)
685 
686 
687@activity.defn
688async def adjudicate_frontier(
689 params: AdjudicateInput,
690) -> tuple[FrontierVerdict, ...]:
691 """Run the model over each frontier item; return aligned verdicts."""
692 from froot.adapters.determinism_judge import DeterminismFrontierJudge
693 
694 judge = DeterminismFrontierJudge()
695 verdicts: list[FrontierVerdict] = []
696 for item in params.frontier:
697 verdicts.append(await judge.adjudicate(item))
698 return tuple(verdicts)
699 
700 
701@activity.defn
702async def post_review(params: PostReviewInput) -> str | None:
703 """Upsert (or clear) the advisory comment; log the ledger row.
704 
705 Posts when there are findings, or when a prior comment must be cleared to
706 "all clear" (true decay — a PR whose hazards were fixed never keeps a stale
707 finding list). A clean PR with no prior comment stays silent.
708 """
709 from froot.adapters.github import GitHubForge
710 
711 forge = GitHubForge()
712 exists = await forge.find_marked_comment(
713 params.target, params.pr.number, REVIEW_MARKER
714 )
715 url: str | None = None
716 if should_post_review(
717 has_findings=bool(params.findings), comment_exists=exists
718 ):
719 body = render_review_comment(params.findings, params.pr.head_sha)
720 url = await forge.upsert_issue_comment(
721 params.target, params.pr.number, REVIEW_MARKER, body
722 )
723 _review_log.info(
724 json.dumps(
725 {
726 "event": "loop_outcome",
727 "loop": "determinism-review",
728 "repo": params.target.repo.slug,
729 "pr": params.pr.number,
730 "head_sha": params.pr.head_sha,
731 "findings": len(params.findings),
732 "rules": sorted({f.rule for f in params.findings}),
733 "comment_url": url,
734 }
735 )
736 )
737 return url
738 
739 
740# ── The a11y reviewer (the source-level design-system ring) ──────────────────
741@activity.defn
742async def scan_pr_a11y(params: PrA11yReviewParams) -> A11yAnalysis:
743 """Check out the PR head and scan its changed templates for a11y risks.
744 
745 Scoped to the PR's changed Vue/JSX templates (shift-left: review what came
746 in), so the advisory stays bounded and high-signal. The source lines are
747 read into memory in the activity, so the pure scan is unaffected by the
748 workspace being cleaned up.
749 """
750 from froot.adapters.github import GitHubForge
751 from froot.adapters.web_source import load_web_sources
752 
753 forge = GitHubForge()
754 changed = await forge.list_pull_request_files(
755 params.target, params.pr.number
756 )
757 with tempfile.TemporaryDirectory() as tmp:
758 workspace = Path(tmp)
759 await forge.checkout_pull_request(
760 params.target, workspace, params.pr.number
761 )
762 sources = load_web_sources(workspace, changed)
763 candidates = scan_sources(sources)
764 return A11yAnalysis(candidates=candidates, scanned_files=len(sources))
765 
766 
767@activity.defn
768async def adjudicate_a11y(
769 params: AdjudicateA11yInput,
770) -> tuple[A11yVerdict, ...]:
771 """Run the model over each flagged candidate; return aligned verdicts."""
772 from froot.adapters.a11y_judge import A11ySourceJudge
773 
774 judge = A11ySourceJudge()
775 verdicts: list[A11yVerdict] = []
776 for candidate in params.candidates:
777 verdicts.append(await judge.adjudicate(candidate))
778 return tuple(verdicts)
779 
780 
781@activity.defn
782async def post_a11y_review(params: PostA11yInput) -> str | None:
783 """Upsert (or clear) the advisory comment; log the ledger row.
784 
785 Posts when there are findings, or when a prior comment must be cleared to
786 "all clear" (true decay — a PR whose gaps were fixed never keeps a stale
787 finding list, the bug the determinism reviewer leaves). A clean PR with
788 no prior comment stays silent, so a clean PR is never spammed.
789 """
790 from froot.adapters.github import GitHubForge
791 
792 forge = GitHubForge()
793 exists = await forge.find_marked_comment(
794 params.target, params.pr.number, A11Y_MARKER
795 )
796 url: str | None = None
797 if should_post(has_findings=bool(params.findings), comment_exists=exists):
798 body = render_a11y_comment(params.findings, params.pr.head_sha)
799 url = await forge.upsert_issue_comment(
800 params.target, params.pr.number, A11Y_MARKER, body
801 )
802 _a11y_log.info(
803 json.dumps(
804 {
805 "event": "loop_outcome",
806 "loop": "a11y-review",
807 "repo": params.target.repo.slug,
808 "pr": params.pr.number,
809 "head_sha": params.pr.head_sha,
810 "findings": len(params.findings),
811 "kinds": sorted({f.kind for f in params.findings}),
812 "comment_url": url,
813 }
814 )
815 )
816 return url
817 
818 
819@activity.defn
820async def dispatch_pr_a11y_review(params: DispatchA11yInput) -> None:
821 """Start a PR's a11y review (idempotent per PR + head SHA)."""
822 from temporalio.common import WorkflowIDReusePolicy
823 from temporalio.exceptions import WorkflowAlreadyStartedError
824 
825 from froot.workflow.pr_a11y_review_workflow import PrA11yReviewWorkflow
826 from froot.workflow.temporal_client import client, task_queue
827 
828 temporal = await client()
829 try:
830 await temporal.start_workflow(
831 PrA11yReviewWorkflow.run,
832 PrA11yReviewParams(target=params.target, pr=params.pr),
833 id=pr_a11y_review_workflow_id(
834 params.target, params.pr.number, params.pr.head_sha
835 ),
836 task_queue=task_queue(),
837 id_reuse_policy=WorkflowIDReusePolicy.REJECT_DUPLICATE,
838 )
839 except WorkflowAlreadyStartedError:
840 # This (PR, head SHA) already has an a11y review — a no-op, so
841 # re-polling never double-reviews the same commit.
842 return

And the changelog-judge framing in the model adapter:

src/froot/adapters/model_judge.py · 218 lines
src/froot/adapters/model_judge.py218 lines · Python
⋯ 93 lines hidden (lines 1–93)
1"""The thin model judgment: how risky is this dependency bump's changelog?
2 
3froot's one model call. A Pydantic AI agent reads the changelog and returns a
4typed :class:`_Assessment`, which :func:`assessment_to_verdict` maps to the
5domain :data:`~froot.domain.changelog.ChangelogVerdict`. The verdict is framing,
6not a gate — the spine proposes the bump regardless — so even a "risky" reading
7just shapes the PR. The *loop* shapes what the model is asked: a patch bump asks
8"is this clean?", a security bump (often a minor/major) asks "what breaks?". The
9model is injected, so tests run it offline with a ``TestModel`` /
10``FunctionModel``; the mapping is a pure function, tested apart from any model.
11"""
12 
13from __future__ import annotations
14 
15from typing import TYPE_CHECKING, Literal, assert_never
16 
17from pydantic import BaseModel, Field
18from pydantic_ai import Agent
19 
20from froot.adapters.model import build_model
21from froot.domain.changelog import (
22 CleanVerdict,
23 RiskyVerdict,
24 UnknownVerdict,
26from froot.domain.loop import Loop
27 
28if TYPE_CHECKING:
29 from pydantic_ai.models import Model
30 
31 from froot.domain.changelog import Changelog, ChangelogVerdict
32 from froot.domain.removal import Removal
33 
34_SYSTEM_PROMPT = (
35 "You assess the changelog of a dependency upgrade for a code-maintenance "
36 "bot. The bot proposes the bump either way; your job is only to frame the "
37 "risk for the human reviewer.\n"
38 "Return one verdict:\n"
39 "- clean: the notes describe only fixes / docs / internal changes with no "
40 "behavioral or API impact.\n"
41 "- risky: the notes hint at behavior change, deprecations, or anything a "
42 "reviewer should look at closely; list the concerns.\n"
43 "- unknown: the notes are empty or uninformative.\n"
44 "Quote-or-omit: base 'risky' concerns on what the text actually says; do "
45 "not speculate. Keep the rationale to one or two sentences."
47 
48# The gate reviewer's stance is adversarial and asymmetric: this bump is about
49# to merge with NO human in the loop, so the burden is on the changelog to prove
50# it is safe, not on the reviewer to prove it is dangerous. The same _Assessment
51# shape, read as approve/hold (clean = approve).
52_GATE_SYSTEM_PROMPT = (
53 "You are the LAST line of defense before a dependency upgrade auto-merges "
54 "with no human review. Re-read the changelog adversarially and decide "
55 "whether it is safe to merge unattended.\n"
56 "Return one verdict:\n"
57 "- clean: ONLY if the notes clearly describe nothing beyond fixes / docs / "
58 "internal changes — no behavioral change, no API change, no deprecation, "
59 "no ambiguity. This approves the merge.\n"
60 "- risky: any hint of behavior change, removal, deprecation, or surface a "
61 "reviewer should see; list the concerns. This holds the PR.\n"
62 "- unknown: the notes are empty, uninformative, or you are unsure. This "
63 "holds the PR.\n"
64 "The burden is on the changelog to prove safety: when in doubt, do NOT "
65 "return clean. Base concerns on what the text says; keep it to one or two "
66 "sentences."
68 
69 
70# The dead-code loop's thin judgment: a static analyzer flagged a dependency as
71# never imported, but "not imported" is not "unused" — CLI/test/build tools
72# (pytest, eslint, tsc), framework plugins, type-only/peer deps, and dynamically
73# loaded packages are all used without an import. This judge vetoes those so the
74# loop never opens a noisy "remove pytest" PR; CI is still the final oracle, but
75# a wrong removal wastes a human's time, so the burden is on "safe".
76_REMOVAL_SYSTEM_PROMPT = (
77 "You decide whether an UNUSED dependency is safe to remove from a project, "
78 "for a code-maintenance bot. A static analyzer flagged it as never "
79 "imported in the source. But 'not imported' does not always mean "
80 "'unused'.\n"
81 "Return one verdict:\n"
82 "- clean: a normal library that, if genuinely never imported, the build no "
83 "longer needs — safe to remove. This proposes the removal.\n"
84 "- risky: plausibly used WITHOUT an import — a CLI/test/build tool run as "
85 "a command (e.g. pytest, eslint, prettier, tsc, ruff), a framework or "
86 "bundler plugin, a type-only or peer dependency, or something loaded "
87 "dynamically or via config. Say which. This holds it back (no PR).\n"
88 "- unknown: you cannot tell. This holds it back.\n"
89 "CI is the final check, but a wrong removal wastes a human's time, so when "
90 "in doubt do NOT return clean. Keep the rationale to one or two sentences."
92 
93 
94def _loop_context(loop: Loop) -> str:
95 """The framing line telling the model what kind of bump it is judging.
96 
97 Sourced from the loop's registered spec (see :mod:`froot.loops`). Only
98 changelog-judging loops reach this; a loop without a changelog judge
99 (dead-code, whose judgment is a signal-time safe-to-remove veto) has a
100 ``judge_context`` of ``None`` and never gets here — the empty fallback just
101 keeps the call total.
102 """
103 from froot.loops import registry
104 
105 return registry.commit_tail(loop).judge_context or ""
⋯ 113 lines hidden (lines 106–218)
106 
107 
108class _Assessment(BaseModel):
109 """The model's structured output, mapped to a domain verdict."""
110 
111 verdict: Literal["clean", "risky", "unknown"]
112 rationale: str
113 concerns: list[str] = Field(default_factory=list)
114 
115 
116def assessment_to_verdict(assessment: _Assessment) -> ChangelogVerdict:
117 """Map the model's structured assessment to a domain verdict (pure)."""
118 match assessment.verdict:
119 case "clean":
120 return CleanVerdict(rationale=assessment.rationale)
121 case "risky":
122 return RiskyVerdict(
123 rationale=assessment.rationale,
124 concerns=tuple(assessment.concerns),
125 )
126 case "unknown":
127 return UnknownVerdict(rationale=assessment.rationale)
128 assert_never(assessment.verdict)
129 
130 
131def _gate_model() -> Model:
132 """Build the gate reviewer's model: the override, else the judge model."""
133 from froot.config.settings import ModelSettings
134 
135 return build_model(model_name=ModelSettings().gate_review_model or None)
136 
137 
138def _changelog_prompt(changelog: Changelog, loop: Loop) -> str:
139 """The shared user prompt: the bump's context and its changelog text."""
140 return (
141 f"{_loop_context(loop)}\n"
142 f"Package: {changelog.package}\n"
143 f"Target version: {changelog.version}\n"
144 f"Changelog:\n{changelog.text}"
145 )
146 
147 
148def _removal_prompt(removal: Removal) -> str:
149 """The user prompt for the safe-to-remove judge: the flagged dependency."""
150 declared = "a dev dependency" if removal.dev else "a runtime dependency"
151 return (
152 f"Package: {removal.package}\n"
153 f"Ecosystem: {removal.ecosystem.value}\n"
154 f"Declared as: {declared}\n"
155 f"Detector note: {removal.justification or 'flagged as unused'}\n"
156 "Is this safe to remove?"
157 )
158 
159 
160class PydanticAiJudge:
161 """A :class:`~froot.ports.protocols.ModelJudge` backed by Pydantic AI.
162 
163 Two agents share the structured ``_Assessment`` output but differ in stance:
164 the neutral framing judge (:meth:`judge`) and the adversarial gate reviewer
165 (:meth:`gate_review`), each its own model so a steward can make the deep
166 review independent in capability, not just prompt.
167 """
168 
169 def __init__(
170 self, model: Model | None = None, gate_model: Model | None = None
171 ) -> None:
172 """Build both agents.
173 
174 ``model`` defaults to the configured Ollama; ``gate_model`` to the
175 gate-review override, else ``model``, else the gate-review model.
176 """
177 self._agent: Agent[None, _Assessment] = Agent(
178 model or build_model(),
179 output_type=_Assessment,
180 system_prompt=_SYSTEM_PROMPT,
181 )
182 self._gate_agent: Agent[None, _Assessment] = Agent(
183 gate_model or model or _gate_model(),
184 output_type=_Assessment,
185 system_prompt=_GATE_SYSTEM_PROMPT,
186 )
187 # The dead-code loop's safe-to-remove judge; the neutral model, its own
188 # prompt. It is a veto (clean = propose), so it carries real weight —
189 # but CI remains the oracle.
190 self._removal_agent: Agent[None, _Assessment] = Agent(
191 model or build_model(),
192 output_type=_Assessment,
193 system_prompt=_REMOVAL_SYSTEM_PROMPT,
194 )
195 
196 async def judge(
197 self, changelog: Changelog, loop: Loop = Loop.DEPENDENCY_PATCH
198 ) -> ChangelogVerdict:
199 """Assess a changelog into a verdict, framed by the loop."""
200 result = await self._agent.run(_changelog_prompt(changelog, loop))
201 return assessment_to_verdict(result.output)
202 
203 async def gate_review(
204 self, changelog: Changelog, loop: Loop = Loop.DEPENDENCY_PATCH
205 ) -> ChangelogVerdict:
206 """Adversarially re-review a bump at the gate (clean = approve)."""
207 result = await self._gate_agent.run(_changelog_prompt(changelog, loop))
208 return assessment_to_verdict(result.output)
209 
210 async def judge_removal(self, removal: Removal) -> ChangelogVerdict:
211 """Assess whether an unused dependency is safe to remove (clean = yes).
212 
213 The dead-code loop's veto: ``clean`` lets the removal become a PR; any
214 other verdict holds it back. Same ``_Assessment`` shape as the changelog
215 judge, a different prompt.
216 """
217 result = await self._removal_agent.run(_removal_prompt(removal))
218 return assessment_to_verdict(result.output)

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
src/froot/domain/loop.py33 lines · Python
1"""The maintenance loops froot runs, and how each is namespaced.
2 
3froot's durable chassis is loop-agnostic; only the signal, the candidate policy,
4and a little namespacing make a loop a specialist. Two families ship. The acting
5(commit-or-revert) loops propose a PR the spine gates and merges:
6:data:`Loop.DEPENDENCY_PATCH` (keep dependencies patched),
7:data:`Loop.SECURITY_PATCH` (clear known advisories), and :data:`Loop.DEAD_CODE`
8(remove unused dependencies). The advisory (emit-signal) loops read open PRs and
9leave one decaying comment, never a merge:
10:data:`Loop.DETERMINISM_REVIEW` (transitive Temporal-determinism hazards) and
11:data:`Loop.A11Y_REVIEW` (source-level accessibility gaps). The family is the
12loop's :class:`~froot.loops.registry.LoopSpec` tail; this enum is just the key.
13 
14A loop's *value* is the kebab name that namespaces everything it owns — the
15branch prefix (``froot/<loop>``), the PR label, the workflow ids, and the
16structured-log identity — so two loops never collide on a branch or a workflow
17id even when they touch the same package. A further loop is one more member plus
18its registered spec; the chassis it runs on does not change.
19"""
20 
21from __future__ import annotations
22 
23from enum import StrEnum
24 
25 
26class Loop(StrEnum):
27 """A maintenance loop froot points at a repo."""
28 
29 DEPENDENCY_PATCH = "dependency-patch"
30 SECURITY_PATCH = "security-patch"
31 DEAD_CODE = "dead-code"
32 DETERMINISM_REVIEW = "determinism-review"
33 A11Y_REVIEW = "a11y-review"

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
src/froot/loops/determinism_review.py25 lines · Python
⋯ 15 lines hidden (lines 1–15)
1"""The determinism-review loop: advisory comments on a PR's determinism risk.
2 
3Scans a repo's open PRs and upserts one decaying comment per PR on the
4transitive Temporal-determinism hazards reachable from its workflows — no
5candidate, no PR of its own, no gate, no merge. The runtime (ReviewWorkflow +
6PrReviewWorkflow) is still its own; this spec makes the loop a registry
7citizen so the dashboard and config derive it uniformly with the others.
8"""
9 
10from __future__ import annotations
11 
12from froot.domain.loop import Loop
13from froot.loops.registry import AdvisoryTail, LoopSpec, register
14from froot.policy.review_comment import REVIEW_MARKER
15 
16register(
17 LoopSpec(
18 loop=Loop.DETERMINISM_REVIEW,
19 dashboard_icon="search",
20 tail=AdvisoryTail(
21 marker=REVIEW_MARKER,
22 panel_title="Determinism review",
23 ),
24 )
src/froot/loops/a11y_review.py · 25 lines
src/froot/loops/a11y_review.py25 lines · Python
⋯ 15 lines hidden (lines 1–15)
1"""The a11y-review loop: advisory comments on source-level a11y gaps.
2 
3Scans a repo's open PRs and upserts one decaying comment per PR on the changed
4templates' accessibility gaps — no candidate, no PR of its own, no gate, no
5merge. The runtime (the per-repo A11yReviewWorkflow + per-PR
6PrA11yReviewWorkflow) is still its own; this spec makes the loop a registry
7citizen so the dashboard and config derive it uniformly with the others.
8"""
9 
10from __future__ import annotations
11 
12from froot.domain.loop import Loop
13from froot.loops.registry import AdvisoryTail, LoopSpec, register
14from froot.policy.a11y_comment import A11Y_MARKER
15 
16register(
17 LoopSpec(
18 loop=Loop.A11Y_REVIEW,
19 dashboard_icon="accessibility",
20 tail=AdvisoryTail(
21 marker=A11Y_MARKER,
22 panel_title="A11y review",
23 ),
24 )

_ensure() imports both new modules so they self-register on first read, alongside the three acting loops.

src/froot/loops/registry.py · 174 lines
src/froot/loops/registry.py174 lines · Python
⋯ 133 lines hidden (lines 1–133)
1"""The loop registry: froot's open loop catalog, keyed by :class:`Loop`.
2 
3The chassis schedules, dispatches, verifies, and gates; a :class:`LoopSpec` is a
4shared core (the loop key, the dashboard icon) plus a disposition-tagged
5*tail* — a :class:`CommitTail` (an acting loop's signal→candidate ``observe``,
6PR-title verb, changelog framing, reconcile trait) or an :class:`AdvisoryTail`
7(an advisory loop's comment marker and dashboard title). The tail's TYPE is the
8discriminant, so :attr:`LoopSpec.disposition` is derived, never stored beside
9inert fields of the other family. Everything else (the scan/dispatch chassis,
10the merge gate, earned autonomy, the dashboard, the workflow-id namespace) is
11loop-agnostic and stays in the spine.
12 
13This cut registers the three acting (CommitTail) loops; the advisory loops fold
14in behind :class:`AdvisoryTail` next.
15 
16Loops self-register at import. The registry is populated lazily on first read
17(see :func:`_ensure`) so importing a consumer never forces a particular import
18order, and the per-loop adapter imports stay inside each ``observe`` body.
19"""
20 
21from __future__ import annotations
22 
23from dataclasses import dataclass
24from enum import StrEnum
25from typing import TYPE_CHECKING
26 
27if TYPE_CHECKING:
28 from collections.abc import Awaitable, Callable
29 from pathlib import Path
30 
31 from froot.domain.loop import Loop
32 from froot.domain.repo import TargetRepo
33 from froot.domain.work import WorkItem
34 from froot.ports.protocols import PackageManager
35 
36 # Check out a repo's manifest and select this loop's work items, returning
37 # (considered, items) — ``considered`` is the upstream signal size, so the
38 # scan tick can show how much was seen versus kept.
39 ObserveFn = Callable[
40 [TargetRepo, PackageManager, Path],
41 Awaitable[tuple[int, tuple[WorkItem, ...]]],
42 ]
43 
44 
45class Disposition(StrEnum):
46 """How a loop's work item terminates (the commit-vs-emit fork)."""
47 
48 # Propose a PR; the spine gates the merge (the acting loops).
49 COMMIT_OR_REVERT = "commit-or-revert"
50 # Upsert a decaying advisory comment/label; no merge, no gate (advisory).
51 EMIT_SIGNAL = "emit-signal"
52 
53 
54@dataclass(frozen=True)
55class CommitTail:
56 """The COMMIT_OR_REVERT per-loop seams (an acting loop that opens a PR).
57 
58 Attributes:
59 observe: The signal→candidate seam — the one genuinely per-loop body.
60 title_prefix: The PR-title verb (``deps`` / ``security`` /
61 ``dead-code``) — a per-loop label, not derivable from the loop name.
62 judge_context: The framing line for the in-loop changelog judge, or
63 ``None`` when the loop does no changelog judging (e.g. dead-code,
64 whose judgment is a safe-to-remove veto *at the signal*).
65 reconciles: Whether version-supersession reconcile applies — ``True``
66 for bump loops, ``False`` for a removal (no version to overtake).
67 """
68 
69 observe: ObserveFn
70 title_prefix: str
71 judge_context: str | None = None
72 reconciles: bool = True
73 
74 
75@dataclass(frozen=True)
76class AdvisoryTail:
77 """The EMIT_SIGNAL per-loop seams (an advisory loop that comments on PRs).
78 
79 An advisory loop scans a repo's open PRs and upserts one decaying comment
80 per PR — no candidate, no PR of its own, no gate, no merge.
81 
82 Attributes:
83 marker: The HTML-comment marker that finds this loop's single per-PR
84 comment (the upsert/decay key, also the dashboard's query key).
85 panel_title: The dashboard tab/panel title (e.g. "Determinism review").
86 """
87 
88 marker: str
89 panel_title: str
90 
91 
92@dataclass(frozen=True)
93class LoopSpec:
94 """One loop's entry — a shared core plus a disposition-tagged tail.
95 
96 The tail's TYPE is the discriminant: a :class:`CommitTail` is an acting
97 (commit-or-revert) loop, an :class:`AdvisoryTail` is an emit-signal loop. No
98 spec ever carries an inert ``observe`` beside an inert ``marker`` — the
99 family machinery lives behind the tail, not beside it.
100 
101 Attributes:
102 loop: The loop this spec specialises (the registry key).
103 dashboard_icon: The icon key for this loop's dashboard tab (one of the
104 renderer's ``_ICONS``) — shared by both families.
105 tail: The per-loop seams for this loop's family (commit vs advisory).
106 
107 The workflow-id/branch namespace segment is NOT carried here: it is a pure
108 derivation in :mod:`froot.policy.naming`.
109 """
110 
111 loop: Loop
112 dashboard_icon: str
113 tail: CommitTail | AdvisoryTail
114 
115 @property
116 def disposition(self) -> Disposition:
117 """The family, derived from the tail's type (one conceptual field)."""
118 return (
119 Disposition.COMMIT_OR_REVERT
120 if isinstance(self.tail, CommitTail)
121 else Disposition.EMIT_SIGNAL
122 )
123 
124 
125_LOOPS: dict[Loop, LoopSpec] = {}
126_REGISTERED = False
127 
128 
129def register(spec: LoopSpec) -> None:
130 """Register a loop spec (idempotent on re-import; last write wins)."""
131 _LOOPS[spec.loop] = spec
132 
133 
134def _ensure() -> None:
135 """Import the loop modules once so they self-register (lazy, idempotent)."""
136 global _REGISTERED
137 if _REGISTERED:
138 return
139 _REGISTERED = True
140 # Importing each module runs its module-level register(...) call. Kept here,
141 # not at package import, so the registry has no import-order constraints.
142 from froot.loops import ( # noqa: F401
143 a11y_review,
144 dead_code,
145 dependency_patch,
146 determinism_review,
147 security_patch,
148 )
⋯ 26 lines hidden (lines 149–174)
149 
150 
151def get(loop: Loop) -> LoopSpec:
152 """The registered spec for a loop (raises ``KeyError`` if unregistered)."""
153 _ensure()
154 return _LOOPS[loop]
155 
156 
157def commit_tail(loop: Loop) -> CommitTail:
158 """The :class:`CommitTail` of an acting loop (asserts it is one).
159 
160 The acting spine reads its per-loop seams (observe / title / judge /
161 reconcile) through this, so a non-commit loop reaching an acting code path
162 fails loudly instead of returning a half-typed tail.
163 """
164 tail = get(loop).tail
165 if not isinstance(tail, CommitTail):
166 msg = f"{loop} is not a commit-or-revert loop"
167 raise TypeError(msg)
168 return tail
169 
170 
171def all_specs() -> tuple[LoopSpec, ...]:
172 """Every registered loop spec, in registration order."""
173 _ensure()
174 return tuple(_LOOPS.values())

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
src/froot/policy/review_comment.py120 lines · Python
⋯ 68 lines hidden (lines 1–68)
1"""Synthesize and render the determinism reviewer's advisory PR comment (pure).
2 
3One marker-tagged comment per PR, upserted in place on each new head SHA (the
4marker lets the forge find-and-update rather than stack comments — a reviewer
5that spams is the entropy it exists to prevent). Findings are synthesized from
6the static transitive hazards and the model-adjudicated frontier; the comment is
7advisory — the blocking gate is the kernel's ``Determinism`` CI check.
8"""
9 
10from __future__ import annotations
11 
12from typing import TYPE_CHECKING
13 
14from froot.domain.determinism import ReviewFinding
15 
16if TYPE_CHECKING:
17 from collections.abc import Sequence
18 
19 from froot.domain.determinism import (
20 FrontierItem,
21 FrontierVerdict,
22 HazardPath,
23 )
24 
25REVIEW_MARKER = "<!-- froot:determinism-review -->"
26 
27_THIRD_PARTY_HINT = "move this dependency behind an activity"
28 
29 
30def synthesize_findings(
31 hazards: Sequence[HazardPath],
32 frontier: Sequence[FrontierItem],
33 verdicts: Sequence[FrontierVerdict],
34) -> tuple[ReviewFinding, ...]:
35 """Combine confirmed hazards and model-confirmed frontier into findings.
36 
37 ``frontier`` and ``verdicts`` are index-aligned (one verdict per item). Only
38 items the model judged ``reaches == "yes"`` are surfaced; ``no`` and
39 ``uncertain`` are dropped to keep the advisory comment high-signal.
40 """
41 findings: list[ReviewFinding] = [
42 ReviewFinding(
43 origin="static",
44 workflow=h.workflow,
45 detail="".join((*h.via, h.impurity.rule)),
46 rule=h.impurity.rule,
47 hint=h.impurity.hint,
48 module=h.impurity.module,
49 line=h.impurity.line,
50 )
51 for h in hazards
52 ]
53 for item, verdict in zip(frontier, verdicts, strict=True):
54 if verdict.reaches == "yes":
55 findings.append(
56 ReviewFinding(
57 origin="model",
58 workflow=item.workflow,
59 detail=verdict.rationale,
60 rule=item.symbol,
61 hint=_THIRD_PARTY_HINT,
62 module=item.module,
63 line=item.line,
64 )
65 )
66 return tuple(findings)
67 
68 
69def render_review_comment(
70 findings: Sequence[ReviewFinding], head_sha: str
71) -> str:
72 """Render the marker-tagged comment body (always a body, for true decay).
73 
74 With findings, the hazard list; with none, an explicit all-clear so a PR
75 whose hazards were fixed gets its comment overwritten instead of lingering
76 stale. :func:`should_post` decides whether this body is actually posted.
77 """
78 out = [REVIEW_MARKER, "", "### 🧭 froot determinism review", ""]
79 if not findings:
80 out.append(
81 "✅ No transitive determinism hazards reachable from this repo's "
82 f"Temporal workflows at `{head_sha[:7]}`."
83 )
84 out.extend(
85 ["", "_Reviewed by froot · re-runs update this comment in place._"]
86 )
87 return "\n".join(out)
88 out.append(
89 "Transitive determinism hazards reachable from this repo's "
90 f"Temporal workflows at `{head_sha[:7]}`. **Advisory** — the "
91 "blocking gate is the `Determinism` CI check; this loop catches "
92 "what that lexical check can't see across calls."
93 )
94 out.append("")
95 for finding in findings:
96 origin = (
97 "static call-path"
98 if finding.origin == "static"
99 else "model-assessed"
100 )
101 out.append(f"- **`{finding.rule}`** — {finding.hint}")
102 out.append(
103 f" - reached from `{finding.workflow}`, "
104 f"at `{finding.module}:{finding.line}` ({origin})"
105 )
106 out.append(f" - path: `{finding.detail}`")
107 out.extend(
108 ["", "_Reviewed by froot · re-runs update this comment in place._"]
109 )
110 return "\n".join(out)
111 
112 
113def should_post(*, has_findings: bool, comment_exists: bool) -> bool:
114 """Whether to (re)post the comment this tick — the decay rule.
115 
116 Post when there is something to say OR a prior comment must be cleared. A
117 clean PR that never had a comment stays silent; a PR whose hazards were
118 fixed gets its comment overwritten with the all-clear.
119 """
120 return has_findings or comment_exists

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
src/froot/workflow/activities.py842 lines · Python
⋯ 700 lines hidden (lines 1–700)
1"""Activities: the effect interpreters that wrap the adapters.
2 
3Each activity is the impure boundary for one effect. Adapter imports are LAZY
4(inside the bodies) so the model and HTTP stacks never enter a workflow sandbox;
5the pure policy and domain imports stay at module level. Activities return
6domain values — the workflow wraps them into events and feeds the pure state
7machine. The activity signatures' types are evaluated at runtime by Temporal's
8data converter, so the domain imports here are deliberately not deferred.
9"""
10 
11from __future__ import annotations
12 
13import json
14import logging
15import tempfile
16from pathlib import Path
17from typing import TYPE_CHECKING, assert_never
18 
19from temporalio import activity
20 
21from froot.domain.a11y import A11yAnalysis, A11yVerdict
22from froot.domain.candidate import Candidate
23from froot.domain.changelog import (
24 ChangelogVerdict,
25 CleanVerdict,
26 UnknownVerdict,
28from froot.domain.ci import CIStatus
29from froot.domain.determinism import AnalysisResult, FrontierVerdict
30from froot.domain.pull_request import PullRequestDraft, PullRequestRef
31from froot.domain.removal import Removal
32from froot.domain.repo import TargetRepo
33from froot.domain.work import WorkItem
34from froot.policy.a11y_comment import (
35 A11Y_MARKER,
36 render_a11y_comment,
37 should_post,
39from froot.policy.a11y_scan import scan_sources
40from froot.policy.compose import (
41 pr_labels,
42 pull_request_draft,
43 removal_pull_request_draft,
45from froot.policy.determinism import analyze_workflow_surface
46from froot.policy.naming import (
47 branch_name,
48 bump_workflow_id,
49 pr_a11y_review_workflow_id,
50 pr_review_workflow_id,
52from froot.policy.review_comment import (
53 REVIEW_MARKER,
54 render_review_comment,
56from froot.policy.review_comment import (
57 should_post as should_post_review,
59from froot.workflow.types import (
60 AdjudicateA11yInput,
61 AdjudicateInput,
62 AutoMergeInput,
63 CiCheckInput,
64 CloseInput,
65 DispatchA11yInput,
66 DispatchInput,
67 DispatchReviewInput,
68 GateReviewInput,
69 GateSelfTestInput,
70 JudgeInput,
71 MergeInput,
72 OpenPrInput,
73 PostA11yInput,
74 PostReviewInput,
75 PrA11yReviewParams,
76 PrReviewParams,
77 ReconcileInput,
78 RecordInput,
79 ScanCandidatesInput,
81 
82if TYPE_CHECKING:
83 from froot.domain.loop import Loop
84 from froot.ports.protocols import PackageManager
85 
86_log = logging.getLogger("froot.outcome")
87_review_log = logging.getLogger("froot.review")
88_a11y_log = logging.getLogger("froot.a11y")
89_reconcile_log = logging.getLogger("froot.reconcile")
90_scan_log = logging.getLogger("froot.scan")
91_gate_log = logging.getLogger("froot.gate")
92 
93 
94def _manifest_dir(target: TargetRepo, workspace: Path) -> Path:
95 """The directory the manifest lives in (a monorepo subdir, or the root)."""
96 return workspace / target.manifest_dir if target.manifest_dir else workspace
97 
98 
99def _draft_for(params: OpenPrInput) -> PullRequestDraft:
100 """Build the PR draft for the work item — bump vs removal compose.
101 
102 The PR-title verb is the loop's, resolved from its registered spec here (the
103 impure boundary) and passed into the pure draft builders.
104 """
105 from froot.loops import registry
106 
107 item = params.candidate
108 title_prefix = registry.commit_tail(params.loop).title_prefix
109 match item:
110 case Candidate():
111 return pull_request_draft(
112 params.target,
113 item,
114 params.verdict,
115 params.loop,
116 title_prefix=title_prefix,
117 )
118 case Removal():
119 return removal_pull_request_draft(
120 params.target, item, params.loop, title_prefix=title_prefix
121 )
122 assert_never(item)
123 
124 
125async def _select_candidates(
126 loop: Loop,
127 target: TargetRepo,
128 package_manager: PackageManager,
129 manifest_dir: Path,
130) -> tuple[int, tuple[WorkItem, ...]]:
131 """Gather this loop's signal from the checkout and select its work items.
132 
133 The one genuinely per-loop seam: dependency-patch reads the available
134 upgrades and picks the highest patch; security-patch reads the installed,
135 asks OSV for advisories, and picks the lowest version clearing each;
136 dead-code reads the unused dependencies a static analyzer flags and vetoes
137 each with the safe-to-remove judge. The impure sources are lazy-imported per
138 arm so none drags another's stack into a sandbox. Each feeds a pure (or, for
139 dead-code, model-vetoed) selection.
140 
141 Returns ``(considered, items)`` — ``considered`` is the size of the upstream
142 signal (available upgrades / advisories found / unused deps flagged) so the
143 scan can make its selectivity legible (how much was seen versus kept).
144 
145 The one genuinely per-loop body now lives in each loop's spec ``observe``
146 (see :mod:`froot.loops`); the spine looks the loop up and runs it, so this
147 selection seam needs no per-loop arm.
148 """
149 from froot.loops import registry
150 
151 return await registry.commit_tail(loop).observe(
152 target, package_manager, manifest_dir
153 )
154 
155 
156@activity.defn
157async def scan_candidates(
158 params: ScanCandidatesInput,
159) -> tuple[WorkItem, ...]:
160 """Check out the repo and select this loop's candidates.
161 
162 Emits the tick's selectivity — how much upstream signal was considered
163 versus how much was kept — as span attributes (when ``FROOT_OTEL`` is on)
164 and as a structured ``scan_tick`` log, so the signal stage is legible in the
165 run ledger even on a tick that proposes nothing.
166 """
167 from froot.adapters.github import GitHubForge
168 from froot.adapters.registry import package_manager_for
169 from froot.adapters.telemetry import set_span_attributes
170 
171 forge = GitHubForge()
172 package_manager = package_manager_for(params.target.ecosystem)
173 with tempfile.TemporaryDirectory() as tmp:
174 workspace = Path(tmp)
175 await forge.checkout(params.target, workspace)
176 considered, candidates = await _select_candidates(
177 params.loop,
178 params.target,
179 package_manager,
180 _manifest_dir(params.target, workspace),
181 )
182 selected = len(candidates)
183 dropped = max(considered - selected, 0)
184 set_span_attributes(
185 scan_loop=params.loop.value,
186 scan_repo=params.target.repo.slug,
187 scan_considered=considered,
188 scan_selected=selected,
189 scan_dropped=dropped,
190 )
191 _scan_log.info(
192 json.dumps(
193 {
194 "event": "scan_tick",
195 "loop": params.loop.value,
196 "repo": params.target.repo.slug,
197 "considered": considered,
198 "selected": selected,
199 "dropped": dropped,
200 }
201 )
202 )
203 return candidates
204 
205 
206@activity.defn
207async def judge_changelog(params: JudgeInput) -> ChangelogVerdict:
208 """Fetch the candidate's changelog and get the model's typed verdict.
209 
210 The model is froot's one thin, non-load-bearing judgment: a clean verdict
211 never *gates* a PR (CI is the oracle), so a model that is down, slow, or
212 erroring must not stall the spine. A judge failure degrades to
213 ``UnknownVerdict`` — the bump proceeds, the human still gets the PR, and the
214 dashboard records the verdict as unknown — rather than failing (and then
215 retrying) the activity. Only the model call is guarded; the fetch is already
216 best-effort (returns ``None``, not an exception). The loop selects what the
217 model is asked (clean-patch vs breaking-change-on-a-security-bump).
218 """
219 from froot.adapters.changelog_http import HttpChangelogSource
220 from froot.adapters.model_judge import PydanticAiJudge
221 
222 candidate = params.candidate
223 if isinstance(candidate, Removal):
224 # A removal already cleared the safe-to-remove veto at scan; there is no
225 # changelog to read, so carry that conclusion straight into the PR
226 # framing (the rationale the veto recorded on the work item).
227 return CleanVerdict(
228 rationale=candidate.justification or "unused; safe to remove"
229 )
230 changelog = await HttpChangelogSource().fetch(candidate)
231 if changelog is None:
232 return UnknownVerdict(rationale="No changelog could be fetched.")
233 try:
234 return await PydanticAiJudge().judge(changelog, params.loop)
235 except Exception as exc:
236 activity.logger.warning(
237 "changelog judge unavailable for %s; degrading to unknown: %r",
238 params.candidate.package,
239 exc,
240 )
241 return UnknownVerdict(
242 rationale=f"Changelog judge unavailable ({type(exc).__name__})."
243 )
244 
245 
246@activity.defn
247async def gate_review(params: GateReviewInput) -> ChangelogVerdict:
248 """Independently deep-review a bump at the gate; fail-closed to a hold.
249 
250 The fourth trust leg (§3.7): a second, adversarial model pass over the
251 changelog, run only when a bump is about to auto-merge. ``clean`` approves
252 the merge; ``risky``/``unknown`` hold the PR for the human. Fail-CLOSED — a
253 missing changelog or a model error returns a non-clean verdict, so an
254 unreviewable bump never merges unattended. This is the opposite disposition
255 from :func:`judge_changelog` (which degrades-to-proceed): here the safe
256 direction is to hold, and a non-clean verdict already means hold.
257 """
258 from froot.adapters.changelog_http import HttpChangelogSource
259 from froot.adapters.model_judge import PydanticAiJudge
260 
261 candidate = params.candidate
262 verdict: ChangelogVerdict
263 if isinstance(candidate, Removal):
264 # The fourth leg for a removal: independently re-judge that it is safe
265 # to remove (the same check the scan veto ran). No changelog to read;
266 # fail-CLOSED to a hold on a model error, like the bump path.
267 try:
268 verdict = await PydanticAiJudge().judge_removal(candidate)
269 except Exception as exc:
270 activity.logger.warning(
271 "safe-to-remove gate review unavailable for %s; holding: %r",
272 candidate.package,
273 exc,
274 )
275 verdict = UnknownVerdict(
276 rationale=(
277 f"Removal reviewer unavailable ({type(exc).__name__})."
278 )
279 )
280 else:
281 changelog = await HttpChangelogSource().fetch(candidate)
282 if changelog is None:
283 verdict = UnknownVerdict(
284 rationale="No changelog to review; holding (fail-closed)."
285 )
286 else:
287 try:
288 verdict = await PydanticAiJudge().gate_review(
289 changelog, params.loop
290 )
291 except Exception as exc:
292 activity.logger.warning(
293 "gate reviewer unavailable for %s; holding: %r",
294 candidate.package,
295 exc,
296 )
297 verdict = UnknownVerdict(
298 rationale=(
299 f"Gate reviewer unavailable ({type(exc).__name__})."
300 )
301 )
302 _gate_log.info(
303 json.dumps(
304 {
305 "event": "gate_review",
306 "loop": params.loop.value,
307 "pr": params.pr.number,
308 "pr_url": params.pr.url,
309 "package": params.candidate.package,
310 "verdict": verdict.kind,
311 "approved": verdict.kind == "clean",
312 }
313 )
314 )
315 return verdict
316 
317 
318@activity.defn
319async def open_pull_request(params: OpenPrInput) -> PullRequestRef:
320 """Rewrite the manifest+lockfile and open (idempotently) the work item's PR.
321 
322 The one place the action differs by work-item kind: a bump regenerates the
323 lockfile at the target version, a removal deletes the dependency. Both are
324 lockfile-only (no install, no scripts); everything around them — the dedup
325 branch, the checkout, the push, the open — is the same chassis.
326 """
327 from froot.adapters.github import GitHubForge
328 from froot.adapters.registry import package_manager_for
329 
330 forge = GitHubForge()
331 package_manager = package_manager_for(params.target.ecosystem)
332 branch = branch_name(params.candidate, params.loop)
333 existing = await forge.find_open_pull_request(params.target, branch)
334 if existing is not None:
335 return existing
336 draft = _draft_for(params)
337 item = params.candidate
338 with tempfile.TemporaryDirectory() as tmp:
339 workspace = Path(tmp)
340 await forge.checkout(params.target, workspace)
341 manifest_dir = _manifest_dir(params.target, workspace)
342 match item:
343 case Candidate():
344 await package_manager.apply_patch_bump(item, manifest_dir)
345 case Removal():
346 await package_manager.remove_dependency(item, manifest_dir)
347 await forge.push_branch(workspace, branch, draft.title)
348 return await forge.open_pull_request(params.target, draft)
349 
350 
351@activity.defn
352async def check_ci(params: CiCheckInput) -> CIStatus:
353 """Read the repo's CI status for the PR's head commit (the oracle)."""
354 from froot.adapters.github import GitHubForge
355 
356 return await GitHubForge().ci_status(params.target, params.head_sha)
357 
358 
359@activity.defn
360async def record_outcome(params: RecordInput) -> None:
361 """Label the PR and log the run telemetry — the signal-update.
362 
363 The labels carry the loop *and* the judgment environment (the judge model)
364 the PR was opened under, so the gate can count only the track record earned
365 under the current environment and reset it when the model changes (§3.7).
366 """
367 from froot.adapters.github import GitHubForge
368 from froot.config.settings import ModelSettings
369 from froot.policy.environment import env_label
370 
371 outcome = params.outcome
372 item = outcome.candidate
373 labels = (
374 *pr_labels(params.loop),
375 env_label(ModelSettings().ollama_model),
376 )
377 await GitHubForge().add_labels(params.target, outcome.pr.number, labels)
378 record = {
379 "event": "loop_outcome",
380 "loop": params.loop.value,
381 "repo": params.target.repo.slug,
382 "package": item.package,
383 "changelog": outcome.verdict.kind,
384 "ci": outcome.ci.kind,
385 "ci_passed": outcome.ci_passed,
386 "pr": outcome.pr.number,
387 "pr_url": outcome.pr.url,
388 }
389 match item:
390 case Candidate():
391 record |= {
392 "action": "bump",
393 "from": str(item.current),
394 "to": str(item.target),
395 }
396 case Removal():
397 record |= {"action": "remove", "dev": item.dev}
398 _log.info(json.dumps(record))
399 
400 
401@activity.defn
402async def dispatch_bump(params: DispatchInput) -> None:
403 """Start the bump loop for a candidate (idempotent per bump identity).
404 
405 Reads the close-on-red toggle here, at the impure boundary, and pins it onto
406 the bump's params — so the running workflow never reads config itself and an
407 in-flight bump keeps the value it was dispatched with.
408 """
409 from temporalio.common import WorkflowIDReusePolicy
410 from temporalio.exceptions import WorkflowAlreadyStartedError
411 
412 from froot.config.settings import BehaviorSettings
413 from froot.workflow.bump_workflow import BumpWorkflow
414 from froot.workflow.temporal_client import client, task_queue
415 from froot.workflow.types import BumpParams
416 
417 temporal = await client()
418 try:
419 await temporal.start_workflow(
420 BumpWorkflow.run,
421 BumpParams(
422 target=params.target,
423 candidate=params.candidate,
424 close_on_red=BehaviorSettings().close_on_red,
425 loop=params.loop,
426 ),
427 id=bump_workflow_id(params.target, params.candidate, params.loop),
428 task_queue=task_queue(),
429 id_reuse_policy=WorkflowIDReusePolicy.REJECT_DUPLICATE,
430 )
431 except WorkflowAlreadyStartedError:
432 # This bump already has a loop (running or completed) — a no-op, so
433 # re-scanning never opens a second PR for the same bump.
434 return
435 
436 
437@activity.defn
438async def auto_merge_eligible(params: AutoMergeInput) -> bool:
439 """Whether this (repo, loop) class has earned the auto-merge grant.
440 
441 Short-circuits to ``False`` for any repo a steward has not allowlisted (the
442 default), so the common case costs nothing. Otherwise it re-derives the
443 class's standing from the live GitHub history — the same triangulated,
444 windowed, environment-scoped computation the dashboard's shadow gate shows
445 (``read_model.earned_now``) — so the acting gate and the advisory panel can
446 never disagree. Best-effort: any read failure degrades to ``False`` (hold),
447 never an auto-merge.
448 """
449 from datetime import UTC, datetime
450 
451 from froot.config.settings import AutonomySettings, ModelSettings
452 from froot.dashboard import github_source, read_model
453 from froot.policy.environment import environment_slug
454 
455 policy = AutonomySettings().policy()
456 repo = params.target.repo.slug
457 if repo not in policy.allowlisted_repos:
458 return False
459 now = datetime.now(UTC)
460 prs, prs_error = await github_source.fetch((repo,))
461 if prs_error is not None:
462 return False # can't confirm the record -> hold, never merge blind
463 outcomes, _ = await github_source.fetch_outcomes(
464 (repo,), prs, now=now, window_days=policy.window_days
465 )
466 return read_model.earned_now(
467 now,
468 prs,
469 outcomes,
470 repo,
471 params.loop,
472 policy,
473 environment_slug(ModelSettings().ollama_model),
474 )
475 
476 
477@activity.defn
478async def merge_pull_request(params: MergeInput) -> None:
479 """Auto-merge an earned, clean+green bump's PR (the acting gate's write).
480 
481 Reached only after the pure machine confirmed clean+green and the class
482 earned the grant on an allowlisted repo. Passes the head SHA so GitHub
483 refuses the merge if the head moved since the gate decided.
484 """
485 from froot.adapters.github import GitHubForge
486 
487 await GitHubForge().merge_pull_request(
488 params.target, params.pr.number, head_sha=params.pr.head_sha
489 )
490 _log.info(
491 json.dumps(
492 {
493 "event": "pr_merged",
494 "loop": params.loop.value,
495 "reason": "auto_merge",
496 "repo": params.target.repo.slug,
497 "pr": params.pr.number,
498 "pr_url": params.pr.url,
499 }
500 )
501 )
502 
503 
504@activity.defn
505async def close_pull_request(params: CloseInput) -> None:
506 """Comment why, then close a red bump's PR and delete its branch.
507 
508 The note goes through the idempotent ``upsert_issue_comment`` and the close
509 itself is idempotent, so a retried close edits its comment in place and
510 never double-posts. The bump's record step still runs after this, so the red
511 outcome is logged either way.
512 """
513 from froot.adapters.github import GitHubForge
514 from froot.policy.compose import CLOSE_MARKER, closed_on_red_comment
515 
516 forge = GitHubForge()
517 body = closed_on_red_comment(params.failing)
518 await forge.upsert_issue_comment(
519 params.target, params.pr.number, CLOSE_MARKER, body
520 )
521 await forge.close_pull_request(
522 params.target, params.pr.number, params.pr.branch
523 )
524 _log.info(
525 json.dumps(
526 {
527 "event": "pr_closed",
528 "loop": params.loop.value,
529 "reason": "ci_red",
530 "repo": params.target.repo.slug,
531 "pr": params.pr.number,
532 "pr_url": params.pr.url,
533 "failing": list(params.failing),
534 }
535 )
536 )
537 
538 
539@activity.defn
540async def reconcile_open_prs(params: ReconcileInput) -> int:
541 """Close this loop's PRs that a newer candidate or the base has overtaken.
542 
543 Self-contained: lists the repo's open PRs, re-derives this loop's live
544 candidates (a fresh checkout + the loop's signal, derive-never-store), asks
545 the pure :func:`~froot.policy.reconcile.reconciliations` policy: which of
546 loop's PRs to close, and closes each (deleting its branch). Scoped to the
547 loop's own branch namespace, so the two loops never reconcile each other's
548 PRs. A no-op when reconcile is off (``FROOT_RECONCILE``). Returns the count.
549 """
550 from froot.adapters.github import GitHubForge
551 from froot.adapters.registry import package_manager_for
552 from froot.config.settings import BehaviorSettings
553 from froot.policy.compose import CLOSE_MARKER
554 from froot.policy.reconcile import reconciliations
555 
556 if not BehaviorSettings().reconcile:
557 return 0
558 # Reconcile is version-supersession cleanup, which only bump loops have: a
559 # removal carries no version to be overtaken. A loop that does not reconcile
560 # (dead-code) is skipped rather than re-running its signal (knip + the veto
561 # judge) every tick only to close nothing. (A removal-specific reconcile —
562 # close when no longer unused — is future work.) The trait is on the spec.
563 from froot.loops import registry
564 
565 if not registry.commit_tail(params.loop).reconciles:
566 return 0
567 
568 target, loop = params.target, params.loop
569 forge = GitHubForge()
570 package_manager = package_manager_for(target.ecosystem)
571 open_prs = await forge.list_open_pull_requests(target)
572 with tempfile.TemporaryDirectory() as tmp:
573 workspace = Path(tmp)
574 await forge.checkout(target, workspace)
575 _considered, candidates = await _select_candidates(
576 loop, target, package_manager, _manifest_dir(target, workspace)
577 )
578 closures = reconciliations(open_prs, candidates, loop)
579 for closure in closures:
580 await forge.upsert_issue_comment(
581 target, closure.pr.number, CLOSE_MARKER, closure.comment
582 )
583 await forge.close_pull_request(
584 target, closure.pr.number, closure.pr.branch
585 )
586 if closures:
587 _reconcile_log.info(
588 json.dumps(
589 {
590 "event": "reconcile",
591 "loop": loop.value,
592 "repo": target.repo.slug,
593 "closed": len(closures),
594 "prs": [closure.pr.number for closure in closures],
595 }
596 )
597 )
598 return len(closures)
599 
600 
601@activity.defn
602async def gate_selftest(params: GateSelfTestInput) -> tuple[str, ...]:
603 """Run the adversarial gate probe against the live policy; alarm on escape.
604 
605 The §2.11 deliberate disturbance for the acting gate: a battery of synthetic
606 known-bad class histories a healthy gate must refuse, scored against the
607 policy froot is *actually running* (config and all). Any escape — a bad
608 class the live gate would grant — is logged at ERROR (the alarm) so it
609 surfaces in telemetry the moment config drifts; a clean pass logs a
610 heartbeat at INFO. Returns the escaped scenario names. Pure compute,
611 repo-independent (the ``target``/``loop`` are only the log's context).
612 """
613 from froot.config.settings import AutonomySettings
614 from froot.policy.gate_probe import gate_escapes
615 
616 escaped = gate_escapes(AutonomySettings().policy())
617 record = json.dumps(
618 {
619 "event": "gate_selftest",
620 "loop": params.loop.value,
621 "repo": params.target.repo.slug,
622 "healthy": not escaped,
623 "escaped": list(escaped),
624 }
625 )
626 if escaped:
627 _gate_log.error(record) # an alarm: the gate would trust a bad class
628 else:
629 _gate_log.info(record)
630 return escaped
631 
632 
633# ── The determinism reviewer (the transitive ring) ──────────────────────────
634@activity.defn
635async def list_review_prs(target: TargetRepo) -> tuple[PullRequestRef, ...]:
636 """List the repo's open PRs for the determinism reviewer to consider."""
637 from froot.adapters.github import GitHubForge
638 
639 return await GitHubForge().list_open_pull_requests(target)
640 
641 
642@activity.defn
643async def dispatch_pr_review(params: DispatchReviewInput) -> None:
644 """Start a PR's determinism review (idempotent per PR + head SHA)."""
645 from temporalio.common import WorkflowIDReusePolicy
646 from temporalio.exceptions import WorkflowAlreadyStartedError
647 
648 from froot.workflow.pr_review_workflow import PrReviewWorkflow
649 from froot.workflow.temporal_client import client, task_queue
650 
651 temporal = await client()
652 try:
653 await temporal.start_workflow(
654 PrReviewWorkflow.run,
655 PrReviewParams(target=params.target, pr=params.pr),
656 id=pr_review_workflow_id(
657 params.target, params.pr.number, params.pr.head_sha
658 ),
659 task_queue=task_queue(),
660 id_reuse_policy=WorkflowIDReusePolicy.REJECT_DUPLICATE,
661 )
662 except WorkflowAlreadyStartedError:
663 # This (PR, head SHA) already has a review — a no-op, so re-polling
664 # never double-reviews the same commit.
665 return
666 
667 
668@activity.defn
669async def analyze_pr(params: PrReviewParams) -> AnalysisResult:
670 """Check out the PR head and analyze the workflow surface for hazards."""
671 from froot.adapters.github import GitHubForge
672 from froot.adapters.source_tree import load_modules
673 from froot.config.settings import ReviewSettings
674 
675 forge = GitHubForge()
676 with tempfile.TemporaryDirectory() as tmp:
677 workspace = Path(tmp)
678 await forge.checkout_pull_request(
679 params.target, workspace, params.pr.number
680 )
681 # The ASTs and source lines are read into memory here, so the analysis
682 # below is unaffected by the workspace being cleaned up.
683 modules = load_modules(workspace)
684 return analyze_workflow_surface(modules, max_depth=ReviewSettings().depth)
685 
686 
687@activity.defn
688async def adjudicate_frontier(
689 params: AdjudicateInput,
690) -> tuple[FrontierVerdict, ...]:
691 """Run the model over each frontier item; return aligned verdicts."""
692 from froot.adapters.determinism_judge import DeterminismFrontierJudge
693 
694 judge = DeterminismFrontierJudge()
695 verdicts: list[FrontierVerdict] = []
696 for item in params.frontier:
697 verdicts.append(await judge.adjudicate(item))
698 return tuple(verdicts)
699 
700 
701@activity.defn
702async def post_review(params: PostReviewInput) -> str | None:
703 """Upsert (or clear) the advisory comment; log the ledger row.
704 
705 Posts when there are findings, or when a prior comment must be cleared to
706 "all clear" (true decay — a PR whose hazards were fixed never keeps a stale
707 finding list). A clean PR with no prior comment stays silent.
708 """
709 from froot.adapters.github import GitHubForge
710 
711 forge = GitHubForge()
712 exists = await forge.find_marked_comment(
713 params.target, params.pr.number, REVIEW_MARKER
714 )
715 url: str | None = None
716 if should_post_review(
717 has_findings=bool(params.findings), comment_exists=exists
718 ):
719 body = render_review_comment(params.findings, params.pr.head_sha)
720 url = await forge.upsert_issue_comment(
721 params.target, params.pr.number, REVIEW_MARKER, body
722 )
723 _review_log.info(
724 json.dumps(
725 {
726 "event": "loop_outcome",
727 "loop": "determinism-review",
728 "repo": params.target.repo.slug,
729 "pr": params.pr.number,
730 "head_sha": params.pr.head_sha,
731 "findings": len(params.findings),
732 "rules": sorted({f.rule for f in params.findings}),
733 "comment_url": url,
734 }
735 )
736 )
737 return url
⋯ 105 lines hidden (lines 738–842)
738 
739 
740# ── The a11y reviewer (the source-level design-system ring) ──────────────────
741@activity.defn
742async def scan_pr_a11y(params: PrA11yReviewParams) -> A11yAnalysis:
743 """Check out the PR head and scan its changed templates for a11y risks.
744 
745 Scoped to the PR's changed Vue/JSX templates (shift-left: review what came
746 in), so the advisory stays bounded and high-signal. The source lines are
747 read into memory in the activity, so the pure scan is unaffected by the
748 workspace being cleaned up.
749 """
750 from froot.adapters.github import GitHubForge
751 from froot.adapters.web_source import load_web_sources
752 
753 forge = GitHubForge()
754 changed = await forge.list_pull_request_files(
755 params.target, params.pr.number
756 )
757 with tempfile.TemporaryDirectory() as tmp:
758 workspace = Path(tmp)
759 await forge.checkout_pull_request(
760 params.target, workspace, params.pr.number
761 )
762 sources = load_web_sources(workspace, changed)
763 candidates = scan_sources(sources)
764 return A11yAnalysis(candidates=candidates, scanned_files=len(sources))
765 
766 
767@activity.defn
768async def adjudicate_a11y(
769 params: AdjudicateA11yInput,
770) -> tuple[A11yVerdict, ...]:
771 """Run the model over each flagged candidate; return aligned verdicts."""
772 from froot.adapters.a11y_judge import A11ySourceJudge
773 
774 judge = A11ySourceJudge()
775 verdicts: list[A11yVerdict] = []
776 for candidate in params.candidates:
777 verdicts.append(await judge.adjudicate(candidate))
778 return tuple(verdicts)
779 
780 
781@activity.defn
782async def post_a11y_review(params: PostA11yInput) -> str | None:
783 """Upsert (or clear) the advisory comment; log the ledger row.
784 
785 Posts when there are findings, or when a prior comment must be cleared to
786 "all clear" (true decay — a PR whose gaps were fixed never keeps a stale
787 finding list, the bug the determinism reviewer leaves). A clean PR with
788 no prior comment stays silent, so a clean PR is never spammed.
789 """
790 from froot.adapters.github import GitHubForge
791 
792 forge = GitHubForge()
793 exists = await forge.find_marked_comment(
794 params.target, params.pr.number, A11Y_MARKER
795 )
796 url: str | None = None
797 if should_post(has_findings=bool(params.findings), comment_exists=exists):
798 body = render_a11y_comment(params.findings, params.pr.head_sha)
799 url = await forge.upsert_issue_comment(
800 params.target, params.pr.number, A11Y_MARKER, body
801 )
802 _a11y_log.info(
803 json.dumps(
804 {
805 "event": "loop_outcome",
806 "loop": "a11y-review",
807 "repo": params.target.repo.slug,
808 "pr": params.pr.number,
809 "head_sha": params.pr.head_sha,
810 "findings": len(params.findings),
811 "kinds": sorted({f.kind for f in params.findings}),
812 "comment_url": url,
813 }
814 )
815 )
816 return url
817 
818 
819@activity.defn
820async def dispatch_pr_a11y_review(params: DispatchA11yInput) -> None:
821 """Start a PR's a11y review (idempotent per PR + head SHA)."""
822 from temporalio.common import WorkflowIDReusePolicy
823 from temporalio.exceptions import WorkflowAlreadyStartedError
824 
825 from froot.workflow.pr_a11y_review_workflow import PrA11yReviewWorkflow
826 from froot.workflow.temporal_client import client, task_queue
827 
828 temporal = await client()
829 try:
830 await temporal.start_workflow(
831 PrA11yReviewWorkflow.run,
832 PrA11yReviewParams(target=params.target, pr=params.pr),
833 id=pr_a11y_review_workflow_id(
834 params.target, params.pr.number, params.pr.head_sha
835 ),
836 task_queue=task_queue(),
837 id_reuse_policy=WorkflowIDReusePolicy.REJECT_DUPLICATE,
838 )
839 except WorkflowAlreadyStartedError:
840 # This (PR, head SHA) already has an a11y review — a no-op, so
841 # re-polling never double-reviews the same commit.
842 return

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
tests/test_loop_registry.py90 lines · Python
⋯ 58 lines hidden (lines 1–58)
1"""The loop registry — the spine reads it instead of branching on the enum.
2 
3These guard the per-loop seams the registry owns: the disposition, the
4changelog-judge framing, the reconcile trait, the PR-title verb, and the
5dashboard icon — so a loop's identity stays single-sourced as loops multiply.
6Behavior equivalence of each loop's ``observe`` is covered by the existing scan
7tests, which now route through the registry.
8"""
9 
10from __future__ import annotations
11 
12import pytest
13 
14from froot.adapters.model_judge import _loop_context
15from froot.domain.loop import Loop
16from froot.loops import registry
17from froot.loops.registry import Disposition
18 
19_ACTING = (Loop.DEPENDENCY_PATCH, Loop.SECURITY_PATCH, Loop.DEAD_CODE)
20 
21 
22def test_acting_loops_registered_as_commit_or_revert() -> None:
23 specs = {spec.loop: spec for spec in registry.all_specs()}
24 for loop in _ACTING:
25 assert specs[loop].disposition is Disposition.COMMIT_OR_REVERT
26 
27 
28def test_title_prefix_is_the_per_loop_pr_verb() -> None:
29 # The PR-title verb is a per-loop label single-sourced in the spec (not
30 # derivable from the loop name), so a new loop carries its own.
31 assert registry.commit_tail(Loop.DEPENDENCY_PATCH).title_prefix == "deps"
32 assert registry.commit_tail(Loop.SECURITY_PATCH).title_prefix == "security"
33 assert registry.commit_tail(Loop.DEAD_CODE).title_prefix == "dead-code"
34 
35 
36def test_judge_context_present_for_changelog_loops_absent_for_dead_code() -> (
37 None
38):
39 # Dependency- and security-patch judge a changelog in-loop (a framing line);
40 # dead-code judges at the signal (a removal veto), so it carries no context.
41 assert registry.commit_tail(Loop.DEPENDENCY_PATCH).judge_context is not None
42 assert registry.commit_tail(Loop.SECURITY_PATCH).judge_context is not None
43 assert registry.commit_tail(Loop.DEAD_CODE).judge_context is None
44 
45 
46def test_loop_context_reads_the_registry() -> None:
47 for loop in (Loop.DEPENDENCY_PATCH, Loop.SECURITY_PATCH):
48 assert _loop_context(loop) == registry.commit_tail(loop).judge_context
49 
50 
51def test_reconciles_true_for_bump_loops_false_for_dead_code() -> None:
52 # Version-supersession reconcile only applies to version-bearing bumps; a
53 # removal has no version to be overtaken, so dead-code declares it skips.
54 assert registry.commit_tail(Loop.DEPENDENCY_PATCH).reconciles is True
55 assert registry.commit_tail(Loop.SECURITY_PATCH).reconciles is True
56 assert registry.commit_tail(Loop.DEAD_CODE).reconciles is False
57 
58 
59def test_disposition_is_derived_from_the_tail_type() -> None:
60 # The discriminant is the tail's TYPE — one conceptual field, not two
61 # species. A CommitTail is commit-or-revert; an AdvisoryTail is emit-signal.
62 from froot.loops.registry import AdvisoryTail, LoopSpec
63 
64 assert (
65 registry.get(Loop.DEPENDENCY_PATCH).disposition
66 is Disposition.COMMIT_OR_REVERT
67 )
68 advisory = LoopSpec(
69 loop=Loop.DEPENDENCY_PATCH,
70 dashboard_icon="search",
71 tail=AdvisoryTail(marker="<!-- m -->", panel_title="Review"),
72 )
73 assert advisory.disposition is Disposition.EMIT_SIGNAL
74 
75 
76def test_advisory_loops_registered_as_emit_signal() -> None:
77 from froot.loops.registry import AdvisoryTail
78 
79 specs = {spec.loop: spec for spec in registry.all_specs()}
80 for loop in (Loop.DETERMINISM_REVIEW, Loop.A11Y_REVIEW):
81 spec = specs[loop]
82 assert spec.disposition is Disposition.EMIT_SIGNAL
83 assert isinstance(spec.tail, AdvisoryTail)
84 assert spec.tail.marker and spec.tail.panel_title
85 
86 
87def test_commit_tail_rejects_an_advisory_loop() -> None:
88 # An advisory loop reaching an acting code path fails loudly, not silently.
89 with pytest.raises(TypeError):
90 registry.commit_tail(Loop.DETERMINISM_REVIEW)

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.

tests/test_review_comment.py · 93 lines
tests/test_review_comment.py93 lines · Python
⋯ 68 lines hidden (lines 1–68)
1"""Pure tests for finding synthesis and the advisory-comment renderer."""
2 
3from __future__ import annotations
4 
5import pytest
6 
7from froot.domain.determinism import (
8 FrontierItem,
9 FrontierVerdict,
10 HazardPath,
11 Impurity,
13from froot.policy.review_comment import (
14 REVIEW_MARKER,
15 render_review_comment,
16 should_post,
17 synthesize_findings,
19 
20 
21def _impurity() -> Impurity:
22 return Impurity(
23 rule="datetime.datetime.now",
24 hint="use workflow.now()",
25 module="app.util",
26 line=4,
27 )
28 
29 
30def _frontier(symbol: str, line: int) -> FrontierItem:
31 return FrontierItem(
32 kind="third_party_import",
33 workflow="app.wf:W",
34 module="app.wf",
35 line=line,
36 symbol=symbol,
37 snippet=f"import {symbol}",
38 )
39 
40 
41def test_synthesize_static_hazard():
42 hazard = HazardPath(
43 workflow="app.wf:W", via=("stamp",), impurity=_impurity()
44 )
45 findings = synthesize_findings((hazard,), (), ())
46 assert len(findings) == 1
47 assert findings[0].origin == "static"
48 assert "stamp" in findings[0].detail
49 assert "datetime.datetime.now" in findings[0].detail
50 
51 
52def test_synthesize_surfaces_only_model_yes():
53 items = (_frontier("httpx", 2), _frontier("requests", 3))
54 verdicts = (
55 FrontierVerdict(reaches="yes", rationale="used in run()"),
56 FrontierVerdict(reaches="no", rationale="only in an activity"),
57 )
58 findings = synthesize_findings((), items, verdicts)
59 assert len(findings) == 1
60 assert findings[0].origin == "model"
61 assert findings[0].rule == "httpx"
62 
63 
64def test_synthesize_requires_aligned_frontier_and_verdicts():
65 with pytest.raises(ValueError, match="argument"):
66 synthesize_findings((), (_frontier("httpx", 2),), ())
67 
68 
69def test_render_all_clear_when_empty():
70 # True decay: an empty review renders an explicit all-clear (never None), so
71 # a PR whose hazards were fixed gets its comment overwritten, not kept.
72 body = render_review_comment((), "abc1234def")
73 assert body.startswith(REVIEW_MARKER)
74 assert "" in body and "No transitive determinism hazards" in body
75 
76 
77def test_should_post_is_the_decay_rule():
78 assert should_post(has_findings=True, comment_exists=False) is True
79 assert should_post(has_findings=False, comment_exists=True) is True
80 assert should_post(has_findings=False, comment_exists=False) is False
⋯ 13 lines hidden (lines 81–93)
81 
82 
83def test_render_carries_marker_and_facts():
84 hazard = HazardPath(
85 workflow="app.wf:W", via=("stamp",), impurity=_impurity()
86 )
87 findings = synthesize_findings((hazard,), (), ())
88 body = render_review_comment(findings, "abc1234def9999")
89 assert body is not None
90 assert body.startswith(REVIEW_MARKER)
91 assert "abc1234" in body
92 assert "datetime.datetime.now" in body
93 assert "use workflow.now()" in body