Repo Health
Fast, scripted, read-only signal scans: tech-debt markers, skipped tests, CI duration/flake, mobile release lag. Each scan is one bundled script emitting parseable output. This is the cheap input layer for prioritization (e.g. a project survey-work); it makes no writes and files no issues.
Scans
Tech debt markers + skipped tests
SCAN_PATHS="apps/ packages/" \
EXCLUDE_PATHSPECS="packages/db/src/migrations/** src/generated/**" \
bash ${CLAUDE_PLUGIN_ROOT}/skills/repo-health/scripts/pull-tech-debt.sh
SCAN_PATHSdefaults to the whole tracked tree; pass source dirs to cut noise.EXCLUDE_PATHSPECSfor generated/migration dirs the caller knows about. Pass bare paths — the script adds the:(exclude)magic itself. A leading:(exclude)is stripped for back-compat with configs written against the old contract, but only one: doubling it produces a pathspec git accepts and silently ignores. Values reach git literally (the loop is fenced withset -f), so a**here is a git pathspec, not a shell glob. An element that is unusable after the strip — a lone:(exclude), or a remainder still starting with:— is dropped with a warning on stderr rather than passed through, because a bare:(exclude)is an empty pattern git reads as "exclude everything".- Output sections:
todo-markers(capped 200),skipped-tests(capped 100),todo-by-dir(top 20 directories by marker count).
CI duration + flake
WORKFLOW=ci.yml bash ${CLAUDE_PLUGIN_ROOT}/skills/repo-health/scripts/pull-ci-health.sh
Emits JSON: {sample, median_min, p90_min, flake_runs, flake_shas}. REPO defaults to cwd; LIMIT defaults to 50 runs. A "flake" is the same (headSha, event) failing then passing — the event key deliberately excludes merge-queue false positives (explained in the script header; don't simplify it away).
Dependency exposure + remediation
bash ${CLAUDE_PLUGIN_ROOT}/skills/repo-health/scripts/pull-dependency-exposure.sh
Emits JSON: {enabled, open, high_crit, oldest_high_crit_age_days, vulnerable_packages, open_fix_prs, unremediated_packages, parked_green}. REPO defaults to cwd.
Rank by remediation state, never by alert count. The count is lagging — it falls only when a fix merges, so a same-day CVE batch with fixes already queued is indistinguishable from a year of neglect:
| Condition | Tier |
|---|---|
parked_green[] with age_days >= 3 |
P0 — green, mergeable, and nobody is merging it. Quote the PR number and the merge command. |
unremediated_packages non-empty, oldest_high_crit_age_days >= 14 |
P0 — no PR was ever opened; the plumbing is broken. |
open_fix_prs[].state is BLOCKED/DIRTY/UNSTABLE |
P1 — the bot did its job, the repo's CI rejects the fix. Usually a lockfile the updater cannot regenerate. |
unremediated_packages non-empty, age < 14 |
P1 — check whether a patched version exists upstream before escalating. |
age <= 2 and every vulnerable package is covered by an open_fix_prs[] entry |
not a finding — one line on the clean list. The system is working. |
open_fix_prs is already filtered to PRs whose head ref names a vulnerable package, so an unrelated actions-group PR is never mistaken for a fix — judge per package, not per repo. enabled: null means "this token cannot see alerts", NOT "disabled"; report it as a scope question.
Code scanning (CodeQL and other SARIF uploads)
bash ${CLAUDE_PLUGIN_ROOT}/skills/repo-health/scripts/pull-code-scanning.sh
Emits JSON: {enabled, analyzed, truncated, open, default_branch, tools, new, inherited}.
REPO defaults to cwd.
analyzed is not enabled. The API answers 404 for both "Advanced Security is off" and "on,
but no analysis has ever run", and both produce open: 0. analyzed: false with enabled: true
is a repo that has never been scanned — a blind spot, not a clean bill of health. enabled: null
is a token-scope question, exactly as with Dependabot.
truncated: true makes open a floor, not a count. Report it as "at least N".
Alerts are filtered to the resolved default branch and clustered by rule, then split at 14 days:
| Condition | Tier |
|---|---|
new[] rule, severity critical |
P0 — just shipped, fixable while the code is fresh |
new[] rule with autofix: "ready" |
P0 — the parked_green shape: the fix exists and only a human press is missing |
new[] rule, severity high |
P1 |
inherited |
one debt line — never enumerated, never in a top 5 |
analyzed: false |
not a finding — a blind spot row |
autofix is probed only for critical/high rules, so it can only upgrade a P1 to P0; a medium rule
is never probed and stays null.
Secret scanning
bash ${CLAUDE_PLUGIN_ROOT}/skills/repo-health/scripts/pull-secret-scanning.sh
Emits JSON: {enabled, open, oldest_age_days, active, unknown_validity, inactive}. REPO defaults
to cwd.
| Condition | Tier |
|---|---|
validity == "active" |
P0 on day zero — GitHub validated it against the provider; it is a live credential. No age math. |
bypassed == true |
P0 — a human overrode push protection to commit it |
unknown_validity[] entry with age_days >= 30 |
P0 — unverified and untriaged for a month is itself the finding |
unknown_validity[] entry with age_days < 30 |
P1 — verify or dismiss |
inactive |
not a finding — already rotated; one line on the clean list |
enabled: false |
not a finding — a blind spot row |
unknown is not "probably fine" — it usually means GitHub cannot validate that provider's format
at all. Never rank an active credential by age; that buries the only unambiguous finding this
endpoint produces.
Mobile release lag
WORKFLOW=mobile-release.yml MOBILE_PATH_PREFIX=apps/mobile/ \
bash ${CLAUDE_PLUGIN_ROOT}/skills/repo-health/scripts/pull-mobile-release-lag.sh
Emits JSON: last green iOS build (run/sha/date), days_since_ios_success, mobile_commits_since, and the latest run's iOS-leg state (catches a currently-stuck build). Skip this scan entirely for repos with no mobile app.
Plugin drift (which checkouts run a stale plugin)
bash ${CLAUDE_PLUGIN_ROOT}/skills/repo-health/scripts/pull-plugin-drift.sh [<abs-checkout-path>...]
Emits JSON: the reference version an update would install, user_scope, one row per live
project pin with current, the behind subset, no_entry for any path you asked about that has
none, and the pruned counts. Read-only, no network, no gh.
A project-scope install pins the version resolved when that checkout was first opened, and
nothing re-resolves it. User and project scope drift independently, so claude plugin update
can report success while a repo keeps loading a months-old plugin — and the first symptom is
usually a feature "not working" in a repo whose config is perfectly correct. Run this before
treating any per-repo plugin behaviour as evidence.
Restarting does not move a project pin, and neither does the bare update command. Both were
measured on 2026-09-06: after a full restart this repo's pin was still 2026.8.94, and
claude plugin update sassy-dog@sassydog-skills answered "Checking for updates … at user
scope … already at the latest version (2026.9.4)" — a success message that changed nothing for
the checkout it was run in. The scope flag is what moves it, from inside the target repo:
claude plugin update <plugin>@<marketplace> --scope project # then restart
So that route is three steps, not two, and the middle one is the one that looks done:
marketplace update refreshes metadata, plugin update --scope project moves the pin, and the
session must restart to load it.
The cheaper route: delete the stale entry and let the next open re-take the snapshot. A pin is
created when a session first opens a checkout whose .claude/settings.json declares the plugin,
and it is created at whatever version is current then — so a deleted pin does not come back
stale, it comes back fresh. Measured 2026-09-06: entries removed from
~/.claude/plugins/installed_plugins.json were re-created at 2026.9.4 on the next open, with no
per-repo update at all. That is also the only way to clear the entries left by torn-down agent
worktrees, which cannot be cd-ed into — 94 of them on this machine, every one a snapshot of a
directory that no longer exists.
Never clear a pin with
claude plugin uninstall --scope project. It does not only remove local state: it edits the repo's committed.claude/settings.json, emptyingenabledPlugins— which strips the declarationsassy-dog:setup-configwrites and #97 requires, so the repo then loads nothing in a cloud session or scheduled routine. Measured 2026-09-06 across 13 checkouts, 10 of which had the key silently deleted from a tracked file. It also makes a naive experiment lie: uninstall-then-reopen looks like the pin "stays gone", when what actually happened is that the declaration which creates it was deleted too.
The declaration and the local pin are one mechanism, and you cannot keep one without the other. The declaration is a committed repo file and is what makes cloud sessions work; the pin is the local snapshot it causes. Removing the declaration to avoid the pin trades a silent staleness problem for a silent no-skills-at-all problem, which is strictly worse.
Two results that are not what they look like:
no_entryis not clean, and it has two causes that look identical here. Either no session has opened that checkout since the declaration reached its working tree — in which case the next one creates an entry pinned to whatever is current — or the repo is missing the.claude/settings.jsondeclarationsassy-dog:setup-configwrites, in which case it would load no skill at all in a cloud session or scheduled routine. This report cannot tell them apart: read the repo's.claude/settings.jsonforenabledPluginsandextraKnownMarketplacesbefore concluding anything, and read it in the checkout, not on the default branch: a repo can be committed-correct and locally behind.mission-controlwas exactly that — it ran a session on 2026-08-16 and still has no entry, because the declaring commit did not reach its working tree until agit mergethree weeks later. "Has never been opened" is the reading that case punishes; the file on disk at session time is what decided it. Measured 2026-09-06, all threeno_entryrepos here had a correct tracked declaration and no pin; assuming the other cause would have manufactured three PRs that changed nothing.stale_clone_hint: truemeans an installed copy is newer than the marketplace clone, soclaude plugin updatecannot reach the current version yet —claude plugin marketplace updatehas to run first. That command refreshes metadata only and reports success either way.
Interpreting results
Read references/scoring.md for the default severity thresholds (CI p90 > 25/40 min, flake > 5%, lag rules, skipped-test placement). Callers with their own scoring override it; absent that, apply the defaults and report:
- One line per signal with the number and its threshold (e.g. "CI p90: 31 min (target < 25) → P1").
- Empty/clean signals collapse into a single "clean" line — no section per empty surface.
- Never enumerate every TODO marker; surface directory hotspots and anything naming an incident or security concern.
Degradation
Each script degrades independently: exit 10 with a skipped: <reason> line on stderr (not in a git repo, gh scope missing, workflow name wrong). Report the surface as "skipped — reason" and continue with the rest; never abort the whole scan over one missing input.