A sweep of ~/dev on flamingo found 243 dangling skill symlinks across 12 repos, plus a stale duplicate checkout serving 20-skills-out-of-date content. All cleaned up manually; this issue is about the mechanism that produced them.
Root cause
wire_claude_repo() in install.sh (and install:claude:repo in Taskfile.yml) creates <repo>/.claude/skills/<name> symlinks by absolute path to $CHECKOUT_DIR. Three properties combine badly:
Absolute path, no indirection. When the checkout location changes, every previously-wired repo breaks. There has been no migration step across the moves.
Only rewires the repo you happen to be standing in. Re-running the installer fixes global + cwd. The other N repos silently keep pointing at a path that no longer exists.
Invisible to git.~/.config/git/ignore has **/.claude/skills/, so git status never surfaces the rot in any repo.
Observed generations on this host:
Gen
Checkout path
Evidence
1
/Users/mathias/dev/.skills (macOS)
.skills-shared links in cobalt-dingo, hyperguild — dead the moment the repo was cloned on Linux
2
/home/mathias/dev/.skills
243 links, May 29 – Jun 12; path no longer exists
3
~/.local/share/skills (installer default)
live but pinned at e553dae, missing 20 skills; tapir was silently loading these
4
~/dev/skills (current, per CLAUDE.md)
healthy
Gen 3 is the nastier failure mode: a working link to a stale checkout serves outdated skill bodies with no error, and project-level skills shadow global ones.
Secondary defect: CHECKOUT_DIR defaults to ~/.local/share/skills in both install.sh and Taskfile.yml, but the documented location in CLAUDE.md is ~/dev/skills. task update against the default therefore pulls a checkout nothing is linked to — or clones a second one.
Proposed fix
Per-repo wiring is redundant on Claude Code: ~/.claude/skills/ is already visible in every project on the host, and the per-repo dir is gitignored, so it is never shared with anyone. It buys nothing and rots by default.
Drop wire_claude_repo / install:claude:repo, or gate it behind an explicit SKILLS_WIRE_REPO=1.
Make CHECKOUT_DIR default match the documented ~/dev/skills, or resolve it from the script's own location when run from a checkout.
Add a task doctor that reports dangling links and links resolving outside the current CHECKOUT_DIR (catches the gen-3 stale-but-live case), across all four harness dirs.
Have task check fail on a stray non-directory entry at the repo root — a loose secret-hygiene.md sat unloadable in ~/.claude/skills/ for months for exactly this reason (fixed in 21be3ea).
## What happened
A sweep of `~/dev` on flamingo found **243 dangling skill symlinks across 12 repos**, plus a stale duplicate checkout serving 20-skills-out-of-date content. All cleaned up manually; this issue is about the mechanism that produced them.
## Root cause
`wire_claude_repo()` in `install.sh` (and `install:claude:repo` in `Taskfile.yml`) creates `<repo>/.claude/skills/<name>` symlinks by **absolute path to `$CHECKOUT_DIR`**. Three properties combine badly:
1. **Absolute path, no indirection.** When the checkout location changes, every previously-wired repo breaks. There has been no migration step across the moves.
2. **Only rewires the repo you happen to be standing in.** Re-running the installer fixes global + cwd. The other N repos silently keep pointing at a path that no longer exists.
3. **Invisible to git.** `~/.config/git/ignore` has `**/.claude/skills/`, so `git status` never surfaces the rot in any repo.
Observed generations on this host:
| Gen | Checkout path | Evidence |
|-----|---------------|----------|
| 1 | `/Users/mathias/dev/.skills` (macOS) | `.skills-shared` links in cobalt-dingo, hyperguild — dead the moment the repo was cloned on Linux |
| 2 | `/home/mathias/dev/.skills` | 243 links, May 29 – Jun 12; path no longer exists |
| 3 | `~/.local/share/skills` (installer default) | live but pinned at `e553dae`, missing 20 skills; tapir was silently loading these |
| 4 | `~/dev/skills` (current, per CLAUDE.md) | healthy |
Gen 3 is the nastier failure mode: a *working* link to a stale checkout serves outdated skill bodies with no error, and project-level skills shadow global ones.
Secondary defect: `CHECKOUT_DIR` defaults to `~/.local/share/skills` in both `install.sh` and `Taskfile.yml`, but the documented location in CLAUDE.md is `~/dev/skills`. `task update` against the default therefore pulls a checkout nothing is linked to — or clones a second one.
## Proposed fix
Per-repo wiring is redundant on Claude Code: `~/.claude/skills/` is already visible in every project on the host, and the per-repo dir is gitignored, so it is never shared with anyone. It buys nothing and rots by default.
1. Drop `wire_claude_repo` / `install:claude:repo`, or gate it behind an explicit `SKILLS_WIRE_REPO=1`.
2. Make `CHECKOUT_DIR` default match the documented `~/dev/skills`, or resolve it from the script's own location when run from a checkout.
3. Add a `task doctor` that reports dangling links **and** links resolving outside the current `CHECKOUT_DIR` (catches the gen-3 stale-but-live case), across all four harness dirs.
4. Have `task check` fail on a stray non-directory entry at the repo root — a loose `secret-hygiene.md` sat unloadable in `~/.claude/skills/` for months for exactly this reason (fixed in 21be3ea).
Related: #7 (`task install` broken → docs recommend `install.sh`).
Correction: the CHECKOUT_DIR "mismatch" is not a bug, and the fix is stronger than I wrote
New fact from the host owner: flamingo and iguana share a single iCloud-synced ~/dev. koala has its own independent clones at the same relative path. That makes the fleet:
Host
~/dev
Path root
flamingo
iCloud folder (shared)
/Users/mathias
iguana
same iCloud folder
/Users/mathias
koala
independent git clones
/home/mathias
Two consequences.
1. Absolute wiring inside ~/dev is unfixable, not merely fragile
~/dev spans two path roots. Any absolute symlink stored in it resolves on exactly one side and dangles on the other, permanently, by construction. No installer re-run repairs it — the same file has to be correct at /Users/mathias/... and /home/mathias/... simultaneously.
Live proof: cobalt-dingo and hyperguildtrack.skills-shared -> /Users/mathias/dev/.skills in git. It resolves on both Macs and has always dangled on koala.
So proposal 1 in the issue body (drop wire_claude_repo) is not a tidiness argument. Per-repo wiring cannot be made correct on this fleet. It should be removed outright rather than gated behind an opt-in flag.
2. ~/.local/share/skills is the right default — the docs are what's wrong
I filed this saying the default CHECKOUT_DIR should change to match CLAUDE.md's ~/dev/skills. That is backwards. On the Macs, ~/dev/skills would put the checkout inside iCloud, i.e. one git working tree with two concurrent writers — the failure mode described in brain knowledge/icloud-git-workspace-appearance-vs-corruption. The host-local default is correct precisely because it is outside the synced folder.
Revised: keep CHECKOUT_DIR=~/.local/share/skills. Fix the documentation (~/dev/CLAUDE.md, SKILLS_INDEX.md) which currently instructs cloning into ~/dev. koala's ~/dev/skills works only because koala's ~/dev is not synced — an accident, not a pattern to document.
Still open, unchanged
task doctor reporting both dangling and stale-but-resolving links (the silent-wrong case).
The shared ~/dev carries local-dev pinned pre-extraction (e6d3c2d), so ~/dev/.skills still exists there. Both Macs have zero engineering skills wired into any harness — ~/.claude/skills on iguana holds gsd-* only, and no crush / antigravity / vibe skills dirs exist. Pulling 1966d5a on either Mac deletes .skills with no replacement in place. Needs a host-local install on each Mac before that pull.
## Correction: the `CHECKOUT_DIR` "mismatch" is not a bug, and the fix is stronger than I wrote
New fact from the host owner: **flamingo and iguana share a single iCloud-synced `~/dev`.** koala has its own independent clones at the same relative path. That makes the fleet:
| Host | `~/dev` | Path root |
|---|---|---|
| flamingo | iCloud folder (shared) | `/Users/mathias` |
| iguana | **same** iCloud folder | `/Users/mathias` |
| koala | independent git clones | `/home/mathias` |
Two consequences.
### 1. Absolute wiring inside `~/dev` is unfixable, not merely fragile
`~/dev` spans two path roots. Any absolute symlink stored in it resolves on exactly one side and dangles on the other, permanently, by construction. No installer re-run repairs it — the same file has to be correct at `/Users/mathias/...` and `/home/mathias/...` simultaneously.
Live proof: `cobalt-dingo` and `hyperguild` **track** `.skills-shared -> /Users/mathias/dev/.skills` in git. It resolves on both Macs and has always dangled on koala.
So proposal 1 in the issue body (drop `wire_claude_repo`) is not a tidiness argument. Per-repo wiring cannot be made correct on this fleet. It should be removed outright rather than gated behind an opt-in flag.
### 2. `~/.local/share/skills` is the *right* default — the docs are what's wrong
I filed this saying the default `CHECKOUT_DIR` should change to match CLAUDE.md's `~/dev/skills`. That is backwards. On the Macs, `~/dev/skills` would put the checkout **inside iCloud**, i.e. one git working tree with two concurrent writers — the failure mode described in brain `knowledge/icloud-git-workspace-appearance-vs-corruption`. The host-local default is correct precisely because it is outside the synced folder.
Revised: keep `CHECKOUT_DIR=~/.local/share/skills`. Fix the **documentation** (`~/dev/CLAUDE.md`, `SKILLS_INDEX.md`) which currently instructs cloning into `~/dev`. koala's `~/dev/skills` works only because koala's `~/dev` is not synced — an accident, not a pattern to document.
### Still open, unchanged
- `task doctor` reporting both dangling **and** stale-but-resolving links (the silent-wrong case).
- `task check` rejecting stray non-directory root entries.
### Separate finding for the Macs
The shared `~/dev` carries `local-dev` pinned pre-extraction (`e6d3c2d`), so `~/dev/.skills` still exists there. Both Macs have **zero** engineering skills wired into any harness — `~/.claude/skills` on iguana holds `gsd-*` only, and no crush / antigravity / vibe skills dirs exist. Pulling `1966d5a` on either Mac deletes `.skills` with no replacement in place. Needs a host-local install on each Mac before that pull.
Skills moved to skills/<name>/SKILL.md. The plugin ships 42 skills + 3 hooks + 3 MCP servers as one versioned unit, so the secret-guard and pre-task-ritual hooks and the brain/gitea/infra server definitions travel with it instead of existing only in koala's ~/.claude/.
The other harnesses went from 168 symlinks to 3.wire_claude_repo and wire_claude_global are deleted outright rather than flag-gated — on a mixed-root fleet per-repo wiring cannot be made correct. Antigravity and Vibe targets removed (unused); Pi and OpenCode added, reached through the convergent ~/.agents/skills convention both read:
Adding a skill now wires it everywhere with no re-run.
Corrections to what I filed above
Two of my original claims were wrong and are worth recording:
CHECKOUT_DIR was never the bug. I filed this saying the default should change to match CLAUDE.md's ~/dev/skills. Backwards — on the Macs that puts a git working tree inside iCloud with two concurrent writers. The host-local default is correct because it is outside the shared folder. The docs were wrong, and they now say so with the reason attached (~/dev/.context/AGENT.md updated and synced).
Per-repo wiring is not fragile, it is impossible.~/dev spans /Users/mathias and /home/mathias; an absolute link stored there is broken on one side by construction. So it had to be removed, not gated.
Detection, since removal only covers Claude Code
bin/doctor (POSIX sh, 27 ms, no network) reports with a non-zero exit: dangling links; links that resolve but land outside the active checkout — the silent-stale case that is worse than dangling because nothing complains; count mismatches; stray entries; leftover per-skill Claude symlinks; and divergence between the plugin copy and the copy the other harnesses link to.
Wired as a SessionStart hook in the plugin, so it reaches every host that installs it with no launchd/systemd wiring. A healthy host prints nothing.
task check gained frontmatter validation, a stray-root-entry gate, and manifest/tag version parity.
task doctor for dangling + stale-but-live — bin/doctor, 7 fixture tests ✅
task check rejects stray root entries✅
Verification
claude plugin validate . clean · 27/27 shell tests green · task check OK at 42 skills · doctor exit 0 · plugin update path proven live (0.3.3 → 0.4.1 through the version field) · secret-guard confirmed blocking the sops decrypt-and-print shape from inside the plugin in a fresh session.
Follow-ups: #24 (Mac rollout — both still have no skills and no secret-guard), #25 (env -u false positive).
Closing.
## Done — the mechanism is gone, not patched
Shipped in v0.4.1. koala is fully migrated and verified.
### What changed
**Claude Code no longer uses symlinks at all.** This repo is now a marketplace hosting one plugin — itself, `source: "."` — installed straight from gitea:
```
/plugin marketplace add https://git.d-ma.be/mathias/skills.git
/plugin install skills@mathias
```
Skills moved to `skills/<name>/SKILL.md`. The plugin ships **42 skills + 3 hooks + 3 MCP servers** as one versioned unit, so the `secret-guard` and `pre-task-ritual` hooks and the brain/gitea/infra server definitions travel with it instead of existing only in koala's `~/.claude/`.
**The other harnesses went from 168 symlinks to 3.** `wire_claude_repo` and `wire_claude_global` are deleted outright rather than flag-gated — on a mixed-root fleet per-repo wiring cannot be made correct. Antigravity and Vibe targets removed (unused); Pi and OpenCode added, reached through the convergent `~/.agents/skills` convention both read:
```
~/.agents/skills -> $CHECKOUT/skills
~/.config/crush/skills -> ~/.agents/skills
~/.config/opencode/skills -> ~/.agents/skills
```
Adding a skill now wires it everywhere with no re-run.
### Corrections to what I filed above
Two of my original claims were wrong and are worth recording:
1. **`CHECKOUT_DIR` was never the bug.** I filed this saying the default should change to match CLAUDE.md's `~/dev/skills`. Backwards — on the Macs that puts a git working tree inside iCloud with two concurrent writers. The host-local default is correct *because* it is outside the shared folder. The **docs** were wrong, and they now say so with the reason attached (`~/dev/.context/AGENT.md` updated and synced).
2. **Per-repo wiring is not fragile, it is impossible.** `~/dev` spans `/Users/mathias` and `/home/mathias`; an absolute link stored there is broken on one side by construction. So it had to be removed, not gated.
### Detection, since removal only covers Claude Code
`bin/doctor` (POSIX sh, 27 ms, no network) reports with a non-zero exit: dangling links; links that **resolve but land outside the active checkout** — the silent-stale case that is worse than dangling because nothing complains; count mismatches; stray entries; leftover per-skill Claude symlinks; and divergence between the plugin copy and the copy the other harnesses link to.
Wired as a `SessionStart` hook in the plugin, so it reaches every host that installs it with no launchd/systemd wiring. A healthy host prints nothing.
`task check` gained frontmatter validation, a stray-root-entry gate, and manifest/tag version parity.
### Original proposals, resolved
- ~~drop `wire_claude_repo`~~ — deleted outright ✅
- ~~fix `CHECKOUT_DIR` default~~ — **rejected**, default was right; docs fixed instead ✅
- ~~`task doctor` for dangling + stale-but-live~~ — `bin/doctor`, 7 fixture tests ✅
- ~~`task check` rejects stray root entries~~ ✅
### Verification
`claude plugin validate .` clean · 27/27 shell tests green · `task check` OK at 42 skills · `doctor` exit 0 · plugin update path proven live (0.3.3 → 0.4.1 through the `version` field) · `secret-guard` confirmed blocking the sops decrypt-and-print shape from inside the plugin in a fresh session.
Follow-ups: #24 (Mac rollout — both still have no skills and no secret-guard), #25 (`env -u` false positive).
Closing.
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
What happened
A sweep of
~/devon flamingo found 243 dangling skill symlinks across 12 repos, plus a stale duplicate checkout serving 20-skills-out-of-date content. All cleaned up manually; this issue is about the mechanism that produced them.Root cause
wire_claude_repo()ininstall.sh(andinstall:claude:repoinTaskfile.yml) creates<repo>/.claude/skills/<name>symlinks by absolute path to$CHECKOUT_DIR. Three properties combine badly:~/.config/git/ignorehas**/.claude/skills/, sogit statusnever surfaces the rot in any repo.Observed generations on this host:
/Users/mathias/dev/.skills(macOS).skills-sharedlinks in cobalt-dingo, hyperguild — dead the moment the repo was cloned on Linux/home/mathias/dev/.skills~/.local/share/skills(installer default)e553dae, missing 20 skills; tapir was silently loading these~/dev/skills(current, per CLAUDE.md)Gen 3 is the nastier failure mode: a working link to a stale checkout serves outdated skill bodies with no error, and project-level skills shadow global ones.
Secondary defect:
CHECKOUT_DIRdefaults to~/.local/share/skillsin bothinstall.shandTaskfile.yml, but the documented location in CLAUDE.md is~/dev/skills.task updateagainst the default therefore pulls a checkout nothing is linked to — or clones a second one.Proposed fix
Per-repo wiring is redundant on Claude Code:
~/.claude/skills/is already visible in every project on the host, and the per-repo dir is gitignored, so it is never shared with anyone. It buys nothing and rots by default.wire_claude_repo/install:claude:repo, or gate it behind an explicitSKILLS_WIRE_REPO=1.CHECKOUT_DIRdefault match the documented~/dev/skills, or resolve it from the script's own location when run from a checkout.task doctorthat reports dangling links and links resolving outside the currentCHECKOUT_DIR(catches the gen-3 stale-but-live case), across all four harness dirs.task checkfail on a stray non-directory entry at the repo root — a loosesecret-hygiene.mdsat unloadable in~/.claude/skills/for months for exactly this reason (fixed in21be3ea).Related: #7 (
task installbroken → docs recommendinstall.sh).Correction: the
CHECKOUT_DIR"mismatch" is not a bug, and the fix is stronger than I wroteNew fact from the host owner: flamingo and iguana share a single iCloud-synced
~/dev. koala has its own independent clones at the same relative path. That makes the fleet:~/dev/Users/mathias/Users/mathias/home/mathiasTwo consequences.
1. Absolute wiring inside
~/devis unfixable, not merely fragile~/devspans two path roots. Any absolute symlink stored in it resolves on exactly one side and dangles on the other, permanently, by construction. No installer re-run repairs it — the same file has to be correct at/Users/mathias/...and/home/mathias/...simultaneously.Live proof:
cobalt-dingoandhyperguildtrack.skills-shared -> /Users/mathias/dev/.skillsin git. It resolves on both Macs and has always dangled on koala.So proposal 1 in the issue body (drop
wire_claude_repo) is not a tidiness argument. Per-repo wiring cannot be made correct on this fleet. It should be removed outright rather than gated behind an opt-in flag.2.
~/.local/share/skillsis the right default — the docs are what's wrongI filed this saying the default
CHECKOUT_DIRshould change to match CLAUDE.md's~/dev/skills. That is backwards. On the Macs,~/dev/skillswould put the checkout inside iCloud, i.e. one git working tree with two concurrent writers — the failure mode described in brainknowledge/icloud-git-workspace-appearance-vs-corruption. The host-local default is correct precisely because it is outside the synced folder.Revised: keep
CHECKOUT_DIR=~/.local/share/skills. Fix the documentation (~/dev/CLAUDE.md,SKILLS_INDEX.md) which currently instructs cloning into~/dev. koala's~/dev/skillsworks only because koala's~/devis not synced — an accident, not a pattern to document.Still open, unchanged
task doctorreporting both dangling and stale-but-resolving links (the silent-wrong case).task checkrejecting stray non-directory root entries.Separate finding for the Macs
The shared
~/devcarrieslocal-devpinned pre-extraction (e6d3c2d), so~/dev/.skillsstill exists there. Both Macs have zero engineering skills wired into any harness —~/.claude/skillson iguana holdsgsd-*only, and no crush / antigravity / vibe skills dirs exist. Pulling1966d5aon either Mac deletes.skillswith no replacement in place. Needs a host-local install on each Mac before that pull.Done — the mechanism is gone, not patched
Shipped in v0.4.1. koala is fully migrated and verified.
What changed
Claude Code no longer uses symlinks at all. This repo is now a marketplace hosting one plugin — itself,
source: "."— installed straight from gitea:Skills moved to
skills/<name>/SKILL.md. The plugin ships 42 skills + 3 hooks + 3 MCP servers as one versioned unit, so thesecret-guardandpre-task-ritualhooks and the brain/gitea/infra server definitions travel with it instead of existing only in koala's~/.claude/.The other harnesses went from 168 symlinks to 3.
wire_claude_repoandwire_claude_globalare deleted outright rather than flag-gated — on a mixed-root fleet per-repo wiring cannot be made correct. Antigravity and Vibe targets removed (unused); Pi and OpenCode added, reached through the convergent~/.agents/skillsconvention both read:Adding a skill now wires it everywhere with no re-run.
Corrections to what I filed above
Two of my original claims were wrong and are worth recording:
CHECKOUT_DIRwas never the bug. I filed this saying the default should change to match CLAUDE.md's~/dev/skills. Backwards — on the Macs that puts a git working tree inside iCloud with two concurrent writers. The host-local default is correct because it is outside the shared folder. The docs were wrong, and they now say so with the reason attached (~/dev/.context/AGENT.mdupdated and synced).Per-repo wiring is not fragile, it is impossible.
~/devspans/Users/mathiasand/home/mathias; an absolute link stored there is broken on one side by construction. So it had to be removed, not gated.Detection, since removal only covers Claude Code
bin/doctor(POSIX sh, 27 ms, no network) reports with a non-zero exit: dangling links; links that resolve but land outside the active checkout — the silent-stale case that is worse than dangling because nothing complains; count mismatches; stray entries; leftover per-skill Claude symlinks; and divergence between the plugin copy and the copy the other harnesses link to.Wired as a
SessionStarthook in the plugin, so it reaches every host that installs it with no launchd/systemd wiring. A healthy host prints nothing.task checkgained frontmatter validation, a stray-root-entry gate, and manifest/tag version parity.Original proposals, resolved
drop— deleted outright ✅wire_claude_repofix— rejected, default was right; docs fixed instead ✅CHECKOUT_DIRdefault—task doctorfor dangling + stale-but-livebin/doctor, 7 fixture tests ✅✅task checkrejects stray root entriesVerification
claude plugin validate .clean · 27/27 shell tests green ·task checkOK at 42 skills ·doctorexit 0 · plugin update path proven live (0.3.3 → 0.4.1 through theversionfield) ·secret-guardconfirmed blocking the sops decrypt-and-print shape from inside the plugin in a fresh session.Follow-ups: #24 (Mac rollout — both still have no skills and no secret-guard), #25 (
env -ufalse positive).Closing.