--- name: skills-doctor description: > Diagnose skill DELIVERY health for the current session: name the surface, diff the expected set in `skills.lock` against what actually loaded (the session's own skill listing, `~/.claude/skills/`, the account `synced/manifest.json`, `claude plugin list`), attribute every skill to the registry and bundle it came from by reading the bootstrap hook's own install record rather than guessing, and flag silent shadowing and dangling payload references. Reports only — it never installs, copies, deletes or repairs anything. Use when a skill you expected is missing or won't trigger, when a repo-owned skill looks overridden, when the session-start `skills:` verdict reads DEGRADED, when you need to know where a skill came from or whether the hook installed it, or when the user says "why didn't that skill load", "which skills do I actually have", "is my registry stale", "audit my skills", "skills doctor". compatibility: >- Claude Code only. Reads ~/.claude/skills, the account synced/ store and the `claude plugin` CLI, none of which exist in other agent harnesses. Needs the registry checked out locally to compare against; everything else is observation of the running session. --- # Skills doctor A session can only see the skills it was *given*. So the failure that matters — a skill that should have loaded and didn't — is invisible by construction: it leaves no entry in the listing, no file on disk, and no error anywhere. Asking "what loaded?" can never find it. The fix is to diff against a **declared expectation**. `skills.lock` is that expectation: registry, pinned commit, bundles, and the exact skill set with a sha256 each. Everything below compares observed reality to it. **Report, never self-heal.** Do not install, copy, delete, re-sync, or "just fix" anything, even when the repair is one obvious command. The whole value of this skill is a trustworthy account of what the session actually got; an agent that silently repairs delivery destroys the evidence and hides a bug that will recur on the next surface. Name the defect, name the knob that fixes it, stop. **Not the built-in `/skill-doctor`.** Claude Code 2.1.261 added a `/skill-doctor` command that shows which loaded skills go unused and what they cost in context, so you can prune them. This skill answers a different question: where each skill came from, and whether delivery matched `skills.lock`. For "which skills go unused, what does each cost per turn, which should I turn off", point the user at `/skill-doctor` (or `/context`) rather than reproducing it here; the names differ by one letter, so say which one you ran. ## 1. Name the surface first Expectations differ per surface, so establish which one this is before judging anything: ```bash echo "entry=$CLAUDE_CODE_ENTRYPOINT remote=$CLAUDE_CODE_REMOTE_SESSION_ID force=$SKILLS_BOOTSTRAP_FORCE env=$CLAUDE_CODE_REMOTE_ENVIRONMENT_TYPE" ``` | Reading | Surface | What SHOULD be true | |---|---|---| | a non-empty remote session id, OR `entry` beginning with `remote`, OR `SKILLS_BOOTSTRAP_FORCE` set | ephemeral (cloud session, CI, container) | the bootstrap hook installed the locked bundle into `~/.claude/skills/`; **no** marketplace plugins (cloud gets none from repo-declared settings) | | all three empty | durable machine | the marketplace install is authoritative; `~/.claude/skills/` should hold **no** hook-installed bundle skills — finding them there means double delivery | | some other entrypoint, no session id, not forced | unsure | not a shade of either row. Judge it as durable — the quiet reading — and settle it against the session's own `skills:` verdict rather than the entrypoint's name | A durable machine with a full set of bundle skills in `~/.claude/skills/` is a finding, not a pass. **Match the hook's test exactly — no wider, and no narrower.** `skills-bootstrap.sh` installs on any of those three readings, so a diagnostic that recognises fewer of them disagrees with the hook silently: it answers `unsure`, which is the quiet reading, on a surface the hook has just installed onto. **The second arm is a PREFIX, not the exact string `remote`.** Claude Code's entrypoint allowlist has 26 legal values and seven of them begin with `remote` (`remote`, `remote_baku`, `remote_cowork`, `remote_trigger`, `remote_cowork_trigger`, `remote_desktop`, `remote_mobile`), so an exact match recognised one and was dead on six live remote surfaces. It reads the hook's own test; the hook's surface-guard comment carries the measurement, the argument about `remote_cowork`, and the residual an open prefix leaves. **Prefix, never substring.** `ssh-remote` is in the same allowlist and names a DURABLE workstation reached over SSH. `startswith` does not match it; a substring test or a `grep remote` does, and would report a workstation's correctly-empty store as a delivery failure. It also does not close everything: `claude_in_slack`, `claude-in-slack` and `claude-in-teams` are in Claude Code's remote family and begin with `claude`, so they still reach the hook only through the session-id arm. ## 2. Read the expectation ```bash cat skills.lock # registry, pinned ref, bundles, expected skills + sha256 each ``` If there is no `skills.lock`, stop and report that: with no declared expectation the rest of this skill can only describe, never verdict. ## 3. Collect the actual Seven independent signals: the five commands below, the session's own skill listing, and on a terminal the `--account-channel` check. Gather them all — each one is blind to something the others see. ```bash ls -1 ~/.claude/skills/ # personal store: hook-installed or hand-placed cat ~/.claude/skills/.skills-bootstrap-installed.json # the hook's own account of what IT installed ls -1 ~/.claude/skills/synced/*/ # account store (claude.ai uploads) cat ~/.claude/skills/synced/*/manifest.json # per skill: skillId, source, updatedAt claude plugin list --json # installed bundles + the commit SHA each resolved to ``` On a terminal, add the `--account-channel` check below, because from 2.1.273+ the account store loads there too and `ls` alone cannot tell you whether it is switched on. The listing signal is **the session's own skill listing** — the names offered to the Skill tool in this context. It is the only signal that says what the model can actually *trigger*, and it is the authority when it disagrees with disk. Read it out of context; do not reconstruct it from the filesystem. ## 4. Attribute every skill to its source A name's shape narrows it to a channel: | Listed as | Channel | Confirm with | |---|---|---| | `adam:foo` (namespaced) | plugin / marketplace bundle | `claude plugin list --json` | | `foo` (bare) | personal `~/.claude/skills/foo/` — hook-installed or hand-placed | the install record, below | | `foo` (bare) | account store | `foo` has a record in `synced/manifest.json` | The last two collide on purpose: both present as a bare name, so the manifest is what separates them. A skill in `synced/manifest.json` came from the account; one on disk in `~/.claude/skills/` but absent from the manifest came from the hook or a hand copy. **One name can be in both, and there the manifest confirms the collision rather than resolving it.** Names reach a cloud session from the hook and the account store at once. The listing shows each such name once, and nothing in it, on disk, or in any log says which copy the model read. `check_provenance.py` reports every one: a `shadowed-by-the-account-store` NOTE where the two copies match once CRLF is folded to LF, and a `shadow-copies-differ` FINDING where they do not. Both verdicts are over the files an UPLOAD carries — the account copy is the ZIP `zip_skill` built, so what the upload filter drops is excluded from both sides (`__pycache__`, `.pytest_cache`, `.pyc`, `.b64` and `node_modules` among them), and a `diff -r` of the two directories can disagree with a NOTE that calls their instructions identical. Treat a match as a measurement of the moment and not a guarantee — the two copies update on different clocks, and only the divergent case is a defect. Which channel wins when they disagree is an open question, not something the report answers. **Which of those two it is, the hook already answered.** It writes `~/.claude/skills/.skills-bootstrap-installed.json` — one entry per skill it installed, with the registry it was fetched from, the bundle, and the digest the bytes had when it verified them. Run the reader rather than reconstructing it: ```bash python3 -I scripts/check_provenance.py --lock skills.lock ``` **That path is relative to this skill's own directory, not to the repo you are standing in** — `~/.claude/skills/skills-doctor/scripts/` when the hook delivered it, `plugins/adam-coding-anywhere/skills/skills-doctor/scripts/` in a registry checkout. Resolve it before running: the registry has a top-level `scripts/` of its own, so the bare form run from the repo root reports `No such file or directory` and exits 2, which is also argparse's code for a bad flag. `-I` because this recomputes sha256 over directories to decide whether a skill is still the bytes that were installed, and without it `sys.path[0]` is **the script's own directory** — which for a delivered skill is content a fetched registry supplied. A `hashlib.py` dropped in beside it would be what answers. (The hook carries `-I` for the neighbouring reason: it runs `python3 -I -`, so `sys.path[0]` would be the *project* directory instead.) Exit 1 means *there are findings*, not that the tool failed; 0 means none. `--skills-dir` and `--project-dir` point it at a store and a project other than this machine's. **`--lock` is optional, and leaving it off is usually right.** Omitted, it takes the project dir's own `skills.lock`; when the project dir has none it resolves the `skills.lock` of every child GIT REPOSITORY one level below and reports per lock — a plain subdirectory carrying a lock is not one, because the hook does not read it either (ADR 0007). That second case is the multi-repo session, where a bare default that resolved to nothing would report the absence of a lock as the absence of a problem. Naming a lock explicitly is still honoured exactly, and never widened into a scan. Several locks judging one store names **no winner** among them; every finding says which lock declared it, and identical findings from several locks are folded into one that names them all. **There is no winner to name because the hook installs the UNION** (`docs/decisions/0007`), and that changes which questions are per-lock. Rows from different locks that agree on a name AND a digest collapse to one install — the ordinary fleet shape, where fourteen repos declare the same `adam` bundle. Rows that disagree about the digest install **neither**, unless the hook proves one is the newer pin of the same registry (`docs/decisions/0022`). The doctor cannot redo that proof offline, so it reads the hook's install record: a name recorded at one contesting lock's digest, with matching bytes and a record newer than every contesting lock, is `resolved-by-newer-pin` (a note naming the winning lock). Anything else is `deleted-by-the-cross-lock-conflict`, worded as contested and naming the locks to reconcile. So "has this skill left the lock", "is its bundle still in scope" and "is its destination contested" are judged against **every discovered lock at once**: a skill dropped from one repo's lock that a sibling repo still declares is not stale, and nothing removes it. What stays per-lock is attribution — which repo declared a name, and therefore whose lock has to change. **It reads the surface, and the surface changes the verdict.** A locked skill missing from the personal store is the correct state on a durable machine and a delivery failure on an ephemeral one, so the same three facts are a NOTE on one and a FINDING on the other. The test is the bootstrap hook's own three arms, copied: a remote session id, OR `CLAUDE_CODE_ENTRYPOINT` beginning with `remote`, OR `SKILLS_BOOTSTRAP_FORCE`. Reading fewer of them than the hook does is disagreement, not caution. Any other entrypoint with no session id — a durable `cli`, or `ssh-remote` — reports as `unsure` and is judged as durable, which is the quiet reading. The same reading gates every sentence about what the NEXT run does. Those three arms are tested by the hook BEFORE it reads the lock, and failing all three it prints `skills: skipped` and exits — so on a durable or unsure machine there is no install, no refusal and no `skills:` verdict to read `DEGRADED`. Findings that promise any of those carry the caveat themselves rather than leaving the reader to reconcile them against the SURFACE block further up the report. **`hook-not-wired` is the finding to look for in a multi-repo session.** A lock plus a SessionStart hook wired only in a *child* of the project dir means no hook is consulted at all, so nothing is delivered and nothing says so — there is no `skills:` verdict because the script that prints one never runs. The two scopes are read the way the binary reads them, and they are not the same shape: the project scope is a **chain** — `settings.local.json` then `settings.json`, both consulted — so a fix in either silences the finding; the user scope is a **selection** of one name by Cowork mode — `cowork_settings.json` when `CLAUDE_CODE_USE_COWORK_PLUGINS` is set, `settings.json` otherwise — and the name not selected is never consulted. Treating that selection as a chain is the mistake that points the wrong way: it reports the user scope as wired off a file the machine never opens, and so suppresses the finding on a machine whose hook genuinely cannot fire. Three links are unreadable from a process and none is a file this can open: a managed/policy settings file, a `--settings` path given on the command line, and the `coworkPlugins` config flag (the other arm of the user-scope selection). The tool **prints that sentence on the finding itself** — it is not documentation you have to have read, because the person running the script is not necessarily the person reading this. If the finding fires on a session you believe is wired through one of those, that is the gap. See `docs/decisions/0005` in the registry. Produce one row per expected skill, and report `registry` and `bundle` on it, not just the channel — "personal copy" does not say whether it came from the fleet registry, a federated one, or somebody's hand, and "loaded" without a source is not a diagnosis. A row the record names is **fact**: it is the writer's own account of what it wrote, not an inference from the filesystem. **These readings are different machines, not shades of one thing.** Do not collapse them into "no record": | State | What it proves | What to do | |---|---|---| | present | the hook installed exactly these, from these sources | nothing; read the rows as fact | | present, `installed: []` | a run *completed* and installed nothing | anything on disk is not the hook's | | absent | no run has ever reached the point of writing it | expected on a durable machine; on an ephemeral one read the session-start `skills:` verdict for why delivery never happened | | unreadable | the hook cannot read it either: it pruned nothing that run, and rewrites it from scratch at the next session start | start one clean session — it self-heals in one run, but everything from before the corruption is forgotten, so anything that left the lock in that window is now left alone forever. Still unreadable after that? The rewrite happens LAST, after the lock read, the git probe and the fetch, so that run never got there: read its `skills:` verdict | The lock has states of its own, and one of them is not a shade of "no lock": a file that parses as JSON but that the hook's reader **refuses** — no `bundles`, a bundle no source claims, a registry that is not `OWNER/REPO` or a URL, a `skills` value that is not an object, a key that is not `/`, a digest that is not 64 hex (with or without a `sha256:` prefix), or a key whose skill part is `synced`. The refusal is the WHOLE LOCK and it happens **before** the install loop exists: it installs nothing at all from such a lock, so every skill judgement made against one names a cause that never happened. `check_provenance.py` reports it as `lock-rejected` and withholds the rest — no declared count, no `missing`. One bad row is therefore never one bad skill. A single hand-edited digest stops delivery of every skill in that lock, and the session-start verdict says only `DEGRADED — could not read …/skills.lock`; the reason is in the hook's `$LOG`, and `lock-rejected` names it. Only when the record cannot answer — absent, or unreadable — does the old heuristic apply: directories the hook wrote in one run share an mtime, because `cp -R` stamps the copy with the copy time. It is inference and the reader must be told so. It degrades in both directions — a hand copy made in the same minute as an install clusters with the install, and a hook-installed skill an editor has touched since falls out of the cluster — and neither failure announces itself. That is the whole reason the record exists; never prefer the cluster to it. ## 5. The things that fail silently ### Shadowing Personal `~/.claude/skills/` **outranks** project `.claude/skills/` (measured). A repo-owned skill can be overridden by a same-named personal copy with no signal in the listing, on disk, or in any log — the model simply reads the wrong instructions. Compare the two directories by basename: ```bash comm -12 <(ls -1 ~/.claude/skills) <(ls -1 .claude/skills) ``` Every name printed is a shadowed repo-owned skill. Report each one with both paths and say which copy is winning (the personal one, always). ### Skills nothing will ever remove The hook removes a skill that has left the lock only when it can prove all four of: the record says it installed it, the bytes are still exactly what it installed, the discovered locks still declare that (registry, bundle) between them, and **none** of them names it any more. Everything else it leaves alone — correctly, because `~/.claude/skills` is the user's own directory and deleting work to satisfy a lock they may not control is the worse failure. **Both halves of that are about every discovered lock, not about one.** The hook writes its prune scope as the union of the accepted locks' claims, so a skill dropped from repo A's lock while repo B still names it is not stale at all — the next run reinstalls it, and a doctor judging repo A's lock alone would tell you the opposite. That is a report whose stated cause never happens, which is the failure the whole FINDINGS section exists to avoid, so `check_provenance.py` asks the union rather than the lock in hand. The consequence is that directories live there permanently with nothing saying so — a skill the record does not name at all, one whose bytes no longer match what was installed, one whose (registry, bundle) no discovered lock declares. The causes differ and so does what to do about each, which is why `check_provenance.py` names the cause rather than just the count. Read its FINDINGS section; the hook being right not to act is exactly why a human has to see them. Two of these are easy to state backwards. What preserves an edited skill is the **digest mismatch**, not its having left the lock — restore the original bytes and the next run removes it, so moving it out of the store is the only way to keep it. And a skill whose bundle no discovered lock claims is not merely unremoved: it is out of scope, so the hook does not mention it in any verdict either, and nothing will ever say it is there — until a sibling repo whose lock does claim that pair joins the session, which brings it back in scope and makes the next run remove it. `stale-out-of-scope` is therefore a statement about the session as it stands, not about the directory. ### The account store is not a delivery channel for this registry [ADR 0014](../../../../docs/decisions/0014-retire-the-account-zip-upload-channel.md) retired the ZIP uploads: every surface takes this registry's skills from the marketplace plugins, and the account store holds none of them. There is therefore no drift audit to run (this skill has no account-drift mode), and a registry-named skill still found in `synced/` is a stale leftover. It surfaces as a shadow note or finding below; the fix is to remove it from the claude.ai account, not to re-upload. Anthropic's own skills (docx, pdf, …) still arrive through `synced/` and are none of this registry's business. ### Which surface the account channel is on From Claude Code 2.1.273+ a **terminal** session signed in with the account downloads every skill enabled on it. A synced skill is named `anthropic-skills:` (cloud sessions since 2.1.269) and the bare name still works when nothing else uses it; from 2.1.281 the `/` menu, `/skills`, `/context` and `/plugin` show it by the short name unless another command uses that name. [ADR 0010](../../../../docs/decisions/0010-let-pinned-channels-own-the-terminal.md) opts durable machines out via `setup.sh` and leaves cloud sessions syncing — they **cannot** opt out, because the key is read only from user, local or managed settings and never from a repo's — so two surfaces now load different sets on purpose. Report which one you are on before concluding anything about why a skill does or does not trigger: ```bash python3 -I scripts/check_provenance.py --account-channel ``` It reports three things and repairs none of them: 1. **the settings-chain verdict** for `syncClaudeAiSkills` and `syncClaudeAiPlugins`, naming the file each was read from. Absent is not `false`: the keys default to ON, so "nothing sets it" and "it is off" are opposite answers and print differently. Only the JSON boolean `false` is an opt-out — a string `"false"` is reported as **still syncing**, because that is what the CLI does with it; 2. **every account skill whose bare name another copy here also delivers**, and which copy owns the short name. The local copy keeps `` and the account copy stays loaded as `anthropic-skills:`. That is the documented rule, stated as a rule — nothing on disk records which one a given turn actually read; 3. **what an opt-out left in `.trash/`** — copies that are loaded by nothing and deleted by nothing, which look like a half-finished install to anyone who finds them without knowing why. Precedence between managed, user and local settings is deliberately **not** modelled: only `false` is honoured and it is honoured from any of them, so "does some file in the chain say false" is the whole question. `syncClaudeAiPlugins` is reported but no decision has been made about it. ### Missing payloads A `SKILL.md` that tells the agent to run a file which is not there is a skill that fails at the moment of use, having looked healthy until then. The check is that every `scripts/`, `references/`, `assets/` or `templates/` path named in a skill's **runnable blocks** exists inside that skill's own directory. Run the mechanised version; do not eyeball it: ```bash python3 /scripts/check_skills.py ``` **A hand-rolled grep for those paths does not approximate this check — it inverts it.** The rule that matters is `PROSE_ONLY_RULE` in that script: only a path inside a *fenced code block* gates, because a skill legitimately names paths belonging to OTHER repos in prose and in backticks. The hand version is not a weaker check, it is a wrong one, and confident false positives will bury the real finding if there ever is one. If the script cannot run, say the check is **unavailable** rather than substituting the grep. It exits **2** — distinct from 1, which is findings — and names the remedy on stderr when a dependency is missing: ```bash python3 -m pip install -r /requirements-dev.txt # distro PyYAML ships without installer metadata on some images; if the above # fails with "Cannot uninstall PyYAML ... RECORD file not found": python3 -m pip install --ignore-installed PyYAML -r /requirements-dev.txt ``` ### Context cost Not measured here. For per-skill context cost and 7-day usage, run `claude -p /skill-doctor` (built-in, Claude Code 2.1.261+; usage is machine-local, and the report is unavailable under `--bare` and over Remote Control). This skill does not depend on it. What it does keep is the delivery consequence: at the default listing budget the descriptions of the least-used skills are **silently dropped**, so a skill can be loaded, present on disk, and still untriggerable — indistinguishable from never having been delivered. When a skill you expected is loaded but never triggers, say so and point at `/skill-doctor` for the cost side. ## 6. Report shape One verdict line, then the evidence: ``` skills-doctor: / expected present, shadowed, stale, unattributed — OK | DEGRADED ``` Then: the surface and what it implies; a table of expected skill → source → status, where source is the `registry # bundle` the record names and not merely the channel; each finding with the file path and the knob that fixes it. Say which record state the attribution rests on, because it is what separates a report that is fact from one that is inference. **Where there is no readable record, write ` unattributable`, never `0 unattributed`** — the zero is arithmetically true and reads as "everything is accounted for", which is the exact inversion. No remediation is performed — recommend, do not do. ## Traps that will mislead you - **The account store is BUCKETED PER ACCOUNT, one level below where it used to be.** Claude Code 2.1.273+ writes `~/.claude/skills/synced/_/`, holding `manifest.json` and one directory per skill, with an empty `.bucket-_` marker file beside it; older CLIs wrote `~/.claude/skills/synced/manifest.json` flat. `ls ~/.claude/skills/synced/` on a current CLI shows the marker and one directory and **no skills** — which reads exactly like an account with no uploads. `check_provenance.py` resolves the bucket itself (`oauthAccount` in `~/.claude.json`, then `$CLAUDE_CODE_ACCOUNT_UUID`) and refuses rather than guessing when a machine has more than one; when you look by hand, glob the bucket. - **`anthropic-skills` and `claude-ai` are reserved namespaces (2.1.282).** A skill folder, command file or workflow command in either no longer loads, and `Skill(anthropic-skills:*)` / `Skill(claude-ai:*)` allow rules cover only skills synced from claude.ai. A plugin so named still loads but ties with the synced skills. So `anthropic-skills:foo` in a listing is a synced skill, not something this registry or a hook install delivered — this registry ships nothing under either name. - **Synced skill bodies are stripped on a machine.** Claude Code strips `!` commands and `@` file expansion from synced skill bodies, so a synced copy may not behave like its registry original. - **`~/.claude/skills/synced/` cannot be seeded or simulated.** It is manifest-gated: writing a directory there does nothing at all. You can only observe it, so never "test" a hypothesis about the account channel by creating files in it — the negative result is meaningless. - **Skills load from every workspace root, not just cwd.** In a multi-repo session, one repo's committed skills are advertised while you are working in another. Enumerate all workspace roots before concluding a skill "came from nowhere". - **The shadow guard consults every repo in a multi-repo shape.** The hook and `check_provenance.py` both look for repo-owned skills in the project dir **plus every accepted lock's own repo**, first answer wins, and the report NAMES the directory that won because "repo-owned" no longer identifies a single repo. The two had to move in one change (ADR 0005's footnote): the doctor's lookup exists to *suppress* a finding, so widening one side alone makes the net effect unreadable. One residual is recorded rather than fixed — under a union, one repo's project-owned skill now suppresses delivery of a locked skill the other repos asked for. Still worth doing the shadowing comparison by hand against each workspace root, per the `comm` recipe above, when the answer matters. - **Absence from the listing is not absence from disk.** Deduplication and the listing budget both drop entries. Check disk *and* listing; a mismatch between them is itself a finding. - **A green session-start verdict only covers the hook's own channel.** It says what the bootstrap installed and verified. It knows nothing about the account store, the marketplace, or shadowing, so it is a starting point and never the whole answer. - **The record's `registry` is a resolved git remote URL; the lock's is a slug.** A lock saying `Adam-S-Daniel/adam-agentskills` produces record entries saying `https://github.com/Adam-S-Daniel/adam-agentskills.git`, and the verdict prints the slug again. Compare them with `==` and every skill reads as coming from a registry its own lock does not declare. - **`AGENTSKILLS_BUNDLE` narrows a run and nothing on disk records that it did.** A run narrowed to one bundle claims authority over that bundle only and deliberately leaves every other bundle's skills alone — so a skill that has left the lock can sit there indefinitely while any reading of the record and the lock says the next run will remove it. The hook names it in that run's verdict; no later inspection can recover it. - **The record's digest is the one taken at install and is never refreshed.** That is what makes "edited since install" detectable at all: re-recording the edited digest would make the next run's comparison succeed and delete the user's work. So a digest mismatch against the record means *edited*, while a mismatch against the **lock** means the copy predates the current lock — two different facts from the same number. One mismatch against the record is neither: when every file an UPLOAD would carry still matches, the difference is a build artefact (`__pycache__`, `.pytest_cache`) and the doctor reports `artefacts-and-locked`, naming the extra files it actually found. - **A directory the hook will not overwrite is not a directory the hook is about to delete.** `may_replace` in `.claude/hooks/skills-bootstrap.sh` overwrites in three cases only — nothing is there, what is there already digests to the digest the lock names, or the record names it and the bytes still digest to what was recorded — and REFUSES everything else. That third clause reads the record under LOOSER rules than the prune does: `name` and `digest` only, no `registry` and no `bundle`, so a row the prune throws away can still authorise an overwrite, and a name recorded twice is decided by the row nearer the top of the file. A refusal copies nothing and removes nothing: the skill is dropped from that run, the bytes stay, and the name is listed after `DEGRADED … shadowed`. So the risk a refusal finding describes is a skill that stops being delivered, never local work about to be overwritten. The refusal findings are `edited-and-locked`, `unmeasurable-and-locked`, `artefacts-and-locked`, `hand-placed-over-locked`, `unattributable-over-locked` and `recorded-twice-and-locked`; that list is `REFUSAL_KINDS` in `scripts/check_provenance.py` and a test holds this sentence to it, because an enumeration nothing checks is how this one came to be missing two. A build artefact left by running a skill's own suite in place stalls that skill's updates until it is deleted, which is why it is a finding rather than a note. - **The middle clause is why some of those are only a note.** "What is there already digests to the digest the lock names" asks nothing about who put the directory there, so a hand-placed copy of exactly the bytes the bundle ships is overwritten like any other. The doctor measures that clause and reports `bytes-are-the-locked-ones` instead of a refusal — the state is not a defect, it is a state that becomes one the next time the lock's digest for that name moves. - **`may_replace` saying yes is not the end of the story, and the doctor walks the rest of the ladder before it says delivery is unaffected.** The install loop asks several more questions after that gate, and seven of its arms end in `rm -rf` on the very directory it just agreed it could replace. The doctor models the three that a reading of your own disk can answer: two rows of ONE lock folding onto one destination name (`deleted-by-the-dup-guard`, a finding — neither row installs), TWO locks naming one destination at different digests (`deleted-by-the-cross-lock-conflict`, a finding — the hook installs neither unless it proves ADR 0022's newer pin, which the doctor reports as the note `resolved-by-newer-pin` only when the install record shows it did; the finding names the locks to reconcile), and a repo in the session shipping a `.claude/skills/` of its own (`deleted-by-the-collision-guard`, a note — repo-owned is supposed to win). The first two share one `dup` arm in the hook and are reported apart because the remedy differs — one lock to correct against two to reconcile — and the intra-lock reading wins when a store manages both, which is the hook's own `if not intra`. That last question is asked the hook's own way — one `[ -f .claude/skills//SKILL.md ]` per name per directory, never a listing of the directory — so a plain file, a missing directory or a symlink loop where `.claude/skills` belongs is a measured "this repo ships nothing", exactly as it is for the hook. Only a stat that fails for a reason which is not about the path leaves it unknown, and the doctor says so (`collision-guard-unmeasured`) rather than assuming the benign reading. `bytes-are-the-locked-ones` is now raised only where every local rung is clear, which is the only state in which "delivery is unaffected" is a measurement rather than a hope.