You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@airflow.apache.org by po...@apache.org on 2026/05/06 10:36:47 UTC
(airflow-steward) branch main updated: feat(pr-management-stats): add maintainer dashboard, trends, and recommendations (#68)
This is an automated email from the ASF dual-hosted git repository.
potiuk pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/airflow-steward.git
The following commit(s) were added to refs/heads/main by this push:
new 6b9d75e feat(pr-management-stats): add maintainer dashboard, trends, and recommendations (#68)
6b9d75e is described below
commit 6b9d75eab9f1b7454f2c70ed3213c4fc1f25418b
Author: Jarek Potiuk <ja...@potiuk.com>
AuthorDate: Wed May 6 12:36:43 2026 +0200
feat(pr-management-stats): add maintainer dashboard, trends, and recommendations (#68)
Restructure pr-management-stats from "two dense tables" to a multi-section
maintainer dashboard. Hero cards show repo-health rating; a deterministic
recommendation panel surfaces the next slash-command to run; weekly charts
cover closure velocity, opened-vs-closed momentum, ready-for-review trend
by top areas, and closed-by-triage-reason breakdown; a pressure ranking
points at where to focus a triage/review session. Original tables move into
collapsible details for maintainers who want raw per-area numbers.
Adds five new sections to aggregate.md (pressure score, weekly velocity,
opened-vs-closed buckets, ready-for-review trend by top areas,
closed-by-triage-reason buckets, health rating), a pressure_weight helper
to classify.md, a ready-label timeline fetch to fetch.md, and a full
dashboard layout + recommendation rules + verbose legend to render.md.
Mirrors the equivalent change to apache/airflow's pr-stats skill.
---
.claude/skills/pr-management-stats/SKILL.md | 151 +++++--
.claude/skills/pr-management-stats/aggregate.md | 186 ++++++++-
.claude/skills/pr-management-stats/classify.md | 35 +-
.claude/skills/pr-management-stats/fetch.md | 61 ++-
.claude/skills/pr-management-stats/render.md | 520 +++++++++++++++++-------
5 files changed, 764 insertions(+), 189 deletions(-)
diff --git a/.claude/skills/pr-management-stats/SKILL.md b/.claude/skills/pr-management-stats/SKILL.md
index 847e1ef..e347de9 100644
--- a/.claude/skills/pr-management-stats/SKILL.md
+++ b/.claude/skills/pr-management-stats/SKILL.md
@@ -1,49 +1,52 @@
---
name: pr-management-stats
-mode: A
description: |
- Produce maintainer-facing statistics about open pull requests on
- the configured `<upstream>` repo (default: read from `<project-config>/project.md → upstream_repo`). Successor to
- `breeze pr stats`: read-only, no mutations — just two summary
- tables grouped by `area:*` label (Triaged final-state, and
- Triaged still-open) plus per-area age-bucket breakdowns so the
- maintainer can see where queue pressure is sitting.
-
- Invoke when the user says "how is the PR queue doing", "run PR
- stats", "show the area breakdown", "how many PRs are still
- waiting on authors after triage", or any variation on the "give
- me numbers about the open PR backlog" theme. Also appropriate
- as a quick health check before or after a triage sweep.
+ Read-only maintainer dashboard for the open-PR backlog of <upstream>.
+ Surfaces a health rating, prioritised action recommendations, weekly closure
+ velocity trends, area pressure ranking, and a triage-funnel breakdown — with
+ the underlying area-grouped tables as a collapsible details section.
+when_to_use: |
+ When the user asks "how is the PR queue doing", "run PR stats", "what should
+ I do today", "show me the trends", "where is queue pressure sitting", or any
+ variation on "give me the maintainer view of the backlog". Good as a daily
+ health check, before or after a triage sweep, or as an input to a planning
+ session.
---
<!-- SPDX-License-Identifier: Apache-2.0
https://www.apache.org/licenses/LICENSE-2.0 -->
<!-- Placeholder convention:
- <repo> → target GitHub repository in `owner/name` form (default: read from `<project-config>/project.md → upstream_repo`)
+ <repo> → target GitHub repository in `owner/name` form (default: <upstream>)
<viewer> → the authenticated GitHub login of the maintainer running the skill
Substitute these before running any `gh` command below. -->
# pr-management-stats
-Read-only skill that answers "what does the open-PR backlog
-*look* like" as two tables:
+Read-only skill that answers "what should the maintainer **do** about the
+open-PR backlog right now". Primary output is a **dashboard** with five
+sections:
-| Table | Row set | Purpose |
+| Section | What it shows | Maintainer use |
|---|---|---|
-| **Triaged final-state** | closed / merged PRs since a cutoff date, broken down by `area:*` label | Shows triage outcomes — what fraction of triaged PRs merged, closed, or got an author response before closing. |
-| **Triaged still-open** | all currently-open PRs, broken down by `area:*` label | Shows current queue pressure — triage coverage, author-response rate, ready-for-review count, age buckets. |
+| **Hero cards** | Health rating, total open, ready-for-review count, untriaged-non-drafts (with >4w callout) | At-a-glance status |
+| **What needs attention** | Prioritised action recommendations (high/medium/low) with the exact slash command to run | Decide what to spend the next hour on |
+| **Closure velocity** | Per-week merged/closed bars over the last 6 weeks, plus avg/peak | Spot slowdowns or burst weeks |
+| **Pressure by area** | `area:*` ranking by weighted untriaged-old PR count | Pick a focused triage / review session |
+| **Triage funnel** | Triage coverage %, author response rate %, stalest bucket, this-week velocity | See whether the funnel is healthy end-to-end |
+
+The two original tables (**Triaged final-state since cutoff** and **Triaged still-open by area**) are kept as a *collapsible details section* at the bottom of the dashboard for maintainers who want the raw per-area numbers.
-The skill is the statistical complement of [`pr-management-triage`](../pr-management-triage/SKILL.md) — same repo, same classification logic, no mutations. Running the two in sequence (stats → triage → stats) lets a maintainer measure a sweep's effect.
+The skill is the statistical complement of [`pr-management-triage`](../pr-management-triage/SKILL.md) — same repo, same classification logic, no mutations. Running the two in sequence (stats → triage → stats) lets a maintainer measure a sweep's effect; the dashboard's recommendations link directly back to specific `pr-management-triage` invocations.
Detail files:
| File | Purpose |
|---|---|
| [`fetch.md`](fetch.md) | GraphQL templates for open-PR list and closed/merged-since-cutoff list. |
-| [`classify.md`](classify.md) | Triage-status detection (waiting vs. responded vs. never-triaged) — reuses the `Pull Request quality criteria` marker from `pr-management-triage`. |
-| [`aggregate.md`](aggregate.md) | Area grouping, age buckets, totals, percentage rules. |
-| [`render.md`](render.md) | The two tables — column order, footers, header wording. |
+| [`classify.md`](classify.md) | Triage-status detection (waiting vs. responded vs. never-triaged) — reuses the `Pull Request quality criteria` marker from `pr-management-triage`. Also defines the per-PR `pressure_weight`. |
+| [`aggregate.md`](aggregate.md) | Area grouping, age buckets, totals, percentage rules. Also defines weekly velocity buckets, area pressure scores, and the health-rating thresholds. |
+| [`render.md`](render.md) | The dashboard layout (hero / actions / trends / hotspots / details) plus the underlying tables, colour scheme, and recommendation rules. |
---
@@ -110,10 +113,14 @@ read-only and inherits everything from `pr-management-triage`'s contract.
**Golden rule 3 — one GraphQL call per batch, not per PR.** Same rule as `pr-management-triage/fetch-and-batch.md`. One aliased query covers the open-PR list for a whole page; the closed/merged fetch is paginated by GitHub's search cursor. Never call `gh pr view` per PR.
-**Golden rule 4 — include a legend with every render.** The tables are dense (15+ columns on Table 2). Always print a short legend after the tables explaining the columns — `Contrib.` = non-collaborator, `Responded` = author replied after the triage comment, `Drafted by triager` = PR converted to draft by the viewer, etc. Nobody remembers column abbreviations in isolation.
+**Golden rule 4 — include a legend with every render.** The tables are dense (15+ columns on the still-open table). Always print a short legend after the tables explaining the columns — `Contrib.` = non-collaborator, `Responded` = author replied after the triage comment, `Drafted by triager` = PR converted to draft by the viewer, etc. Nobody remembers column abbreviations in isolation. The dashboard's hero cards and recommendation panel are themselves self-explanatory and don't need the [...]
**Golden rule 5 — state the input scope up front.** Before rendering, print one line summarising what the stats cover: repo name, total open PR count, closed-since cutoff date, and viewer login. The numbers only make sense in context.
+**Golden rule 6 — recommendations are deterministic, not opinions.** Every action surfaced in the "What needs attention" panel comes from a fixed rule in [`render.md#recommendation-rules`](render.md). The skill never editorialises ("queue is doing well", "you should focus on X") — it surfaces the rule's trigger and the suggested next-step command. The maintainer reads the trigger and decides; the skill never decides for them. New rules are added by editing the rules table, not by adding [...]
+
+**Golden rule 7 — actions link to other skills, never mutate.** Every recommendation's `action` field is the *exact* slash-command the maintainer can paste to do the work — almost always `/pr-management-triage`, `/maintainer-review`, or a focused variant with a label/PR-number filter. The stats skill itself remains pure-read (Golden rule 1); the dashboard makes downstream skills *one paste away* from running.
+
---
## Inputs
@@ -180,15 +187,96 @@ Also compute a `TOTAL` row where each PR is counted exactly once (NOT the sum of
---
-## Step 5 — Render
+## Step 5a — Compute health rating + action recommendations
+
+Pure function of the classified open-PR set. No network.
+
+1. Apply the **health rating** thresholds from [`aggregate.md#health-rating`](aggregate.md): each fired threshold is a "issue point". Map total points → `✅ Healthy` / `⚠️ Needs attention` / `🔥 Action needed`.
+2. Walk the **recommendation rules** from [`render.md#recommendation-rules`](render.md) in declared order. Each rule that fires produces one entry with `priority`, `icon`, `title`, `detail`, `action` (the exact slash command), and a count.
+3. The recommendation list is the input to the dashboard's "What needs attention" panel. If zero rules fire, surface the explicit "no urgent actions detected" panel — never leave the section empty.
+
+---
+
+## Step 5b — Compute weekly velocity buckets
+
+Pure function of the closed/merged-since-cutoff PR set.
+
+For each of the last 6 weeks (rolling, anchored on the fetch-start `<now>`), bucket PRs by `closedAt` and count `merged` and `closed` separately. Also count the triaged-then-merged / triaged-then-closed / triaged-then-responded subsets — those are what feed the trend mini-stats below the velocity bars.
+
+See [`aggregate.md#weekly-velocity`](aggregate.md) for the exact bucket boundaries and the avg/peak summary computation.
+
+---
+
+## Step 5c — Compute opened-vs-closed weekly buckets
+
+Pure function of *both* the open-PR set (Step 1) and the closed/merged-since-cutoff PR set (Step 3) — every PR's `createdAt` is checked against each weekly window regardless of current state.
+
+For each of the same six rolling weekly windows, compute:
+
+- `opened` — PR's `createdAt` falls in the window
+- `closed_total` — PR was closed/merged in the window (reuses the velocity buckets from Step 5b)
+- `net_delta = opened - closed_total`
+
+These per-week numbers feed the dashboard's "Opened vs closed momentum" line chart and the two-line "Net delta" summary below it. See [`aggregate.md#opened-vs-closed-weekly-buckets`](aggregate.md) for the exact spec.
+
+---
+
+## Step 5d — Compute ready-for-review trend by top areas
+
+Needs one extra fetch (per [`fetch.md#ready-label-timeline`](fetch.md)): for each currently-`ready for maintainer review` PR, the timestamp of its most recent `LabeledEvent` adding that label. Aliased GraphQL, ~30 PRs per call.
+
+Then for each top-pressure area (top 5 by Step 5f's score, filtered to areas with ≥ 3 currently-ready PRs), compute a 6-bucket cumulative count: `ready_count[a][w] = count of currently-ready PRs in area a where labeled_at <= w.end`.
+
+Feeds the dashboard's "Ready-for-review trend" multi-line chart. See [`aggregate.md#ready-for-review-trend-by-top-areas`](aggregate.md) for the exact spec and rendering rules.
+
+---
+
+## Step 5e — Compute closed-by-triage-reason buckets
+
+Pure function of the closed/merged-since-cutoff PR set (Step 3) — reuses the existing per-PR `is_triaged` / `responded_before_close` / `merged` flags.
+
+For each weekly bucket, classify each closed PR into exactly one of four categories: `merged`, `closed-after-responded`, `closed-after-triage-no-response`, `closed-no-triage`. Sum per category per week.
+
+Feeds the dashboard's "Closed-by-triage-reason per week" stacked bar chart. See [`aggregate.md#closed-by-triage-reason-per-week`](aggregate.md) for the category definitions, colour map, and summary line.
+
+---
+
+## Step 5f — Compute area pressure scores
+
+Pure function of the classified open-PR set.
+
+Per area, compute a **pressure score** = weighted sum of urgent PR conditions. The weights are defined in [`aggregate.md#pressure-score`](aggregate.md):
+
+- untriaged non-draft, > 4 weeks old → 5 pts
+- untriaged non-draft, 1–4 weeks old → 3 pts
+- untriaged non-draft, < 1 week old → 1 pt
+- triaged-waiting, > 7 days old → 2 pts (author abandoned, sweep candidate)
+- ready-for-review (label present) → 1 pt (queue waiting on maintainer review)
+- everything else → 0 pts (drafts the maintainer can ignore until author engages)
+
+Sort areas by score descending; render the top 8 (filtering areas with < 3 contributor PRs as noise) in the "Pressure by area" panel.
+
+---
+
+## Step 6 — Render dashboard
-Emit the two tables in the order defined by [`render.md`](render.md):
+Render the maintainer dashboard per the layout in [`render.md#dashboard-layout`](render.md):
-1. **Triaged PRs — Final State since `<cutoff>`** — one row per area where `Triaged Total > 0`.
-2. **Triaged PRs — Still Open** — one row per area where `Total > 0`, plus the `TOTAL` row.
-3. **Legend** — one short paragraph explaining the non-obvious columns.
+1. **Context line** — repo, open count, cutoff, viewer, timestamp.
+2. **Hero cards (4)** — health rating, total open, ready count, untriaged-non-draft count.
+3. **What needs attention** — recommendation list from Step 5a.
+4. **Closure velocity** — weekly bar chart from Step 5b.
+5. **Opened vs closed momentum** — line chart from Step 5c.
+6. **Ready-for-review trend** — multi-line chart from Step 5d (top areas).
+7. **Closed by triage reason** — stacked-bar chart from Step 5e.
+8. **Pressure by area** — top areas from Step 5f.
+9. **Triage funnel** — coverage %, response rate %, stalest bucket, this-week velocity.
+10. **Detailed tables** (collapsed by default):
+ 1. **Triaged PRs — Final State since `<cutoff>`** — one row per area where `Triaged Total > 0`.
+ 2. **Triaged PRs — Still Open** — one row per area where `Total > 0`, plus the `TOTAL` row.
+11. **Legend** — verbose explanation of every colour, column abbreviation, and computed metric on the dashboard.
-The tables are Markdown (GitHub-flavoured) so the same output renders cleanly in the CLI, in a Slack paste, or pasted into a GitHub comment.
+The dashboard is **HTML by default** so the colour-coded hero cards, action priority bars, and velocity bars render correctly. A Markdown fallback (and a Rich terminal-tables variant for the detailed-tables section only) is produced when the maintainer passes `markdown` or `tables-only`. See [`render.md`](render.md) for the full layout, the colour scheme, and the recommendation rule definitions.
---
@@ -196,9 +284,10 @@ The tables are Markdown (GitHub-flavoured) so the same output renders cleanly in
- **No mutations.** See Golden rule 1.
- **No per-PR drill-in.** The output is aggregate — if the maintainer wants to inspect a specific PR, they run `pr-management-triage pr:<N>` or open it in the browser.
-- **No timeline / trend charts.** A single snapshot per invocation. Tracking week-over-week is the maintainer's job — re-run the skill at a different `since:` date if needed.
- **No author-level stats.** Grouping is by area label, not by author login. A stats-by-author skill is a separate scope.
- **No PR *quality* scoring.** CI pass/fail, diff size, and review-thread counts are all omitted from the aggregate — they belong in the per-PR `pr-management-triage` view.
+- **No long-term historical trends.** The closure-velocity panel covers the last 6 weeks computed from the closed-since-cutoff fetch (one snapshot at fetch time). There is no persistent time-series store; tracking month-over-month is the maintainer's job — re-run the skill at a different `since:` date if needed.
+- **No automatic actions from recommendations.** Every "What needs attention" entry is a *suggestion* with a slash-command the maintainer can paste. The stats skill itself never invokes another skill, never adds labels, never closes PRs.
---
diff --git a/.claude/skills/pr-management-stats/aggregate.md b/.claude/skills/pr-management-stats/aggregate.md
index 627e557..3d4aafc 100644
--- a/.claude/skills/pr-management-stats/aggregate.md
+++ b/.claude/skills/pr-management-stats/aggregate.md
@@ -99,8 +99,192 @@ Percentages can legitimately sum to > 100% when a PR was both merged and respond
---
+## Pressure score
+
+Per area, the dashboard's "Pressure by area" ranking uses a weighted urgency score so areas with stale-and-untriaged contributor PRs surface above areas with healthy queues regardless of raw size.
+
+For each contributor PR in an area, add the matching weight (first-match-wins, top to bottom):
+
+| Condition | Weight | Rationale |
+|---|---|---|
+| ready-for-review label present | **1** | queue waiting on maintainer review — soft pressure |
+| triaged-waiting AND triage comment age ≥ 7 days | **2** | author abandoned; stale-sweep candidate |
+| draft (any age, any triage state) | **0** | author's court — not maintainer pressure |
+| untriaged non-draft AND `last_author_at` ≥ 28 days | **5** | most urgent — slipped through triage |
+| untriaged non-draft AND 7–28 days | **3** | author still likely active; needs triage soon |
+| untriaged non-draft AND < 7 days | **1** | recent — give the next sweep a chance |
+
+Collaborator-authored PRs (`OWNER`/`MEMBER`/`COLLABORATOR`) score **0** regardless of state — they have a different lifecycle (see [`#counters-per-area`](#counters-per-area)).
+
+Sort areas by score descending. The dashboard renders the top 8 areas; areas with fewer than 3 contributor PRs are filtered as noise (a tiny area with one stale PR shouldn't dominate the ranking).
+
+The score is also used to bucket the area into a severity colour for the dashboard:
+
+| Score | Severity | Border colour |
+|---|---|---|
+| ≥ 30 | high | red |
+| 15–29 | medium | amber |
+| < 15 | low | grey |
+
+Tune the weights here in lockstep with the recommendation rules in [`render.md#recommendation-rules`](render.md) — they share the same notion of "what's urgent" and drift between the two would produce contradictory dashboard sections.
+
+---
+
+## Weekly velocity
+
+The dashboard's "Closure velocity" panel buckets the closed-since-cutoff PR set into the last 6 calendar-weeks (rolling, anchored on the fetch-start `<now>`).
+
+For each `w` in `0..5`:
+
+```text
+window_end = now - w * 7 days
+window_start = window_end - 7 days
+```text
+
+`w == 0` is the current week (oldest = `<now> - 7d`, newest = `<now>`). `w == 5` is the oldest week in the chart.
+
+Per window, count three metrics:
+
+| Field | Count rule |
+|---|---|
+| `merged` | PR has `merged == true` AND `closedAt` falls in the window |
+| `closed` | PR has `merged == false` AND `closedAt` falls in the window |
+| `triaged_then_responded` | PR is triaged (per [`classify.md#triage-marker`](classify.md)) AND `closedAt` falls in the window AND the author commented after the triage comment |
+
+Render the bars oldest → newest (so the eye sweeps left-to-right matching natural time order). Each bar is a stacked `merged` (green) + `closed` (grey) segment, normalised to the maximum total in the 6-week window.
+
+Below the bars, print three summary numbers:
+
+- **6-week total** — sum of `merged + closed` across all six windows.
+- **avg/wk** — `total / 6`, rounded.
+- **peak** — `max(merged + closed)` across the six windows.
+
+The avg and peak give the maintainer a quick sense of whether this week is normal, slow, or unusually busy.
+
+---
+
+## Opened-vs-closed weekly buckets
+
+The dashboard's "Opened vs closed momentum" line chart needs a parallel bucket counting **PRs opened** per week (in addition to the closures already computed above).
+
+For each of the same six rolling windows (`w` in `0..5`), count:
+
+| Field | Count rule |
+|---|---|
+| `opened` | any PR (open or closed at fetch time) where `createdAt` falls in the window |
+| `closed_total` | `merged + closed` from the velocity bucket above |
+| `net_delta` | `opened - closed_total` (positive = backlog growing that week, negative = shrinking) |
+
+`opened` requires combining the open-PR set (Step 1 fetch) and the closed-since-cutoff set (Step 3 fetch) into a single iteration — every PR's `createdAt` is checked against the window regardless of current state. PRs opened *before* the cutoff but closed *within* the window count for the closed bucket but not the opened bucket (their createdAt is out of window).
+
+Below the chart, print two summary lines computed from these buckets:
+
+```text
+Net delta this week: ±<N> PRs (<opened> opened - <closed_total> closed)
+6-week net: ±<N> PRs (<sum_opened> opened - <sum_closed> closed) — backlog <growing|shrinking|stable>
+```text
+
+`stable` is used when `|6-week net| < 10`. Anything bigger reads as a real direction.
+
+The line chart itself is rendered via inline SVG in [`render.md`](render.md). The aggregate layer just produces the per-week numbers — chart geometry (line interpolation, axes, gridlines) is purely a render concern.
+
+### Why opened *and* closed (not just net)
+
+Net alone hides activity. A week with 100 opened and 100 closed has the same net as a week with 0 opened and 0 closed, but the maintainer experience is wildly different — the first is a busy week, the second is dormant. Rendering both lines lets the maintainer see "we're keeping up" vs "nothing's happening" at a glance.
+
+---
+
+## Ready-for-review trend by top areas
+
+The dashboard's "Ready-for-review trend" panel shows the cumulative count of currently-`ready for maintainer review` PRs over the last 6 weeks, broken down by the top-N highest-pressure areas (default N = 5; areas with fewer than 3 currently-ready PRs are excluded as noise).
+
+For each PR currently carrying the label, the **labeled-at timestamp** is the `createdAt` of the most recent `LabeledEvent` where `label.name == "ready for maintainer review"` from the PR's timeline (see [`fetch.md#ready-label-timeline`](fetch.md)).
+
+For each top area `a` and each weekly bucket `w` in `0..5`:
+
+```text
+ready_count[a][w] = count of currently-ready PRs in area a where labeled_at <= w.end
+```text
+
+This is a **cumulative** count, not a per-week delta — by construction it's monotonically non-decreasing because PRs that lose the label drop out of the *currently-ready* set entirely (they're not in this dataset).
+
+Render the result as a multi-line chart, one line per area, with the area's pressure-band colour from [`#pressure-score`](#pressure-score) (red / amber / grey lines). Each line ends at the current count visible in the dashboard's hero card.
+
+Below the chart, print a one-line per-area summary:
+
+```text
+providers: 46 ready (+8 in last 7d)
+task-sdk: 40 ready (+5 in last 7d)
+…
+```text
+
+The "+N in last 7d" is the count of PRs labeled within the last week — surfaces whether the queue is growing faster than it can be reviewed.
+
+### Why cumulative, not weekly-delta
+
+A maintainer looking at the trend wants to see "how is the review backlog evolving" — a steadily-growing line means review velocity isn't keeping up with triage promotion. Per-week deltas would show only the additions and obscure that the queue keeps *being* big. The cumulative view answers the actual question.
+
+---
+
+## Closed by triage reason per week
+
+The dashboard's "Closed-by-triage-reason" panel shows the per-week stacked breakdown of closed/merged PRs by triage outcome. Each closed PR falls into exactly one of four categories:
+
+| Category | Definition | Colour |
+|---|---|---|
+| `merged` | `merged == true` (regardless of triage state) | green |
+| `closed-after-responded` | `merged == false` AND `is_triaged` AND `responded_before_close == true` | amber |
+| `closed-after-triage-no-response` | `merged == false` AND `is_triaged` AND `responded_before_close == false` | red |
+| `closed-no-triage` | `merged == false` AND NOT `is_triaged` | grey |
+
+For each weekly bucket, count PRs in each category. Render as a 6-row stacked horizontal bar (same layout as the velocity chart — newest at the bottom).
+
+The four colours map directly to maintainer outcomes:
+
+- **green** = success path (PR shipped)
+- **amber** = engagement-but-no-merge (author responded but PR didn't make it — could be design rejection, scope change, etc.)
+- **red** = stale-sweep / abandonment (triaged then ghosted; usually closed via sweep 1a)
+- **grey** = no-triage closure (author closed it themselves, or maintainer closed without going through triage)
+
+A healthy week has a tall green segment with thin amber/red/grey segments. A week dominated by red is a triage-followup pile-up; a week dominated by grey is contributors self-cleaning their own PRs (also fine).
+
+Below the bars, print three summary numbers:
+
+```text
+6-week breakdown: <merged_total> merged · <closed_after_responded> engaged-then-closed · <closed_no_response> sweep-closed · <closed_no_triage> no-triage
+```text
+
+This panel makes the *quality* of closures visible — the velocity panel says "how many", this panel says "of what type".
+
+---
+
+## Health rating
+
+Top-of-dashboard hero card. Computed as a count of fired threshold conditions:
+
+| Condition | Issue points |
+|---|---|
+| Any contributor non-draft PR untriaged AND > 4 weeks old | **2** |
+| > 30 contributor non-draft PRs untriaged AND in 1–4 weeks bucket | **1** |
+| > 100 PRs labelled `ready for maintainer review` | **1** |
+| > 20 stale-triaged drafts (drafts where triage comment ≥ 7 days old AND no author response) | **1** |
+
+Sum the points and map:
+
+| Total points | Label | Colour |
+|---|---|---|
+| 0 | `✅ Healthy` | green |
+| 1–2 | `⚠️ Needs attention` | amber |
+| ≥ 3 | `🔥 Action needed` | red |
+
+The `>4w untriaged` condition is weighted 2x because PRs that have slipped past the 4-week mark without triage are the highest-cost failure mode — they make the project look unresponsive even though everything else may be fine. A single `>4w` PR alone reaches "needs attention".
+
+The thresholds are intentionally conservative — most well-tended repos sit at 0 or 1 issue point. If a maintainer sees the rating regularly hitting "Action needed", that's the signal to schedule a focused triage day.
+
+---
+
## Cache
-Persist `area_stats` and `totals` into the scratch cache as JSON. The cache entry is keyed by `(fetch_timestamp, cutoff)` — if the maintainer re-invokes with the same cutoff inside the 15-minute freshness window, render from cache without re-fetching.
+Persist `area_stats`, `totals`, the per-area pressure scores, the weekly velocity buckets, and the recommendation list into the scratch cache as JSON. The cache entry is keyed by `(fetch_timestamp, cutoff)` — if the maintainer re-invokes with the same cutoff inside the 15-minute freshness window, render from cache without re-fetching.
The cache is advisory for stats. If a consumer (e.g. a wrapping `loop` that re-runs every 30 minutes) wants live numbers, invalidate the cache explicitly with `clear-cache`.
diff --git a/.claude/skills/pr-management-stats/classify.md b/.claude/skills/pr-management-stats/classify.md
index a3a0785..556b7b9 100644
--- a/.claude/skills/pr-management-stats/classify.md
+++ b/.claude/skills/pr-management-stats/classify.md
@@ -73,7 +73,7 @@ pullRequest(number: $n) {
}
}
}
-```
+```text
If `actor.login` is the viewer (or any maintainer login tracked in the session cache) and `createdAt >= triage_comment_createdAt`, mark the PR as `drafted_by_triager` with `drafted_at = createdAt`.
@@ -97,7 +97,7 @@ last_author_interaction = max(
last_commit.committedDate,
pr.createdAt,
)
-```
+```text
Why `max`: a PR freshly opened without activity still needs *some* age signal — `createdAt` is the floor. A PR where the author commented after pushing a commit should be counted by the comment timestamp, not the commit.
@@ -122,7 +122,7 @@ A PR is by a *contributor* (for the `Contrib.` column) when:
```text
authorAssociation NOT IN (OWNER, MEMBER, COLLABORATOR)
-```
+```text
Everything else (including `FIRST_TIME_CONTRIBUTOR`, `FIRST_TIMER`, `CONTRIBUTOR`, `NONE`) counts as contributor. Bots (`[bot]`-suffixed logins or `dependabot` / `github-actions`) are NOT contributors — they're a separate class and should be excluded from the open-PR stats entirely. Filter bots at fetch time, not at classification time, so the denominator in every percentage excludes them.
@@ -142,12 +142,39 @@ Table 1's `Responded` column measures, per area, how many triaged PRs got an aut
responded_before_close =
is_triaged AND
exists(comment by pr.author where comment.createdAt > triage_comment.createdAt AND comment.createdAt <= pr.closedAt)
-```
+```text
Count the PR as responded if it has the marker AND an author comment between triage and close. `%Responded` = responded / triaged_total for that area.
---
+## Pressure weight
+
+Per-PR helper used by [`aggregate.md#pressure-score`](aggregate.md). Returns the integer weight a single contributor PR contributes to its area's pressure score. Pure function of fields already populated above; no extra fetches.
+
+```text
+def pressure_weight(pr) -> int:
+ if pr.author_association in ("OWNER", "MEMBER", "COLLABORATOR"):
+ return 0 # collaborator PRs don't add maintainer pressure
+ if pr.is_ready_for_review:
+ return 1 # waiting on maintainer review — soft pressure
+ if pr.is_triaged_waiting and (now - pr.triage_ts) >= 7 days:
+ return 2 # stale triaged — sweep candidate
+ if pr.is_draft:
+ return 0 # author's court
+ # untriaged non-draft
+ age = now - pr.last_author_at
+ if age >= 28 days: return 5
+ if age >= 7 days: return 3
+ return 1
+```text
+
+The first-match-wins ordering matters: a ready-for-review PR that's also a stale triaged draft scores 1 (ready takes precedence — once it has the label, the maintainer is the gate, not the author). Keep this function in lockstep with the table in [`aggregate.md#pressure-score`](aggregate.md).
+
+---
+
## Re-classification stability
The stats run must produce the same numbers when invoked twice on the same cached state. Keep the classification pure (no time-dependent randomness) and anchor age-bucket cutoffs to `<now>` captured at fetch start, not at render time. Otherwise a slow run drifts PRs across buckets between fetch and render.
+
+This applies to `pressure_weight` too — the `7d` / `28d` thresholds are computed from the same `<now>` as the age buckets, so a PR that's exactly on a bucket boundary scores deterministically across re-runs of the same fetch.
diff --git a/.claude/skills/pr-management-stats/fetch.md b/.claude/skills/pr-management-stats/fetch.md
index 412fabb..bbe49d9 100644
--- a/.claude/skills/pr-management-stats/fetch.md
+++ b/.claude/skills/pr-management-stats/fetch.md
@@ -51,13 +51,13 @@ query(
}
}
}
-```
+```text
### `searchQuery`
```text
is:pr is:open repo:<repo> sort:created-asc
-```
+```text
Sort is `created-asc` (oldest PR first) so the age-bucket counts accumulate deterministically — same PR always lands in the same row in a re-run. `is:pr` filters out issues in the same search.
@@ -69,7 +69,7 @@ gh api graphql \
-F batchSize=50 \
-F cursor="$CURSOR" \
--field query=@/tmp/pr-management-stats-open.graphql
-```
+```text
### Batch size
@@ -116,7 +116,7 @@ query(
}
}
}
-```
+```text
Notice `comments(last: 25)` — higher than the 10 used for open PRs because a triaged PR that was then closed will often have extra follow-up comments; we still need to find the original triage marker. If the marker isn't in the last 25 comments for a given PR, drop that PR from Table 1 (it wasn't triaged by the bot/viewer convention).
@@ -134,7 +134,7 @@ This pull request has had no activity from the author for over 4 weeks.
@<author>, you are welcome to reopen this PR when you are ready to continue working on it. Thank you for your contribution!
<!-- Pull Request quality criteria -->
-```
+```text
In this case the visible body contains no "Pull Request quality criteria" text at all — the only marker is the HTML comment at the bottom. Running the same marker match against `bodyText` misses these entirely. A spot-check on a 40-PR sample from `<upstream>` found ~10% of triaged-marker comments were HTML-comment-only: invisible to a `bodyText`-based search.
@@ -174,7 +174,7 @@ Two stages:
```graphql
query {
- repository(owner:$owner,name:$repo) {
+ repository(owner:"apache",name:"airflow") {
pr63407: pullRequest(number:63407) {
number author{login} authorAssociation closedAt mergedAt state merged
labels(first:30){nodes{name}}
@@ -184,7 +184,7 @@ query {
# … 30 aliases per query
}
}
-```
+```text
For each returned PR, apply the same marker check as [`classify.md`](classify.md) (`Pull Request quality criteria` substring in raw `body`, author in `OWNER/MEMBER/COLLABORATOR`). Record `responded_before_close` when the author has a comment after the triage marker and on or before `closedAt`.
@@ -201,7 +201,7 @@ When the maintainer explicitly asks for a quick approximation (`fast-closed` fla
```text
is:pr -is:open repo:<repo> closed:>=<cutoff> sort:updated-desc
-```
+```text
The fast path must print a clear caveat above Table 1: *"fast-closed mode: Table 1 uses the free-text search index which currently undercounts older triaged+merged PRs on `<upstream>`. Re-run without `fast-closed` for accurate numbers."*
@@ -209,7 +209,7 @@ The fast path must print a clear caveat above Table 1: *"fast-closed mode: Table
```text
is:pr -is:open repo:<repo> closed:>=<cutoff> sort:updated-desc
-```
+```text
`-is:open` matches both `closed` and `merged` states. `closed:>=` is GitHub's search qualifier for closed/merged date. `sort:updated-desc` keeps the most recent final actions at the top (so Ctrl-C'ing a long pagination returns the freshest portion).
@@ -221,7 +221,7 @@ gh api graphql \
-F batchSize=50 \
-F cursor="$CURSOR" \
--field query=@/tmp/pr-management-stats-closed.graphql
-```
+```text
### Cutoff default
@@ -229,7 +229,7 @@ If the maintainer doesn't pass `since:<date>`, default to six weeks ago:
```bash
cutoff=$(date -u -d "-42 days" +%Y-%m-%d)
-```
+```text
Six weeks covers ~a sprint-and-a-half, which is long enough to smooth out day-to-day variation in closures without being so far back that the numbers lose meaning.
@@ -248,7 +248,7 @@ while : ; do
cursor=$(echo "$out" | jq -r '.data.search.pageInfo.endCursor')
[ "$hasNext" = "true" ] || break
done
-```
+```text
Two safety valves:
@@ -284,6 +284,41 @@ sequence (e.g. `\z`, `\e`), even `strict=False` fails with
---
+## Ready-label timeline
+
+Needed for the dashboard's "Ready-for-review trend" chart (see [`aggregate.md#ready-for-review-trend-by-top-areas`](aggregate.md)). The open-PRs query above tells us *which* PRs currently carry the `ready for maintainer review` label, but not *when* the label was added. Without that timestamp the trend chart can't show growth.
+
+Run an aliased GraphQL query, **30 PRs per call**, that fetches each PR's `LabeledEvent` timeline filtered to the relevant label:
+
+```graphql
+query {
+ repository(owner:"<owner>",name:"<name>") {
+ pr12345: pullRequest(number:12345) {
+ number
+ timelineItems(last:50, itemTypes:[LABELED_EVENT]) {
+ nodes {
+ ... on LabeledEvent {
+ createdAt
+ label { name }
+ }
+ }
+ }
+ }
+ # … repeated for the other 29 PRs in the batch
+ }
+}
+```text
+
+Per-PR processing: pick the most recent `LabeledEvent` whose `label.name == "ready for maintainer review"`. That `createdAt` is the PR's `ready_at` timestamp. PRs that never had the label (shouldn't happen — input set is filtered to currently-labelled PRs) are dropped silently.
+
+`itemTypes:[LABELED_EVENT]` is critical — without it the timeline returns every event (commits, comments, reviews) and blows the complexity budget. With the filter, the per-PR cost is tiny.
+
+Budget: ~7 GraphQL calls for ~200 currently-ready PRs on `<upstream>`. Well under the per-session ceiling.
+
+Cache the per-PR `ready_at` in `/tmp/pr-management-stats-cache-<repo-slug>.json` keyed by `(pr_number, head_sha)` — same convention as the rest of the cache. The label-add timestamp is stable across head SHAs (it's a label event, not a commit event), so the cache hit rate is high.
+
+---
+
## Why no `statusCheckRollup` / `mergeable` / `reviewThreads`
`pr-management-triage` needs all three for classification; `pr-management-stats` does not. Dropping them keeps the query complexity well below GitHub's per-page ceiling, which is how we can safely run `batchSize=50` here versus `20` in `pr-management-triage`. If a future stats column ever needs one of those fields, raise only that query's complexity — don't pull them into the default shape "just in case".
@@ -305,7 +340,7 @@ sequence (e.g. `\z`, `\e`), even `strict=False` fails with
"prs": { "12300": {"state": "MERGED", "responded_before_close": true, "areas": ["scheduler"]} }
}
}
-```
+```text
### Invalidation
diff --git a/.claude/skills/pr-management-stats/render.md b/.claude/skills/pr-management-stats/render.md
index 9f57ae7..e9981fb 100644
--- a/.claude/skills/pr-management-stats/render.md
+++ b/.claude/skills/pr-management-stats/render.md
@@ -3,162 +3,297 @@
# Render
-Print the two tables, the legend, and a summary line. **Primary output is Rich-rendered colored tables to the terminal** — the same library the now-removed `breeze pr auto-triage` and `breeze pr stats` commands used, so the colour scheme and state-marker vocabulary stay consistent with what the maintainer already knows. Rich handles wide tables correctly (horizontal overflow, per-column wrapping) so Table 2's 20+ columns don't fall apart in a normal terminal.
+The skill produces a **maintainer dashboard** as the primary output: an HTML page with five colour-coded sections at the top, a time-trend line chart, and the full per-area tables collapsed underneath. The dashboard is designed to answer "what should I do today" at a glance, with the underlying numbers one click away.
-A Markdown fallback is produced when the maintainer explicitly asks for shareable output (`markdown` flag, or the output is being piped to a non-tty).
+A Rich-rendered terminal-tables variant and a Markdown fallback are produced when the maintainer asks for them (`tables-only` or `markdown` flag, or output is being piped to a non-tty). Those modes render only the per-area tables — they skip the hero cards, recommendation panel, charts, and pressure ranking, since those rely on visual layout that doesn't translate to a terminal.
---
-## Why Rich, not Markdown by default
+## Why HTML is the default
-The first cut of this skill emitted GitHub-flavoured Markdown tables. In practice this produced two problems:
+The previous skill iteration emitted Rich tables as the primary output. Two empirical problems pushed the dashboard to HTML:
-1. Table 2's width (20+ columns) exceeded the rendering width of common Markdown viewers. The table collapsed into a wrapped mess of `|`-separated values that read as prose, not a table.
-2. Markdown carries no colour, so state-relevant cells (CI failing, many commits behind, etc.) had no visual cue — the maintainer had to compare numbers by eye.
+1. **The signal is in the recommendations, not the tables.** Maintainers asked variations of "what should I do" — a 17-column table requires the maintainer to visually scan, compare, and infer. The dashboard surfaces the same conclusions explicitly with priority colours.
+2. **Trends need pictures.** Closure velocity over time and opened-vs-closed momentum are intuitive as bar / line charts and dense as numeric columns. SVG inline charts solve both.
-Rich solves both: its table renderer computes column widths from the actual terminal size, overflows horizontally instead of wrapping (or truncates cells with an ellipsis if set), and supports inline colour markup. Rich is also what the now-removed `breeze pr stats` and `breeze pr auto-triage` commands used, so anyone migrating from the old breeze CLI sees the same colours and state names.
+Rich's per-cell colour markup is still useful for the *details* tables (which the dashboard embeds in collapsed `<details>` blocks), so the colour vocabulary below is shared between HTML and Rich.
---
-## Rich rendering
+## Dashboard layout
-Use `rich.console.Console` + `rich.table.Table`. The shape mirrors what the now-removed `breeze pr stats` command used, kept verbatim so the output stays familiar to maintainers used to that command.
+The HTML page renders sections in this exact order. Section headings and their meanings are stable across runs so a maintainer can scroll-jump consistently.
-Minimum table setup:
+### 1. Title bar + context line
-```python
-from rich.console import Console
-from rich.table import Table
-from rich.panel import Panel
+```text
+📊 <upstream> — Maintainer dashboard
+Tuesday, May 6, 2026 · 14:33 UTC · viewer @potiuk · 6-week window since 2026-03-24
+```text
-console = Console()
+The title is plain text. Context line includes `<weekday>, <month> <day>, <year> · <HH:MM> UTC · viewer @<login> · 6-week window since <cutoff>`. Use the fetch-start `<now>`, not render-end, so a slow run remains a single-moment snapshot.
-t = Table(
- title=f"Triaged PRs — Final State since {cutoff} ({repo})",
- title_style="bold",
- show_lines=True,
- show_footer=True,
-)
-t.add_column("Area", style="bold cyan", min_width=12, footer="Area")
-t.add_column("Triaged Total", justify="right", style="yellow", footer="Triaged Total")
-# … (see colour scheme below)
+### 2. Hero cards (4-column grid)
-for area in sorted_areas:
- t.add_row(area, str(triaged_total), …)
+Four equally-sized cards, each one big number with a sub-label:
-# TOTAL row with bold-white style per cell
-t.add_row("[bold white]TOTAL[/]", f"[bold white]{n}[/]", …, style="on grey7", end_section=True)
+| Card | Big number | Sub-label | Colour rule |
+|---|---|---|---|
+| **Repo Health** | the rating label (`✅ Healthy` / `⚠️ Needs attention` / `🔥 Action needed`) | "based on triage backlog + queue size" | green / amber / red, per [`aggregate.md#health-rating`](aggregate.md) |
+| **Open PRs (non-bot)** | total open count | `<contrib_count> from contributors · <collab_count> collaborator-authored` | blue (informational) |
+| **Ready for review** | `len(ready_open)` | `<pct>% of contributor queue` | green |
+| **Untriaged non-drafts** | `len(untriaged_nondraft)` | `<X> are >4 weeks old` | red if >0 are >4w, amber if total > 30, green otherwise |
-console.print(t)
-```
+Card layout is responsive: 4-column on wide screens, 2-column on narrow, 1-column on mobile-width. The big number uses 32px font; sub-labels are 12px dim grey.
-Key settings:
+### 3. What needs attention (action panel)
-- `show_lines=True` — horizontal separators between rows make the 20-column Table 2 readable.
-- `show_footer=True` with a `footer=` on each column — column headers repeat at the bottom for long tables.
-- `end_section=True` on the TOTAL row plus `style="on grey7"` — the totals row is visually separated and tinted, matching the look the predecessor `breeze pr stats` command used.
+A vertical list of action cards built from the recommendation rules in [`#recommendation-rules`](#recommendation-rules) below. Each card has a coloured left border (red = high, amber = medium, grey = low), an icon, a one-line title, a 1–2-line detail explanation, and a monospace `code` block holding the exact slash-command the maintainer can paste.
----
+If zero rules fire, render a single low-priority card with a `✨` icon and the body "No urgent actions detected. Queue is in healthy shape — periodic /pr-management-triage when convenient." Never leave the section visually empty.
+
+The order inside the panel is: high-priority first (sorted by count descending), then medium (same), then low. Within a tier the rule firing order from [`#recommendation-rules`](#recommendation-rules) breaks ties.
+
+### 4. Closure velocity (per-week bar chart)
+
+Title: **Closures per week (oldest → newest)**
+
+A 6-row stacked horizontal bar chart, one row per week (W-5, W-4, W-3, W-2, W-1, this wk).
+
+Each bar is two stacked segments:
+
+- **green** — `merged` count
+- **grey** — `closed` count (closed without merging)
+
+Bar width = `(merged + closed) / max_weekly_total * 100%`. Numbers inside each segment when wide enough; otherwise to the right of the bar.
+
+Below the bars: a one-line summary of `6-week total: N · avg N/wk · peak N/wk`.
+
+This panel reads as "how much did we ship per week, and is that trending up or down". Use the labelled date (`05-06`) on the left axis so the maintainer sees the actual calendar weeks, not abstract `W-N` labels.
+
+### 5. Opened vs closed momentum (line chart)
+
+Title: **Opened vs closed PRs (last 6 weeks)**
+
+An inline SVG line chart with two lines:
+
+- **blue line** — count of PRs *opened* per week (createdAt in the window)
+- **green line** — count of PRs *closed/merged* per week (closedAt in the window, includes both merge and close-without-merge)
-## Colour scheme (inherited from the predecessor breeze commands)
+A horizontal grid line at 0 and at the max value. Y-axis labels (3 ticks: 0, mid, max). X-axis labels: week-start dates.
-Use the same palette the now-removed `breeze pr auto-triage` and `breeze pr stats` commands used so state meanings transfer without relearning. Markup uses Rich's inline `[color]…[/]` syntax.
+Below the chart, a small "**Net delta**" line:
-| Concept | Colour | Used in |
+```text
+Net delta this week: +12 PRs (102 opened - 90 closed)
+6-week net: -45 PRs (1450 opened - 1495 closed) — backlog shrinking
+```text
+
+The two-line mini-summary translates the chart into the maintainer-relevant question: "is the open-PR backlog growing or shrinking?". Negative net = backlog shrinking (good); positive net = growing (need more close-out velocity).
+
+The line chart is kept inline-SVG (no JavaScript) so it renders in any browser, in a Slack image preview, or in any embedded HTML viewer. Keep the chart 720×220px so it's readable but doesn't dominate the page.
+
+### 6. Ready-for-review trend (multi-line chart)
+
+Title: **Ready-for-review trend (last 6 weeks, top areas)**
+
+An inline SVG line chart with one line per top-pressure area (default: top 5, filtered to areas with ≥ 3 currently-ready PRs). Each line is **cumulative**: at week W it shows the count of PRs that *currently* carry the `ready for maintainer review` label and were labelled on or before W.
+
+Each area's line uses its pressure-band colour (red, amber, or grey per [`aggregate.md#pressure-score`](aggregate.md)). Same SVG dimensions as the opened-vs-closed chart (720×220px). The chart legend in the top-left lists each area name with its line colour swatch.
+
+Below the chart, a per-area summary list:
+
+```text
+providers: 46 ready (+8 in last 7d)
+task-sdk: 40 ready (+5 in last 7d)
+…
+```text
+
+The "+N in last 7d" surfaces whether the queue is growing faster than maintainers are reviewing — a steadily climbing line means review velocity isn't keeping up.
+
+### 7. Closed by triage reason (stacked bar chart)
+
+Title: **Closed by triage reason (last 6 weeks)**
+
+Six rows, oldest → newest, one row per week. Each row is a horizontally-stacked bar with four coloured segments:
+
+| Segment | Definition | Colour |
|---|---|---|
-| Area name | `bold cyan` | Area column of both tables |
-| Triage / Waiting for Author | `yellow` | `Triaged`, `%Responded` when < 100% |
-| Responded | `green` | `Responded` column, `%Responded` when ≥ 50% |
-| Ready for review | `bold green` | `Ready`, `%Ready` columns, Table 1 `Merged` column |
-| Flagged / failing | `red` | `Closed` (when no merge), `%Closed`, CI-failing indicators |
-| Drafted by triager | `magenta` | `Drafted by triager` |
-| Drafted age buckets (secondary) | `magenta dim` | `Drafted <1d` … `Drafted >4w` |
-| Author-resp age buckets (secondary) | `dim` | `Author resp <1d` … `Author resp >4w` |
-| Contributor (non-collab) | `bright_cyan` | `Contrib.`, `%Contrib.` |
-| Unknown / ambiguous | `dim` | fallback for `?` / `-` |
-| TOTAL row | `bold white` on `grey7` | the footer-before-footer TOTAL row |
-
-When a percentage cell is a close call (e.g. `%Responded` of exactly 50%), keep the happier colour — `green` above 50, `yellow` below — matching the "when in doubt, show it as still-ok" convention the predecessor breeze commands used.
+| **merged** | `merged == true` | green |
+| **closed-after-responded** | not merged, was triaged, author responded before close | amber |
+| **closed-after-triage-no-response** | not merged, was triaged, author never responded (sweep close) | red |
+| **closed-no-triage** | not merged, never triaged (author self-close, etc.) | grey |
+
+Bar width per row = `(sum of all four) / max_weekly_total * 100%`. Numbers inside each segment when wide enough; otherwise to the right of the bar.
+
+Below the bars, a one-line breakdown of the 6-week totals:
+
+```text
+6-week breakdown: <merged> merged · <closed-after-responded> engaged-then-closed · <closed-no-response> sweep-closed · <closed-no-triage> no-triage
+```text
+
+A healthy week is mostly green with thin amber/red segments. A red-dominated week means a stale-sweep landed; a grey-dominated week means contributors are self-cleaning (also healthy, but worth noticing).
+
+### 8. Pressure by area
+
+Title: **Pressure by area** (with a sub-line *"Pressure score = weighted sum of untriaged-old PRs per area. Higher score = more maintainer attention needed."*)
+
+Up to 8 rows, sorted by pressure score descending (filtering areas with < 3 contributor PRs). Each row is a horizontally-laid-out card with a coloured left border (red / amber / grey, severity bands per [`aggregate.md#pressure-score`](aggregate.md)) containing:
+
+- area name (cyan, bold, e.g. `providers`)
+- one-line stat: `<contrib_total> contributor PRs · <red>untriaged_4w</red> >4w · <amber>untriaged_1_4w</amber> 1-4w · <grey>untriaged_recent</grey> recent · <green>ready_pending</green> ready for review`
+- pressure score (right-aligned)
+- the slash-command to focus on this area: `/pr-management-triage label:area:<X>` (dimmed)
+
+This panel answers "if I have 30 minutes, which area moves the most needles?". Top row is always the highest-leverage focus.
+
+### 9. Triage funnel (4-column hero grid)
+
+A second hero grid, same layout as the top one, showing the funnel-health summary numbers:
+
+| Card | Big number | Sub-label | Colour rule |
+|---|---|---|---|
+| **Triage Coverage** | `<pct>%` of contributor PRs that have been seen by a maintainer (triaged + ready + draft / contributors) | `<seen> of <total> contributor PRs have been seen by a maintainer` | green ≥ 50, amber 20–49, red < 20 |
+| **Author Response Rate** | `<pct>%` of triaged PRs where the author replied | `<responded> of <triaged> triaged PRs got an author reply` | same colour rule |
+| **Stalest Bucket** | count of contributor PRs in the `>4w` age bucket | "contributor PRs untouched >4 weeks" | red if > 50, amber if > 20, green otherwise |
+| **This Week's Velocity** | this week's `merged + closed` total | `<merged> merged · <closed> closed (avg <N>/wk)` | default (informational) |
+
+This grid completes the dashboard: hero cards at the top (queue size + immediate red flags), recommendations next (what to do), velocity + opened-vs-closed (momentum), pressure by area (where), and triage funnel (process health).
+
+### 10. Detailed tables (collapsed `<details>` blocks)
+
+Two `<details>` elements, each opening into a compact area-grouped table:
+
+- **Triaged PRs — Final State since `<cutoff>`** — same structure as the [Table 1](#table-1--triaged-prs-final-state) section below.
+- **Triaged PRs — Still Open** — same structure as [Table 2](#table-2--triaged-prs-still-open) below, possibly compact (drop the 8 age-bucket columns) for screen width.
+
+Both tables are HTML-rendered with the same colour scheme as the dashboard. Maintainers who want the raw per-area numbers click to expand; the default view is the dashboard sections above.
+
+### 11. Legend / methodology
+
+A bordered panel at the bottom explaining all the colours, columns, and computed values. Critical content because the dashboard packs a lot of distinct numbers into small footprints. See [`#legend`](#legend) below for the verbatim block.
---
-## Triage state markers
+## Recommendation rules
-The triage workflow categorises every non-collab PR into one of four states (the same four the now-removed `breeze pr auto-triage` overview table surfaced, and that the [`pr-management-triage`](../pr-management-triage/SKILL.md) skill continues to use):
+The "What needs attention" panel is built from this fixed rule set, evaluated in order. Each rule that fires produces one entry in the panel.
-| Marker | Meaning | Colour |
-|---|---|---|
-| `Ready for review` | PR has the `ready for maintainer review` label | `green` |
-| `Waiting for Author` | PR is triaged (quality-criteria comment posted), no author response, OR the PR is draft | `yellow` |
-| `Responded` | author commented / pushed after the triage comment | `bright_cyan` |
-| `-` | not yet triaged | `blue` |
+| # | Trigger | Priority | Icon | Title template | Action template |
+|---|---|---|---|---|---|
+| 1 | `len(untriaged_old) > 0` (any contributor non-draft >4w) | high | 🔥 | `Triage <N> non-draft contributor PRs older than 4 weeks` | `/pr-management-triage all PR issues (focus on >4w bucket)` |
+| 2 | `len(untriaged_old) == 0 AND len(untriaged_med) > 0` (1-4w bucket non-empty) | medium | 👀 | `Triage <N> non-draft PRs aged 1-4 weeks` | `/pr-management-triage all PR issues` |
+| 3 | `len(stale_triaged_drafts) > 0` (drafts triaged ≥ 7d ago, no reply) | medium | 🗑️ | `Close <N> stale-triaged drafts (≥7d, no response)` | `/pr-management-triage stale → sweep 1a` |
+| 4 | `len(ready_open) >= 50` | high | 📥 | `<N> PRs labeled "ready for maintainer review"` | `/maintainer-review (filter: ready for maintainer review)` |
+| 5 | `20 <= len(ready_open) < 50` | medium | 📥 | `<N> PRs in "ready for maintainer review" queue` | same as rule 4 |
+| 6 | `len(responded_no_ready) > 0` (triaged + responded but not ready-for-review) | medium | 🔄 | `<N> triaged PRs have author responses awaiting re-triage` | `/pr-management-triage all PR issues (will surface as mark-ready-with-ping)` |
+| 7 | top area's `untriaged_4w + untriaged_1_4w >= 5` | medium | 📍 | `Area "<area>" has <total> contributor PRs (<X> untriaged >4w)` | `/pr-management-triage label:area:<area>` |
+| 8 | `velocity_drop > 30` (last_wk total - this_wk total) | low | 📉 | `PR closure velocity dropped <N> this week` | `No immediate action — check next week.` |
+| 9 | top ready-trend area's growth in last 7d ≥ 10 PRs | low | 📈 | `Ready-for-review queue in "<area>" grew by <N> this week` | `/maintainer-review label:area:<area>` |
+| 10 | weekly closed-by-reason `closed_no_response > merged` for 2+ recent weeks | medium | 🧹 | `Stale-sweep is dominating closures (last 2 weeks: <N> sweep-close vs <M> merged)` | review `/pr-management-triage stale` cadence — too many PRs reaching the sweep |
-`pr-management-stats` surfaces the same buckets at the area level. After Table 2, print a small **state-breakdown panel** that slices the full open-PR set into the four triage-state markers:
+Rules 1 and 2 are **mutually exclusive** (only one fires depending on whether any >4w PRs exist). Rules 4 and 5 are **mutually exclusive** (banding on `ready_open` count). All other rules can fire independently.
-```python
-state_panel_lines = [
- "Triage-state breakdown:",
- f" [green]Ready for review[/] : {ready} PRs",
- f" [bright_cyan]Responded[/] : {responded} PRs (triaged, author has replied)",
- f" [yellow]Waiting for Author[/] : {waiting} PRs (triaged, no response) + {drafts_not_ready} drafts",
- f" [blue]- (not yet triaged)[/] : {untriaged} PRs",
-]
-console.print(Panel("\n".join(state_panel_lines), title="By triage state", border_style="cyan", expand=False))
-```
+When adding a new rule:
+
+- Prefer count-based triggers over percentage-based ones (counts are easier to reason about for the maintainer).
+- The action template must be a literal slash-command the maintainer can paste — never instructions like "consider running …".
+- Detail string should explain *why* the rule fired in one sentence, plus what command will help.
+
+---
+
+## Colour scheme
+
+Shared between the HTML dashboard and the Rich terminal-tables fallback. HTML uses CSS hex values; Rich uses its semantic colour names. Both palettes are derived from the now-removed `breeze pr stats` / `breeze pr auto-triage` commands so state-meaning carries over from the old CLI.
+
+| Concept | HTML (hex) | Rich | Used in |
+|---|---|---|---|
+| Area name | `#56d4dd` (cyan) | `bold cyan` | Area column of all tables, area cards in pressure section |
+| Triage / Waiting for Author | `#d29922` (amber) | `yellow` | `Triaged` count, `%Responded < 50%`, "Needs attention" health label |
+| Responded | `#56d364` (green) | `green` | `Responded` column, `%Responded ≥ 50%`, "Healthy" health label |
+| Ready for review | `#56d364` (green, bold) | `bold green` | `Ready` count, `%Ready` columns, "merged" Table 1 column |
+| Flagged / failing | `#f85149` (red) | `red` | `Closed` (without merge) column, "Action needed" health label, > 4w untriaged callouts |
+| Drafted by triager | `#db61a2` (magenta) | `magenta` | `Drafted by triager` column |
+| Drafted age buckets (secondary) | `#6f3a55` (dim magenta) | `magenta dim` | `Drafted <1d` … `Drafted >4w` |
+| Author-resp age buckets (secondary) | `#6e7681` (dim) | `dim` | `Author resp <1d` … `Author resp >4w` |
+| Contributor (non-collab) | `#76e3ea` (bright cyan) | `bright_cyan` | `Contrib.`, `%Contrib.`, "Open PRs" hero card |
+| Informational (counts) | `#76e3ea` (bright cyan) | `bright_cyan` | "Open PRs" hero, neutral big numbers |
+| Unknown / ambiguous | `#6e7681` (dim) | `dim` | `?` / `—` cells, low-priority recommendations |
+| TOTAL row | `#f0f6fc` on `#21262d` (white on dark grey) | `bold white on grey7` | Footer-before-footer TOTAL row |
+| **Velocity bars: merged segment** | `#56d364` (green) | n/a | Per-week bar chart, merged portion |
+| **Velocity bars: closed segment** | `#6e7681` (grey) | n/a | Per-week bar chart, closed-without-merge portion |
+| **Line chart: opened line** | `#58a6ff` (blue) | n/a | Opened-vs-closed momentum chart |
+| **Line chart: closed line** | `#56d364` (green) | n/a | Opened-vs-closed momentum chart |
+| **Ready-trend: high-pressure area line** | `#f85149` (red) | n/a | Ready-for-review trend chart, areas with pressure ≥ 30 |
+| **Ready-trend: medium-pressure area line** | `#d29922` (amber) | n/a | Ready-for-review trend chart, areas with pressure 15-29 |
+| **Ready-trend: low-pressure area line** | `#6e7681` (grey) | n/a | Ready-for-review trend chart, areas with pressure < 15 |
+| **Closed-by-reason: merged segment** | `#56d364` (green) | n/a | Closed-by-triage-reason stacked bar chart |
+| **Closed-by-reason: closed-after-responded** | `#d29922` (amber) | n/a | Closed-by-triage-reason stacked bar chart |
+| **Closed-by-reason: closed-no-response** | `#f85149` (red) | n/a | Closed-by-triage-reason stacked bar chart |
+| **Closed-by-reason: closed-no-triage** | `#6e7681` (grey) | n/a | Closed-by-triage-reason stacked bar chart |
+| **Recommendation priority — high** | `#f85149` (red border) | n/a | Action card left border |
+| **Recommendation priority — medium** | `#d29922` (amber border) | n/a | Action card left border |
+| **Recommendation priority — low** | `#6e7681` (grey border) | n/a | Action card left border |
+
+When a percentage is on a colour boundary (e.g. `%Responded` exactly 50%), keep the happier colour — green ≥ 50, amber 20-49, red < 20. The "when in doubt show it as still-ok" convention matches the predecessor breeze commands.
+
+---
+
+## Triage state markers
-Exact counting rules:
+Distinct from the colour scheme: these are the four conceptual *states* a contributor PR can be in at any moment. The dashboard uses them in the "Triage funnel" hero grid and in the recommendation triggers.
-- `ready` — `ready_for_review` label present (takes precedence over the other markers).
-- `responded` — triaged with author reply after the triage comment (and NOT marked ready).
-- `waiting` — triaged with no author reply (and NOT marked ready). Drafts without a triage marker fall into `waiting` too, matching the "drafts are author's court" logic the predecessor breeze commands used.
-- `untriaged` — none of the above. Non-draft PR that hasn't been triaged yet.
+| Marker | Definition | Colour | Maintainer action |
+|---|---|---|---|
+| `Ready for review` | `ready for maintainer review` label is present | green | run `/maintainer-review` to actually code-review the PR |
+| `Responded` | PR is triaged AND author has commented or pushed after the triage comment, AND not yet `Ready` | bright cyan | re-triage; may now qualify for `mark-ready-with-ping` |
+| `Waiting for Author` | PR is triaged, no author response — OR — PR is a draft (whether triaged or not) | amber | nothing; author owns the next move (may become a sweep candidate after 7d) |
+| `Not yet triaged` | None of the above. Non-draft PR that has never received a quality-criteria comment | blue (or grey) | run `/pr-management-triage` to give it a first look |
-Counts must sum to the open-PR total (non-bot). If they don't, print a warning and the discrepancy.
+Counting rules are precedence-based — `Ready` takes precedence over the others, then `Responded`, then `Waiting`, with `Not yet triaged` as the fallback. So a single PR is in exactly one bucket; the four counts must sum to the total open non-bot PR count.
---
## Context line
-Before the first table, print one line summarising the scope. This line is plain text (no Rich markup needed — it's header-style, not a table) so it renders identically in the terminal and in the Markdown fallback.
+Plain text, before the first hero card:
```text
-<upstream> — 413 open PRs (non-bot) · closed/merged since 2026-03-11 · viewer @potiuk · 2026-04-22 22:33 UTC
-```
+<upstream> — 459 open PRs (non-bot) · closed/merged since 2026-03-24 · viewer @potiuk · 2026-05-06 14:33 UTC
+```text
Structure: `<repo> — <open_count> open PRs (non-bot) · closed/merged since <cutoff> · viewer @<login> · <now>`.
-The `<now>` is the fetch-start timestamp, not the render-end timestamp — a slow run should still be interpretable as a snapshot at a single moment.
-
-If the closed-since counts came from the lagging search index (see [`fetch.md#known-limitation`](fetch.md)), print a one-line caveat between the context line and Table 1:
+If the closed-since counts came from the lagging search index (see [`fetch.md#known-limitation`](fetch.md)), prepend a one-line caveat before the hero cards:
```text
-⚠ Table 1 built from GitHub's free-text search of the quality-criteria marker. The index lags — older triaged+merged PRs are likely undercounted. Pass accurate-closed for the hybrid REST + GraphQL path.
-```
+⚠ Closed-PR table built from GitHub's free-text search of the quality-criteria marker. The index lags — older triaged+merged PRs are likely undercounted. Pass accurate-closed for the hybrid REST + GraphQL path.
+```text
---
## Table 1 — Triaged PRs, final state
-Title: `Triaged PRs — Final State since <cutoff> (<repo>)`
+Lives inside the collapsed `<details>` block at the bottom of the dashboard. Title: `Triaged PRs — Final State since <cutoff> (<repo>)`.
One row per area where `triaged_total > 0`, sorted by `triaged_total` descending. `(no area)` goes last. Append a bold **TOTAL** row.
| Column | Source | Colour |
|---|---|---|
-| Area | area name without the `area:` prefix | `bold cyan` |
-| Triaged Total | `triaged_total` | `yellow` |
-| Closed | `closed` | `red` |
+| Area | area name without the `area:` prefix | cyan |
+| Triaged Total | `triaged_total` | amber |
+| Closed | `closed` | red |
| %Closed | `pct_closed` | default |
-| Merged | `merged` | `green` |
+| Merged | `merged` | green |
| %Merged | `pct_merged` | default |
-| Responded | `responded_before_close` | `bright_cyan` |
-| %Responded | `pct_responded` | default |
+| Responded | `responded_before_close` | bright cyan |
+| %Responded | `pct_responded` | green if ≥ 50, amber if 20–49, red < 20 |
---
## Table 2 — Triaged PRs, still open
-Title: `Triaged PRs — Still Open (<repo>)`
+Lives inside the second collapsed `<details>` block. Title: `Triaged PRs — Still Open (<repo>)`.
One row per area where `total > 0`, sorted by `total` descending. `(no area)` last. Append a bold **TOTAL** row.
@@ -166,81 +301,186 @@ One row per area where `total > 0`, sorted by `total` descending. `(no area)` la
| Column | Source | Denominator | Colour |
|---|---|---|---|
-| Area | area name | — | `bold cyan` |
-| Total | `total` (all PRs) | — | `dim` |
-| Contrib. | `contributors` | — | `bright_cyan` |
+| Area | area name | — | cyan |
+| Total | `total` (all PRs) | — | dim |
+| Contrib. | `contributors` | — | bright cyan |
| %Contrib. | `contributors / total` | `total` | default |
| Draft | `drafts` (contributor) | — | default |
-| %Draft | `drafts / contributors` | `contributors` | default |
+| %Draft | `drafts / contributors` | `contributors` | red if > 60%, otherwise default |
| Non-Draft | `non_drafts` (contributor) | — | default |
-| Triaged | `triaged_waiting + triaged_responded` (contributor) | — | `yellow` |
-| Responded | `triaged_responded` (contributor) | — | `green` |
-| %Responded | `triaged_responded / (triaged_waiting + triaged_responded)` | the triaged set | default |
-| Ready | `ready_for_review` (contributor) | — | `bold green` |
-| %Ready | `ready_for_review / contributors` | `contributors` | default |
-| Drafted by triager | `triager_drafted` (contributor) | — | `magenta` |
-| Drafted `<1d` / `1-7d` / `1-4w` / `>4w` (4 cols) | `draft_age_buckets[bucket]` (contributor) | — | `magenta dim` |
-| Author resp `<1d` / `1-7d` / `1-4w` / `>4w` (4 cols) | `age_buckets[bucket]` (contributor) | — | `dim` |
+| Triaged | `triaged_waiting + triaged_responded` | — | amber |
+| Responded | `triaged_responded` | — | green |
+| %Responded | `triaged_responded / (triaged_waiting + triaged_responded)` | the triaged set | green ≥ 50, amber 20–49, red < 20 |
+| Ready | `ready_for_review` | — | green (bold) |
+| %Ready | `ready_for_review / contributors` | `contributors` | green ≥ 50, amber 20–49, red < 20 |
+| Drafted by triager | `triager_drafted` | — | magenta |
+| Drafted `<1d` / `1-7d` / `1-4w` / `>4w` (4 cols, optional) | `draft_age_buckets[bucket]` | — | dim magenta |
+| Author resp `<1d` / `1-7d` / `1-4w` / `>4w` (4 cols, optional) | `age_buckets[bucket]` | — | dim |
-Column order: Area → Total → Contrib./%Contrib. (area composition) → Draft/%Draft/Non-Draft (where the contributor work sits) → Triaged/Resp./%Resp. (how the triage funnel is going) → Ready/%Ready (what's at the review bar) → Drafted by triager + age buckets (time-since slices of the active contributor work).
+Column order: Area → Total → Contrib./%Contrib. (area composition) → Draft/%Draft/Non-Draft (where the contributor work sits) → Triaged/Resp./%Resp. (how the triage funnel is going) → Ready/%Ready (what's at the review bar) → Drafted by triager + age buckets.
All numeric columns right-aligned. Keep area name left-aligned.
-### Wide-table note
+### Compact mode
+
+If the rendered HTML is being embedded into a narrower context, drop the 8 age-bucket columns (keep through `Drafted by triager`). The dashboard's "Stalest Bucket" hero card still surfaces the >4w count globally.
+
+---
+
+## Legend
+
+Render at the bottom of the dashboard inside a bordered panel. The legend explains every column, colour, and concept the dashboard introduces. Verbose by design — no maintainer should have to memorise the column abbreviations.
+
+```html
+<div class="legend">
+<strong>Reading the dashboard</strong>
+
+<dl>
+<dt>Hero card colours</dt>
+<dd>
+ <span style="color:#56d364">green</span> = healthy / on-target;
+ <span style="color:#d29922">amber</span> = needs attention soon;
+ <span style="color:#f85149">red</span> = action needed now;
+ <span style="color:#76e3ea">cyan</span> = informational (raw counts).
+</dd>
+
+<dt>Recommendation priorities</dt>
+<dd>
+ Each "What needs attention" card has a coloured left border:
+ <span style="color:#f85149">red</span> = high priority (do today),
+ <span style="color:#d29922">amber</span> = medium (this week),
+ <span style="color:#6e7681">grey</span> = low (background awareness only).
+</dd>
+
+<dt>Closure velocity bars</dt>
+<dd>
+ Per-week stacked bars: <span style="color:#56d364">green</span> = PRs merged that week, <span style="color:#6e7681">grey</span> = PRs closed without merging. Total bar width is normalised to the busiest week in the 6-week window.
+</dd>
+
+<dt>Opened-vs-closed line chart</dt>
+<dd>
+ <span style="color:#58a6ff">Blue line</span> = PRs opened per week (createdAt). <span style="color:#56d364">Green line</span> = PRs closed/merged per week (closedAt). Where blue is above green the backlog grew that week; where green is above blue the backlog shrank. The "Net delta" lines under the chart translate this to actual ± numbers.
+</dd>
+
+<dt>Ready-for-review trend (multi-line chart)</dt>
+<dd>
+ Cumulative count of currently-`ready for maintainer review` PRs by week, one line per top-pressure area. Each line uses its area's pressure-band colour (<span style="color:#f85149">red</span> ≥ 30, <span style="color:#d29922">amber</span> 15–29, <span style="color:#6e7681">grey</span> < 15). A steeply climbing line means review velocity isn't keeping up with triage promotion in that area. The "+N in last 7d" lines below the chart show recent growth pace per area.
+</dd>
+
+<dt>Closed by triage reason (stacked bars)</dt>
+<dd>
+ Per-week stacked bars showing how each week's closures break down by triage outcome:
+ <span style="color:#56d364">green</span> = merged (success),
+ <span style="color:#d29922">amber</span> = closed after author responded (engaged but didn't ship — design rejection, scope change),
+ <span style="color:#f85149">red</span> = closed without author response (sweep-close on abandoned PRs),
+ <span style="color:#6e7681">grey</span> = closed without ever being triaged (author self-close or maintainer fast-close).
+ Healthy weeks are mostly green; a red-dominated week means a stale-sweep landed; a grey-dominated week means contributors are self-cleaning.
+</dd>
+
+<dt>Pressure score</dt>
+<dd>
+ Per area: weighted sum of urgent contributor PRs. Each PR contributes 0–5 points based on triage state and age (see <a href="aggregate.md#pressure-score">aggregate.md#pressure-score</a>). Higher score → area needs more maintainer attention. Border colour: <span style="color:#f85149">red ≥ 30</span>, <span style="color:#d29922">amber 15–29</span>, <span style="color:#6e7681">grey < 15</span>.
+</dd>
+
+<dt>Triage states (used in the funnel cards and recommendation rules)</dt>
+<dd>
+ <span style="color:#56d364"><strong>Ready for review</strong></span>: PR has the <code>ready for maintainer review</code> label.
+ <span style="color:#76e3ea"><strong>Responded</strong></span>: maintainer left a triage comment AND the author replied/pushed after it.
+ <span style="color:#d29922"><strong>Waiting for Author</strong></span>: triaged but no author reply, OR draft (any state).
+ <span style="color:#58a6ff"><strong>Not yet triaged</strong></span>: non-draft PR that has never received a quality-criteria comment.
+</dd>
+
+<dt>Detailed-table columns</dt>
+<dd>
+ <span style="color:#76e3ea"><strong>Contrib.</strong></span> — non-collaborator-authored PRs (denominator for nearly every contributor-scoped metric).
+ <span style="color:#d29922"><strong>Triaged</strong></span> — PRs where a maintainer comment contains <code>Pull Request quality criteria</code> after the last commit.
+ <span style="color:#56d364"><strong>Responded</strong></span> — author commented or pushed after the triage comment.
+ <span style="color:#56d364"><strong>Ready</strong></span> — PRs carrying the <code>ready for maintainer review</code> label.
+ <span style="color:#db61a2"><strong>Drafted by triager</strong></span> — drafts that also have a triage marker (heuristic: <code>isDraft AND is_triaged</code>).
+ <span style="color:#6e7681"><strong><1d / 1-7d / 1-4w / >4w</strong></span> — time since the PR author's last interaction (comment, commit, or PR creation).
+</dd>
+
+<dt>Percentage-cell colours</dt>
+<dd>
+ <span style="color:#56d364">green</span> if ≥ 50%, <span style="color:#d29922">amber</span> if 20–49%, <span style="color:#f85149">red</span> if < 20%. The convention is "happier colour wins on a tie" so 50% reads as green, not amber.
+</dd>
+
+<dt>Methodology</dt>
+<dd>
+ Snapshot taken at the timestamp shown in the context line. Open-PR enumeration via GraphQL search. Closed/merged enumeration via REST <code>/pulls?state=closed</code> paginated until 3 consecutive pages out-of-window. Triage detection: comment by OWNER/MEMBER/COLLABORATOR containing the literal string <code>Pull Request quality criteria</code> after the last commit. Bots filtered at fetch time (<code>*[bot]</code>, <code>dependabot</code>, <code>github-actions</code>).
+</dd>
+</dl>
+</div>
+```text
+
+The legend is the *only* place the dashboard repeats the meaning of its colour conventions. Everything else relies on visual parsing — which is why the legend is verbose.
-21 columns is wide but Rich handles it. Two behaviours to be aware of:
+---
-- **Rich computes column widths from the real terminal size.** A narrow terminal will trim to fit. `console.size.width` is available if the skill wants to override — but don't; let Rich decide.
-- **Optional compact mode.** If the maintainer passes `compact`, drop the 8 age-bucket columns, keeping only through `Drafted by triager`. The state-breakdown panel still prints. Mention compact mode in the context line when active.
+## End-of-output summary
-### Markdown fallback
+Close with a single-line summary the maintainer can paste into Slack or a status email:
-When rendering to Markdown (piped, or `markdown` flag), keep the same column order and colour-meaning mapping but express colour with emoji markers in the percentage cells:
+```text
+Summary: 459 open · 37 triaged (8%) · 8 responded (22% of triaged) · 187 ready for review · 3 drafted by triager in last 7d.
+```text
-- 🟢 `%Ready` ≥ 50% (green)
-- 🟡 `%Responded` < 50% *of triaged* (yellow / waiting)
-- 🔴 `%Draft` > 60% in an area (flag)
+Format: `Summary: <total> open · <triaged> triaged (<pct>%) · <responded> responded (<pct>% of triaged) · <ready> ready for review · <recent-drafts> drafted by triager in last 7d.`
-These emoji markers are the **only** place emoji is allowed (the tone-rules section below still bans emoji in comment bodies / prose). Colour isn't available in Markdown, so a small semantic marker substitutes.
+Plain text (no HTML, no Rich markup). Keep the structure stable across runs — scripts that scrape this line shouldn't break between skill revisions.
---
-## Legend
+## Markdown fallback
-After both tables and the state-breakdown panel, print a short legend. Keep it under 10 lines — the goal is to let someone cold-read the tables without opening this doc.
+When rendering to Markdown (output piped to a file, or `markdown` flag), drop the HTML hero cards / SVG charts / coloured borders, and emit the same logical sections in flat Markdown:
-```python
-legend_lines = [
- "[bold]Column legend:[/]",
- " [bright_cyan]Contrib.[/] — PRs by non-collaborator contributors.",
- " [yellow]Triaged[/] — PRs where a maintainer posted a quality-criteria triage comment after the last commit.",
- " [green]Responded[/] — author commented or pushed a commit after the triage comment.",
- " [bold green]Ready[/] — PRs carrying the `ready for maintainer review` label.",
- " [magenta]Drafted by triager[/] — PRs converted to draft by a maintainer (heuristic: draft + triaged).",
- " [dim]Author resp[/] columns — time since the PR author's last interaction (comment, commit, or PR creation).",
- " [magenta dim]Drafted[/] columns — time since the triage comment landed on a draft PR.",
-]
-console.print(Panel("\n".join(legend_lines), border_style="dim", expand=False))
-```
+- Hero cards → a 4-column GFM table
+- Recommendations → an unordered list with `🔥` / `👀` / `📥` / `🔄` / `📍` / `📉` / `✨` icons in front of each item
+- Velocity → a bullet list `<date>: <merged>✓ / <closed>✗ (total <N>)`
+- Opened-vs-closed → a bullet list `<date>: opened <X> / closed <Y> (net <±N>)`
+- Pressure by area → a Markdown table with the same columns
+- Triage funnel → another 4-column GFM table
+- Detailed tables → unchanged GFM tables (no `<details>` collapse — render expanded)
+- Legend → a bullet list
+
+Emoji markers are allowed in Markdown to substitute for colour. They are the *only* place emoji is allowed (the tone-rules section below still bans emoji in comment bodies).
---
-## End-of-output summary
+## Rich terminal-tables variant
-Close with a single-line summary the maintainer can use for at-a-glance reporting:
+When the maintainer passes `tables-only`, render only the two detailed tables using `rich.console.Console` + `rich.table.Table` (the same layout the now-removed `breeze pr stats` produced). Skip the dashboard sections entirely — Rich can't render the hero cards or charts cleanly in a typical terminal.
-```text
-Summary: 413 open · 66 triaged (16%) · 3 responded (5% of triaged) · 126 ready for review · 43 drafted by triager in last 7d.
-```
+```python
+from rich.console import Console
+from rich.table import Table
-Format: `Summary: <total> open · <triaged> triaged (<pct>%) · <responded> responded (<pct>% of triaged) · <ready> ready for review · <recent-drafts> drafted by triager in last 7d.`
+console = Console()
+
+t = Table(
+ title=f"Triaged PRs — Final State since {cutoff} ({repo})",
+ title_style="bold",
+ show_lines=True,
+ show_footer=True,
+)
+t.add_column("Area", style="bold cyan", min_width=12, footer="Area")
+t.add_column("Triaged Total", justify="right", style="yellow", footer="Triaged Total")
+# … (see colour scheme above)
+
+for area in sorted_areas:
+ t.add_row(area, str(triaged_total), …)
+
+t.add_row("[bold white]TOTAL[/]", f"[bold white]{n}[/]", …, style="on grey7", end_section=True)
+console.print(t)
+```text
-This line is plain text (no Rich markup) so it copies cleanly into Slack or a status email. Keep the structure stable across runs — scripts that scrape this line shouldn't break between skill revisions.
+Use `show_lines=True` and `show_footer=True` so the footer mirrors the header on long tables. The `tables-only` mode is the legacy fallback for maintainers who script around the output and don't render HTML.
---
## Tone rules
-- **No emoji in Rich output.** Colour is the visual cue. The only place emoji is allowed is the Markdown-fallback percentage cells described under *Markdown fallback*.
-- **No opinions.** The stats describe state; interpretation belongs to the maintainer reading them. Don't add "queue is in good shape" or "need to close stale drafts" sentences.
-- **No PR-level drill-in** in the stats output. If the maintainer wants to zoom in on a specific area, the follow-up is `pr-management-triage label:area:<X>`, not a stats continuation.
+- **No emoji in HTML body text** outside of recommendation icons and the health-rating label. The icons are functional (priority signals); free-text emoji is noise.
+- **No opinions.** The dashboard surfaces deterministic numbers; interpretation belongs to the maintainer reading them. Don't let the renderer add "queue is in good shape" or "need to close stale drafts" sentences. The recommendation panel's `detail` strings explain the *trigger* and the *action* — not editorial.
+- **No PR-level drill-in** in any rendered output. If the maintainer wants to zoom in on a specific area, the follow-up is `pr-management-triage label:area:<X>`, not a stats continuation. Recommendations encode this discipline by always pointing at another skill, never embedding PR numbers.