Purpose
Answers one question: can the model actually see each of my skills, and if not, why?
That is the question behind the one operators usually ask, which is why does most of my skill fleet never get used? A skill the model cannot see cannot be chosen, so "unused" is very often a visibility failure wearing a preference costume.
Visibility is Claude Code's own term for this: skillOverrides is documented under
"Override skill visibility". This skill audits every way a skill loses it.
Claude Code budgets the model-visible skill listing at a fraction of the context
window (skillListingBudgetFraction, default 0.01) and, when it overflows,
sheds descriptions from the lowest-scoring skills first. Names always
survive, descriptions do not. A skill at zero usage scores zero, so it loses its
description, loses the keywords a request would match against, and stays at
zero. Unused is partly self-causing.
The budget fraction and per-entry cap are owned by
https://code.claude.com/docs/en/settings (skillListingBudgetFraction,
skillListingMaxDescChars); that page is authoritative and matches. The drop
ORDER is not. https://code.claude.com/docs/en/skills ("Skill descriptions are
cut short") says "starting with the skills you invoke least", still as of
2026-08-31; the binary ranks by a decay-weighted score and then walks the list
first-fit, so neither the ordering nor the guarantee holds. Take the ordering
from the binary: reference/listing-scorer.md
carries the counterexamples, the greps, and the stamp.
So the useful question is not which skills are unused. Claude Code already
reports that: the built-in /skill-doctor command when it resolves in your
session, and the bundled /doctor skill's checkup when that one does, each
behind its own gate, with the Stats tab carrying the /skill-doctor report in
an interactive session. It is which skills are starved by
that loop and still wanted, versus genuinely unwanted, versus not observable at
all.
The refusal that defines this skill
A usage store younger than the window being asked about cannot distinguish "never invoked" from "never observed". Reporting the second as the first libels most of a fleet on any fresh install. A days-old install measured against 30-day and 90-day tiers puts nearly the whole fleet in a "never used" bucket.
This skill therefore computes an observed_horizon, clamps every window to it,
and routes any claim the span cannot support into a first-class withheld
section with its reason. A declined verdict is reported, never omitted.
Run it
It collects live by default. No fixture required. Two inputs resolve differently, and the difference decides which command you want:
- Usage comes from this machine regardless of where you run it
(
~/.claude.json, overridable with--claude-json). - The fleet being audited, the denominator, is a plugins directory
enumerated from disk, and it defaults to
./pluginsrelative to your current directory, not to an installed root.
So from a plugins-layout repo (this marketplace, or any checkout with a
plugins/ directory), bare audits the tree you are standing in:
python3 "${CLAUDE_PLUGIN_ROOT}/skills/audit-skill-visibility/scripts/audit_skill_visibility.py"
Anywhere else that exits non-zero with no skills found. Nothing is silently
audited. Name the fleet you mean:
python3 "${CLAUDE_PLUGIN_ROOT}/skills/audit-skill-visibility/scripts/audit_skill_visibility.py" \
--plugins-root <dir>
<dir> is any directory holding one subdirectory per plugin, each with a
skills/ directory, the layout both a marketplace checkout and an installed
plugin root use.
Auditing the INSTALLED fleet
--plugins-root measures a directory. To measure what is actually installed
instead, read the plugin manifest:
python3 "${CLAUDE_PLUGIN_ROOT}/skills/audit-skill-visibility/scripts/audit_skill_visibility.py" \
--installed
It reads ~/.claude/plugins by default (pass a directory to --installed to
point elsewhere). The two answers differ on purpose: a checkout can hold
plugins that are not installed, so the repo count and the installed count
disagree. Neither number is wrong; they answer different questions.
The manifest lists one entry per install SCOPE, not per plugin. A
marketplace installed at both project and user scope carries two entries
per plugin. This resolves to one entry per plugin, and the report states both
numbers so the collapse is auditable. Counting entries would inflate the fleet
and, since the fleet is the denominator, roughly double the reported overflow.
Where a plugin is installed at more than one scope, resolution follows the
documented precedence local > project > user, the record that loads is
the highest-precedence applicable one, never the newest version installed.
That rule and its "not the newest" warning are stated in this plugin's own
skills/plugins/context/scope-semantics.md,
which verified it against the official plugins-reference docs. Getting it wrong
is not cosmetic: plugins pinned at different versions across scopes can ship
different skill sets and different description text. Superseded records are
listed under Fleet resolution so a pin being outranked is visible.
Applicability matters as much as precedence: project and local records load
only in the projectPath they name, so another project's records are
excluded and reported rather than counted. Current project comes from
CLAUDE_PROJECT_DIR, falling back to the working directory.
A marketplace whose source is a local directory loads from that checkout,
not from either cached installPath. Verified by a skill executing out of the
marketplace directory. For those, the plugin's root comes from the catalog's
declared source, since plugins/<name> is the common layout but not a rule.
--render json swaps the Markdown report for the machine-readable model, and
--now <RFC3339> pins the clock the horizon is measured against.
--fixture <bundle.json> reads a recorded collection instead of the live one, the reproduction path, used by the tests and for handing someone else's state to
the same engine. It is not needed to get a report.
Python 3.11+ is the only requirement. No third-party packages, matching
inventory.py and install_state.py.
Reading the output
Three independent fields per skill; a single flat verdict would collapse questions that demand different actions.
| Field | Answers | Phase |
|---|---|---|
observation |
What has actually been seen, within a stated horizon | live |
reachability |
Can the model ever select this skill | live |
starvation |
Is it competing for description budget, and likely losing | live |
observation values: active · cooling · dormant · no-observation-in-horizon
· not-observable. The last is the default whenever the data cannot support
better, and it is never a synonym for unused.
reachability values: model-reachable · user-only · hidden ·
misconfigured · unknown. Only model-reachable with no observation is a
starvation candidate. user-only means you type it by design, and
misconfigured is a fix. Each carries its causes, evidence, and a remedy.
The reachability causes are not an official list. No such list is published;
this catalogue is assembled from scattered documentation plus strings in the
shipped binary, and every row says so in its provenance. Do not present it to
a user as documented.
Counting rules that are not obvious
- Never sum sources. Native counters and the JSONL store both record the same invocation, so adding them double-counts. At a given instant the count is the MAX across sources, while two events from ONE source at the same instant still count twice, because those are genuinely two invocations.
pluginUsageis not a skill signal. It counts hook, agent, MCP, and LSP dispatch, and is seeded at install withusageCount: 0beside a currentlastUsedAt. Recency from it is meaningless unlessusageCount > 0.- Ambiguous attribution is reported, not guessed. Two marketplaces shipping
a same-named plugin collapse to one usage key; those rows are marked
ambiguous-attributionrather than attributed to one of them. - Two possible usage keys per skill. The stores hold the qualified
<plugin>:<leaf>key and the bare leaf as separate rows. Both are collected; a bare key is attributed only when exactly one skill owns that leaf, ambiguous ones are withheld with their candidates. - The starvation band is decay-weighted, not a count, and reports
score_basis: "unscored"when nothing survives to weigh. Read reference/listing-scorer.md before changing that ordering or quoting it to a user.
Scope boundary
| Question | Owner |
|---|---|
| Why is my fleet unused, starved, unwanted, or unobserved? Does skill B get invoked where skill A ran? | this skill, the second via scripts/skill-pair-cooccurrence.sh, co-occurrence and never attribution (reference/pair-cooccurrence.md) |
| Which skills are unused vs their context cost, right now? | Claude Code's own built-in /skill-doctor command, when it resolves in your session; the bundled /doctor skill's checkup, when that one resolves; the Stats tab carries the /skill-doctor report in an interactive session |
| Is a repo's authored listing over budget? | skill-quality's check-listing-budget.sh |
| What is installed and invocable? | /claude-ops:inventory |
| Is the telemetry pipeline healthy? | /claude-ops:observability |
Read-only. It never disables, deletes, or edits a skill, and it never recommends deleting one it classified as misconfigured. That class is a fix-me, not a removal candidate.
Gotchas
- A short horizon is the normal case, not an error. Fresh installs, new machines, and ephemeral cloud containers all produce spans below the exposure floor. The correct output there is a withheld verdict, not a smaller number: if a run reports most of the fleet as cold on a days-old install, the report is wrong, not the fleet.
- Do not "fix" a fixture that asserts everything is
not-observable. That is the honesty floor being tested, and it is the defect this skill exists to prevent. - Never sum two sources. Native counters and the JSONL store record the same invocation; summing them doubles every count. The reconciliation is MAX across sources at an instant, and it is deliberately not MAX across an entire skill: two same-instant events from one source are two invocations.
- OTEL and native counts legitimately disagree. The native counter is debounced (one write per skill per 60 s, suppressing the timestamp refresh too); telemetry is not. Divergence is expected and must not be reconciled away.
misconfigurednever renders as a removal candidate. Several of its causes are silent misconfigurations, a skill that looks fine and can never be selected. The remedy is a fix.