What's On Fire
One cross-product sweep of everything currently broken or stalled across a GitHub org, ranked, and routed to the repo that owns it.
This skill is read-only. It pulls, correlates, ranks, and reports. It NEVER files issues, changes Sentry status, or mutates anything — which is what makes it safe to run on a loop or a schedule.
Scope boundary. This decides which product deserves attention today. It deliberately does not
decide what to do inside that product — no tech-debt scan, no next-bet synthesis, no backlog
grooming. Those don't roll up meaningfully across a dozen products, and each repo's own survey-work
already does them with repo-specific knowledge this skill has no business duplicating. Report the
fire, name the repo, hand off.
1. Prerequisites
Run these probes. For each failure, label that surface skipped — <reason> in the output and
continue. Never abort the whole sweep on one missing precondition.
command -v gh && gh auth status
- GitHub CLI —
ghmissing entirely (cloud/CCR routine sessions ship without it) is the one probe failure that does NOT skip its surfaces: route the section 2B pulls through the GitHub-MCP fallback inreferences/cloud-fallback.mdinstead. Three parts of that surface have no MCP equivalent at all — Dependabot alerts, code-scanning alerts and secret-scanning alerts — and each is named on the sources line like any other skip:skipped — no gh CLI (Dependabot API unreachable)andSecurity scanning skipped — no MCP capability (code + secret scanning alerts unreachable).check-dispatch-recovery.sh(section 2A) also needsgh, and its fallback is in the same reference: query the runs capability scoped to the ONE workflow, never a repo-wide page. That distinction is not a detail — a repo-wide page truncates the evidence away and turns a verified-green control into a P0.ghpresent but unauthenticated stays an ordinary skip. - Sentry — probe by listing projects for the Sentry org; on error, skip both Sentry surfaces.
- Portfolio root — the local checkout directory (env
PORTFOLIO_ROOT, default~/Repos/sassy-dog). Only needed for the two blind spots that compare the org against local clones; if it's missing, skip those two rows and keep the rest.
Defaults for Sassy Dog: GitHub org Sassy-Dog, Sentry org sassy-dog, region
https://us.sentry.io. Pass a different ORG to the scripts for another org.
2. Pull all surfaces in parallel
Issue the independent pulls in a single message with multiple tool calls.
A. Production fires
Sentry issues — invoke sassy-dog:sentry-triage: org sassy-dog, all projects. Gate
policy: report-only, no escalation — this skill has no write path, so the gate classifies and
ranks but never files. It already owns the qualifying gate, the !is:resolved query shape, and GH
cross-referencing; don't reimplement any of that here.
Sentry crons — MCP-first, resolving the tool by capability rather than a hardcoded tool id
(same convention as sentry-triage; never a literal mcp__... id). No MCP connected → fall back
to REST exactly as the issues surface does: the cron-monitor recipe in sentry-triage's
references/api-fallback.md (SENTRY_AUTH_TOKEN; the monitors endpoint needs org:read or
alerts:read, not just the issue scopes). With neither MCP nor a token, or on a failed call, mark
the surface skipped — <reason> per section 1 — the fallback must never turn "could not check"
into silence. List cron monitors for the org and read each monitor's per-environment state
(environments[].status, not the lifecycle-only monitor status). A missed or timeout
environment is a P0, always — no dispatch can vouch for a schedule that never fired. An error
environment is a P0 only if BOTH cross-references come back negative, and they answer different
questions:
- Findings (
references/cron-recovery.md, "the findings cross-reference"): a check-in has onlyokanderror, and most controls deliberately map both "I found something" and "I could not look" ontoerror. If the owning repo has an open auto-managed issue naming this workflow, the control WORKED and reported a tracked finding — the fourth state, "working — tracked in<issue>" (section 5). Rank the finding on its own merits; never the monitor. Skipping this ranked a correct CVE scan as a P0 production outage on 2026-08-20 (Sassy-Dog/platform#735). - Recovery (same file): check-ins are gated to
scheduleruns, so a fix verified green viaworkflow_dispatchleaves the monitor red until the next scheduled run — up to a week — and reporting that as a live fire is a false alarm this sweep has produced twice. A downgraded monitor becomes the third state "fixed, awaiting scheduled confirmation" (section 5).
Neither downgrade makes it ✓ Clean. Run both for every error environment before ranking it.
Note which projects own monitors — a product with zero monitors isn't passing, it's unmonitored,
which belongs in blind spots.
B. Stuck shipping + backlog heat
bash ${CLAUDE_PLUGIN_ROOT}/skills/whats-on-fire/scripts/pull-org-github.sh
bash ${CLAUDE_PLUGIN_ROOT}/skills/whats-on-fire/scripts/pull-repo-signals.sh
pull-org-github.sh— three org-level calls: the repo roster, every open PR (withidle_daysprecomputed), and every open issue. Archived repos are flagged inreposbut excluded fromprsandissues; read the script header for why that filtering is load-bearing.pull-repo-signals.sh— per-repo workflow failure counts, the newest concluded default-branch CI conclusion, currently-failing scheduled workflows, and Dependabot state — including alert AGE and per-package fix-PR state. Slower: 1 + 4N calls, plus one more per repo that actually has high/critical alerts (free for a healthy org) and one more per repo whose sample yields no default-branch CI verdict — which is not free for a healthy org, since a quiet repo triggers it (5 of 15 in #367). Run it concurrently with the Sentry pulls, not after them.
default_branch_ci and scheduled_failing are separate fields and must stay separate in the
report, carried with its age. Push-class red within 14 days means shipping is blocked (P0) —
older than that it is a last known state, not a live outage, and ranks P1 (references/scoring.md).
A failing nightly job is an ops problem that
blocks nobody (P1). Merging them manufactures false alarms.
Both honor ORG, degrade with exit 10 + skipped: <reason> on stderr, and emit one JSON object.
No gh on PATH — both scripts exit 10 with skipped: gh not on PATH, but do not accept that
skip: follow references/cloud-fallback.md instead. It rebuilds both pulls from the session's
GitHub MCP tools, resolved by capability exactly like the Sentry crons above (never a literal
mcp__... id): roster via list-repos, per-repo tool scope widened with add_repo, per-repo pulls
fanned out to parallel subagents, every output field mapped so sections 3–5 consume the same shape.
The one exception is the Dependabot half of pull-repo-signals.sh, which has NO MCP equivalent: it
MUST be rendered skipped — no gh CLI (Dependabot API unreachable) and named on the sources line
(sections 1 and 5) — a named skip, never an approximation.
C. Blind spots
Assemble from data already pulled, plus two local comparisons:
- Dependabot
enabled == false→ disabled.enabled == null→ unknown, report as a token-scope question, never as "disabled". - A repo with
unremediated_packagesnon-empty and noopen_fix_prsat all → Dependabot is alerting but not fixing. This is a structural blind spot, not a backlog item: the usual cause is that the repo'sdependabot.ymldropped the ecosystem to dodge a lockfile its updater cannot regenerate (Bun, CocoaPods), while security updates keep firing regardless — they cannot be opted out of that way. Name the ecosystem and route it; the remedy is a lockfile-sync workflow, not a version bump. code_scanning.enabled == false→ code scanning disabled.analyzed == falsewithenabled == true→ enabled but never analyzed, which is a different row: the workflow exists or the feature is on, and no scan has ever produced a result. Both renderopen: 0, so reporting either as clean is the failure this row exists to prevent.enabled == null→ token scope.secret_scanning.enabled == false→ secret scanning disabled.enabled == null→ token scope.- Active repos with no matching Sentry project → no error monitoring.
- Zero metric alert rules org-wide → no alerting. Check once via the alert-rules tool.
- Roster entries with no directory under
PORTFOLIO_ROOT→ never cloned. - Local directories whose repo is
archived→ archived but still checked out. - Active repos with no
.claude/sassy-dog/survey-work.md→ no tuned deep-dive to route to.survey-workitself is a plugin skill present everywhere, so directory existence proves nothing; the config is what makes it repo-aware. An unconfigured repo still runssurvey-work, just degraded. A repo carrying only the legacy.claude/sassy-dog/plate-it.mdis a different row: "config predates the survey-work rename" — it has a tuned config, it just needssetup-configupdate mode; never report it as missing.
3. Roster and rollup
The roster is the org, not the local directory. Local checkouts drift: repos get archived while
their clone lingers, and org repos may never have been cloned at all. Take the active set from
repos[] | select(.archived | not), and use the local comparison only to produce blind spots.
Products map to repos many-to-one. Report per repo, then group under the product heading. At Sassy
Dog the non-obvious ones are velovate → repos velovate + velovate-web (the local directory
velovate-app maps to the repo named velovate), lupita → lupita + lupita-web (both
archived), and devcanopy → devcanopy. Everything else is one product, one repo — and that
default is a name match, which is the one piece of evidence here that lies.
A Sentry project and a repo sharing a name are not evidence of ownership. A project slug can
match a repo that does not own it — a marketing-site repo <product>-web alongside a Sentry
project <product>-web fed by a member-app frontend that lives in a different repo entirely. Note
which way the selection runs: the entries listed above are in the map precisely because their
names lie, so the projects that most need the map are exactly the ones a name match resolves most
confidently. Absence from the map means unverified, never verified.
So the default is qualified, and the qualification applies to every walk on this map. A pairing the
map names explicitly is routed. A pairing resolved only because the two names match is
unconfirmed routing — still report it and still name the best-candidate repo, but mark it on
the line (<repo> — routing unconfirmed, name match only) rather than asserting that repo owns it.
To confirm, check that the project's recent culprit/file paths resolve in that repo, or that the
repo actually initializes that project's DSN; once confirmed, add the pairing above so the next
sweep reads it as fact. Never silently promote a name match to ownership.
This same map is what resolves a red cron monitor's owning repo for the recovery
cross-reference (references/cron-recovery.md): monitor → its Sentry project → the owning product
→ that product's repo(s). It is already load-bearing in the reverse direction — blind spots walk
repo → Sentry project — so keep both walks on this one map; a second, separate mapping would be
one more place to drift. The caveat above travels with the map for the same reason: it is stated
here once, and the cron walk points back at it instead of carrying its own copy. That walk is where
an unconfirmed route costs the most — it decides which repo's green dispatch is allowed to vouch
for a monitor — so references/cron-recovery.md spells out what unconfirmed means there.
Coverage is counted, never assumed
The roster is the denominator, and every aggregate claim needs a per-repo verdict behind it.
State coverage explicitly — swept N of M active repos — and name every repo that produced no
verdict, with why. A repo the sweep could not read is reported as unread; it is never folded into
an aggregate and never quietly dropped from the count.
The sentence this exists to prevent is "CI is green across all M repos" written after only N of them were actually reduced. That is not a ranking mistake, so no tier rule catches it: the repos that were read really were green, and the claim is false only because of the ones that were not. An unread repo omitted from the report is indistinguishable from a healthy one.
Two numbers make it checkable, so carry both: the roster count, and the count of repos that produced a verdict. If they differ, the difference is the finding — say which repos and why before saying anything about the ones that worked.
This binds the report, not the puller. A puller is free to fail per repo; what it may not do is let the failure disappear between the pull and the summary.
4. Score
Apply references/scoring.md. The two rules that matter most:
Stuck work escalates with age; customer pain decays with it. Never share one curve. A PR idle 123 days outranks one idle 3 days.
A red default branch is P0 on its own when
default_branch_ci_age_daysis 14 or less, independent of historical failure rate. Rate answers "is CI trustworthy"; a recent conclusion answers "is it broken right now". A verdict may be recovered by a narrow re-query and be months old, so age is what separates a live outage from a last known state: afailureolder than 14 days ranks P1, and a stalesuccessnever earns✓ Clean today:— it renders under🕰 Stale CI verdictsbelow, its one destination. A null age is not a young one: an undated verdict resolves the same way in both directions (references/scoring.md).Dependabot exposure is ranked by REMEDIATION STATE, never by alert count. The count is a lagging indicator — it falls only when a fix merges, so "we were slow" and "the world just changed" produce the identical number. Rank from
dependabot.oldest_high_crit_age_days,open_fix_prs[].state, andunremediated_packages:Condition Tier Why open_fix_prs[]has aCLEANPR withage_days >= 3P0 The fix exists, it is green, and nobody is merging it. A pure process failure — the cheapest P0 you will ever close. unremediated_packagesnon-empty andoldest_high_crit_age_days >= 14P0 No PR was ever opened and the exposure is real. Broken plumbing, not a busy week. open_fix_prs[]PR isBLOCKED/DIRTY/UNSTABLEP1 Dependabot did its job; the repo's CI rejects the fix. Name the failing check — this is usually a lockfile the bot cannot regenerate. unremediated_packagesnon-empty,oldest_high_crit_age_days < 14P1 Genuinely unfixed, but young enough that it may simply be unpatched upstream. Check whether a patched version exists before escalating. oldest_high_crit_age_days <= 2and every vulnerable package appears in someopen_fix_prs[].addressesnot a fire A fresh advisory batch with fixes already queued is the system WORKING. One line under ✓ Clean today:.Judge remediation per package, never per repo —
open_fix_prsis already filtered to PRs whose head ref names a vulnerable package, precisely so an unrelated actions-group PR cannot be mistaken for a fix. Reportunremediated_packagesby name; "3 high/critical" tells nobody what to do.
Normalize priority labels across the repos' differing taxonomies using the map in the reference — but never re-derive a priority a maintainer already assigned.
An issue labelled security or area:security is always listed by number, whatever tier it
normalizes to — unranked included. Its tier is untouched; only its visibility is guaranteed. A security issue must
never end up inside a bare P2: 10 count — and since this edition cannot read code- or
secret-scanning alerts at all, a human-filed issue is the only route those findings have into
this report.
5. Output format
Render inline as markdown. Two anti-verbosity rules are non-negotiable: (1) empty surfaces get a
single token on the consolidated ✓ Clean today: line, never their own section; (2) within a
section, skip empty tiers. Recommendations go LAST.
Load: is a REQUIRED field on the sources line — always rendered, never dropped, and the
anti-verbosity rules above do not apply to it: it has no empty state to collapse. Its value is
plugin when this skill ran as sassy-dog:whats-on-fire, and fallback (degraded) when this body
was read from skills/whats-on-fire/SKILL.md because the plugin skill was not among the session's
available skills (the routine fallback clause in CLAUDE.md). It is a field rather than a sentence
for one reason: a missing sentence reads as a healthy run, while an empty slot reads as broken — on
2026-08-11 a degraded run silently omitted its degraded note and delivered an otherwise complete,
authoritative report (#146). It is distinct from the skips that follow it on the same line: those
say a surface could not be pulled, this says the skill body itself came from the fallback path.
The field is still this run's own claim about itself, so it is never the authority: where the
report and the run log disagree, the run log wins — settle it with the out-of-band check in
docs/ROUTINES.md.
# What's on fire (YYYY-MM-DD)
_Load: <plugin|fallback (degraded)> · Sources: <pulled, with any "skipped — reason">_
_Coverage: swept <N> of <M> active repos — <named repos with no verdict, and why>_
✓ Clean today: <surface> · <surface> · ...
## 🔥 Production fires (P0: N · P1: N)
### P0
- **<product> — <title>**
- Impact: <N users, M events, last seen Yh ago> | <monitor env state> | <branch is red>
- Sources: [Sentry](url) · [run](url)
- Why this matters: <one line>
### ⏳ Fixed, awaiting scheduled confirmation
- **<product> — `<monitor>` (<project>/<env>)** — green [dispatch](url) at <time>; awaiting
scheduled confirmation <nextCheckIn date>
### ✅ Working, reporting a tracked finding
- **<product> — `<monitor>` (<project>/<env>)** — control ran and reported; tracked in
[<repo>#<N>](url). Finding ranked separately above.
## 🔒 Security exposure (P0: N · P1: N)
### P0
- **<product> — <credential type or rule>** — <repo>
- Evidence: <alert numbers> · <validity or severity> · open <N>d
- Fix: <rotate and revoke | merge PR #N | autofix ready>
_Inherited: <repo> N alerts across M rules, oldest Dd._
## 🚧 Stuck shipping
- **<repo>#<N> <title>** — idle <D>d · <one-line why>
## 🎯 Backlog heat
- **<repo>#<N> <title>** — `<label>` — <one-line why>
## 🕰 Stale CI verdicts (not a tier)
- **<repo>** — `CI last green <N>d ago` — [run](url)
- **<repo>** — `CI last green — age unknown` — [run](url)
## 🕶 Blind spots (silent, not healthy)
- <condition> — <affected repos>
## 👉 Today's top 5 (cross-product)
1. **<title>** — <product> · <one-line why>
_⚠ Cron recovery cross-reference could not run for: <repo> · <repo> — red monitors there are
shown at full severity._
_To dig in: `cd <product> && /survey-work`_
_To ship: `cd <product> && take #<N>`_
Keep the footer. This skill's job ends at naming the product; the per-repo survey-work and take-it
take it from there, and the footer is what makes that handoff explicit rather than implied.
🕰 Stale CI verdicts is the ONLY place a stale success renders, and it is a real section
rather than a phrase an agent is left to place. A default_branch_ci of success whose
default_branch_ci_age_days is over 14 or null is the newest evidence there is and is not
evidence about today: it is not a fire, not a tier, and not ✓ Clean today:. It does not go on the
Coverage: line either — that line names repos with no verdict, and a stale success has one.
Empty, this section collapses to a token on the clean line like any other surface. Full rule:
references/scoring.md.
P0 security items are not duplicated into Production fires. The cross-product top 5 is where urgency gets expressed; this skill's existing rule is that semantically distinct signals stay in separate fields rather than merging into false alarms.
Four rules for the cron lines, from references/cron-recovery.md:
- "Working, reporting a tracked finding" is a fourth state (rule 0) — never folded into P0 and
never onto
✓ Clean today:. Link the owning auto-managed issue. Do NOT report an outage duration for it: the monitor has beenerrorfor as long as the finding has existed, which says nothing about the world. Unlike the third state it never clears on its own, so it must not re-page daily.
- A
monitor_check_in_failureSentry issue is not an independent source. It is generated by the check-in, so citing it beside the monitor row counts one non-event twice — which is exactly how the 2026-08-20 alert came to readSentry · CI runfor a single cron failure. Read the issue'smonitor.slug; if a cron row already covers that slug, drop it asdupe-cron. - "Fixed, awaiting scheduled confirmation" is a third state — never folded into P0, never onto
✓ Clean today:. Link the verifying dispatch and name the environment's ownnextCheckInas the confirmation date (unknown when null — never invent one). Do not lead with the outage duration: "errorfor 6d" is true of the monitor and misleading about the world. The section follows the skip-empty-tiers rule like any other. - The cross-reference footer appears only when a lookup actually failed (missing
actions:read, network, 5xx — the exit-10 cases), and it names each repo it could not read. A 404 — no such workflow in the owning repo — is "not applicable", never a footer entry; a footer that fires on every sweep is a footer nobody reads.
6. Read-only contract
This skill NEVER files GitHub issues, changes Sentry status, moves board cards, or mutates anything. If a fire deserves an issue, say so in the report and name the flow that would file it — the human runs that deliberately, in the owning repo, where the repo's own gates and labels apply.
There is no conditional write gate here, and adding one would be a mistake: a portfolio-level filer has no principled answer to "which repo does this get filed against" for a cross-product signal, and the per-repo skills already own filing with better context.