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 <[email protected]>
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> &lt; 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 &lt; 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>&lt;1d / 1-7d / 1-4w / 
&gt;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 &lt; 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.

Reply via email to