What changed, and why

This PR touches no behavior. It is a truth-correction pass over froot's docs and docstrings, made because the code has out-run the prose. The old docs described one loop (dependency-patch), npm-only, pre-launch, with no track record and no reputation surface. None of that is true anymore.

What's true today: two loops run on the shared chassis — dependency-patch (now npm and uv) and a determinism reviewer for Temporal workflows. The dependency-patch loop has opened, CI-verified, and merged real PRs across several repos, and correctly closed at least one after CI went red. The reputation read-model that the SPEC promised as a non-goal-for-now now exists as a derived dashboard.

The risk in a docs PR is the opposite of a code PR: not "does it work" but "is each claim now accurate." So this guide walks the load-bearing edits — the dated Status block, the Roadmap stage markers, the reconciled Principle 7, the user-facing dashboard copy, the .env.example knobs, and the source docstrings — and checks each against the code it now describes.

SPEC.md — a dated Status block and honest stage markers

The SPEC is the charter; it had no statement of where reality stands against it. The PR adds a dated Status block right under the charter preamble. It names what is built and running (both dependency-patch ecosystems, the determinism reviewer, the derived dashboard) and what is not (security-patch, any autonomy gate — every PR is still human-approved).

The new Status block — dated, and explicit about built-vs-not.

SPEC.md · 271 lines
SPEC.md271 lines · Markdown
⋯ 14 lines hidden (lines 1–14)
1# froot
2 
3*Durable maintenance loops, pointed at any repo.*
4 
5> ⚠️ **Experimental, WIP, and written agentically.** froot is an early
6> work-in-progress, built largely by AI agents, and specific to the author's
7> own projects and infrastructure today. It is **not** general-purpose or
8> production-ready for others. Generalizing to other repos — and possibly a
9> hosted solution — is intended future work, not a current claim.
10 
11> This is the charter — the **what** and the **why**. Technical design (Temporal topology,
12> loop config, infra) follows in a later revision. When in doubt, this document is the
13> tiebreaker: it says what froot is *for*, so the how can be argued on the merits.
14 
15> **Status — 2026-06-04.** The charter still holds; reality has run ahead of it in places.
16> **Built and running:** the **dependency-patch** loop across several target repos in *both*
17> ecosystems (npm + uv) — it has opened, CI-verified, and merged real PRs (and correctly
18> *rejected* at least one after CI went red); a **determinism-reviewer** loop; and a derived
19> read-model **dashboard**. **Not yet started:** **security-patch** (the planned stage 2) and any
20> **autonomy gate** — every PR is still human-approved (record-only). The per-stage state is in
21> the [Roadmap](#roadmap) below.
⋯ 250 lines hidden (lines 22–271)
22 
23---
24 
25## What froot is
26 
27froot is a Temporal worker that runs autonomous code-maintenance loops and points them at
28target repositories. A loop watches a repo for one class of decay, proposes a bounded fix
29as a pull request, lets the repo's **own CI** verify it, and leaves the outcome behind as a
30signal others can read. A human approves the merge.
31 
32The first loop keeps dependencies patched. froot is not that loop — it is the **chassis** the
33loop runs on: one durable substrate, many specialized loops, any number of repos.
34 
35## Why froot exists
36 
37We already have a suite of maintenance agents across several existing repos.
38They are useful and they are **open loops** — they fire from a local command,
39do their work, print a result, and forget. Held against the Many Hands Engineering definition
40of a loop — *signal → action → verification → commit/revert → **update*** ([MHE §3.2][mhe]) —
41they are missing the last ingredient and the authority to act on it. And those two missing
42pieces — a **signal-update** (a decaying trace left in the terrain) and an **authority surface**
43(rules for when autonomy expands or contracts) — are *exactly* the two that require state to
44survive across runs.
45 
46That is the whole reason froot is built on Temporal. Durability is not a reliability upgrade;
47it is what **closes the loop**. A durable schedule is the self-trigger. Durable state is the
48persisted outcome and the track record. A durable wait is how a loop sits on CI for an hour
49without holding anything open.
50 
51> **froot exists to close loops** — and to do it first on the most boring, most reversible loop,
52> so that loop becomes the **template** every later loop is cheaper for. The platform grows by
53> adding loops, not by making any one loop smarter ([MHE §3.11][mhe]).
54 
55## Principles
56 
57These govern every froot decision. When a design choice is unclear, it is wrong if it
58violates one of these.
59 
601. **Loops must close.** Every loop has all six ingredients — signal, bounded action,
61 verification, reversibility, signal-update, authority surface. A run that leaves no durable
62 trace and no authority rule behind is a script, not a loop.
63 *Why: closing is the difference between work that compounds and work that becomes noise.*
64 
652. **Spine-heavy, model-thin.** Deterministic code owns *when* and *whether*; the model owns
66 only *what needs judgment*. dependency-patch is ~90% mechanical, so the model's entire job
67 is "is this changelog truly a clean patch."
68 *Why: determinism is replay-safe, cheap, and auditable; model autonomy is none of those —
69 spend it only where judgment is irreducible.*
70 
713. **CI is the oracle.** froot never re-runs a repo's tests. It opens a PR and lets the repo's
72 own CI verify, durably waiting on the result.
73 *Why: the verification terrain already exists and is already trusted for human PRs. Building
74 a second one is waste, and a second one will drift from the first.*
75 
764. **Derive, never store.** Outcomes live in GitHub (merged / closed / reverted / time-to-merge).
77 Run telemetry lives in ClickStack (cost, candidates dropped, CI-wait). froot keeps no
78 database of its own.
79 *Why: this is froot obeying the same derived-state invariant its loops enforce
80 ([MHE §2.3][mhe]). Two independent external truths also give triangulation against gaming
81 for free ([MHE §3.8][mhe]).*
82 
835. **The chassis generalizes; the loop specializes.** The durable machinery is identical for
84 every loop and every repo. Only the signal, the lockfile command, and the prompt change.
85 *Why: this is how one substrate fields an army of specialists without forking — and it is
86 the exact line between "build once" and "tune per target."*
87 
886. **Earn autonomy; record first, gate later.** Every PR is human-approved to start. froot
89 records the track record but does not yet act on it. Trust, when granted, is earned, narrow,
90 conditional, revocable, and expiring ([MHE §3.7][mhe]).
91 *Why: you cannot move a gate honestly without a track record, and you cannot earn one without
92 first running supervised.*
93 
947. **Grow by adding loops, not by broadening one.** froot stays at one loop until it closes
95 end-to-end. The next capability is the next loop, reusing the chassis. *(In practice the
96 determinism reviewer arrived before dependency-patch's planned successor — a deliberate,
97 disclosed deviation; see the Roadmap.)*
98 *Why: a dozen simple loops compounding beats one clever loop burning attention.*
99 
100## The first loop: dependency-patch
101 
102The canonical starter ([MHE §3.1][mhe]): small, reversible, present in every codebase, and
103the loop humans most dislike doing by hand.
104 
105**The flow** — a loop that closes. Nodes are colored by who owns each step:
106🔵 chassis (deterministic, durable) · 🟣 model (thin pydantic-ai) · 🟢 terrain (external) · 🟠 steward (human).
107 
108```mermaid
109%%{init: {'theme': 'base', 'themeVariables': {'edgeLabelBackground':'#ffffff', 'lineColor':'#9e9e9e', 'tertiaryTextColor':'#424242', 'primaryTextColor':'#424242'}}}%%
110flowchart LR
111 %% froot — the dependency-patch loop, closing through derived external truth
112 %% stages map to MHE §3.2: signal → action → verification → commit/revert → update
113 
114 Schedule["⏰ Durable schedule<br/>(Temporal timer)"]
115 Checkout["📥 Shallow checkout<br/>(target repo)"]
116 Scan["🔍 Find patch candidates<br/>(npm outdated / uv · deterministic)"]
117 Judge["🤖 Judge changelog risk<br/>(thin pydantic-ai)"]
118 PR["🔀 One PR per bump<br/>(manifest + lockfile · idempotent)"]
119 CI{"✅ Repo's own CI<br/>green?"}
120 Merge["👤 Human merges<br/>(approval gate)"]
121 Closed["↩️ Close PR<br/>(mark signal)"]
122 Record["📊 Record outcome"]
123 GitHub[("🐙 GitHub<br/>outcome ledger")]
124 Click[("📈 ClickStack<br/>run telemetry")]
125 
126 %% ① signal
127 Schedule -->|"&nbsp;① signal&nbsp;"| Checkout
128 Checkout --> Scan
129 %% ② action
130 Scan --> Judge
131 Judge -->|"&nbsp;② action&nbsp;"| PR
132 %% ③ verification — the durable wait on the oracle
133 PR -->|"&nbsp;③ durably wait on CI (mins–hrs)&nbsp;"| CI
134 %% ④ commit / revert
135 CI -->|"&nbsp;green&nbsp;"| Merge
136 CI -->|"&nbsp;red&nbsp;"| Closed
137 Merge -->|"&nbsp;④ commit&nbsp;"| Record
138 Closed -->|"&nbsp;revert&nbsp;"| Record
139 %% ⑤ update — derived, never stored
140 Record -->|"&nbsp;⑤ update&nbsp;"| GitHub
141 Record --> Click
142 %% the loop closes
143 Record -.->|"&nbsp;decays into the next run's signal&nbsp;"| Schedule
144 
145 %% link colors blend between the categories they connect
146 linkStyle 0 stroke:#0288d1,stroke-width:2px
147 linkStyle 1 stroke:#0288d1,stroke-width:2px
148 linkStyle 2 stroke:#4f5bc4,stroke-width:2px
149 linkStyle 3 stroke:#4f5bc4,stroke-width:2px
150 linkStyle 4 stroke:#2f8f8a,stroke-width:2px
151 linkStyle 5 stroke:#7e8a2e,stroke-width:2px
152 linkStyle 6 stroke:#2f8f8a,stroke-width:2px
153 linkStyle 7 stroke:#7a6a90,stroke-width:2px
154 linkStyle 8 stroke:#0288d1,stroke-width:2px
155 linkStyle 9 stroke:#2f8f8a,stroke-width:2px
156 linkStyle 10 stroke:#2f8f8a,stroke-width:2px
157 linkStyle 11 stroke:#9e9e9e,stroke-width:2px,stroke-dasharray:5
158 
159 classDef chassis fill:#e1f5ff,stroke:#0288d1,stroke-width:2px,color:#01579b
160 classDef model fill:#ede7f6,stroke:#5e35b1,stroke-width:2px,color:#311b92
161 classDef terrain fill:#e8f5e9,stroke:#388e3c,stroke-width:2px,color:#1b5e20
162 classDef steward fill:#fff3e0,stroke:#f57c00,stroke-width:2px,color:#e65100
163 
164 class Schedule,Checkout,Scan,PR,Record,Closed chassis
165 class Judge model
166 class CI,GitHub,Click terrain
167 class Merge steward
168```
169 
170**It now closes.** The four ingredients it already had carry over; the two it lacked are what
171froot supplies:
172 
173| Ingredient | Before (local script) | In froot |
174|---|---|---|
175| Signal | `npm outdated`, ephemeral | same, fired by a durable schedule |
176| Bounded action | bump in the working tree | manifest + lockfile edit → one PR per bump |
177| Verification | local suite re-run | **the repo's own CI**, durably awaited |
178| Reversibility | `git restore` | revert the merged PR |
179| **Signal-update** | printed, then discarded | the PR + its GitHub/CI outcome + ClickStack telemetry |
180| **Authority surface** | "in the operator's head" | human-approves-every-PR; write authority, not commit authority |
181 
182**The lockfile.** A patch bump that only edits `package.json` is inconsistent — CI's `npm ci`
183fails on a manifest/lockfile mismatch, so CI cannot be the oracle. froot regenerates the
184lockfile too, in **lockfile-only, no-scripts mode** (`npm install <pkg>@<v> --package-lock-only
185--ignore-scripts`; `uv lock --upgrade-package <pkg>` for Python), and commits both files to the
186branch. CI then runs `npm ci` / `uv sync --frozen` against a consistent pair.
187 
188*Why this way:* regenerating a lockfile is far lighter than installing and testing, so the
189worker stays small — it carries package managers, not test toolchains. And `--ignore-scripts`
190means **no third-party dependency code ever executes inside the privileged, token-bearing
191worker**; the real install, scripts, build, and tests happen in CI, which is already a sandbox.
192Tiny blast radius, by construction. (This is the proven Renovate/Dependabot mechanic — we own
193it, rather than depend on it, because the loop is the template, not the bumps.)
194 
195## Chassis vs loop: the seam
196 
197**The chassis (build once, in froot):** the durable schedule; checkout; the durable wait on CI;
198PR plumbing and idempotent branch naming; GitHub auth; telemetry; the structured outcome record.
199Modeled on an existing durable Temporal app's chassis — *its shape, not its domain.*
200 
201**The loop (a small config the chassis consumes):** the **signal** (`npm outdated`, `npm audit`,
202an AST scan, a regex sweep — genuinely heterogeneous, never forced behind one interface); the
203**lockfile command** (the one per-ecosystem seam); the **prompt** (what judgment the model makes).
204 
205Rule of thumb: the chassis is the same for every loop and every repo; signal + lockfile-command
206+ prompt are what make a loop a *specialist*.
207 
208## Reputation: derived, never stored
209 
210froot builds **no reputation store**. Reputation is a read-model computed when it's needed, from
211two sources it already has:
212 
213- **GitHub** — the outcome ledger. A consistent `loop/<name>/...` branch and label convention
214 makes every PR queryable: merged, closed, reverted, time-to-merge, CI pass/fail.
215- **ClickStack** — the run ledger. Cost, candidates considered-but-dropped, CI-wait, escalations,
216 emitted as OTEL (the observability path already in place).
217 
218Day one is **record-only**: the gate stays at human-approves-every-PR. The point is to *have* a
219track record so that, later, moving a gate is what the numbers say rather than a guess
220([MHE §3.6][mhe]) — and so trust can be granted with all five of its properties intact.
221 
222## Roadmap
223 
224Staged, deliberately ([MHE §3.4][mhe]). Each stage earns the next. Status markers
225(✅ done · ◐ partial · ○ not started) are as of 2026-06-04.
226 
2271.**Close one loop.** dependency-patch, end-to-end. *Done, and past the original bar:* it
228 runs on **several** target repos across **both** ecosystems (npm + uv), and has opened,
229 CI-verified, and merged real PRs — with at least one bump correctly closed after CI went red.
230 This is the template the rest reuses.
2312.**Replicate.** security-patch — the same chassis, a sharper signal, an objective vuln-delta.
232 Nearly free; proves the template is a template. *Not started — the next intended loop.*
2333.**Coordinate.** Add notifier loops (`derived-state`, `determinism`) that durably guard an
234 already-running durable app — froot's second durable system keeping the first one healthy —
235 and let loops start reading each other's GitHub/ClickStack signals. *Early and partial:* the
236 **determinism** reviewer is built and shipped, but it currently guards froot's *own*
237 workflow-determinism (an advisory PR comment) and it landed *ahead* of stage 2 — a
238 deliberate-but-noted deviation from "one loop at a time." `derived-state` and cross-loop
239 signal-reading are still ○.
2404.**Later — fixers.** Loops that write arbitrary code (flaky-test, refactor-candidate). These
241 are the ones that need a real agentic coding harness; that decision is made *then*, on terrain
242 that already works — not now.
243 
244## Non-goals (for now)
245 
246Naming these is how we stay KISS. Each is deferred on purpose, not forgotten.
247 
248- **Auto-merge / earned autonomy.** Record the track record first; act on it once it's earned.
249 *The record now exists (real merges + a CI-caught rejection); the gate is still
250 human-approves-every-PR.*
251- **A reputation store.** Derive it from GitHub + ClickStack. *(The read-model dashboard now does
252 exactly this — it computes reputation on read and stores nothing.)*
253- **An agentic coding harness.** A mechanical loop doesn't need one. It arrives with the fixers.
254- **Running tests or builds on the cluster.** CI does that.
255- **Multi-ecosystem beyond npm + uv.** Add ecosystems as real loops demand them.
256- **A cross-repo "loop platform" abstraction.** Extract the shared chassis from a *sibling*
257 loop's needs, not from a guess. A second loop (determinism-review) already reuses this chassis
258 by convention, not via an extracted abstraction — that stays deferred until a sibling loop
259 (security-patch) reveals the real contract. One loop's chassis is allowed to look like one loop.
260- **Unifying the three `lib` harnesses.** Note the duplication; converge it when froot's chassis
261 has proven what the real contract is.
262 
263## Lineage
264 
265froot is [Many Hands Engineering][mhe] put into practice — its loop anatomy, its trust economy,
266its staged transition, its placement discipline. It reuses the durable chassis pioneered by an
267existing durable Temporal app (the self-scheduling loop, signal-with-start, propose→verify, the
268deployment scaffold) while leaving that project's domain behind. The maintenance loops themselves
269are harvested from existing local agent suites — froot is where they finally close.
270 
271[mhe]: https://github.com/mseeks/many-hands-engineering

The Roadmap then gets per-stage markers (done / partial / not-started), so a reader can see the charter's staging against the actual state. Stage 1 is done and past its original bar; stage 2 (security-patch) is untouched; stage 3 is partial because the determinism reviewer shipped — and shipped ahead of stage 2, which the text flags as a deliberate deviation rather than hiding it.

Roadmap with ✅ / ◐ / ○ markers and the disclosed out-of-order stage 3.

SPEC.md · 271 lines
SPEC.md271 lines · Markdown
⋯ 223 lines hidden (lines 1–223)
1# froot
2 
3*Durable maintenance loops, pointed at any repo.*
4 
5> ⚠️ **Experimental, WIP, and written agentically.** froot is an early
6> work-in-progress, built largely by AI agents, and specific to the author's
7> own projects and infrastructure today. It is **not** general-purpose or
8> production-ready for others. Generalizing to other repos — and possibly a
9> hosted solution — is intended future work, not a current claim.
10 
11> This is the charter — the **what** and the **why**. Technical design (Temporal topology,
12> loop config, infra) follows in a later revision. When in doubt, this document is the
13> tiebreaker: it says what froot is *for*, so the how can be argued on the merits.
14 
15> **Status — 2026-06-04.** The charter still holds; reality has run ahead of it in places.
16> **Built and running:** the **dependency-patch** loop across several target repos in *both*
17> ecosystems (npm + uv) — it has opened, CI-verified, and merged real PRs (and correctly
18> *rejected* at least one after CI went red); a **determinism-reviewer** loop; and a derived
19> read-model **dashboard**. **Not yet started:** **security-patch** (the planned stage 2) and any
20> **autonomy gate** — every PR is still human-approved (record-only). The per-stage state is in
21> the [Roadmap](#roadmap) below.
22 
23---
24 
25## What froot is
26 
27froot is a Temporal worker that runs autonomous code-maintenance loops and points them at
28target repositories. A loop watches a repo for one class of decay, proposes a bounded fix
29as a pull request, lets the repo's **own CI** verify it, and leaves the outcome behind as a
30signal others can read. A human approves the merge.
31 
32The first loop keeps dependencies patched. froot is not that loop — it is the **chassis** the
33loop runs on: one durable substrate, many specialized loops, any number of repos.
34 
35## Why froot exists
36 
37We already have a suite of maintenance agents across several existing repos.
38They are useful and they are **open loops** — they fire from a local command,
39do their work, print a result, and forget. Held against the Many Hands Engineering definition
40of a loop — *signal → action → verification → commit/revert → **update*** ([MHE §3.2][mhe]) —
41they are missing the last ingredient and the authority to act on it. And those two missing
42pieces — a **signal-update** (a decaying trace left in the terrain) and an **authority surface**
43(rules for when autonomy expands or contracts) — are *exactly* the two that require state to
44survive across runs.
45 
46That is the whole reason froot is built on Temporal. Durability is not a reliability upgrade;
47it is what **closes the loop**. A durable schedule is the self-trigger. Durable state is the
48persisted outcome and the track record. A durable wait is how a loop sits on CI for an hour
49without holding anything open.
50 
51> **froot exists to close loops** — and to do it first on the most boring, most reversible loop,
52> so that loop becomes the **template** every later loop is cheaper for. The platform grows by
53> adding loops, not by making any one loop smarter ([MHE §3.11][mhe]).
54 
55## Principles
56 
57These govern every froot decision. When a design choice is unclear, it is wrong if it
58violates one of these.
59 
601. **Loops must close.** Every loop has all six ingredients — signal, bounded action,
61 verification, reversibility, signal-update, authority surface. A run that leaves no durable
62 trace and no authority rule behind is a script, not a loop.
63 *Why: closing is the difference between work that compounds and work that becomes noise.*
64 
652. **Spine-heavy, model-thin.** Deterministic code owns *when* and *whether*; the model owns
66 only *what needs judgment*. dependency-patch is ~90% mechanical, so the model's entire job
67 is "is this changelog truly a clean patch."
68 *Why: determinism is replay-safe, cheap, and auditable; model autonomy is none of those —
69 spend it only where judgment is irreducible.*
70 
713. **CI is the oracle.** froot never re-runs a repo's tests. It opens a PR and lets the repo's
72 own CI verify, durably waiting on the result.
73 *Why: the verification terrain already exists and is already trusted for human PRs. Building
74 a second one is waste, and a second one will drift from the first.*
75 
764. **Derive, never store.** Outcomes live in GitHub (merged / closed / reverted / time-to-merge).
77 Run telemetry lives in ClickStack (cost, candidates dropped, CI-wait). froot keeps no
78 database of its own.
79 *Why: this is froot obeying the same derived-state invariant its loops enforce
80 ([MHE §2.3][mhe]). Two independent external truths also give triangulation against gaming
81 for free ([MHE §3.8][mhe]).*
82 
835. **The chassis generalizes; the loop specializes.** The durable machinery is identical for
84 every loop and every repo. Only the signal, the lockfile command, and the prompt change.
85 *Why: this is how one substrate fields an army of specialists without forking — and it is
86 the exact line between "build once" and "tune per target."*
87 
886. **Earn autonomy; record first, gate later.** Every PR is human-approved to start. froot
89 records the track record but does not yet act on it. Trust, when granted, is earned, narrow,
90 conditional, revocable, and expiring ([MHE §3.7][mhe]).
91 *Why: you cannot move a gate honestly without a track record, and you cannot earn one without
92 first running supervised.*
93 
947. **Grow by adding loops, not by broadening one.** froot stays at one loop until it closes
95 end-to-end. The next capability is the next loop, reusing the chassis. *(In practice the
96 determinism reviewer arrived before dependency-patch's planned successor — a deliberate,
97 disclosed deviation; see the Roadmap.)*
98 *Why: a dozen simple loops compounding beats one clever loop burning attention.*
99 
100## The first loop: dependency-patch
101 
102The canonical starter ([MHE §3.1][mhe]): small, reversible, present in every codebase, and
103the loop humans most dislike doing by hand.
104 
105**The flow** — a loop that closes. Nodes are colored by who owns each step:
106🔵 chassis (deterministic, durable) · 🟣 model (thin pydantic-ai) · 🟢 terrain (external) · 🟠 steward (human).
107 
108```mermaid
109%%{init: {'theme': 'base', 'themeVariables': {'edgeLabelBackground':'#ffffff', 'lineColor':'#9e9e9e', 'tertiaryTextColor':'#424242', 'primaryTextColor':'#424242'}}}%%
110flowchart LR
111 %% froot — the dependency-patch loop, closing through derived external truth
112 %% stages map to MHE §3.2: signal → action → verification → commit/revert → update
113 
114 Schedule["⏰ Durable schedule<br/>(Temporal timer)"]
115 Checkout["📥 Shallow checkout<br/>(target repo)"]
116 Scan["🔍 Find patch candidates<br/>(npm outdated / uv · deterministic)"]
117 Judge["🤖 Judge changelog risk<br/>(thin pydantic-ai)"]
118 PR["🔀 One PR per bump<br/>(manifest + lockfile · idempotent)"]
119 CI{"✅ Repo's own CI<br/>green?"}
120 Merge["👤 Human merges<br/>(approval gate)"]
121 Closed["↩️ Close PR<br/>(mark signal)"]
122 Record["📊 Record outcome"]
123 GitHub[("🐙 GitHub<br/>outcome ledger")]
124 Click[("📈 ClickStack<br/>run telemetry")]
125 
126 %% ① signal
127 Schedule -->|"&nbsp;① signal&nbsp;"| Checkout
128 Checkout --> Scan
129 %% ② action
130 Scan --> Judge
131 Judge -->|"&nbsp;② action&nbsp;"| PR
132 %% ③ verification — the durable wait on the oracle
133 PR -->|"&nbsp;③ durably wait on CI (mins–hrs)&nbsp;"| CI
134 %% ④ commit / revert
135 CI -->|"&nbsp;green&nbsp;"| Merge
136 CI -->|"&nbsp;red&nbsp;"| Closed
137 Merge -->|"&nbsp;④ commit&nbsp;"| Record
138 Closed -->|"&nbsp;revert&nbsp;"| Record
139 %% ⑤ update — derived, never stored
140 Record -->|"&nbsp;⑤ update&nbsp;"| GitHub
141 Record --> Click
142 %% the loop closes
143 Record -.->|"&nbsp;decays into the next run's signal&nbsp;"| Schedule
144 
145 %% link colors blend between the categories they connect
146 linkStyle 0 stroke:#0288d1,stroke-width:2px
147 linkStyle 1 stroke:#0288d1,stroke-width:2px
148 linkStyle 2 stroke:#4f5bc4,stroke-width:2px
149 linkStyle 3 stroke:#4f5bc4,stroke-width:2px
150 linkStyle 4 stroke:#2f8f8a,stroke-width:2px
151 linkStyle 5 stroke:#7e8a2e,stroke-width:2px
152 linkStyle 6 stroke:#2f8f8a,stroke-width:2px
153 linkStyle 7 stroke:#7a6a90,stroke-width:2px
154 linkStyle 8 stroke:#0288d1,stroke-width:2px
155 linkStyle 9 stroke:#2f8f8a,stroke-width:2px
156 linkStyle 10 stroke:#2f8f8a,stroke-width:2px
157 linkStyle 11 stroke:#9e9e9e,stroke-width:2px,stroke-dasharray:5
158 
159 classDef chassis fill:#e1f5ff,stroke:#0288d1,stroke-width:2px,color:#01579b
160 classDef model fill:#ede7f6,stroke:#5e35b1,stroke-width:2px,color:#311b92
161 classDef terrain fill:#e8f5e9,stroke:#388e3c,stroke-width:2px,color:#1b5e20
162 classDef steward fill:#fff3e0,stroke:#f57c00,stroke-width:2px,color:#e65100
163 
164 class Schedule,Checkout,Scan,PR,Record,Closed chassis
165 class Judge model
166 class CI,GitHub,Click terrain
167 class Merge steward
168```
169 
170**It now closes.** The four ingredients it already had carry over; the two it lacked are what
171froot supplies:
172 
173| Ingredient | Before (local script) | In froot |
174|---|---|---|
175| Signal | `npm outdated`, ephemeral | same, fired by a durable schedule |
176| Bounded action | bump in the working tree | manifest + lockfile edit → one PR per bump |
177| Verification | local suite re-run | **the repo's own CI**, durably awaited |
178| Reversibility | `git restore` | revert the merged PR |
179| **Signal-update** | printed, then discarded | the PR + its GitHub/CI outcome + ClickStack telemetry |
180| **Authority surface** | "in the operator's head" | human-approves-every-PR; write authority, not commit authority |
181 
182**The lockfile.** A patch bump that only edits `package.json` is inconsistent — CI's `npm ci`
183fails on a manifest/lockfile mismatch, so CI cannot be the oracle. froot regenerates the
184lockfile too, in **lockfile-only, no-scripts mode** (`npm install <pkg>@<v> --package-lock-only
185--ignore-scripts`; `uv lock --upgrade-package <pkg>` for Python), and commits both files to the
186branch. CI then runs `npm ci` / `uv sync --frozen` against a consistent pair.
187 
188*Why this way:* regenerating a lockfile is far lighter than installing and testing, so the
189worker stays small — it carries package managers, not test toolchains. And `--ignore-scripts`
190means **no third-party dependency code ever executes inside the privileged, token-bearing
191worker**; the real install, scripts, build, and tests happen in CI, which is already a sandbox.
192Tiny blast radius, by construction. (This is the proven Renovate/Dependabot mechanic — we own
193it, rather than depend on it, because the loop is the template, not the bumps.)
194 
195## Chassis vs loop: the seam
196 
197**The chassis (build once, in froot):** the durable schedule; checkout; the durable wait on CI;
198PR plumbing and idempotent branch naming; GitHub auth; telemetry; the structured outcome record.
199Modeled on an existing durable Temporal app's chassis — *its shape, not its domain.*
200 
201**The loop (a small config the chassis consumes):** the **signal** (`npm outdated`, `npm audit`,
202an AST scan, a regex sweep — genuinely heterogeneous, never forced behind one interface); the
203**lockfile command** (the one per-ecosystem seam); the **prompt** (what judgment the model makes).
204 
205Rule of thumb: the chassis is the same for every loop and every repo; signal + lockfile-command
206+ prompt are what make a loop a *specialist*.
207 
208## Reputation: derived, never stored
209 
210froot builds **no reputation store**. Reputation is a read-model computed when it's needed, from
211two sources it already has:
212 
213- **GitHub** — the outcome ledger. A consistent `loop/<name>/...` branch and label convention
214 makes every PR queryable: merged, closed, reverted, time-to-merge, CI pass/fail.
215- **ClickStack** — the run ledger. Cost, candidates considered-but-dropped, CI-wait, escalations,
216 emitted as OTEL (the observability path already in place).
217 
218Day one is **record-only**: the gate stays at human-approves-every-PR. The point is to *have* a
219track record so that, later, moving a gate is what the numbers say rather than a guess
220([MHE §3.6][mhe]) — and so trust can be granted with all five of its properties intact.
221 
222## Roadmap
223 
224Staged, deliberately ([MHE §3.4][mhe]). Each stage earns the next. Status markers
225(✅ done · ◐ partial · ○ not started) are as of 2026-06-04.
226 
2271.**Close one loop.** dependency-patch, end-to-end. *Done, and past the original bar:* it
228 runs on **several** target repos across **both** ecosystems (npm + uv), and has opened,
229 CI-verified, and merged real PRs — with at least one bump correctly closed after CI went red.
230 This is the template the rest reuses.
2312.**Replicate.** security-patch — the same chassis, a sharper signal, an objective vuln-delta.
232 Nearly free; proves the template is a template. *Not started — the next intended loop.*
2333.**Coordinate.** Add notifier loops (`derived-state`, `determinism`) that durably guard an
234 already-running durable app — froot's second durable system keeping the first one healthy —
235 and let loops start reading each other's GitHub/ClickStack signals. *Early and partial:* the
236 **determinism** reviewer is built and shipped, but it currently guards froot's *own*
237 workflow-determinism (an advisory PR comment) and it landed *ahead* of stage 2 — a
238 deliberate-but-noted deviation from "one loop at a time." `derived-state` and cross-loop
239 signal-reading are still ○.
2404.**Later — fixers.** Loops that write arbitrary code (flaky-test, refactor-candidate). These
241 are the ones that need a real agentic coding harness; that decision is made *then*, on terrain
242 that already works — not now.
⋯ 29 lines hidden (lines 243–271)
243 
244## Non-goals (for now)
245 
246Naming these is how we stay KISS. Each is deferred on purpose, not forgotten.
247 
248- **Auto-merge / earned autonomy.** Record the track record first; act on it once it's earned.
249 *The record now exists (real merges + a CI-caught rejection); the gate is still
250 human-approves-every-PR.*
251- **A reputation store.** Derive it from GitHub + ClickStack. *(The read-model dashboard now does
252 exactly this — it computes reputation on read and stores nothing.)*
253- **An agentic coding harness.** A mechanical loop doesn't need one. It arrives with the fixers.
254- **Running tests or builds on the cluster.** CI does that.
255- **Multi-ecosystem beyond npm + uv.** Add ecosystems as real loops demand them.
256- **A cross-repo "loop platform" abstraction.** Extract the shared chassis from a *sibling*
257 loop's needs, not from a guess. A second loop (determinism-review) already reuses this chassis
258 by convention, not via an extracted abstraction — that stays deferred until a sibling loop
259 (security-patch) reveals the real contract. One loop's chassis is allowed to look like one loop.
260- **Unifying the three `lib` harnesses.** Note the duplication; converge it when froot's chassis
261 has proven what the real contract is.
262 
263## Lineage
264 
265froot is [Many Hands Engineering][mhe] put into practice — its loop anatomy, its trust economy,
266its staged transition, its placement discipline. It reuses the durable chassis pioneered by an
267existing durable Temporal app (the self-scheduling loop, signal-with-start, propose→verify, the
268deployment scaffold) while leaving that project's domain behind. The maintenance loops themselves
269are harvested from existing local agent suites — froot is where they finally close.
270 
271[mhe]: https://github.com/mseeks/many-hands-engineering

SPEC.md — non-goals catch up, and Principle 7 is reconciled

Two non-goals were written as future but have since happened, so the PR annotates them in place rather than deleting them — the deferral history stays legible. The track record now exists (real merges plus a CI-caught rejection). The reputation store is still deliberately not built, but the read-model dashboard now does exactly what that non-goal described: it computes reputation on read and stores nothing.

Non-goals annotated: the record exists; the dashboard is the read-model; a sibling loop already reuses the chassis.

SPEC.md · 271 lines
SPEC.md271 lines · Markdown
⋯ 247 lines hidden (lines 1–247)
1# froot
2 
3*Durable maintenance loops, pointed at any repo.*
4 
5> ⚠️ **Experimental, WIP, and written agentically.** froot is an early
6> work-in-progress, built largely by AI agents, and specific to the author's
7> own projects and infrastructure today. It is **not** general-purpose or
8> production-ready for others. Generalizing to other repos — and possibly a
9> hosted solution — is intended future work, not a current claim.
10 
11> This is the charter — the **what** and the **why**. Technical design (Temporal topology,
12> loop config, infra) follows in a later revision. When in doubt, this document is the
13> tiebreaker: it says what froot is *for*, so the how can be argued on the merits.
14 
15> **Status — 2026-06-04.** The charter still holds; reality has run ahead of it in places.
16> **Built and running:** the **dependency-patch** loop across several target repos in *both*
17> ecosystems (npm + uv) — it has opened, CI-verified, and merged real PRs (and correctly
18> *rejected* at least one after CI went red); a **determinism-reviewer** loop; and a derived
19> read-model **dashboard**. **Not yet started:** **security-patch** (the planned stage 2) and any
20> **autonomy gate** — every PR is still human-approved (record-only). The per-stage state is in
21> the [Roadmap](#roadmap) below.
22 
23---
24 
25## What froot is
26 
27froot is a Temporal worker that runs autonomous code-maintenance loops and points them at
28target repositories. A loop watches a repo for one class of decay, proposes a bounded fix
29as a pull request, lets the repo's **own CI** verify it, and leaves the outcome behind as a
30signal others can read. A human approves the merge.
31 
32The first loop keeps dependencies patched. froot is not that loop — it is the **chassis** the
33loop runs on: one durable substrate, many specialized loops, any number of repos.
34 
35## Why froot exists
36 
37We already have a suite of maintenance agents across several existing repos.
38They are useful and they are **open loops** — they fire from a local command,
39do their work, print a result, and forget. Held against the Many Hands Engineering definition
40of a loop — *signal → action → verification → commit/revert → **update*** ([MHE §3.2][mhe]) —
41they are missing the last ingredient and the authority to act on it. And those two missing
42pieces — a **signal-update** (a decaying trace left in the terrain) and an **authority surface**
43(rules for when autonomy expands or contracts) — are *exactly* the two that require state to
44survive across runs.
45 
46That is the whole reason froot is built on Temporal. Durability is not a reliability upgrade;
47it is what **closes the loop**. A durable schedule is the self-trigger. Durable state is the
48persisted outcome and the track record. A durable wait is how a loop sits on CI for an hour
49without holding anything open.
50 
51> **froot exists to close loops** — and to do it first on the most boring, most reversible loop,
52> so that loop becomes the **template** every later loop is cheaper for. The platform grows by
53> adding loops, not by making any one loop smarter ([MHE §3.11][mhe]).
54 
55## Principles
56 
57These govern every froot decision. When a design choice is unclear, it is wrong if it
58violates one of these.
59 
601. **Loops must close.** Every loop has all six ingredients — signal, bounded action,
61 verification, reversibility, signal-update, authority surface. A run that leaves no durable
62 trace and no authority rule behind is a script, not a loop.
63 *Why: closing is the difference between work that compounds and work that becomes noise.*
64 
652. **Spine-heavy, model-thin.** Deterministic code owns *when* and *whether*; the model owns
66 only *what needs judgment*. dependency-patch is ~90% mechanical, so the model's entire job
67 is "is this changelog truly a clean patch."
68 *Why: determinism is replay-safe, cheap, and auditable; model autonomy is none of those —
69 spend it only where judgment is irreducible.*
70 
713. **CI is the oracle.** froot never re-runs a repo's tests. It opens a PR and lets the repo's
72 own CI verify, durably waiting on the result.
73 *Why: the verification terrain already exists and is already trusted for human PRs. Building
74 a second one is waste, and a second one will drift from the first.*
75 
764. **Derive, never store.** Outcomes live in GitHub (merged / closed / reverted / time-to-merge).
77 Run telemetry lives in ClickStack (cost, candidates dropped, CI-wait). froot keeps no
78 database of its own.
79 *Why: this is froot obeying the same derived-state invariant its loops enforce
80 ([MHE §2.3][mhe]). Two independent external truths also give triangulation against gaming
81 for free ([MHE §3.8][mhe]).*
82 
835. **The chassis generalizes; the loop specializes.** The durable machinery is identical for
84 every loop and every repo. Only the signal, the lockfile command, and the prompt change.
85 *Why: this is how one substrate fields an army of specialists without forking — and it is
86 the exact line between "build once" and "tune per target."*
87 
886. **Earn autonomy; record first, gate later.** Every PR is human-approved to start. froot
89 records the track record but does not yet act on it. Trust, when granted, is earned, narrow,
90 conditional, revocable, and expiring ([MHE §3.7][mhe]).
91 *Why: you cannot move a gate honestly without a track record, and you cannot earn one without
92 first running supervised.*
93 
947. **Grow by adding loops, not by broadening one.** froot stays at one loop until it closes
95 end-to-end. The next capability is the next loop, reusing the chassis. *(In practice the
96 determinism reviewer arrived before dependency-patch's planned successor — a deliberate,
97 disclosed deviation; see the Roadmap.)*
98 *Why: a dozen simple loops compounding beats one clever loop burning attention.*
99 
100## The first loop: dependency-patch
101 
102The canonical starter ([MHE §3.1][mhe]): small, reversible, present in every codebase, and
103the loop humans most dislike doing by hand.
104 
105**The flow** — a loop that closes. Nodes are colored by who owns each step:
106🔵 chassis (deterministic, durable) · 🟣 model (thin pydantic-ai) · 🟢 terrain (external) · 🟠 steward (human).
107 
108```mermaid
109%%{init: {'theme': 'base', 'themeVariables': {'edgeLabelBackground':'#ffffff', 'lineColor':'#9e9e9e', 'tertiaryTextColor':'#424242', 'primaryTextColor':'#424242'}}}%%
110flowchart LR
111 %% froot — the dependency-patch loop, closing through derived external truth
112 %% stages map to MHE §3.2: signal → action → verification → commit/revert → update
113 
114 Schedule["⏰ Durable schedule<br/>(Temporal timer)"]
115 Checkout["📥 Shallow checkout<br/>(target repo)"]
116 Scan["🔍 Find patch candidates<br/>(npm outdated / uv · deterministic)"]
117 Judge["🤖 Judge changelog risk<br/>(thin pydantic-ai)"]
118 PR["🔀 One PR per bump<br/>(manifest + lockfile · idempotent)"]
119 CI{"✅ Repo's own CI<br/>green?"}
120 Merge["👤 Human merges<br/>(approval gate)"]
121 Closed["↩️ Close PR<br/>(mark signal)"]
122 Record["📊 Record outcome"]
123 GitHub[("🐙 GitHub<br/>outcome ledger")]
124 Click[("📈 ClickStack<br/>run telemetry")]
125 
126 %% ① signal
127 Schedule -->|"&nbsp;① signal&nbsp;"| Checkout
128 Checkout --> Scan
129 %% ② action
130 Scan --> Judge
131 Judge -->|"&nbsp;② action&nbsp;"| PR
132 %% ③ verification — the durable wait on the oracle
133 PR -->|"&nbsp;③ durably wait on CI (mins–hrs)&nbsp;"| CI
134 %% ④ commit / revert
135 CI -->|"&nbsp;green&nbsp;"| Merge
136 CI -->|"&nbsp;red&nbsp;"| Closed
137 Merge -->|"&nbsp;④ commit&nbsp;"| Record
138 Closed -->|"&nbsp;revert&nbsp;"| Record
139 %% ⑤ update — derived, never stored
140 Record -->|"&nbsp;⑤ update&nbsp;"| GitHub
141 Record --> Click
142 %% the loop closes
143 Record -.->|"&nbsp;decays into the next run's signal&nbsp;"| Schedule
144 
145 %% link colors blend between the categories they connect
146 linkStyle 0 stroke:#0288d1,stroke-width:2px
147 linkStyle 1 stroke:#0288d1,stroke-width:2px
148 linkStyle 2 stroke:#4f5bc4,stroke-width:2px
149 linkStyle 3 stroke:#4f5bc4,stroke-width:2px
150 linkStyle 4 stroke:#2f8f8a,stroke-width:2px
151 linkStyle 5 stroke:#7e8a2e,stroke-width:2px
152 linkStyle 6 stroke:#2f8f8a,stroke-width:2px
153 linkStyle 7 stroke:#7a6a90,stroke-width:2px
154 linkStyle 8 stroke:#0288d1,stroke-width:2px
155 linkStyle 9 stroke:#2f8f8a,stroke-width:2px
156 linkStyle 10 stroke:#2f8f8a,stroke-width:2px
157 linkStyle 11 stroke:#9e9e9e,stroke-width:2px,stroke-dasharray:5
158 
159 classDef chassis fill:#e1f5ff,stroke:#0288d1,stroke-width:2px,color:#01579b
160 classDef model fill:#ede7f6,stroke:#5e35b1,stroke-width:2px,color:#311b92
161 classDef terrain fill:#e8f5e9,stroke:#388e3c,stroke-width:2px,color:#1b5e20
162 classDef steward fill:#fff3e0,stroke:#f57c00,stroke-width:2px,color:#e65100
163 
164 class Schedule,Checkout,Scan,PR,Record,Closed chassis
165 class Judge model
166 class CI,GitHub,Click terrain
167 class Merge steward
168```
169 
170**It now closes.** The four ingredients it already had carry over; the two it lacked are what
171froot supplies:
172 
173| Ingredient | Before (local script) | In froot |
174|---|---|---|
175| Signal | `npm outdated`, ephemeral | same, fired by a durable schedule |
176| Bounded action | bump in the working tree | manifest + lockfile edit → one PR per bump |
177| Verification | local suite re-run | **the repo's own CI**, durably awaited |
178| Reversibility | `git restore` | revert the merged PR |
179| **Signal-update** | printed, then discarded | the PR + its GitHub/CI outcome + ClickStack telemetry |
180| **Authority surface** | "in the operator's head" | human-approves-every-PR; write authority, not commit authority |
181 
182**The lockfile.** A patch bump that only edits `package.json` is inconsistent — CI's `npm ci`
183fails on a manifest/lockfile mismatch, so CI cannot be the oracle. froot regenerates the
184lockfile too, in **lockfile-only, no-scripts mode** (`npm install <pkg>@<v> --package-lock-only
185--ignore-scripts`; `uv lock --upgrade-package <pkg>` for Python), and commits both files to the
186branch. CI then runs `npm ci` / `uv sync --frozen` against a consistent pair.
187 
188*Why this way:* regenerating a lockfile is far lighter than installing and testing, so the
189worker stays small — it carries package managers, not test toolchains. And `--ignore-scripts`
190means **no third-party dependency code ever executes inside the privileged, token-bearing
191worker**; the real install, scripts, build, and tests happen in CI, which is already a sandbox.
192Tiny blast radius, by construction. (This is the proven Renovate/Dependabot mechanic — we own
193it, rather than depend on it, because the loop is the template, not the bumps.)
194 
195## Chassis vs loop: the seam
196 
197**The chassis (build once, in froot):** the durable schedule; checkout; the durable wait on CI;
198PR plumbing and idempotent branch naming; GitHub auth; telemetry; the structured outcome record.
199Modeled on an existing durable Temporal app's chassis — *its shape, not its domain.*
200 
201**The loop (a small config the chassis consumes):** the **signal** (`npm outdated`, `npm audit`,
202an AST scan, a regex sweep — genuinely heterogeneous, never forced behind one interface); the
203**lockfile command** (the one per-ecosystem seam); the **prompt** (what judgment the model makes).
204 
205Rule of thumb: the chassis is the same for every loop and every repo; signal + lockfile-command
206+ prompt are what make a loop a *specialist*.
207 
208## Reputation: derived, never stored
209 
210froot builds **no reputation store**. Reputation is a read-model computed when it's needed, from
211two sources it already has:
212 
213- **GitHub** — the outcome ledger. A consistent `loop/<name>/...` branch and label convention
214 makes every PR queryable: merged, closed, reverted, time-to-merge, CI pass/fail.
215- **ClickStack** — the run ledger. Cost, candidates considered-but-dropped, CI-wait, escalations,
216 emitted as OTEL (the observability path already in place).
217 
218Day one is **record-only**: the gate stays at human-approves-every-PR. The point is to *have* a
219track record so that, later, moving a gate is what the numbers say rather than a guess
220([MHE §3.6][mhe]) — and so trust can be granted with all five of its properties intact.
221 
222## Roadmap
223 
224Staged, deliberately ([MHE §3.4][mhe]). Each stage earns the next. Status markers
225(✅ done · ◐ partial · ○ not started) are as of 2026-06-04.
226 
2271.**Close one loop.** dependency-patch, end-to-end. *Done, and past the original bar:* it
228 runs on **several** target repos across **both** ecosystems (npm + uv), and has opened,
229 CI-verified, and merged real PRs — with at least one bump correctly closed after CI went red.
230 This is the template the rest reuses.
2312.**Replicate.** security-patch — the same chassis, a sharper signal, an objective vuln-delta.
232 Nearly free; proves the template is a template. *Not started — the next intended loop.*
2333.**Coordinate.** Add notifier loops (`derived-state`, `determinism`) that durably guard an
234 already-running durable app — froot's second durable system keeping the first one healthy —
235 and let loops start reading each other's GitHub/ClickStack signals. *Early and partial:* the
236 **determinism** reviewer is built and shipped, but it currently guards froot's *own*
237 workflow-determinism (an advisory PR comment) and it landed *ahead* of stage 2 — a
238 deliberate-but-noted deviation from "one loop at a time." `derived-state` and cross-loop
239 signal-reading are still ○.
2404.**Later — fixers.** Loops that write arbitrary code (flaky-test, refactor-candidate). These
241 are the ones that need a real agentic coding harness; that decision is made *then*, on terrain
242 that already works — not now.
243 
244## Non-goals (for now)
245 
246Naming these is how we stay KISS. Each is deferred on purpose, not forgotten.
247 
248- **Auto-merge / earned autonomy.** Record the track record first; act on it once it's earned.
249 *The record now exists (real merges + a CI-caught rejection); the gate is still
250 human-approves-every-PR.*
251- **A reputation store.** Derive it from GitHub + ClickStack. *(The read-model dashboard now does
252 exactly this — it computes reputation on read and stores nothing.)*
253- **An agentic coding harness.** A mechanical loop doesn't need one. It arrives with the fixers.
254- **Running tests or builds on the cluster.** CI does that.
255- **Multi-ecosystem beyond npm + uv.** Add ecosystems as real loops demand them.
256- **A cross-repo "loop platform" abstraction.** Extract the shared chassis from a *sibling*
257 loop's needs, not from a guess. A second loop (determinism-review) already reuses this chassis
258 by convention, not via an extracted abstraction — that stays deferred until a sibling loop
259 (security-patch) reveals the real contract. One loop's chassis is allowed to look like one loop.
⋯ 12 lines hidden (lines 260–271)
260- **Unifying the three `lib` harnesses.** Note the duplication; converge it when froot's chassis
261 has proven what the real contract is.
262 
263## Lineage
264 
265froot is [Many Hands Engineering][mhe] put into practice — its loop anatomy, its trust economy,
266its staged transition, its placement discipline. It reuses the durable chassis pioneered by an
267existing durable Temporal app (the self-scheduling loop, signal-with-start, propose→verify, the
268deployment scaffold) while leaving that project's domain behind. The maintenance loops themselves
269are harvested from existing local agent suites — froot is where they finally close.
270 
271[mhe]: https://github.com/mseeks/many-hands-engineering

Principle 7 — grow by adding loops, not by broadening one — said froot "stays at one loop until it closes end-to-end." A second loop now exists, so the principle needed reconciling, not quiet contradiction. The edit keeps the principle and appends the disclosure: the determinism reviewer arrived before dependency-patch's planned successor, a deliberate, noted deviation that points back to the Roadmap.

Principle 7, with the disclosed deviation appended in italics.

SPEC.md · 271 lines
SPEC.md271 lines · Markdown
⋯ 93 lines hidden (lines 1–93)
1# froot
2 
3*Durable maintenance loops, pointed at any repo.*
4 
5> ⚠️ **Experimental, WIP, and written agentically.** froot is an early
6> work-in-progress, built largely by AI agents, and specific to the author's
7> own projects and infrastructure today. It is **not** general-purpose or
8> production-ready for others. Generalizing to other repos — and possibly a
9> hosted solution — is intended future work, not a current claim.
10 
11> This is the charter — the **what** and the **why**. Technical design (Temporal topology,
12> loop config, infra) follows in a later revision. When in doubt, this document is the
13> tiebreaker: it says what froot is *for*, so the how can be argued on the merits.
14 
15> **Status — 2026-06-04.** The charter still holds; reality has run ahead of it in places.
16> **Built and running:** the **dependency-patch** loop across several target repos in *both*
17> ecosystems (npm + uv) — it has opened, CI-verified, and merged real PRs (and correctly
18> *rejected* at least one after CI went red); a **determinism-reviewer** loop; and a derived
19> read-model **dashboard**. **Not yet started:** **security-patch** (the planned stage 2) and any
20> **autonomy gate** — every PR is still human-approved (record-only). The per-stage state is in
21> the [Roadmap](#roadmap) below.
22 
23---
24 
25## What froot is
26 
27froot is a Temporal worker that runs autonomous code-maintenance loops and points them at
28target repositories. A loop watches a repo for one class of decay, proposes a bounded fix
29as a pull request, lets the repo's **own CI** verify it, and leaves the outcome behind as a
30signal others can read. A human approves the merge.
31 
32The first loop keeps dependencies patched. froot is not that loop — it is the **chassis** the
33loop runs on: one durable substrate, many specialized loops, any number of repos.
34 
35## Why froot exists
36 
37We already have a suite of maintenance agents across several existing repos.
38They are useful and they are **open loops** — they fire from a local command,
39do their work, print a result, and forget. Held against the Many Hands Engineering definition
40of a loop — *signal → action → verification → commit/revert → **update*** ([MHE §3.2][mhe]) —
41they are missing the last ingredient and the authority to act on it. And those two missing
42pieces — a **signal-update** (a decaying trace left in the terrain) and an **authority surface**
43(rules for when autonomy expands or contracts) — are *exactly* the two that require state to
44survive across runs.
45 
46That is the whole reason froot is built on Temporal. Durability is not a reliability upgrade;
47it is what **closes the loop**. A durable schedule is the self-trigger. Durable state is the
48persisted outcome and the track record. A durable wait is how a loop sits on CI for an hour
49without holding anything open.
50 
51> **froot exists to close loops** — and to do it first on the most boring, most reversible loop,
52> so that loop becomes the **template** every later loop is cheaper for. The platform grows by
53> adding loops, not by making any one loop smarter ([MHE §3.11][mhe]).
54 
55## Principles
56 
57These govern every froot decision. When a design choice is unclear, it is wrong if it
58violates one of these.
59 
601. **Loops must close.** Every loop has all six ingredients — signal, bounded action,
61 verification, reversibility, signal-update, authority surface. A run that leaves no durable
62 trace and no authority rule behind is a script, not a loop.
63 *Why: closing is the difference between work that compounds and work that becomes noise.*
64 
652. **Spine-heavy, model-thin.** Deterministic code owns *when* and *whether*; the model owns
66 only *what needs judgment*. dependency-patch is ~90% mechanical, so the model's entire job
67 is "is this changelog truly a clean patch."
68 *Why: determinism is replay-safe, cheap, and auditable; model autonomy is none of those —
69 spend it only where judgment is irreducible.*
70 
713. **CI is the oracle.** froot never re-runs a repo's tests. It opens a PR and lets the repo's
72 own CI verify, durably waiting on the result.
73 *Why: the verification terrain already exists and is already trusted for human PRs. Building
74 a second one is waste, and a second one will drift from the first.*
75 
764. **Derive, never store.** Outcomes live in GitHub (merged / closed / reverted / time-to-merge).
77 Run telemetry lives in ClickStack (cost, candidates dropped, CI-wait). froot keeps no
78 database of its own.
79 *Why: this is froot obeying the same derived-state invariant its loops enforce
80 ([MHE §2.3][mhe]). Two independent external truths also give triangulation against gaming
81 for free ([MHE §3.8][mhe]).*
82 
835. **The chassis generalizes; the loop specializes.** The durable machinery is identical for
84 every loop and every repo. Only the signal, the lockfile command, and the prompt change.
85 *Why: this is how one substrate fields an army of specialists without forking — and it is
86 the exact line between "build once" and "tune per target."*
87 
886. **Earn autonomy; record first, gate later.** Every PR is human-approved to start. froot
89 records the track record but does not yet act on it. Trust, when granted, is earned, narrow,
90 conditional, revocable, and expiring ([MHE §3.7][mhe]).
91 *Why: you cannot move a gate honestly without a track record, and you cannot earn one without
92 first running supervised.*
93 
947. **Grow by adding loops, not by broadening one.** froot stays at one loop until it closes
95 end-to-end. The next capability is the next loop, reusing the chassis. *(In practice the
96 determinism reviewer arrived before dependency-patch's planned successor — a deliberate,
97 disclosed deviation; see the Roadmap.)*
98 *Why: a dozen simple loops compounding beats one clever loop burning attention.*
⋯ 173 lines hidden (lines 99–271)
99 
100## The first loop: dependency-patch
101 
102The canonical starter ([MHE §3.1][mhe]): small, reversible, present in every codebase, and
103the loop humans most dislike doing by hand.
104 
105**The flow** — a loop that closes. Nodes are colored by who owns each step:
106🔵 chassis (deterministic, durable) · 🟣 model (thin pydantic-ai) · 🟢 terrain (external) · 🟠 steward (human).
107 
108```mermaid
109%%{init: {'theme': 'base', 'themeVariables': {'edgeLabelBackground':'#ffffff', 'lineColor':'#9e9e9e', 'tertiaryTextColor':'#424242', 'primaryTextColor':'#424242'}}}%%
110flowchart LR
111 %% froot — the dependency-patch loop, closing through derived external truth
112 %% stages map to MHE §3.2: signal → action → verification → commit/revert → update
113 
114 Schedule["⏰ Durable schedule<br/>(Temporal timer)"]
115 Checkout["📥 Shallow checkout<br/>(target repo)"]
116 Scan["🔍 Find patch candidates<br/>(npm outdated / uv · deterministic)"]
117 Judge["🤖 Judge changelog risk<br/>(thin pydantic-ai)"]
118 PR["🔀 One PR per bump<br/>(manifest + lockfile · idempotent)"]
119 CI{"✅ Repo's own CI<br/>green?"}
120 Merge["👤 Human merges<br/>(approval gate)"]
121 Closed["↩️ Close PR<br/>(mark signal)"]
122 Record["📊 Record outcome"]
123 GitHub[("🐙 GitHub<br/>outcome ledger")]
124 Click[("📈 ClickStack<br/>run telemetry")]
125 
126 %% ① signal
127 Schedule -->|"&nbsp;① signal&nbsp;"| Checkout
128 Checkout --> Scan
129 %% ② action
130 Scan --> Judge
131 Judge -->|"&nbsp;② action&nbsp;"| PR
132 %% ③ verification — the durable wait on the oracle
133 PR -->|"&nbsp;③ durably wait on CI (mins–hrs)&nbsp;"| CI
134 %% ④ commit / revert
135 CI -->|"&nbsp;green&nbsp;"| Merge
136 CI -->|"&nbsp;red&nbsp;"| Closed
137 Merge -->|"&nbsp;④ commit&nbsp;"| Record
138 Closed -->|"&nbsp;revert&nbsp;"| Record
139 %% ⑤ update — derived, never stored
140 Record -->|"&nbsp;⑤ update&nbsp;"| GitHub
141 Record --> Click
142 %% the loop closes
143 Record -.->|"&nbsp;decays into the next run's signal&nbsp;"| Schedule
144 
145 %% link colors blend between the categories they connect
146 linkStyle 0 stroke:#0288d1,stroke-width:2px
147 linkStyle 1 stroke:#0288d1,stroke-width:2px
148 linkStyle 2 stroke:#4f5bc4,stroke-width:2px
149 linkStyle 3 stroke:#4f5bc4,stroke-width:2px
150 linkStyle 4 stroke:#2f8f8a,stroke-width:2px
151 linkStyle 5 stroke:#7e8a2e,stroke-width:2px
152 linkStyle 6 stroke:#2f8f8a,stroke-width:2px
153 linkStyle 7 stroke:#7a6a90,stroke-width:2px
154 linkStyle 8 stroke:#0288d1,stroke-width:2px
155 linkStyle 9 stroke:#2f8f8a,stroke-width:2px
156 linkStyle 10 stroke:#2f8f8a,stroke-width:2px
157 linkStyle 11 stroke:#9e9e9e,stroke-width:2px,stroke-dasharray:5
158 
159 classDef chassis fill:#e1f5ff,stroke:#0288d1,stroke-width:2px,color:#01579b
160 classDef model fill:#ede7f6,stroke:#5e35b1,stroke-width:2px,color:#311b92
161 classDef terrain fill:#e8f5e9,stroke:#388e3c,stroke-width:2px,color:#1b5e20
162 classDef steward fill:#fff3e0,stroke:#f57c00,stroke-width:2px,color:#e65100
163 
164 class Schedule,Checkout,Scan,PR,Record,Closed chassis
165 class Judge model
166 class CI,GitHub,Click terrain
167 class Merge steward
168```
169 
170**It now closes.** The four ingredients it already had carry over; the two it lacked are what
171froot supplies:
172 
173| Ingredient | Before (local script) | In froot |
174|---|---|---|
175| Signal | `npm outdated`, ephemeral | same, fired by a durable schedule |
176| Bounded action | bump in the working tree | manifest + lockfile edit → one PR per bump |
177| Verification | local suite re-run | **the repo's own CI**, durably awaited |
178| Reversibility | `git restore` | revert the merged PR |
179| **Signal-update** | printed, then discarded | the PR + its GitHub/CI outcome + ClickStack telemetry |
180| **Authority surface** | "in the operator's head" | human-approves-every-PR; write authority, not commit authority |
181 
182**The lockfile.** A patch bump that only edits `package.json` is inconsistent — CI's `npm ci`
183fails on a manifest/lockfile mismatch, so CI cannot be the oracle. froot regenerates the
184lockfile too, in **lockfile-only, no-scripts mode** (`npm install <pkg>@<v> --package-lock-only
185--ignore-scripts`; `uv lock --upgrade-package <pkg>` for Python), and commits both files to the
186branch. CI then runs `npm ci` / `uv sync --frozen` against a consistent pair.
187 
188*Why this way:* regenerating a lockfile is far lighter than installing and testing, so the
189worker stays small — it carries package managers, not test toolchains. And `--ignore-scripts`
190means **no third-party dependency code ever executes inside the privileged, token-bearing
191worker**; the real install, scripts, build, and tests happen in CI, which is already a sandbox.
192Tiny blast radius, by construction. (This is the proven Renovate/Dependabot mechanic — we own
193it, rather than depend on it, because the loop is the template, not the bumps.)
194 
195## Chassis vs loop: the seam
196 
197**The chassis (build once, in froot):** the durable schedule; checkout; the durable wait on CI;
198PR plumbing and idempotent branch naming; GitHub auth; telemetry; the structured outcome record.
199Modeled on an existing durable Temporal app's chassis — *its shape, not its domain.*
200 
201**The loop (a small config the chassis consumes):** the **signal** (`npm outdated`, `npm audit`,
202an AST scan, a regex sweep — genuinely heterogeneous, never forced behind one interface); the
203**lockfile command** (the one per-ecosystem seam); the **prompt** (what judgment the model makes).
204 
205Rule of thumb: the chassis is the same for every loop and every repo; signal + lockfile-command
206+ prompt are what make a loop a *specialist*.
207 
208## Reputation: derived, never stored
209 
210froot builds **no reputation store**. Reputation is a read-model computed when it's needed, from
211two sources it already has:
212 
213- **GitHub** — the outcome ledger. A consistent `loop/<name>/...` branch and label convention
214 makes every PR queryable: merged, closed, reverted, time-to-merge, CI pass/fail.
215- **ClickStack** — the run ledger. Cost, candidates considered-but-dropped, CI-wait, escalations,
216 emitted as OTEL (the observability path already in place).
217 
218Day one is **record-only**: the gate stays at human-approves-every-PR. The point is to *have* a
219track record so that, later, moving a gate is what the numbers say rather than a guess
220([MHE §3.6][mhe]) — and so trust can be granted with all five of its properties intact.
221 
222## Roadmap
223 
224Staged, deliberately ([MHE §3.4][mhe]). Each stage earns the next. Status markers
225(✅ done · ◐ partial · ○ not started) are as of 2026-06-04.
226 
2271.**Close one loop.** dependency-patch, end-to-end. *Done, and past the original bar:* it
228 runs on **several** target repos across **both** ecosystems (npm + uv), and has opened,
229 CI-verified, and merged real PRs — with at least one bump correctly closed after CI went red.
230 This is the template the rest reuses.
2312.**Replicate.** security-patch — the same chassis, a sharper signal, an objective vuln-delta.
232 Nearly free; proves the template is a template. *Not started — the next intended loop.*
2333.**Coordinate.** Add notifier loops (`derived-state`, `determinism`) that durably guard an
234 already-running durable app — froot's second durable system keeping the first one healthy —
235 and let loops start reading each other's GitHub/ClickStack signals. *Early and partial:* the
236 **determinism** reviewer is built and shipped, but it currently guards froot's *own*
237 workflow-determinism (an advisory PR comment) and it landed *ahead* of stage 2 — a
238 deliberate-but-noted deviation from "one loop at a time." `derived-state` and cross-loop
239 signal-reading are still ○.
2404.**Later — fixers.** Loops that write arbitrary code (flaky-test, refactor-candidate). These
241 are the ones that need a real agentic coding harness; that decision is made *then*, on terrain
242 that already works — not now.
243 
244## Non-goals (for now)
245 
246Naming these is how we stay KISS. Each is deferred on purpose, not forgotten.
247 
248- **Auto-merge / earned autonomy.** Record the track record first; act on it once it's earned.
249 *The record now exists (real merges + a CI-caught rejection); the gate is still
250 human-approves-every-PR.*
251- **A reputation store.** Derive it from GitHub + ClickStack. *(The read-model dashboard now does
252 exactly this — it computes reputation on read and stores nothing.)*
253- **An agentic coding harness.** A mechanical loop doesn't need one. It arrives with the fixers.
254- **Running tests or builds on the cluster.** CI does that.
255- **Multi-ecosystem beyond npm + uv.** Add ecosystems as real loops demand them.
256- **A cross-repo "loop platform" abstraction.** Extract the shared chassis from a *sibling*
257 loop's needs, not from a guess. A second loop (determinism-review) already reuses this chassis
258 by convention, not via an extracted abstraction — that stays deferred until a sibling loop
259 (security-patch) reveals the real contract. One loop's chassis is allowed to look like one loop.
260- **Unifying the three `lib` harnesses.** Note the duplication; converge it when froot's chassis
261 has proven what the real contract is.
262 
263## Lineage
264 
265froot is [Many Hands Engineering][mhe] put into practice — its loop anatomy, its trust economy,
266its staged transition, its placement discipline. It reuses the durable chassis pioneered by an
267existing durable Temporal app (the self-scheduling loop, signal-with-start, propose→verify, the
268deployment scaffold) while leaving that project's domain behind. The maintenance loops themselves
269are harvested from existing local agent suites — froot is where they finally close.
270 
271[mhe]: https://github.com/mseeks/many-hands-engineering

dashboard/render.py — user-facing copy that two loops broke

The dashboard renders strings a human reads. Two of them silently became wrong once a second judge appeared over the same model. The judgment section's heading said "the one model call" — but there are now two judges (changelog risk and the determinism frontier) sharing one model, so "one model call" is false. The fix scopes the claim to this loop and renames the heading to "the changelog verdict."

Heading and note rescoped: this loop's model job is the changelog verdict.

src/froot/dashboard/render.py · 659 lines
src/froot/dashboard/render.py659 lines · Python
⋯ 292 lines hidden (lines 1–292)
1"""Render the view model to one self-contained HTML page (pure).
2 
3All CSS is inline, there is no JavaScript, and the page makes no network
4request of its own — it is a static projection of an already-computed
5:class:`~froot.dashboard.model.DashboardModel`. Every dynamic value is
6HTML-escaped at the boundary. The ordering is trust-first (is it alive → track
7record → oracle → judgment → the human's queue), so it reads top to bottom.
8"""
9 
10from __future__ import annotations
11 
12from datetime import UTC, datetime, timedelta
13from html import escape
14from typing import TYPE_CHECKING
15 
16if TYPE_CHECKING:
17 from froot.dashboard.model import (
18 BumpRow,
19 DashboardModel,
20 ReviewLoop,
21 ReviewRow,
22 RunTelemetry,
23 ScanLoop,
24 )
25 
26_CSS = """
27:root{--fg:#1a1a1a;--mut:#6b6b6b;--line:#e4e4e4;--bg:#fff;--ok:#1a7f37;
28--warn:#9a6700;--bad:#cf222e;--accent:#0969da;--card:#fafafa}
29@media(prefers-color-scheme:dark){:root{--fg:#e6e6e6;--mut:#9a9a9a;
30--line:#262626;--bg:#0d0d0d;--ok:#3fb950;--warn:#d29922;--bad:#f85149;
31--accent:#58a6ff;--card:#141414}}
32*{box-sizing:border-box}
33body{margin:0;background:var(--bg);color:var(--fg);
34font:15px/1.55 system-ui,-apple-system,"Segoe UI",Roboto,sans-serif}
35main{max-width:820px;margin:0 auto;padding:34px 20px 72px}
36h1{font-size:22px;margin:0;letter-spacing:-.01em}
37.tag{color:var(--mut);margin:3px 0 0;font-size:13px}
38.meta{color:var(--mut);font-size:12px;margin:12px 0 0}
39.sources{display:flex;flex-wrap:wrap;gap:16px;margin:10px 0 0;font-size:12px}
40section{margin:30px 0 0}
41h2{font-size:12px;text-transform:uppercase;letter-spacing:.08em;
42color:var(--mut);margin:0 0 12px;font-weight:600;
43border-bottom:1px solid var(--line);padding-bottom:6px}
44.stats{display:flex;flex-wrap:wrap;gap:26px}
45.stat .n{font-size:26px;font-weight:600;line-height:1.1}
46.stat .l{color:var(--mut);font-size:12px;margin-top:2px}
47.dot{display:inline-block;width:9px;height:9px;border-radius:50%;
48margin-right:7px}
49.dot.ok{background:var(--ok)}.dot.warn{background:var(--warn)}
50.dot.bad{background:var(--bad)}.dot.mute{background:var(--mut)}
51table{width:100%;border-collapse:collapse;font-size:13px}
52th,td{text-align:left;padding:6px 12px 6px 0;
53border-bottom:1px solid var(--line);vertical-align:top}
54th{color:var(--mut);font-weight:600;font-size:11px;text-transform:uppercase;
55letter-spacing:.04em}
56.mono{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:12px}
57a{color:var(--accent);text-decoration:none}a:hover{text-decoration:underline}
58.row{display:flex;align-items:baseline;gap:8px;padding:4px 0}
59.mut{color:var(--mut)}.ok{color:var(--ok)}.warn{color:var(--warn)}
60.bad{color:var(--bad)}
61.note{color:var(--mut);font-size:12px;margin:10px 0 0}
62footer{margin:44px 0 0;padding-top:16px;border-top:1px solid var(--line);
63color:var(--mut);font-size:12px;line-height:1.7}
64footer b{color:var(--fg);font-weight:600}
65/* loop groups — collapsible (collapsed by default), two-level hierarchy */
66details.loop{margin:24px 0 0}
67summary.loophead{display:flex;align-items:baseline;gap:6px 12px;flex-wrap:wrap;
68cursor:pointer;list-style:none;margin:0;padding:0 0 7px;
69border-bottom:2px solid var(--fg)}
70summary.loophead::-webkit-details-marker{display:none}
71summary.loophead::before{content:"\\25B8";color:var(--mut);margin-right:5px;
72font-size:11px;position:relative;top:-1px}
73details.loop[open]>summary.loophead::before{content:"\\25BE"}
74summary.loophead h2{font-size:16px;text-transform:none;letter-spacing:-.01em;
75color:var(--fg);border:0;margin:0;padding:0;font-weight:700}
76summary.loophead .sub{color:var(--mut);font-size:12px}
77summary.loophead .glance{margin-left:auto;display:flex;flex-wrap:wrap;
78align-items:baseline;gap:3px 14px;font-size:12px;color:var(--mut)}
79summary.loophead .glance b{color:var(--fg);font-weight:600}
80/* the shared-infra group reads as not-a-loop: lighter, dashed, muted */
81details.loop.shared>summary.loophead{border-bottom:1px dashed var(--line)}
82details.loop.shared>summary.loophead h2{font-size:13px;color:var(--mut);
83font-weight:600;text-transform:uppercase;letter-spacing:.06em}
84/* subsections sit tighter inside a loop; the loop rule carries the weight */
85details.loop section{margin:18px 0 0}
86details.loop section>h2{border-bottom:0;padding-bottom:0;margin-bottom:9px}
87/* foldable framing — the MHE notes, one click away, so the page scans clean */
88details.why{margin:9px 0 0}
89details.why>summary{color:var(--accent);font-size:12px;cursor:pointer;
90list-style:none}
91details.why>summary::-webkit-details-marker{display:none}
92details.why>summary::before{content:"\\002b why";font-weight:600}
93details.why[open]>summary::before{content:"\\2212 why"}
94details.why .note{margin:6px 0 0}
95"""
96 
97_CI_CLASS = {
98 "passed": "ok",
99 "failed": "bad",
100 "absent": "mut",
101 "timed_out": "warn",
103_VERDICT_CLASS = {"clean": "ok", "risky": "warn", "unknown": "mut"}
104 
105 
106def _aware(when: datetime) -> datetime:
107 """Treat a stray naive timestamp as UTC so arithmetic never raises."""
108 return when if when.tzinfo is not None else when.replace(tzinfo=UTC)
109 
110 
111def _ago(when: datetime | None, now: datetime) -> str:
112 """A compact 'time since' label (``6h ago``)."""
113 if when is None:
114 return ""
115 secs = (now - _aware(when)).total_seconds()
116 if secs < 90:
117 return "just now"
118 if secs < 5400:
119 return f"{int(secs // 60)}m ago"
120 if secs < 129600:
121 return f"{int(secs // 3600)}h ago"
122 return f"{int(secs // 86400)}d ago"
123 
124 
125def _until(when: datetime | None, now: datetime) -> str:
126 """A compact 'time until' label (``in 18h`` / ``due now``)."""
127 if when is None:
128 return ""
129 secs = (_aware(when) - now).total_seconds()
130 if secs <= 0:
131 return "due now"
132 if secs < 5400:
133 return f"in {int(secs // 60)}m"
134 if secs < 129600:
135 return f"in {int(secs // 3600)}h"
136 return f"in {int(secs // 86400)}d"
137 
138 
139def _dot(kind: str) -> str:
140 """A status dot span (``ok`` / ``warn`` / ``bad`` / ``mute``)."""
141 return f'<span class="dot {kind}"></span>'
142 
143 
144def _tag(value: str | None, classes: dict[str, str]) -> str:
145 """A small coloured label for a verdict/CI value, or an em-dash."""
146 if value is None:
147 return '<span class="mut">—</span>'
148 cls = classes.get(value, "mut")
149 return f'<span class="{cls}">{escape(value)}</span>'
150 
151 
152def _stat(n: object, label: str) -> str:
153 return (
154 f'<div class="stat"><div class="n">{escape(str(n))}</div>'
155 f'<div class="l">{escape(label)}</div></div>'
156 )
157 
158 
159def _why(note_html: str) -> str:
160 """Fold a framing note behind a native ``<details>`` so the page scans."""
161 return f'<details class="why"><summary></summary>{note_html}</details>'
162 
163 
164def _glance(n: object, label: str) -> str:
165 """One at-a-glance item for a loop header (``<b>12</b> proposed``)."""
166 return f"<span><b>{escape(str(n))}</b> {escape(label)}</span>"
167 
168 
169def _header(model: DashboardModel) -> str:
170 now = model.generated_at
171 repos = ", ".join(model.repos_configured) or "none configured"
172 dots = "".join(
173 f"<span>{_dot('ok' if s.ok else 'bad')}"
174 f'{escape(s.name)} <span class="mut">{escape(s.detail)}</span></span>'
175 for s in model.sources
176 )
177 stamp = now.strftime("%Y-%m-%d %H:%M UTC")
178 return (
179 "<header>"
180 "<h1>froot</h1>"
181 '<p class="tag">durable maintenance loops &middot; '
182 "reputation read-model</p>"
183 f'<p class="meta">watching <span class="mono">{escape(repos)}</span>'
184 f" &middot; generated {escape(stamp)} &middot; "
185 "derived live, stored nowhere</p>"
186 f'<div class="sources">{dots}</div>'
187 "</header>"
188 )
189 
190 
191def _heartbeat(model: DashboardModel) -> str:
192 now = model.generated_at
193 interval = model.scan_interval_seconds
194 
195 def line(loop: ScanLoop) -> str:
196 if loop.live:
197 dot, tail = "ok", ""
198 if loop.last_tick is not None:
199 nxt = _aware(loop.last_tick) + timedelta(seconds=interval)
200 last = _ago(loop.last_tick, now)
201 tail = (
202 f' <span class="mut">&middot; last {last}'
203 f" &middot; next {_until(nxt, now)}</span>"
204 )
205 else:
206 dot = "bad" if loop.status in ("terminated", "none") else "warn"
207 tail = f' <span class="mut">&middot; {escape(loop.status)}</span>'
208 return (
209 f'<div class="row">{_dot(dot)}'
210 f'<span class="mono">{escape(loop.repo)}</span>{tail}</div>'
211 )
212 
213 if not model.scan_loops:
214 body = '<p class="note">No repos configured (FROOT_REPOS unset).</p>'
215 else:
216 body = "".join(line(loop) for loop in model.scan_loops)
217 return f"<section><h2>Is it alive?</h2>{body}</section>"
218 
219 
220def _track_record(model: DashboardModel) -> str:
221 t = model.track_record
222 rate = "" if t.merge_rate is None else f"{t.merge_rate * 100:.0f}%"
223 ttm = (
224 ""
225 if t.median_ttm_minutes is None
226 else f"{t.median_ttm_minutes:.0f} min"
227 )
228 stats = "".join(
229 (
230 _stat(t.opened, "proposed"),
231 _stat(t.merged, "merged"),
232 _stat(t.open_now, "awaiting"),
233 _stat(t.closed_unmerged, "closed"),
234 _stat(rate, "merge rate"),
235 _stat(ttm, "median time-to-merge"),
236 )
237 )
238 note = (
239 '<p class="note">Merge rate is the Stage-1 signal &mdash; narrow to '
240 "npm + uv patch bumps by construction. It counts a human merge, not a "
241 "confirmed good outcome: revert tracking is a later loop, so merge is "
242 "not yet proof of success.</p>"
243 )
244 return (
245 "<section><h2>Track record &middot; the reputation</h2>"
246 f'<div class="stats">{stats}</div>{_why(note)}</section>'
247 )
248 
249 
250def _verification(model: DashboardModel) -> str:
251 v = model.verification
252 stats = "".join(
253 (
254 _stat(v.passed, "CI passed"),
255 _stat(v.failed, "CI failed"),
256 _stat(v.absent, "no checks"),
257 _stat(v.timed_out, "timed out"),
258 _stat(v.unknown, "unknown"),
259 )
260 )
261 if v.with_reading == 0:
262 note = '<p class="note">No CI readings yet.</p>'
263 else:
264 note = _why(
265 f'<p class="note">A real oracle reported on '
266 f"<b>{v.oracle_existed}</b> of {v.with_reading} bumps with a "
267 'reading. <span class="mut">&lsquo;no checks&rsquo; means CI was '
268 "absent &mdash; not a pass; never conflated.</span>"
269 "</p>"
270 )
271 return (
272 "<section><h2>Verification &middot; CI is the oracle</h2>"
273 f'<div class="stats">{stats}</div>{note}</section>'
274 )
275 
276 
277def _judgment(model: DashboardModel) -> str:
278 j = model.judgment
279 stats = "".join(
280 (
281 _stat(j.clean, "clean"),
282 _stat(j.risky, "risky"),
283 _stat(j.unknown, "unknown"),
284 _stat(j.none, "no verdict"),
285 )
286 )
287 if j.clean_but_failed or j.flagged_but_passed:
288 note = (
289 f'<p class="note">Calibration: <b>{j.clean_but_failed}</b> '
290 "&lsquo;clean&rsquo; bumps whose CI failed, "
291 f"<b>{j.flagged_but_passed}</b> flagged bumps whose CI passed.</p>"
292 )
293 else:
294 note = (
295 '<p class="note">In this loop the model&rsquo;s only job is the '
296 "changelog verdict; the spine proposes the bump either way.</p>"
297 )
298 return (
299 "<section><h2>Model judgment &middot; the changelog verdict</h2>"
300 f'<div class="stats">{stats}</div>{_why(note)}</section>'
301 )
⋯ 358 lines hidden (lines 302–659)
302 
303 
304def _gate(model: DashboardModel) -> str:
305 now = model.generated_at
306 if not model.gate:
307 body = '<p class="note">Queue empty &mdash; nothing awaiting you.</p>'
308 else:
309 rows = "".join(
310 "<tr>"
311 f'<td class="mono">{escape(row.package)}</td>'
312 f"<td>{escape(_ago(row.opened_at, now))}</td>"
313 f"<td>{_pr_link(row)}</td>"
314 "</tr>"
315 for row in model.gate
316 )
317 body = (
318 "<table><thead><tr><th>package</th><th>waiting</th><th>pr</th>"
319 f"</tr></thead><tbody>{rows}</tbody></table>"
320 )
321 return (
322 "<section><h2>Approval gate &middot; what a human owns</h2>"
323 f"{body}</section>"
324 )
325 
326 
327def _bumps(model: DashboardModel) -> str:
328 now = model.generated_at
329 if not model.bumps:
330 body = '<p class="note">No bumps proposed yet.</p>'
331 else:
332 rows = "".join(
333 "<tr>"
334 f'<td class="mono">{escape(row.package)}</td>'
335 f'<td class="mono mut">{escape(row.from_version or "?")} &rarr; '
336 f"{escape(row.to_version)}</td>"
337 f"<td>{_tag(row.verdict, _VERDICT_CLASS)}</td>"
338 f"<td>{_tag(row.ci, _CI_CLASS)}</td>"
339 f"<td>{_state_tag(row.state)}</td>"
340 f'<td class="mut">{escape(_ago(row.opened_at, now))}</td>'
341 f"<td>{_pr_link(row)}</td>"
342 "</tr>"
343 for row in model.bumps
344 )
345 body = (
346 "<table><thead><tr><th>package</th><th>bump</th><th>verdict</th>"
347 "<th>ci</th><th>state</th><th>opened</th><th>pr</th></tr></thead>"
348 f"<tbody>{rows}</tbody></table>"
349 )
350 return f"<section><h2>Bumps &middot; the detail</h2>{body}</section>"
351 
352 
353def _failures(model: DashboardModel) -> str:
354 if not model.failures:
355 return ""
356 now = model.generated_at
357 rows = "".join(
358 "<tr>"
359 f'<td class="mono">{escape(_short_id(f.workflow_id))}</td>'
360 f"<td>{_state_tag(f.kind)}</td>"
361 f'<td class="mut">{escape(f.reason or "")}</td>'
362 f'<td class="mut">{escape(_ago(f.when, now))}</td>'
363 "</tr>"
364 for f in model.failures
365 )
366 return (
367 "<section><h2>Failures &middot; where the loop did not close</h2>"
368 "<table><thead><tr><th>bump</th><th>kind</th><th>reason</th>"
369 f"<th>when</th></tr></thead><tbody>{rows}</tbody></table></section>"
370 )
371 
372 
373def _telem_glance(model: DashboardModel) -> str:
374 """The telemetry group's at-a-glance (or an ``unavailable`` marker)."""
375 t = model.telemetry
376 if not t.available:
377 return '<span class="mut">unavailable</span>'
378 return _glance(t.total_spans, f"spans / {t.window_days}d")
379 
380 
381def _telemetry(model: DashboardModel) -> str:
382 """The telemetry body — header lives in the shared loop group above it."""
383 t: RunTelemetry = model.telemetry
384 if not t.available:
385 return (
386 "<section>"
387 '<p class="note">Unavailable (not configured or unreachable). '
388 "GitHub + Temporal carry the dashboard regardless.</p></section>"
389 )
390 now = model.generated_at
391 if t.activities:
392 rows = "".join(
393 "<tr>"
394 f'<td class="mono">{escape(a.name)}</td>'
395 f"<td>{a.count}</td>"
396 f'<td class="mut">{a.avg_ms:.0f} ms</td>'
397 f'<td class="mut">{a.max_ms:.0f} ms</td>'
398 "</tr>"
399 for a in t.activities
400 )
401 table = (
402 "<table><thead><tr><th>activity</th><th>runs</th><th>avg</th>"
403 f"<th>max</th></tr></thead><tbody>{rows}</tbody></table>"
404 )
405 else:
406 table = '<p class="note">No froot spans in the window.</p>'
407 summary = (
408 f'<p class="note">{t.total_spans} spans &middot; '
409 f"{t.error_spans} errored &middot; last activity "
410 f"{escape(_ago(t.last_activity, now))} &middot; "
411 f"{t.window_days}-day window.</p>"
412 )
413 return f"<section>{summary}{table}</section>"
414 
415 
416def _review_heartbeat(model: DashboardModel) -> str:
417 now = model.generated_at
418 interval = model.review_interval_seconds
419 
420 def line(loop: ReviewLoop) -> str:
421 if loop.live:
422 dot, tail = "ok", ""
423 if loop.last_tick is not None:
424 nxt = _aware(loop.last_tick) + timedelta(seconds=interval)
425 last = _ago(loop.last_tick, now)
426 tail = (
427 f' <span class="mut">&middot; last {last}'
428 f" &middot; next {_until(nxt, now)}</span>"
429 )
430 else:
431 dot = "bad" if loop.status in ("terminated", "none") else "warn"
432 tail = f' <span class="mut">&middot; {escape(loop.status)}</span>'
433 return (
434 f'<div class="row">{_dot(dot)}'
435 f'<span class="mono">{escape(loop.repo)}</span>{tail}</div>'
436 )
437 
438 if not model.review_loops:
439 body = (
440 '<p class="note">No determinism-review loops running '
441 "(the transitive ring watches the @workflow.defn repos).</p>"
442 )
443 else:
444 body = "".join(line(loop) for loop in model.review_loops)
445 return f"<section><h2>Is it alive?</h2>{body}</section>"
446 
447 
448def _review_record(model: DashboardModel) -> str:
449 r = model.review_record
450 stats = "".join(
451 (
452 _stat(r.reviewed, "reviewed"),
453 _stat(r.flagged, "flagged"),
454 _stat(r.clean, "clean"),
455 _stat(r.hazards, "hazards"),
456 _stat(r.repos_covered, "repos covered"),
457 )
458 )
459 note = (
460 '<p class="note">The transitive ring: it chases first-party helper '
461 "calls out of each workflow to catch a hazard the lexical CI kernel "
462 "can&rsquo;t see. <b>Advisory</b> &mdash; the blocking gate stays the "
463 "kernel&rsquo;s CI check. The hazard-resolved rate (was a flag gone on "
464 "a later commit?) is a later loop; it needs accumulated history.</p>"
465 )
466 return (
467 "<section><h2>Track record &middot; the reputation</h2>"
468 f'<div class="stats">{stats}</div>{_why(note)}</section>'
469 )
470 
471 
472def _reviews(model: DashboardModel) -> str:
473 now = model.generated_at
474 if not model.reviews:
475 body = '<p class="note">No PRs reviewed yet.</p>'
476 else:
477 rows = "".join(
478 "<tr>"
479 f'<td class="mono">{escape(row.repo)}</td>'
480 f"<td>{_review_pr_link(row)}</td>"
481 f'<td class="mono mut">{escape((row.head_sha or "")[:7]) or ""}'
482 "</td>"
483 f"<td>{_findings_cell(row)}</td>"
484 f'<td class="mut">{escape(_ago(row.reviewed_at, now))}</td>'
485 "</tr>"
486 for row in model.reviews
487 )
488 body = (
489 "<table><thead><tr><th>repo</th><th>pr</th><th>head</th>"
490 "<th>findings</th><th>reviewed</th></tr></thead>"
491 f"<tbody>{rows}</tbody></table>"
492 )
493 return f"<section><h2>Reviews &middot; the detail</h2>{body}</section>"
494 
495 
496def _footer() -> str:
497 return (
498 "<footer>"
499 "<b>Authority envelope.</b> Stage 1 &mdash; froot holds "
500 "<b>write authority</b> only: it opens PRs, a human approves every "
501 "merge (commit authority = none). Trust, when any is granted, is "
502 "earned, narrow to npm + uv patch bumps, conditional on its "
503 'environment (judge <span class="mono">gemma4:e4b</span>, '
504 "lockfile-only regen), "
505 "revocable, and time-expiring. Today it records the track record; it "
506 "does not yet act on it.<br>"
507 "Everything above is derived on this request from GitHub (outcomes) + "
508 "Temporal (runs) + ClickHouse (telemetry). froot keeps no database; "
509 "reload to recompute."
510 "</footer>"
511 )
512 
513 
514def _pr_link(row: BumpRow) -> str:
515 if row.pr_url is None or row.pr_number is None:
516 return '<span class="mut">—</span>'
517 return f'<a href="{escape(row.pr_url, quote=True)}">#{row.pr_number}</a>'
518 
519 
520def _review_pr_link(row: ReviewRow) -> str:
521 if row.pr_url is None or row.pr_number is None:
522 return '<span class="mut">—</span>'
523 return f'<a href="{escape(row.pr_url, quote=True)}">#{row.pr_number}</a>'
524 
525 
526def _findings_cell(row: ReviewRow) -> str:
527 """A review's findings: 'clean', or the hazard count + rules + comment."""
528 if row.findings == 0:
529 return '<span class="ok">clean</span>'
530 rules = (
531 f' <span class="mono mut">{escape(", ".join(row.rules))}</span>'
532 if row.rules
533 else ""
534 )
535 comment = (
536 f' <a href="{escape(row.comment_url, quote=True)}">comment</a>'
537 if row.comment_url
538 else ""
539 )
540 noun = "hazard" if row.findings == 1 else "hazards"
541 return f'<span class="bad">{row.findings} {noun}</span>{rules}{comment}'
542 
543 
544def _state_tag(state: str) -> str:
545 cls = {
546 "merged": "ok",
547 "open": "warn",
548 "closed": "mut",
549 "terminated": "bad",
550 "failed": "bad",
551 "timed_out": "bad",
552 "canceled": "warn",
553 }.get(state, "mut")
554 return f'<span class="{cls}">{escape(state)}</span>'
555 
556 
557def _short_id(workflow_id: str) -> str:
558 """Drop the ``froot-bump-`` prefix for a readable failures row."""
559 return workflow_id.removeprefix("froot-bump-")
560 
561 
562def _dep_glance(model: DashboardModel) -> str:
563 """The dependency-patch loop's at-a-glance, shown in its header."""
564 t = model.track_record
565 live = sum(1 for loop in model.scan_loops if loop.live)
566 total = len(model.scan_loops)
567 rate = "" if t.merge_rate is None else f"{t.merge_rate * 100:.0f}%"
568 items = [
569 _glance(f"{live}/{total}", "live"),
570 _glance(t.opened, "proposed"),
571 _glance(rate, "merged"),
572 ]
573 if t.open_now:
574 items.append(_glance(t.open_now, "awaiting you"))
575 return "".join(items)
576 
577 
578def _det_glance(model: DashboardModel) -> str:
579 """The determinism-review loop's at-a-glance, shown in its header."""
580 r = model.review_record
581 return "".join(
582 (
583 _glance(r.repos_covered, "repos"),
584 _glance(r.reviewed, "reviewed"),
585 _glance(r.hazards, "hazards"),
586 )
587 )
588 
589 
590def _loop(
591 title: str,
592 sub: str,
593 glance: str,
594 sections: tuple[str, ...],
595 *,
596 shared: bool = False,
597) -> str:
598 """A collapsible group: a prominent header + at-a-glance, collapsed.
599 
600 The header (with its at-a-glance) is the ``<summary>``, so a collapsed group
601 still shows the headline; expanding reveals the subsections. ``shared`` is a
602 non-loop infra group (telemetry) with a lighter, distinct header.
603 """
604 cls = "loop shared" if shared else "loop"
605 head = (
606 '<summary class="loophead">'
607 f"<h2>{escape(title)}</h2>"
608 f'<span class="sub">{sub}</span>'
609 f'<span class="glance">{glance}</span>'
610 "</summary>"
611 )
612 return f'<details class="{cls}">{head}{"".join(sections)}</details>'
613 
614 
615def page(model: DashboardModel) -> str:
616 """Render the whole dashboard as one self-contained HTML document."""
617 parts = (
618 _header(model),
619 _loop(
620 "Dependency-patch",
621 "npm + uv &middot; scan &rarr; bump &rarr; CI &rarr; merge",
622 _dep_glance(model),
623 (
624 _heartbeat(model),
625 _track_record(model),
626 _verification(model),
627 _judgment(model),
628 _gate(model),
629 _bumps(model),
630 _failures(model),
631 ),
632 ),
633 _loop(
634 "Determinism review",
635 "the transitive ring &middot; advisory",
636 _det_glance(model),
637 (
638 _review_heartbeat(model),
639 _review_record(model),
640 _reviews(model),
641 ),
642 ),
643 _loop(
644 "Run telemetry",
645 "ClickHouse &middot; trace-derived, best-effort",
646 _telem_glance(model),
647 (_telemetry(model),),
648 shared=True,
649 ),
650 _footer(),
651 )
652 return (
653 '<!doctype html><html lang="en"><head><meta charset="utf-8">'
654 '<meta name="viewport" content="width=device-width,initial-scale=1">'
655 "<title>froot &middot; read-model</title>"
656 f"<style>{_CSS}</style></head><body><main>"
657 + "".join(parts)
658 + "</main></body></html>"
659 )

The other class of fix is ecosystem scope. Several strings said "npm patch bumps" when the loop now also handles uv. The track-record note and the authority-envelope footer both get the same correction: npm + uv patch bumps.

Track-record note: 'npm + uv patch bumps by construction.'

src/froot/dashboard/render.py · 659 lines
src/froot/dashboard/render.py659 lines · Python
⋯ 237 lines hidden (lines 1–237)
1"""Render the view model to one self-contained HTML page (pure).
2 
3All CSS is inline, there is no JavaScript, and the page makes no network
4request of its own — it is a static projection of an already-computed
5:class:`~froot.dashboard.model.DashboardModel`. Every dynamic value is
6HTML-escaped at the boundary. The ordering is trust-first (is it alive → track
7record → oracle → judgment → the human's queue), so it reads top to bottom.
8"""
9 
10from __future__ import annotations
11 
12from datetime import UTC, datetime, timedelta
13from html import escape
14from typing import TYPE_CHECKING
15 
16if TYPE_CHECKING:
17 from froot.dashboard.model import (
18 BumpRow,
19 DashboardModel,
20 ReviewLoop,
21 ReviewRow,
22 RunTelemetry,
23 ScanLoop,
24 )
25 
26_CSS = """
27:root{--fg:#1a1a1a;--mut:#6b6b6b;--line:#e4e4e4;--bg:#fff;--ok:#1a7f37;
28--warn:#9a6700;--bad:#cf222e;--accent:#0969da;--card:#fafafa}
29@media(prefers-color-scheme:dark){:root{--fg:#e6e6e6;--mut:#9a9a9a;
30--line:#262626;--bg:#0d0d0d;--ok:#3fb950;--warn:#d29922;--bad:#f85149;
31--accent:#58a6ff;--card:#141414}}
32*{box-sizing:border-box}
33body{margin:0;background:var(--bg);color:var(--fg);
34font:15px/1.55 system-ui,-apple-system,"Segoe UI",Roboto,sans-serif}
35main{max-width:820px;margin:0 auto;padding:34px 20px 72px}
36h1{font-size:22px;margin:0;letter-spacing:-.01em}
37.tag{color:var(--mut);margin:3px 0 0;font-size:13px}
38.meta{color:var(--mut);font-size:12px;margin:12px 0 0}
39.sources{display:flex;flex-wrap:wrap;gap:16px;margin:10px 0 0;font-size:12px}
40section{margin:30px 0 0}
41h2{font-size:12px;text-transform:uppercase;letter-spacing:.08em;
42color:var(--mut);margin:0 0 12px;font-weight:600;
43border-bottom:1px solid var(--line);padding-bottom:6px}
44.stats{display:flex;flex-wrap:wrap;gap:26px}
45.stat .n{font-size:26px;font-weight:600;line-height:1.1}
46.stat .l{color:var(--mut);font-size:12px;margin-top:2px}
47.dot{display:inline-block;width:9px;height:9px;border-radius:50%;
48margin-right:7px}
49.dot.ok{background:var(--ok)}.dot.warn{background:var(--warn)}
50.dot.bad{background:var(--bad)}.dot.mute{background:var(--mut)}
51table{width:100%;border-collapse:collapse;font-size:13px}
52th,td{text-align:left;padding:6px 12px 6px 0;
53border-bottom:1px solid var(--line);vertical-align:top}
54th{color:var(--mut);font-weight:600;font-size:11px;text-transform:uppercase;
55letter-spacing:.04em}
56.mono{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:12px}
57a{color:var(--accent);text-decoration:none}a:hover{text-decoration:underline}
58.row{display:flex;align-items:baseline;gap:8px;padding:4px 0}
59.mut{color:var(--mut)}.ok{color:var(--ok)}.warn{color:var(--warn)}
60.bad{color:var(--bad)}
61.note{color:var(--mut);font-size:12px;margin:10px 0 0}
62footer{margin:44px 0 0;padding-top:16px;border-top:1px solid var(--line);
63color:var(--mut);font-size:12px;line-height:1.7}
64footer b{color:var(--fg);font-weight:600}
65/* loop groups — collapsible (collapsed by default), two-level hierarchy */
66details.loop{margin:24px 0 0}
67summary.loophead{display:flex;align-items:baseline;gap:6px 12px;flex-wrap:wrap;
68cursor:pointer;list-style:none;margin:0;padding:0 0 7px;
69border-bottom:2px solid var(--fg)}
70summary.loophead::-webkit-details-marker{display:none}
71summary.loophead::before{content:"\\25B8";color:var(--mut);margin-right:5px;
72font-size:11px;position:relative;top:-1px}
73details.loop[open]>summary.loophead::before{content:"\\25BE"}
74summary.loophead h2{font-size:16px;text-transform:none;letter-spacing:-.01em;
75color:var(--fg);border:0;margin:0;padding:0;font-weight:700}
76summary.loophead .sub{color:var(--mut);font-size:12px}
77summary.loophead .glance{margin-left:auto;display:flex;flex-wrap:wrap;
78align-items:baseline;gap:3px 14px;font-size:12px;color:var(--mut)}
79summary.loophead .glance b{color:var(--fg);font-weight:600}
80/* the shared-infra group reads as not-a-loop: lighter, dashed, muted */
81details.loop.shared>summary.loophead{border-bottom:1px dashed var(--line)}
82details.loop.shared>summary.loophead h2{font-size:13px;color:var(--mut);
83font-weight:600;text-transform:uppercase;letter-spacing:.06em}
84/* subsections sit tighter inside a loop; the loop rule carries the weight */
85details.loop section{margin:18px 0 0}
86details.loop section>h2{border-bottom:0;padding-bottom:0;margin-bottom:9px}
87/* foldable framing — the MHE notes, one click away, so the page scans clean */
88details.why{margin:9px 0 0}
89details.why>summary{color:var(--accent);font-size:12px;cursor:pointer;
90list-style:none}
91details.why>summary::-webkit-details-marker{display:none}
92details.why>summary::before{content:"\\002b why";font-weight:600}
93details.why[open]>summary::before{content:"\\2212 why"}
94details.why .note{margin:6px 0 0}
95"""
96 
97_CI_CLASS = {
98 "passed": "ok",
99 "failed": "bad",
100 "absent": "mut",
101 "timed_out": "warn",
103_VERDICT_CLASS = {"clean": "ok", "risky": "warn", "unknown": "mut"}
104 
105 
106def _aware(when: datetime) -> datetime:
107 """Treat a stray naive timestamp as UTC so arithmetic never raises."""
108 return when if when.tzinfo is not None else when.replace(tzinfo=UTC)
109 
110 
111def _ago(when: datetime | None, now: datetime) -> str:
112 """A compact 'time since' label (``6h ago``)."""
113 if when is None:
114 return ""
115 secs = (now - _aware(when)).total_seconds()
116 if secs < 90:
117 return "just now"
118 if secs < 5400:
119 return f"{int(secs // 60)}m ago"
120 if secs < 129600:
121 return f"{int(secs // 3600)}h ago"
122 return f"{int(secs // 86400)}d ago"
123 
124 
125def _until(when: datetime | None, now: datetime) -> str:
126 """A compact 'time until' label (``in 18h`` / ``due now``)."""
127 if when is None:
128 return ""
129 secs = (_aware(when) - now).total_seconds()
130 if secs <= 0:
131 return "due now"
132 if secs < 5400:
133 return f"in {int(secs // 60)}m"
134 if secs < 129600:
135 return f"in {int(secs // 3600)}h"
136 return f"in {int(secs // 86400)}d"
137 
138 
139def _dot(kind: str) -> str:
140 """A status dot span (``ok`` / ``warn`` / ``bad`` / ``mute``)."""
141 return f'<span class="dot {kind}"></span>'
142 
143 
144def _tag(value: str | None, classes: dict[str, str]) -> str:
145 """A small coloured label for a verdict/CI value, or an em-dash."""
146 if value is None:
147 return '<span class="mut">—</span>'
148 cls = classes.get(value, "mut")
149 return f'<span class="{cls}">{escape(value)}</span>'
150 
151 
152def _stat(n: object, label: str) -> str:
153 return (
154 f'<div class="stat"><div class="n">{escape(str(n))}</div>'
155 f'<div class="l">{escape(label)}</div></div>'
156 )
157 
158 
159def _why(note_html: str) -> str:
160 """Fold a framing note behind a native ``<details>`` so the page scans."""
161 return f'<details class="why"><summary></summary>{note_html}</details>'
162 
163 
164def _glance(n: object, label: str) -> str:
165 """One at-a-glance item for a loop header (``<b>12</b> proposed``)."""
166 return f"<span><b>{escape(str(n))}</b> {escape(label)}</span>"
167 
168 
169def _header(model: DashboardModel) -> str:
170 now = model.generated_at
171 repos = ", ".join(model.repos_configured) or "none configured"
172 dots = "".join(
173 f"<span>{_dot('ok' if s.ok else 'bad')}"
174 f'{escape(s.name)} <span class="mut">{escape(s.detail)}</span></span>'
175 for s in model.sources
176 )
177 stamp = now.strftime("%Y-%m-%d %H:%M UTC")
178 return (
179 "<header>"
180 "<h1>froot</h1>"
181 '<p class="tag">durable maintenance loops &middot; '
182 "reputation read-model</p>"
183 f'<p class="meta">watching <span class="mono">{escape(repos)}</span>'
184 f" &middot; generated {escape(stamp)} &middot; "
185 "derived live, stored nowhere</p>"
186 f'<div class="sources">{dots}</div>'
187 "</header>"
188 )
189 
190 
191def _heartbeat(model: DashboardModel) -> str:
192 now = model.generated_at
193 interval = model.scan_interval_seconds
194 
195 def line(loop: ScanLoop) -> str:
196 if loop.live:
197 dot, tail = "ok", ""
198 if loop.last_tick is not None:
199 nxt = _aware(loop.last_tick) + timedelta(seconds=interval)
200 last = _ago(loop.last_tick, now)
201 tail = (
202 f' <span class="mut">&middot; last {last}'
203 f" &middot; next {_until(nxt, now)}</span>"
204 )
205 else:
206 dot = "bad" if loop.status in ("terminated", "none") else "warn"
207 tail = f' <span class="mut">&middot; {escape(loop.status)}</span>'
208 return (
209 f'<div class="row">{_dot(dot)}'
210 f'<span class="mono">{escape(loop.repo)}</span>{tail}</div>'
211 )
212 
213 if not model.scan_loops:
214 body = '<p class="note">No repos configured (FROOT_REPOS unset).</p>'
215 else:
216 body = "".join(line(loop) for loop in model.scan_loops)
217 return f"<section><h2>Is it alive?</h2>{body}</section>"
218 
219 
220def _track_record(model: DashboardModel) -> str:
221 t = model.track_record
222 rate = "" if t.merge_rate is None else f"{t.merge_rate * 100:.0f}%"
223 ttm = (
224 ""
225 if t.median_ttm_minutes is None
226 else f"{t.median_ttm_minutes:.0f} min"
227 )
228 stats = "".join(
229 (
230 _stat(t.opened, "proposed"),
231 _stat(t.merged, "merged"),
232 _stat(t.open_now, "awaiting"),
233 _stat(t.closed_unmerged, "closed"),
234 _stat(rate, "merge rate"),
235 _stat(ttm, "median time-to-merge"),
236 )
237 )
238 note = (
239 '<p class="note">Merge rate is the Stage-1 signal &mdash; narrow to '
240 "npm + uv patch bumps by construction. It counts a human merge, not a "
241 "confirmed good outcome: revert tracking is a later loop, so merge is "
242 "not yet proof of success.</p>"
243 )
⋯ 416 lines hidden (lines 244–659)
244 return (
245 "<section><h2>Track record &middot; the reputation</h2>"
246 f'<div class="stats">{stats}</div>{_why(note)}</section>'
247 )
248 
249 
250def _verification(model: DashboardModel) -> str:
251 v = model.verification
252 stats = "".join(
253 (
254 _stat(v.passed, "CI passed"),
255 _stat(v.failed, "CI failed"),
256 _stat(v.absent, "no checks"),
257 _stat(v.timed_out, "timed out"),
258 _stat(v.unknown, "unknown"),
259 )
260 )
261 if v.with_reading == 0:
262 note = '<p class="note">No CI readings yet.</p>'
263 else:
264 note = _why(
265 f'<p class="note">A real oracle reported on '
266 f"<b>{v.oracle_existed}</b> of {v.with_reading} bumps with a "
267 'reading. <span class="mut">&lsquo;no checks&rsquo; means CI was '
268 "absent &mdash; not a pass; never conflated.</span>"
269 "</p>"
270 )
271 return (
272 "<section><h2>Verification &middot; CI is the oracle</h2>"
273 f'<div class="stats">{stats}</div>{note}</section>'
274 )
275 
276 
277def _judgment(model: DashboardModel) -> str:
278 j = model.judgment
279 stats = "".join(
280 (
281 _stat(j.clean, "clean"),
282 _stat(j.risky, "risky"),
283 _stat(j.unknown, "unknown"),
284 _stat(j.none, "no verdict"),
285 )
286 )
287 if j.clean_but_failed or j.flagged_but_passed:
288 note = (
289 f'<p class="note">Calibration: <b>{j.clean_but_failed}</b> '
290 "&lsquo;clean&rsquo; bumps whose CI failed, "
291 f"<b>{j.flagged_but_passed}</b> flagged bumps whose CI passed.</p>"
292 )
293 else:
294 note = (
295 '<p class="note">In this loop the model&rsquo;s only job is the '
296 "changelog verdict; the spine proposes the bump either way.</p>"
297 )
298 return (
299 "<section><h2>Model judgment &middot; the changelog verdict</h2>"
300 f'<div class="stats">{stats}</div>{_why(note)}</section>'
301 )
302 
303 
304def _gate(model: DashboardModel) -> str:
305 now = model.generated_at
306 if not model.gate:
307 body = '<p class="note">Queue empty &mdash; nothing awaiting you.</p>'
308 else:
309 rows = "".join(
310 "<tr>"
311 f'<td class="mono">{escape(row.package)}</td>'
312 f"<td>{escape(_ago(row.opened_at, now))}</td>"
313 f"<td>{_pr_link(row)}</td>"
314 "</tr>"
315 for row in model.gate
316 )
317 body = (
318 "<table><thead><tr><th>package</th><th>waiting</th><th>pr</th>"
319 f"</tr></thead><tbody>{rows}</tbody></table>"
320 )
321 return (
322 "<section><h2>Approval gate &middot; what a human owns</h2>"
323 f"{body}</section>"
324 )
325 
326 
327def _bumps(model: DashboardModel) -> str:
328 now = model.generated_at
329 if not model.bumps:
330 body = '<p class="note">No bumps proposed yet.</p>'
331 else:
332 rows = "".join(
333 "<tr>"
334 f'<td class="mono">{escape(row.package)}</td>'
335 f'<td class="mono mut">{escape(row.from_version or "?")} &rarr; '
336 f"{escape(row.to_version)}</td>"
337 f"<td>{_tag(row.verdict, _VERDICT_CLASS)}</td>"
338 f"<td>{_tag(row.ci, _CI_CLASS)}</td>"
339 f"<td>{_state_tag(row.state)}</td>"
340 f'<td class="mut">{escape(_ago(row.opened_at, now))}</td>'
341 f"<td>{_pr_link(row)}</td>"
342 "</tr>"
343 for row in model.bumps
344 )
345 body = (
346 "<table><thead><tr><th>package</th><th>bump</th><th>verdict</th>"
347 "<th>ci</th><th>state</th><th>opened</th><th>pr</th></tr></thead>"
348 f"<tbody>{rows}</tbody></table>"
349 )
350 return f"<section><h2>Bumps &middot; the detail</h2>{body}</section>"
351 
352 
353def _failures(model: DashboardModel) -> str:
354 if not model.failures:
355 return ""
356 now = model.generated_at
357 rows = "".join(
358 "<tr>"
359 f'<td class="mono">{escape(_short_id(f.workflow_id))}</td>'
360 f"<td>{_state_tag(f.kind)}</td>"
361 f'<td class="mut">{escape(f.reason or "")}</td>'
362 f'<td class="mut">{escape(_ago(f.when, now))}</td>'
363 "</tr>"
364 for f in model.failures
365 )
366 return (
367 "<section><h2>Failures &middot; where the loop did not close</h2>"
368 "<table><thead><tr><th>bump</th><th>kind</th><th>reason</th>"
369 f"<th>when</th></tr></thead><tbody>{rows}</tbody></table></section>"
370 )
371 
372 
373def _telem_glance(model: DashboardModel) -> str:
374 """The telemetry group's at-a-glance (or an ``unavailable`` marker)."""
375 t = model.telemetry
376 if not t.available:
377 return '<span class="mut">unavailable</span>'
378 return _glance(t.total_spans, f"spans / {t.window_days}d")
379 
380 
381def _telemetry(model: DashboardModel) -> str:
382 """The telemetry body — header lives in the shared loop group above it."""
383 t: RunTelemetry = model.telemetry
384 if not t.available:
385 return (
386 "<section>"
387 '<p class="note">Unavailable (not configured or unreachable). '
388 "GitHub + Temporal carry the dashboard regardless.</p></section>"
389 )
390 now = model.generated_at
391 if t.activities:
392 rows = "".join(
393 "<tr>"
394 f'<td class="mono">{escape(a.name)}</td>'
395 f"<td>{a.count}</td>"
396 f'<td class="mut">{a.avg_ms:.0f} ms</td>'
397 f'<td class="mut">{a.max_ms:.0f} ms</td>'
398 "</tr>"
399 for a in t.activities
400 )
401 table = (
402 "<table><thead><tr><th>activity</th><th>runs</th><th>avg</th>"
403 f"<th>max</th></tr></thead><tbody>{rows}</tbody></table>"
404 )
405 else:
406 table = '<p class="note">No froot spans in the window.</p>'
407 summary = (
408 f'<p class="note">{t.total_spans} spans &middot; '
409 f"{t.error_spans} errored &middot; last activity "
410 f"{escape(_ago(t.last_activity, now))} &middot; "
411 f"{t.window_days}-day window.</p>"
412 )
413 return f"<section>{summary}{table}</section>"
414 
415 
416def _review_heartbeat(model: DashboardModel) -> str:
417 now = model.generated_at
418 interval = model.review_interval_seconds
419 
420 def line(loop: ReviewLoop) -> str:
421 if loop.live:
422 dot, tail = "ok", ""
423 if loop.last_tick is not None:
424 nxt = _aware(loop.last_tick) + timedelta(seconds=interval)
425 last = _ago(loop.last_tick, now)
426 tail = (
427 f' <span class="mut">&middot; last {last}'
428 f" &middot; next {_until(nxt, now)}</span>"
429 )
430 else:
431 dot = "bad" if loop.status in ("terminated", "none") else "warn"
432 tail = f' <span class="mut">&middot; {escape(loop.status)}</span>'
433 return (
434 f'<div class="row">{_dot(dot)}'
435 f'<span class="mono">{escape(loop.repo)}</span>{tail}</div>'
436 )
437 
438 if not model.review_loops:
439 body = (
440 '<p class="note">No determinism-review loops running '
441 "(the transitive ring watches the @workflow.defn repos).</p>"
442 )
443 else:
444 body = "".join(line(loop) for loop in model.review_loops)
445 return f"<section><h2>Is it alive?</h2>{body}</section>"
446 
447 
448def _review_record(model: DashboardModel) -> str:
449 r = model.review_record
450 stats = "".join(
451 (
452 _stat(r.reviewed, "reviewed"),
453 _stat(r.flagged, "flagged"),
454 _stat(r.clean, "clean"),
455 _stat(r.hazards, "hazards"),
456 _stat(r.repos_covered, "repos covered"),
457 )
458 )
459 note = (
460 '<p class="note">The transitive ring: it chases first-party helper '
461 "calls out of each workflow to catch a hazard the lexical CI kernel "
462 "can&rsquo;t see. <b>Advisory</b> &mdash; the blocking gate stays the "
463 "kernel&rsquo;s CI check. The hazard-resolved rate (was a flag gone on "
464 "a later commit?) is a later loop; it needs accumulated history.</p>"
465 )
466 return (
467 "<section><h2>Track record &middot; the reputation</h2>"
468 f'<div class="stats">{stats}</div>{_why(note)}</section>'
469 )
470 
471 
472def _reviews(model: DashboardModel) -> str:
473 now = model.generated_at
474 if not model.reviews:
475 body = '<p class="note">No PRs reviewed yet.</p>'
476 else:
477 rows = "".join(
478 "<tr>"
479 f'<td class="mono">{escape(row.repo)}</td>'
480 f"<td>{_review_pr_link(row)}</td>"
481 f'<td class="mono mut">{escape((row.head_sha or "")[:7]) or ""}'
482 "</td>"
483 f"<td>{_findings_cell(row)}</td>"
484 f'<td class="mut">{escape(_ago(row.reviewed_at, now))}</td>'
485 "</tr>"
486 for row in model.reviews
487 )
488 body = (
489 "<table><thead><tr><th>repo</th><th>pr</th><th>head</th>"
490 "<th>findings</th><th>reviewed</th></tr></thead>"
491 f"<tbody>{rows}</tbody></table>"
492 )
493 return f"<section><h2>Reviews &middot; the detail</h2>{body}</section>"
494 
495 
496def _footer() -> str:
497 return (
498 "<footer>"
499 "<b>Authority envelope.</b> Stage 1 &mdash; froot holds "
500 "<b>write authority</b> only: it opens PRs, a human approves every "
501 "merge (commit authority = none). Trust, when any is granted, is "
502 "earned, narrow to npm + uv patch bumps, conditional on its "
503 'environment (judge <span class="mono">gemma4:e4b</span>, '
504 "lockfile-only regen), "
505 "revocable, and time-expiring. Today it records the track record; it "
506 "does not yet act on it.<br>"
507 "Everything above is derived on this request from GitHub (outcomes) + "
508 "Temporal (runs) + ClickHouse (telemetry). froot keeps no database; "
509 "reload to recompute."
510 "</footer>"
511 )
512 
513 
514def _pr_link(row: BumpRow) -> str:
515 if row.pr_url is None or row.pr_number is None:
516 return '<span class="mut">—</span>'
517 return f'<a href="{escape(row.pr_url, quote=True)}">#{row.pr_number}</a>'
518 
519 
520def _review_pr_link(row: ReviewRow) -> str:
521 if row.pr_url is None or row.pr_number is None:
522 return '<span class="mut">—</span>'
523 return f'<a href="{escape(row.pr_url, quote=True)}">#{row.pr_number}</a>'
524 
525 
526def _findings_cell(row: ReviewRow) -> str:
527 """A review's findings: 'clean', or the hazard count + rules + comment."""
528 if row.findings == 0:
529 return '<span class="ok">clean</span>'
530 rules = (
531 f' <span class="mono mut">{escape(", ".join(row.rules))}</span>'
532 if row.rules
533 else ""
534 )
535 comment = (
536 f' <a href="{escape(row.comment_url, quote=True)}">comment</a>'
537 if row.comment_url
538 else ""
539 )
540 noun = "hazard" if row.findings == 1 else "hazards"
541 return f'<span class="bad">{row.findings} {noun}</span>{rules}{comment}'
542 
543 
544def _state_tag(state: str) -> str:
545 cls = {
546 "merged": "ok",
547 "open": "warn",
548 "closed": "mut",
549 "terminated": "bad",
550 "failed": "bad",
551 "timed_out": "bad",
552 "canceled": "warn",
553 }.get(state, "mut")
554 return f'<span class="{cls}">{escape(state)}</span>'
555 
556 
557def _short_id(workflow_id: str) -> str:
558 """Drop the ``froot-bump-`` prefix for a readable failures row."""
559 return workflow_id.removeprefix("froot-bump-")
560 
561 
562def _dep_glance(model: DashboardModel) -> str:
563 """The dependency-patch loop's at-a-glance, shown in its header."""
564 t = model.track_record
565 live = sum(1 for loop in model.scan_loops if loop.live)
566 total = len(model.scan_loops)
567 rate = "" if t.merge_rate is None else f"{t.merge_rate * 100:.0f}%"
568 items = [
569 _glance(f"{live}/{total}", "live"),
570 _glance(t.opened, "proposed"),
571 _glance(rate, "merged"),
572 ]
573 if t.open_now:
574 items.append(_glance(t.open_now, "awaiting you"))
575 return "".join(items)
576 
577 
578def _det_glance(model: DashboardModel) -> str:
579 """The determinism-review loop's at-a-glance, shown in its header."""
580 r = model.review_record
581 return "".join(
582 (
583 _glance(r.repos_covered, "repos"),
584 _glance(r.reviewed, "reviewed"),
585 _glance(r.hazards, "hazards"),
586 )
587 )
588 
589 
590def _loop(
591 title: str,
592 sub: str,
593 glance: str,
594 sections: tuple[str, ...],
595 *,
596 shared: bool = False,
597) -> str:
598 """A collapsible group: a prominent header + at-a-glance, collapsed.
599 
600 The header (with its at-a-glance) is the ``<summary>``, so a collapsed group
601 still shows the headline; expanding reveals the subsections. ``shared`` is a
602 non-loop infra group (telemetry) with a lighter, distinct header.
603 """
604 cls = "loop shared" if shared else "loop"
605 head = (
606 '<summary class="loophead">'
607 f"<h2>{escape(title)}</h2>"
608 f'<span class="sub">{sub}</span>'
609 f'<span class="glance">{glance}</span>'
610 "</summary>"
611 )
612 return f'<details class="{cls}">{head}{"".join(sections)}</details>'
613 
614 
615def page(model: DashboardModel) -> str:
616 """Render the whole dashboard as one self-contained HTML document."""
617 parts = (
618 _header(model),
619 _loop(
620 "Dependency-patch",
621 "npm + uv &middot; scan &rarr; bump &rarr; CI &rarr; merge",
622 _dep_glance(model),
623 (
624 _heartbeat(model),
625 _track_record(model),
626 _verification(model),
627 _judgment(model),
628 _gate(model),
629 _bumps(model),
630 _failures(model),
631 ),
632 ),
633 _loop(
634 "Determinism review",
635 "the transitive ring &middot; advisory",
636 _det_glance(model),
637 (
638 _review_heartbeat(model),
639 _review_record(model),
640 _reviews(model),
641 ),
642 ),
643 _loop(
644 "Run telemetry",
645 "ClickHouse &middot; trace-derived, best-effort",
646 _telem_glance(model),
647 (_telemetry(model),),
648 shared=True,
649 ),
650 _footer(),
651 )
652 return (
653 '<!doctype html><html lang="en"><head><meta charset="utf-8">'
654 '<meta name="viewport" content="width=device-width,initial-scale=1">'
655 "<title>froot &middot; read-model</title>"
656 f"<style>{_CSS}</style></head><body><main>"
657 + "".join(parts)
658 + "</main></body></html>"
659 )

Footer authority envelope: trust is narrow to npm + uv patch bumps.

src/froot/dashboard/render.py · 659 lines
src/froot/dashboard/render.py659 lines · Python
⋯ 498 lines hidden (lines 1–498)
1"""Render the view model to one self-contained HTML page (pure).
2 
3All CSS is inline, there is no JavaScript, and the page makes no network
4request of its own — it is a static projection of an already-computed
5:class:`~froot.dashboard.model.DashboardModel`. Every dynamic value is
6HTML-escaped at the boundary. The ordering is trust-first (is it alive → track
7record → oracle → judgment → the human's queue), so it reads top to bottom.
8"""
9 
10from __future__ import annotations
11 
12from datetime import UTC, datetime, timedelta
13from html import escape
14from typing import TYPE_CHECKING
15 
16if TYPE_CHECKING:
17 from froot.dashboard.model import (
18 BumpRow,
19 DashboardModel,
20 ReviewLoop,
21 ReviewRow,
22 RunTelemetry,
23 ScanLoop,
24 )
25 
26_CSS = """
27:root{--fg:#1a1a1a;--mut:#6b6b6b;--line:#e4e4e4;--bg:#fff;--ok:#1a7f37;
28--warn:#9a6700;--bad:#cf222e;--accent:#0969da;--card:#fafafa}
29@media(prefers-color-scheme:dark){:root{--fg:#e6e6e6;--mut:#9a9a9a;
30--line:#262626;--bg:#0d0d0d;--ok:#3fb950;--warn:#d29922;--bad:#f85149;
31--accent:#58a6ff;--card:#141414}}
32*{box-sizing:border-box}
33body{margin:0;background:var(--bg);color:var(--fg);
34font:15px/1.55 system-ui,-apple-system,"Segoe UI",Roboto,sans-serif}
35main{max-width:820px;margin:0 auto;padding:34px 20px 72px}
36h1{font-size:22px;margin:0;letter-spacing:-.01em}
37.tag{color:var(--mut);margin:3px 0 0;font-size:13px}
38.meta{color:var(--mut);font-size:12px;margin:12px 0 0}
39.sources{display:flex;flex-wrap:wrap;gap:16px;margin:10px 0 0;font-size:12px}
40section{margin:30px 0 0}
41h2{font-size:12px;text-transform:uppercase;letter-spacing:.08em;
42color:var(--mut);margin:0 0 12px;font-weight:600;
43border-bottom:1px solid var(--line);padding-bottom:6px}
44.stats{display:flex;flex-wrap:wrap;gap:26px}
45.stat .n{font-size:26px;font-weight:600;line-height:1.1}
46.stat .l{color:var(--mut);font-size:12px;margin-top:2px}
47.dot{display:inline-block;width:9px;height:9px;border-radius:50%;
48margin-right:7px}
49.dot.ok{background:var(--ok)}.dot.warn{background:var(--warn)}
50.dot.bad{background:var(--bad)}.dot.mute{background:var(--mut)}
51table{width:100%;border-collapse:collapse;font-size:13px}
52th,td{text-align:left;padding:6px 12px 6px 0;
53border-bottom:1px solid var(--line);vertical-align:top}
54th{color:var(--mut);font-weight:600;font-size:11px;text-transform:uppercase;
55letter-spacing:.04em}
56.mono{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:12px}
57a{color:var(--accent);text-decoration:none}a:hover{text-decoration:underline}
58.row{display:flex;align-items:baseline;gap:8px;padding:4px 0}
59.mut{color:var(--mut)}.ok{color:var(--ok)}.warn{color:var(--warn)}
60.bad{color:var(--bad)}
61.note{color:var(--mut);font-size:12px;margin:10px 0 0}
62footer{margin:44px 0 0;padding-top:16px;border-top:1px solid var(--line);
63color:var(--mut);font-size:12px;line-height:1.7}
64footer b{color:var(--fg);font-weight:600}
65/* loop groups — collapsible (collapsed by default), two-level hierarchy */
66details.loop{margin:24px 0 0}
67summary.loophead{display:flex;align-items:baseline;gap:6px 12px;flex-wrap:wrap;
68cursor:pointer;list-style:none;margin:0;padding:0 0 7px;
69border-bottom:2px solid var(--fg)}
70summary.loophead::-webkit-details-marker{display:none}
71summary.loophead::before{content:"\\25B8";color:var(--mut);margin-right:5px;
72font-size:11px;position:relative;top:-1px}
73details.loop[open]>summary.loophead::before{content:"\\25BE"}
74summary.loophead h2{font-size:16px;text-transform:none;letter-spacing:-.01em;
75color:var(--fg);border:0;margin:0;padding:0;font-weight:700}
76summary.loophead .sub{color:var(--mut);font-size:12px}
77summary.loophead .glance{margin-left:auto;display:flex;flex-wrap:wrap;
78align-items:baseline;gap:3px 14px;font-size:12px;color:var(--mut)}
79summary.loophead .glance b{color:var(--fg);font-weight:600}
80/* the shared-infra group reads as not-a-loop: lighter, dashed, muted */
81details.loop.shared>summary.loophead{border-bottom:1px dashed var(--line)}
82details.loop.shared>summary.loophead h2{font-size:13px;color:var(--mut);
83font-weight:600;text-transform:uppercase;letter-spacing:.06em}
84/* subsections sit tighter inside a loop; the loop rule carries the weight */
85details.loop section{margin:18px 0 0}
86details.loop section>h2{border-bottom:0;padding-bottom:0;margin-bottom:9px}
87/* foldable framing — the MHE notes, one click away, so the page scans clean */
88details.why{margin:9px 0 0}
89details.why>summary{color:var(--accent);font-size:12px;cursor:pointer;
90list-style:none}
91details.why>summary::-webkit-details-marker{display:none}
92details.why>summary::before{content:"\\002b why";font-weight:600}
93details.why[open]>summary::before{content:"\\2212 why"}
94details.why .note{margin:6px 0 0}
95"""
96 
97_CI_CLASS = {
98 "passed": "ok",
99 "failed": "bad",
100 "absent": "mut",
101 "timed_out": "warn",
103_VERDICT_CLASS = {"clean": "ok", "risky": "warn", "unknown": "mut"}
104 
105 
106def _aware(when: datetime) -> datetime:
107 """Treat a stray naive timestamp as UTC so arithmetic never raises."""
108 return when if when.tzinfo is not None else when.replace(tzinfo=UTC)
109 
110 
111def _ago(when: datetime | None, now: datetime) -> str:
112 """A compact 'time since' label (``6h ago``)."""
113 if when is None:
114 return ""
115 secs = (now - _aware(when)).total_seconds()
116 if secs < 90:
117 return "just now"
118 if secs < 5400:
119 return f"{int(secs // 60)}m ago"
120 if secs < 129600:
121 return f"{int(secs // 3600)}h ago"
122 return f"{int(secs // 86400)}d ago"
123 
124 
125def _until(when: datetime | None, now: datetime) -> str:
126 """A compact 'time until' label (``in 18h`` / ``due now``)."""
127 if when is None:
128 return ""
129 secs = (_aware(when) - now).total_seconds()
130 if secs <= 0:
131 return "due now"
132 if secs < 5400:
133 return f"in {int(secs // 60)}m"
134 if secs < 129600:
135 return f"in {int(secs // 3600)}h"
136 return f"in {int(secs // 86400)}d"
137 
138 
139def _dot(kind: str) -> str:
140 """A status dot span (``ok`` / ``warn`` / ``bad`` / ``mute``)."""
141 return f'<span class="dot {kind}"></span>'
142 
143 
144def _tag(value: str | None, classes: dict[str, str]) -> str:
145 """A small coloured label for a verdict/CI value, or an em-dash."""
146 if value is None:
147 return '<span class="mut">—</span>'
148 cls = classes.get(value, "mut")
149 return f'<span class="{cls}">{escape(value)}</span>'
150 
151 
152def _stat(n: object, label: str) -> str:
153 return (
154 f'<div class="stat"><div class="n">{escape(str(n))}</div>'
155 f'<div class="l">{escape(label)}</div></div>'
156 )
157 
158 
159def _why(note_html: str) -> str:
160 """Fold a framing note behind a native ``<details>`` so the page scans."""
161 return f'<details class="why"><summary></summary>{note_html}</details>'
162 
163 
164def _glance(n: object, label: str) -> str:
165 """One at-a-glance item for a loop header (``<b>12</b> proposed``)."""
166 return f"<span><b>{escape(str(n))}</b> {escape(label)}</span>"
167 
168 
169def _header(model: DashboardModel) -> str:
170 now = model.generated_at
171 repos = ", ".join(model.repos_configured) or "none configured"
172 dots = "".join(
173 f"<span>{_dot('ok' if s.ok else 'bad')}"
174 f'{escape(s.name)} <span class="mut">{escape(s.detail)}</span></span>'
175 for s in model.sources
176 )
177 stamp = now.strftime("%Y-%m-%d %H:%M UTC")
178 return (
179 "<header>"
180 "<h1>froot</h1>"
181 '<p class="tag">durable maintenance loops &middot; '
182 "reputation read-model</p>"
183 f'<p class="meta">watching <span class="mono">{escape(repos)}</span>'
184 f" &middot; generated {escape(stamp)} &middot; "
185 "derived live, stored nowhere</p>"
186 f'<div class="sources">{dots}</div>'
187 "</header>"
188 )
189 
190 
191def _heartbeat(model: DashboardModel) -> str:
192 now = model.generated_at
193 interval = model.scan_interval_seconds
194 
195 def line(loop: ScanLoop) -> str:
196 if loop.live:
197 dot, tail = "ok", ""
198 if loop.last_tick is not None:
199 nxt = _aware(loop.last_tick) + timedelta(seconds=interval)
200 last = _ago(loop.last_tick, now)
201 tail = (
202 f' <span class="mut">&middot; last {last}'
203 f" &middot; next {_until(nxt, now)}</span>"
204 )
205 else:
206 dot = "bad" if loop.status in ("terminated", "none") else "warn"
207 tail = f' <span class="mut">&middot; {escape(loop.status)}</span>'
208 return (
209 f'<div class="row">{_dot(dot)}'
210 f'<span class="mono">{escape(loop.repo)}</span>{tail}</div>'
211 )
212 
213 if not model.scan_loops:
214 body = '<p class="note">No repos configured (FROOT_REPOS unset).</p>'
215 else:
216 body = "".join(line(loop) for loop in model.scan_loops)
217 return f"<section><h2>Is it alive?</h2>{body}</section>"
218 
219 
220def _track_record(model: DashboardModel) -> str:
221 t = model.track_record
222 rate = "" if t.merge_rate is None else f"{t.merge_rate * 100:.0f}%"
223 ttm = (
224 ""
225 if t.median_ttm_minutes is None
226 else f"{t.median_ttm_minutes:.0f} min"
227 )
228 stats = "".join(
229 (
230 _stat(t.opened, "proposed"),
231 _stat(t.merged, "merged"),
232 _stat(t.open_now, "awaiting"),
233 _stat(t.closed_unmerged, "closed"),
234 _stat(rate, "merge rate"),
235 _stat(ttm, "median time-to-merge"),
236 )
237 )
238 note = (
239 '<p class="note">Merge rate is the Stage-1 signal &mdash; narrow to '
240 "npm + uv patch bumps by construction. It counts a human merge, not a "
241 "confirmed good outcome: revert tracking is a later loop, so merge is "
242 "not yet proof of success.</p>"
243 )
244 return (
245 "<section><h2>Track record &middot; the reputation</h2>"
246 f'<div class="stats">{stats}</div>{_why(note)}</section>'
247 )
248 
249 
250def _verification(model: DashboardModel) -> str:
251 v = model.verification
252 stats = "".join(
253 (
254 _stat(v.passed, "CI passed"),
255 _stat(v.failed, "CI failed"),
256 _stat(v.absent, "no checks"),
257 _stat(v.timed_out, "timed out"),
258 _stat(v.unknown, "unknown"),
259 )
260 )
261 if v.with_reading == 0:
262 note = '<p class="note">No CI readings yet.</p>'
263 else:
264 note = _why(
265 f'<p class="note">A real oracle reported on '
266 f"<b>{v.oracle_existed}</b> of {v.with_reading} bumps with a "
267 'reading. <span class="mut">&lsquo;no checks&rsquo; means CI was '
268 "absent &mdash; not a pass; never conflated.</span>"
269 "</p>"
270 )
271 return (
272 "<section><h2>Verification &middot; CI is the oracle</h2>"
273 f'<div class="stats">{stats}</div>{note}</section>'
274 )
275 
276 
277def _judgment(model: DashboardModel) -> str:
278 j = model.judgment
279 stats = "".join(
280 (
281 _stat(j.clean, "clean"),
282 _stat(j.risky, "risky"),
283 _stat(j.unknown, "unknown"),
284 _stat(j.none, "no verdict"),
285 )
286 )
287 if j.clean_but_failed or j.flagged_but_passed:
288 note = (
289 f'<p class="note">Calibration: <b>{j.clean_but_failed}</b> '
290 "&lsquo;clean&rsquo; bumps whose CI failed, "
291 f"<b>{j.flagged_but_passed}</b> flagged bumps whose CI passed.</p>"
292 )
293 else:
294 note = (
295 '<p class="note">In this loop the model&rsquo;s only job is the '
296 "changelog verdict; the spine proposes the bump either way.</p>"
297 )
298 return (
299 "<section><h2>Model judgment &middot; the changelog verdict</h2>"
300 f'<div class="stats">{stats}</div>{_why(note)}</section>'
301 )
302 
303 
304def _gate(model: DashboardModel) -> str:
305 now = model.generated_at
306 if not model.gate:
307 body = '<p class="note">Queue empty &mdash; nothing awaiting you.</p>'
308 else:
309 rows = "".join(
310 "<tr>"
311 f'<td class="mono">{escape(row.package)}</td>'
312 f"<td>{escape(_ago(row.opened_at, now))}</td>"
313 f"<td>{_pr_link(row)}</td>"
314 "</tr>"
315 for row in model.gate
316 )
317 body = (
318 "<table><thead><tr><th>package</th><th>waiting</th><th>pr</th>"
319 f"</tr></thead><tbody>{rows}</tbody></table>"
320 )
321 return (
322 "<section><h2>Approval gate &middot; what a human owns</h2>"
323 f"{body}</section>"
324 )
325 
326 
327def _bumps(model: DashboardModel) -> str:
328 now = model.generated_at
329 if not model.bumps:
330 body = '<p class="note">No bumps proposed yet.</p>'
331 else:
332 rows = "".join(
333 "<tr>"
334 f'<td class="mono">{escape(row.package)}</td>'
335 f'<td class="mono mut">{escape(row.from_version or "?")} &rarr; '
336 f"{escape(row.to_version)}</td>"
337 f"<td>{_tag(row.verdict, _VERDICT_CLASS)}</td>"
338 f"<td>{_tag(row.ci, _CI_CLASS)}</td>"
339 f"<td>{_state_tag(row.state)}</td>"
340 f'<td class="mut">{escape(_ago(row.opened_at, now))}</td>'
341 f"<td>{_pr_link(row)}</td>"
342 "</tr>"
343 for row in model.bumps
344 )
345 body = (
346 "<table><thead><tr><th>package</th><th>bump</th><th>verdict</th>"
347 "<th>ci</th><th>state</th><th>opened</th><th>pr</th></tr></thead>"
348 f"<tbody>{rows}</tbody></table>"
349 )
350 return f"<section><h2>Bumps &middot; the detail</h2>{body}</section>"
351 
352 
353def _failures(model: DashboardModel) -> str:
354 if not model.failures:
355 return ""
356 now = model.generated_at
357 rows = "".join(
358 "<tr>"
359 f'<td class="mono">{escape(_short_id(f.workflow_id))}</td>'
360 f"<td>{_state_tag(f.kind)}</td>"
361 f'<td class="mut">{escape(f.reason or "")}</td>'
362 f'<td class="mut">{escape(_ago(f.when, now))}</td>'
363 "</tr>"
364 for f in model.failures
365 )
366 return (
367 "<section><h2>Failures &middot; where the loop did not close</h2>"
368 "<table><thead><tr><th>bump</th><th>kind</th><th>reason</th>"
369 f"<th>when</th></tr></thead><tbody>{rows}</tbody></table></section>"
370 )
371 
372 
373def _telem_glance(model: DashboardModel) -> str:
374 """The telemetry group's at-a-glance (or an ``unavailable`` marker)."""
375 t = model.telemetry
376 if not t.available:
377 return '<span class="mut">unavailable</span>'
378 return _glance(t.total_spans, f"spans / {t.window_days}d")
379 
380 
381def _telemetry(model: DashboardModel) -> str:
382 """The telemetry body — header lives in the shared loop group above it."""
383 t: RunTelemetry = model.telemetry
384 if not t.available:
385 return (
386 "<section>"
387 '<p class="note">Unavailable (not configured or unreachable). '
388 "GitHub + Temporal carry the dashboard regardless.</p></section>"
389 )
390 now = model.generated_at
391 if t.activities:
392 rows = "".join(
393 "<tr>"
394 f'<td class="mono">{escape(a.name)}</td>'
395 f"<td>{a.count}</td>"
396 f'<td class="mut">{a.avg_ms:.0f} ms</td>'
397 f'<td class="mut">{a.max_ms:.0f} ms</td>'
398 "</tr>"
399 for a in t.activities
400 )
401 table = (
402 "<table><thead><tr><th>activity</th><th>runs</th><th>avg</th>"
403 f"<th>max</th></tr></thead><tbody>{rows}</tbody></table>"
404 )
405 else:
406 table = '<p class="note">No froot spans in the window.</p>'
407 summary = (
408 f'<p class="note">{t.total_spans} spans &middot; '
409 f"{t.error_spans} errored &middot; last activity "
410 f"{escape(_ago(t.last_activity, now))} &middot; "
411 f"{t.window_days}-day window.</p>"
412 )
413 return f"<section>{summary}{table}</section>"
414 
415 
416def _review_heartbeat(model: DashboardModel) -> str:
417 now = model.generated_at
418 interval = model.review_interval_seconds
419 
420 def line(loop: ReviewLoop) -> str:
421 if loop.live:
422 dot, tail = "ok", ""
423 if loop.last_tick is not None:
424 nxt = _aware(loop.last_tick) + timedelta(seconds=interval)
425 last = _ago(loop.last_tick, now)
426 tail = (
427 f' <span class="mut">&middot; last {last}'
428 f" &middot; next {_until(nxt, now)}</span>"
429 )
430 else:
431 dot = "bad" if loop.status in ("terminated", "none") else "warn"
432 tail = f' <span class="mut">&middot; {escape(loop.status)}</span>'
433 return (
434 f'<div class="row">{_dot(dot)}'
435 f'<span class="mono">{escape(loop.repo)}</span>{tail}</div>'
436 )
437 
438 if not model.review_loops:
439 body = (
440 '<p class="note">No determinism-review loops running '
441 "(the transitive ring watches the @workflow.defn repos).</p>"
442 )
443 else:
444 body = "".join(line(loop) for loop in model.review_loops)
445 return f"<section><h2>Is it alive?</h2>{body}</section>"
446 
447 
448def _review_record(model: DashboardModel) -> str:
449 r = model.review_record
450 stats = "".join(
451 (
452 _stat(r.reviewed, "reviewed"),
453 _stat(r.flagged, "flagged"),
454 _stat(r.clean, "clean"),
455 _stat(r.hazards, "hazards"),
456 _stat(r.repos_covered, "repos covered"),
457 )
458 )
459 note = (
460 '<p class="note">The transitive ring: it chases first-party helper '
461 "calls out of each workflow to catch a hazard the lexical CI kernel "
462 "can&rsquo;t see. <b>Advisory</b> &mdash; the blocking gate stays the "
463 "kernel&rsquo;s CI check. The hazard-resolved rate (was a flag gone on "
464 "a later commit?) is a later loop; it needs accumulated history.</p>"
465 )
466 return (
467 "<section><h2>Track record &middot; the reputation</h2>"
468 f'<div class="stats">{stats}</div>{_why(note)}</section>'
469 )
470 
471 
472def _reviews(model: DashboardModel) -> str:
473 now = model.generated_at
474 if not model.reviews:
475 body = '<p class="note">No PRs reviewed yet.</p>'
476 else:
477 rows = "".join(
478 "<tr>"
479 f'<td class="mono">{escape(row.repo)}</td>'
480 f"<td>{_review_pr_link(row)}</td>"
481 f'<td class="mono mut">{escape((row.head_sha or "")[:7]) or ""}'
482 "</td>"
483 f"<td>{_findings_cell(row)}</td>"
484 f'<td class="mut">{escape(_ago(row.reviewed_at, now))}</td>'
485 "</tr>"
486 for row in model.reviews
487 )
488 body = (
489 "<table><thead><tr><th>repo</th><th>pr</th><th>head</th>"
490 "<th>findings</th><th>reviewed</th></tr></thead>"
491 f"<tbody>{rows}</tbody></table>"
492 )
493 return f"<section><h2>Reviews &middot; the detail</h2>{body}</section>"
494 
495 
496def _footer() -> str:
497 return (
498 "<footer>"
499 "<b>Authority envelope.</b> Stage 1 &mdash; froot holds "
500 "<b>write authority</b> only: it opens PRs, a human approves every "
501 "merge (commit authority = none). Trust, when any is granted, is "
502 "earned, narrow to npm + uv patch bumps, conditional on its "
503 'environment (judge <span class="mono">gemma4:e4b</span>, '
504 "lockfile-only regen), "
505 "revocable, and time-expiring. Today it records the track record; it "
506 "does not yet act on it.<br>"
⋯ 153 lines hidden (lines 507–659)
507 "Everything above is derived on this request from GitHub (outcomes) + "
508 "Temporal (runs) + ClickHouse (telemetry). froot keeps no database; "
509 "reload to recompute."
510 "</footer>"
511 )
512 
513 
514def _pr_link(row: BumpRow) -> str:
515 if row.pr_url is None or row.pr_number is None:
516 return '<span class="mut">—</span>'
517 return f'<a href="{escape(row.pr_url, quote=True)}">#{row.pr_number}</a>'
518 
519 
520def _review_pr_link(row: ReviewRow) -> str:
521 if row.pr_url is None or row.pr_number is None:
522 return '<span class="mut">—</span>'
523 return f'<a href="{escape(row.pr_url, quote=True)}">#{row.pr_number}</a>'
524 
525 
526def _findings_cell(row: ReviewRow) -> str:
527 """A review's findings: 'clean', or the hazard count + rules + comment."""
528 if row.findings == 0:
529 return '<span class="ok">clean</span>'
530 rules = (
531 f' <span class="mono mut">{escape(", ".join(row.rules))}</span>'
532 if row.rules
533 else ""
534 )
535 comment = (
536 f' <a href="{escape(row.comment_url, quote=True)}">comment</a>'
537 if row.comment_url
538 else ""
539 )
540 noun = "hazard" if row.findings == 1 else "hazards"
541 return f'<span class="bad">{row.findings} {noun}</span>{rules}{comment}'
542 
543 
544def _state_tag(state: str) -> str:
545 cls = {
546 "merged": "ok",
547 "open": "warn",
548 "closed": "mut",
549 "terminated": "bad",
550 "failed": "bad",
551 "timed_out": "bad",
552 "canceled": "warn",
553 }.get(state, "mut")
554 return f'<span class="{cls}">{escape(state)}</span>'
555 
556 
557def _short_id(workflow_id: str) -> str:
558 """Drop the ``froot-bump-`` prefix for a readable failures row."""
559 return workflow_id.removeprefix("froot-bump-")
560 
561 
562def _dep_glance(model: DashboardModel) -> str:
563 """The dependency-patch loop's at-a-glance, shown in its header."""
564 t = model.track_record
565 live = sum(1 for loop in model.scan_loops if loop.live)
566 total = len(model.scan_loops)
567 rate = "" if t.merge_rate is None else f"{t.merge_rate * 100:.0f}%"
568 items = [
569 _glance(f"{live}/{total}", "live"),
570 _glance(t.opened, "proposed"),
571 _glance(rate, "merged"),
572 ]
573 if t.open_now:
574 items.append(_glance(t.open_now, "awaiting you"))
575 return "".join(items)
576 
577 
578def _det_glance(model: DashboardModel) -> str:
579 """The determinism-review loop's at-a-glance, shown in its header."""
580 r = model.review_record
581 return "".join(
582 (
583 _glance(r.repos_covered, "repos"),
584 _glance(r.reviewed, "reviewed"),
585 _glance(r.hazards, "hazards"),
586 )
587 )
588 
589 
590def _loop(
591 title: str,
592 sub: str,
593 glance: str,
594 sections: tuple[str, ...],
595 *,
596 shared: bool = False,
597) -> str:
598 """A collapsible group: a prominent header + at-a-glance, collapsed.
599 
600 The header (with its at-a-glance) is the ``<summary>``, so a collapsed group
601 still shows the headline; expanding reveals the subsections. ``shared`` is a
602 non-loop infra group (telemetry) with a lighter, distinct header.
603 """
604 cls = "loop shared" if shared else "loop"
605 head = (
606 '<summary class="loophead">'
607 f"<h2>{escape(title)}</h2>"
608 f'<span class="sub">{sub}</span>'
609 f'<span class="glance">{glance}</span>'
610 "</summary>"
611 )
612 return f'<details class="{cls}">{head}{"".join(sections)}</details>'
613 
614 
615def page(model: DashboardModel) -> str:
616 """Render the whole dashboard as one self-contained HTML document."""
617 parts = (
618 _header(model),
619 _loop(
620 "Dependency-patch",
621 "npm + uv &middot; scan &rarr; bump &rarr; CI &rarr; merge",
622 _dep_glance(model),
623 (
624 _heartbeat(model),
625 _track_record(model),
626 _verification(model),
627 _judgment(model),
628 _gate(model),
629 _bumps(model),
630 _failures(model),
631 ),
632 ),
633 _loop(
634 "Determinism review",
635 "the transitive ring &middot; advisory",
636 _det_glance(model),
637 (
638 _review_heartbeat(model),
639 _review_record(model),
640 _reviews(model),
641 ),
642 ),
643 _loop(
644 "Run telemetry",
645 "ClickHouse &middot; trace-derived, best-effort",
646 _telem_glance(model),
647 (_telemetry(model),),
648 shared=True,
649 ),
650 _footer(),
651 )
652 return (
653 '<!doctype html><html lang="en"><head><meta charset="utf-8">'
654 '<meta name="viewport" content="width=device-width,initial-scale=1">'
655 "<title>froot &middot; read-model</title>"
656 f"<style>{_CSS}</style></head><body><main>"
657 + "".join(parts)
658 + "</main></body></html>"
659 )

.env.example — the new knobs and the @uv repo syntax

The example env file is the operator's contract; it must list every knob that exists. The PR documents three real things the code already reads. First, the repo list now takes a per-repo ecosystem suffix: a bare slug is npm, @uv marks a Python/uv repo, and both loops read the same list.

FROOT_REPOS @uv suffix and the dependency-patch scan interval.

.env.example · 41 lines
.env.example41 lines · Text only
⋯ 8 lines hidden (lines 1–8)
1# froot configuration. Copy to `.env` (gitignored) for local runs, or set these
2# in the environment. No real secrets belong in this example file.
3 
4# GitHub token — needs contents:write + pull_requests:write on the target repos
5# (it pushes a branch and opens a PR; it never merges). FROOT_GITHUB_TOKEN is
6# preferred; a plain GITHUB_TOKEN is not read (use the FROOT_-prefixed name).
7FROOT_GITHUB_TOKEN=ghp_replace_me
8 
9# Comma-separated owner/name repos to point the loops at. A bare slug is an npm
10# repo; suffix with @uv for a Python/uv repo (e.g. acme/pylib@uv). Both loops
11# (dependency-patch and the determinism reviewer) read this same list.
12FROOT_REPOS=octocat/hello-world
13 
14# How often each repo's dependency-patch scan loop ticks (seconds; default
15# 86400 = daily).
16FROOT_SCAN_INTERVAL_SECONDS=86400
⋯ 25 lines hidden (lines 17–41)
17 
18# Determinism-review loop (advisory PR comments where a Temporal workflow risks a
19# replay-nondeterminism hazard). On by default; started by `python -m
20# froot.review_starter`. Poll cadence and first-party call-depth are optional.
21# FROOT_REVIEW_ENABLED=1
22# FROOT_REVIEW_POLL_INTERVAL_SECONDS=300
23# FROOT_REVIEW_DEPTH=2
24 
25# Read-only read-model dashboard the worker also serves (on by default).
26# FROOT_DASHBOARD_ENABLED=1
27# FROOT_DASHBOARD_PORT=8080
28 
29# Temporal connection (these defaults are for a local server; the cluster
30# deployment sets them to the in-cluster frontend / namespace / queue).
31TEMPORAL_HOST=localhost:7233
32TEMPORAL_NAMESPACE=default
33TEMPORAL_TASK_QUEUE=froot
34 
35# The judge model (a local Ollama by default) — used by both the changelog-risk
36# judge and the determinism reviewer's frontier judge.
37FROOT_OLLAMA_MODEL=gemma4:e4b
38FROOT_OLLAMA_URL=http://localhost:11434/v1
39 
40# OpenTelemetry off unless truthy (the cluster sets FROOT_OTEL=1).
41FROOT_OTEL=0

Then the two subsystems that had no documented config: the determinism-review loop (enable flag, poll cadence, call-depth) and the read-model dashboard (enable flag, port). Both are on by default and commented out here as the defaults. The model comment is also rescoped — it is the shared judge model behind both judges, not just the changelog judge.

FROOT_REVIEW_* and FROOT_DASHBOARD_* — the two undocumented subsystems.

.env.example · 41 lines
.env.example41 lines · Text only
⋯ 17 lines hidden (lines 1–17)
1# froot configuration. Copy to `.env` (gitignored) for local runs, or set these
2# in the environment. No real secrets belong in this example file.
3 
4# GitHub token — needs contents:write + pull_requests:write on the target repos
5# (it pushes a branch and opens a PR; it never merges). FROOT_GITHUB_TOKEN is
6# preferred; a plain GITHUB_TOKEN is not read (use the FROOT_-prefixed name).
7FROOT_GITHUB_TOKEN=ghp_replace_me
8 
9# Comma-separated owner/name repos to point the loops at. A bare slug is an npm
10# repo; suffix with @uv for a Python/uv repo (e.g. acme/pylib@uv). Both loops
11# (dependency-patch and the determinism reviewer) read this same list.
12FROOT_REPOS=octocat/hello-world
13 
14# How often each repo's dependency-patch scan loop ticks (seconds; default
15# 86400 = daily).
16FROOT_SCAN_INTERVAL_SECONDS=86400
17 
18# Determinism-review loop (advisory PR comments where a Temporal workflow risks a
19# replay-nondeterminism hazard). On by default; started by `python -m
20# froot.review_starter`. Poll cadence and first-party call-depth are optional.
21# FROOT_REVIEW_ENABLED=1
22# FROOT_REVIEW_POLL_INTERVAL_SECONDS=300
23# FROOT_REVIEW_DEPTH=2
24 
25# Read-only read-model dashboard the worker also serves (on by default).
26# FROOT_DASHBOARD_ENABLED=1
27# FROOT_DASHBOARD_PORT=8080
⋯ 14 lines hidden (lines 28–41)
28 
29# Temporal connection (these defaults are for a local server; the cluster
30# deployment sets them to the in-cluster frontend / namespace / queue).
31TEMPORAL_HOST=localhost:7233
32TEMPORAL_NAMESPACE=default
33TEMPORAL_TASK_QUEUE=froot
34 
35# The judge model (a local Ollama by default) — used by both the changelog-risk
36# judge and the determinism reviewer's frontier judge.
37FROOT_OLLAMA_MODEL=gemma4:e4b
38FROOT_OLLAMA_URL=http://localhost:11434/v1
39 
40# OpenTelemetry off unless truthy (the cluster sets FROOT_OTEL=1).
41FROOT_OTEL=0

The model comment rescoped: shared across both judges.

.env.example · 41 lines
.env.example41 lines · Text only
⋯ 34 lines hidden (lines 1–34)
1# froot configuration. Copy to `.env` (gitignored) for local runs, or set these
2# in the environment. No real secrets belong in this example file.
3 
4# GitHub token — needs contents:write + pull_requests:write on the target repos
5# (it pushes a branch and opens a PR; it never merges). FROOT_GITHUB_TOKEN is
6# preferred; a plain GITHUB_TOKEN is not read (use the FROOT_-prefixed name).
7FROOT_GITHUB_TOKEN=ghp_replace_me
8 
9# Comma-separated owner/name repos to point the loops at. A bare slug is an npm
10# repo; suffix with @uv for a Python/uv repo (e.g. acme/pylib@uv). Both loops
11# (dependency-patch and the determinism reviewer) read this same list.
12FROOT_REPOS=octocat/hello-world
13 
14# How often each repo's dependency-patch scan loop ticks (seconds; default
15# 86400 = daily).
16FROOT_SCAN_INTERVAL_SECONDS=86400
17 
18# Determinism-review loop (advisory PR comments where a Temporal workflow risks a
19# replay-nondeterminism hazard). On by default; started by `python -m
20# froot.review_starter`. Poll cadence and first-party call-depth are optional.
21# FROOT_REVIEW_ENABLED=1
22# FROOT_REVIEW_POLL_INTERVAL_SECONDS=300
23# FROOT_REVIEW_DEPTH=2
24 
25# Read-only read-model dashboard the worker also serves (on by default).
26# FROOT_DASHBOARD_ENABLED=1
27# FROOT_DASHBOARD_PORT=8080
28 
29# Temporal connection (these defaults are for a local server; the cluster
30# deployment sets them to the in-cluster frontend / namespace / queue).
31TEMPORAL_HOST=localhost:7233
32TEMPORAL_NAMESPACE=default
33TEMPORAL_TASK_QUEUE=froot
34 
35# The judge model (a local Ollama by default) — used by both the changelog-risk
36# judge and the determinism reviewer's frontier judge.
37FROOT_OLLAMA_MODEL=gemma4:e4b
38FROOT_OLLAMA_URL=http://localhost:11434/v1
⋯ 3 lines hidden (lines 39–41)
39 
40# OpenTelemetry off unless truthy (the cluster sets FROOT_OTEL=1).
41FROOT_OTEL=0

Source docstrings — 'one model call' and 'both loops' corrected

The same two falsehoods — one model call, and two (rather than four) workflows — lived in module docstrings too. These are the docs a developer reads at the import site, so they get the same correction. The changelog-judge module no longer claims to be "froot's one model call"; it is one of two, with the determinism reviewer's frontier judge as the other.

'froot's changelog model call — one of two.'

src/froot/adapters/model_judge.py · 89 lines
src/froot/adapters/model_judge.py89 lines · Python
1"""The thin model judgment: is this changelog a clean patch?
2 
3froot's changelog model call — one of two (the determinism reviewer's frontier
4judge is the other). A Pydantic AI agent reads the changelog and returns a
5typed :class:`_Assessment`, which :func:`assessment_to_verdict` maps to the
6domain :data:`~froot.domain.changelog.ChangelogVerdict`. The verdict is framing,
7not a gate — the spine proposes the bump regardless — so even a "risky" reading
8just shapes the PR. The model is injected, so tests run it offline with a
9``TestModel`` / ``FunctionModel``; the mapping is a pure function, tested apart
10from any model.
11"""
⋯ 78 lines hidden (lines 12–89)
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,
26 
27if TYPE_CHECKING:
28 from pydantic_ai.models import Model
29 
30 from froot.domain.changelog import Changelog, ChangelogVerdict
31 
32_SYSTEM_PROMPT = (
33 "You assess the changelog of a dependency's PATCH-level upgrade for a "
34 "code-maintenance bot. The bot proposes the bump either way; your job is "
35 "only to frame the risk for the human reviewer.\n"
36 "Return one verdict:\n"
37 "- clean: the notes describe only fixes / docs / internal changes with no "
38 "behavioral or API impact.\n"
39 "- risky: the notes hint at behavior change, deprecations, or anything a "
40 "reviewer should look at closely; list the concerns.\n"
41 "- unknown: the notes are empty or uninformative.\n"
42 "Quote-or-omit: base 'risky' concerns on what the text actually says; do "
43 "not speculate. Keep the rationale to one or two sentences."
45 
46 
47class _Assessment(BaseModel):
48 """The model's structured output, mapped to a domain verdict."""
49 
50 verdict: Literal["clean", "risky", "unknown"]
51 rationale: str
52 concerns: list[str] = Field(default_factory=list)
53 
54 
55def assessment_to_verdict(assessment: _Assessment) -> ChangelogVerdict:
56 """Map the model's structured assessment to a domain verdict (pure)."""
57 match assessment.verdict:
58 case "clean":
59 return CleanVerdict(rationale=assessment.rationale)
60 case "risky":
61 return RiskyVerdict(
62 rationale=assessment.rationale,
63 concerns=tuple(assessment.concerns),
64 )
65 case "unknown":
66 return UnknownVerdict(rationale=assessment.rationale)
67 assert_never(assessment.verdict)
68 
69 
70class PydanticAiJudge:
71 """A :class:`~froot.ports.protocols.ModelJudge` backed by Pydantic AI."""
72 
73 def __init__(self, model: Model | None = None) -> None:
74 """Build the agent; ``model`` defaults to the configured Ollama."""
75 self._agent: Agent[None, _Assessment] = Agent(
76 model or build_model(),
77 output_type=_Assessment,
78 system_prompt=_SYSTEM_PROMPT,
79 )
80 
81 async def judge(self, changelog: Changelog) -> ChangelogVerdict:
82 """Assess a changelog and return a typed verdict."""
83 prompt = (
84 f"Package: {changelog.package}\n"
85 f"Target version: {changelog.version}\n"
86 f"Changelog:\n{changelog.text}"
87 )
88 result = await self._agent.run(prompt)
89 return assessment_to_verdict(result.output)

The shared model module is retitled from "the model behind the changelog judgment" to "the shared judge model," and the adapters package docstring lists both judges over one shared model. The settings docstring for ModelSettings makes the same shared-endpoint point.

Retitled: the shared judge model, behind both judges.

src/froot/adapters/model.py · 53 lines
src/froot/adapters/model.py53 lines · Python
1"""The shared judge model.
2 
3Behind both the changelog-risk judge and the determinism reviewer's frontier
4judge.
⋯ 49 lines hidden (lines 5–53)
5 
6A local Ollama (Gemma) driven through its OpenAI-compatible ``/v1`` by Pydantic
7AI's OpenAI provider, so froot keeps heavy inference off the request-tight
8cluster node. The model and endpoint come from
9:class:`~froot.config.settings.ModelSettings` (``FROOT_OLLAMA_MODEL`` /
10``FROOT_OLLAMA_URL``); tests pass their own ``TestModel`` / ``FunctionModel``
11and never touch this.
12 
13This module lives outside the pure core and the workflow modules — the model
14stack must never be imported into a Temporal workflow sandbox.
15"""
16 
17from __future__ import annotations
18 
19from typing import TYPE_CHECKING
20 
21from pydantic_ai.models.openai import OpenAIChatModel
22from pydantic_ai.providers.openai import OpenAIProvider
23 
24from froot.config.settings import ModelSettings
25 
26if TYPE_CHECKING:
27 from pydantic_ai.models import Model
28 
29 
30def build_model(
31 *, model_name: str | None = None, base_url: str | None = None
32) -> Model:
33 """Build the configured Ollama/Gemma model.
34 
35 Args:
36 model_name: Override the model; defaults to
37 ``ModelSettings.ollama_model`` (``$FROOT_OLLAMA_MODEL`` or
38 ``gemma4:e4b``).
39 base_url: Override the endpoint; defaults to
40 ``ModelSettings.ollama_url`` (``$FROOT_OLLAMA_URL`` or the local
41 Ollama ``/v1``).
42 
43 Returns:
44 A Pydantic AI model ready to pass to an agent.
45 """
46 settings = ModelSettings()
47 # Ollama ignores the key but the OpenAI client requires a non-empty one.
48 return OpenAIChatModel(
49 model_name or settings.ollama_model,
50 provider=OpenAIProvider(
51 base_url=base_url or settings.ollama_url, api_key="ollama"
52 ),
53 )

ModelSettings: shared endpoint, used by both judges.

src/froot/config/settings.py · 230 lines
src/froot/config/settings.py230 lines · Python
⋯ 112 lines hidden (lines 1–112)
1"""All deployment config, as pydantic-settings models (env or ``.env``), frozen.
2 
3* :class:`Settings` (``FROOT_*``) — loop config: repositories and scan interval.
4* :class:`TemporalSettings` (``TEMPORAL_*``) — connection: host / namespace /
5 task queue, shared by the worker, the scan starter, and the activity client.
6* :class:`GitHubSettings` — the API token (``FROOT_GITHUB_TOKEN``) as a
7 :class:`~pydantic.SecretStr`, so it is masked in ``repr``, logs, and
8 tracebacks and cannot leak accidentally.
9* :class:`ModelSettings` (``FROOT_OLLAMA_MODEL`` / ``FROOT_OLLAMA_URL``) — the
10 shared judge-model endpoint (the changelog-risk judge and the determinism
11 reviewer's frontier judge).
12* :class:`TelemetrySettings` (``FROOT_OTEL``) — observability toggle.
13 
14Each consumer builds the small model it needs at its point of use; nothing
15secret lives in the repo. ``repos`` is ``NoDecode`` so ``FROOT_REPOS`` is a
16comma-separated list of ``owner/name`` slugs rather than JSON; a slug may carry
17an optional ``@<ecosystem>`` suffix (e.g. ``acme/pylib@uv``), defaulting to npm.
18"""
19 
20from __future__ import annotations
21 
22from typing import Annotated
23 
24from pydantic import Field, SecretStr, field_validator
25from pydantic_settings import BaseSettings, NoDecode, SettingsConfigDict
26 
27from froot.domain.ecosystem import Ecosystem
28from froot.domain.repo import RepoRef, TargetRepo
29from froot.result import Ok
30 
31_DEFAULT_SCAN_INTERVAL_SECONDS = 86_400
32 
33 
34class Settings(BaseSettings):
35 """Non-secret worker config from ``FROOT_*`` (env or ``.env``)."""
36 
37 model_config = SettingsConfigDict(
38 env_prefix="FROOT_",
39 env_file=".env",
40 extra="ignore",
41 frozen=True,
42 )
43 
44 repos: Annotated[tuple[TargetRepo, ...], NoDecode] = Field(min_length=1)
45 scan_interval_seconds: int = Field(
46 default=_DEFAULT_SCAN_INTERVAL_SECONDS, gt=0
47 )
48 
49 @field_validator("repos", mode="before")
50 @classmethod
51 def _parse_repos(cls, value: object) -> object:
52 """Parse ``FROOT_REPOS`` as a comma-separated target list.
53 
54 Each entry is an ``owner/name`` slug, optionally suffixed with
55 ``@<ecosystem>`` (e.g. ``acme/pylib@uv``); the suffix is omitted for the
56 default ``npm``.
57 """
58 if not isinstance(value, str):
59 return value
60 targets: list[TargetRepo] = []
61 for raw in value.split(","):
62 entry = raw.strip()
63 if not entry:
64 continue
65 slug, _, eco = entry.partition("@")
66 match RepoRef.parse(slug):
67 case Ok(ref):
68 pass
69 case _:
70 raise ValueError(f"invalid repo slug: {slug!r}")
71 try:
72 ecosystem = Ecosystem(eco) if eco else Ecosystem.NPM
73 except ValueError:
74 raise ValueError(f"unknown ecosystem: {eco!r}") from None
75 targets.append(TargetRepo(repo=ref, ecosystem=ecosystem))
76 return tuple(targets)
77 
78 
79class TemporalSettings(BaseSettings):
80 """Temporal connection config from ``TEMPORAL_*`` (env or ``.env``).
81 
82 The same image runs anywhere by configuring these; the in-cluster
83 deployment sets them to the cluster's frontend, namespace, and queue.
84 """
85 
86 model_config = SettingsConfigDict(
87 env_prefix="TEMPORAL_",
88 env_file=".env",
89 extra="ignore",
90 frozen=True,
91 )
92 
93 host: str = Field(default="localhost:7233", min_length=1)
94 namespace: str = Field(default="default", min_length=1)
95 task_queue: str = Field(default="froot", min_length=1)
96 
97 
98class GitHubSettings(BaseSettings):
99 """GitHub credentials, from ``FROOT_GITHUB_TOKEN``.
100 
101 The token is a :class:`~pydantic.SecretStr`, so it is masked in ``repr``,
102 logs, and tracebacks and cannot leak accidentally; call
103 ``github_token.get_secret_value()`` only where the real value is sent.
104 """
105 
106 model_config = SettingsConfigDict(
107 env_prefix="FROOT_", env_file=".env", extra="ignore", frozen=True
108 )
109 
110 github_token: SecretStr | None = None
111 
112 
113class ModelSettings(BaseSettings):
114 """The shared judge-model endpoint (a local Ollama by default).
115 
116 Used by both the changelog-risk judge and the determinism frontier judge.
117 """
⋯ 113 lines hidden (lines 118–230)
118 
119 model_config = SettingsConfigDict(
120 env_prefix="FROOT_", env_file=".env", extra="ignore", frozen=True
121 )
122 
123 ollama_model: str = Field(default="gemma4:e4b", min_length=1)
124 ollama_url: str = Field(default="http://localhost:11434/v1", min_length=1)
125 
126 
127class TelemetrySettings(BaseSettings):
128 """OpenTelemetry toggle — off unless ``FROOT_OTEL`` is truthy."""
129 
130 model_config = SettingsConfigDict(
131 env_prefix="FROOT_", env_file=".env", extra="ignore", frozen=True
132 )
133 
134 otel: bool = False
135 
136 @field_validator("otel", mode="before")
137 @classmethod
138 def _blank_is_off(cls, value: object) -> object:
139 """Treat an empty/whitespace ``FROOT_OTEL`` as off, not an error."""
140 if isinstance(value, str) and not value.strip():
141 return False
142 return value
143 
144 
145class ClickHouseSettings(BaseSettings):
146 """ClickHouse (the run ledger) connection for the dashboard read-model.
147 
148 Every field is optional: when ``FROOT_CLICKHOUSE_URL`` is unset the
149 dashboard renders the run-telemetry panel as *unavailable* rather than
150 failing. That panel is best-effort enrichment — GitHub (outcomes) and
151 Temporal (live runs) are the dependable sources; ClickHouse only adds
152 trace-derived run telemetry, on a 3-day TTL at that. The password is a
153 :class:`~pydantic.SecretStr`.
154 """
155 
156 model_config = SettingsConfigDict(
157 env_prefix="FROOT_CLICKHOUSE_",
158 env_file=".env",
159 extra="ignore",
160 frozen=True,
161 )
162 
163 # Host only, e.g. ``http://clickhouse:8123`` — NEVER embed credentials as
164 # userinfo (``http://user:pw@host``): they belong in ``user``/``password``
165 # so they stay out of error strings the dashboard may surface.
166 url: str | None = None
167 user: str = Field(default="default", min_length=1)
168 password: SecretStr | None = None
169 database: str = Field(default="default", min_length=1)
170 
171 
172class DashboardSettings(BaseSettings):
173 """The read-model dashboard's HTTP surface, served by the worker.
174 
175 A read-only page the worker serves on ``FROOT_DASHBOARD_PORT``; reach it
176 with ``kubectl port-forward``. It derives everything on request and stores
177 nothing (froot's own derived-state invariant), so it is safe to leave on.
178 """
179 
180 model_config = SettingsConfigDict(
181 env_prefix="FROOT_DASHBOARD_",
182 env_file=".env",
183 extra="ignore",
184 frozen=True,
185 )
186 
187 enabled: bool = True
188 # Bind all interfaces so an in-cluster ``kubectl port-forward`` reaches it.
189 host: str = Field(default="0.0.0.0", min_length=1)
190 port: int = Field(default=8080, gt=0, le=65535)
191 
192 @field_validator("enabled", mode="before")
193 @classmethod
194 def _blank_is_on(cls, value: object) -> object:
195 """Treat an empty/whitespace value as the default (on), not an error."""
196 if isinstance(value, str) and not value.strip():
197 return True
198 return value
199 
200 
201class ReviewSettings(BaseSettings):
202 """The determinism-reviewer loop (``FROOT_REVIEW_*``).
203 
204 The transitive ring: a per-repo loop that polls open PRs and leaves an
205 advisory comment when a workflow reaches a determinism hazard through a
206 first-party helper (``depth`` call levels) or a risky third-party import.
207 Advisory only — the blocking gate is the kernel's ``Determinism`` CI check.
208 """
209 
210 model_config = SettingsConfigDict(
211 env_prefix="FROOT_REVIEW_",
212 env_file=".env",
213 extra="ignore",
214 frozen=True,
215 )
216 
217 enabled: bool = True
218 # PRs merge fast, but this loop is advisory (not a gate), so a little
219 # latency is fine — it never needs to win the merge race.
220 poll_interval_seconds: int = Field(default=300, gt=0)
221 # How many first-party call levels to chase out of each workflow method.
222 depth: int = Field(default=2, ge=1, le=4)
223 
224 @field_validator("enabled", mode="before")
225 @classmethod
226 def _blank_is_on(cls, value: object) -> object:
227 """Treat an empty/whitespace value as the default (on), not an error."""
228 if isinstance(value, str) and not value.strip():
229 return True
230 return value

Finally the workflow package docstring described the chassis as if only dependency-patch's two workflows existed. It now names the chassis as shared by both loops and adds the reviewer's mirrored shape: review_workflow self-schedules and dispatches a pr_review_workflow per open PR — the same scan→per-item structure as scan/bump.

The chassis is shared by both loops; the reviewer mirrors scan/bump.

src/froot/workflow/__init__.py · 13 lines
src/froot/workflow/__init__.py13 lines · Python
1"""The Temporal spine: thin workflows that drive the pure core.
2 
3The durable-loop chassis, shared by both loops. For dependency-patch,
4:class:`~froot.workflow.scan_workflow` is the self-scheduling loop that
5discovers candidates and dispatches a :class:`~froot.workflow.bump_workflow` per
6bump; the bump workflow is a thin driver around
7:func:`froot.policy.state_machine.advance`, interpreting each effect into an
8activity (and a durable CI wait). The determinism reviewer mirrors the shape:
9:class:`~froot.workflow.review_workflow` self-schedules and dispatches a
10:class:`~froot.workflow.pr_review_workflow` per open PR. All nondeterminism
11lives in :mod:`froot.workflow.activities`; the workflows use only pure state and
12Temporal APIs, so they replay deterministically.
13"""