fix(source-control): reap a torn-down worktree's project-scope plugin install records - #3116
Conversation
… records (0.55.0) Claude Code records a project-scope plugin install in ~/.claude/plugins/installed_plugins.json keyed by a literal `projectPath`, and nothing reaps that record when the path goes away. Every worktree this plugin created and destroyed therefore left one record per installed plugin behind permanently. Measured on the author's machine: 108 project-scope records across 8 marketplaces, all naming one worktree directory that no longer exists — every project-scope record on that machine an orphan, from a directory tree this plugin's own option describes as ephemeral. `cleanup` Step 4b now runs scripts/reap-project-plugin-records.sh from inside the candidate, after the stranded-work and carried-file guards clear and before the directory is removed. The reap is ordered last of the three because it is the only one that cannot be undone: it must not fire for a worktree the guards are about to save. The trigger is the teardown, never path non-resolution. A project-scope record for a live repository on an unmounted share or a detached volume is indistinguishable from a dead worktree to a bare existence check, so the helper refuses unless --worktree-path names the directory it is already standing in — the cwd boundary rendered in code rather than in prose. `audit` gains a Step 2b that REPORTS pre-existing orphans (unreachable by a step that did not exist when they were created), bucketed as orphaned worktree records versus other project records, the latter listed with no remedy offered. Removing one needs its directory recreated first, which the audit emits for the user and never performs. Nothing edits installed_plugins.json; enumeration goes through `claude plugin list --json` and removal through `claude plugin uninstall <id> -s project`. Never `-s user` — the CLI's own failure text suggests it, and following that would uninstall the plugin fleet-wide — and never `--prune`. fixtures/project-scope-reap-probe.sh records six arms establishing that `-s project` has no path flag and resolves strictly against the resolved absolute cwd, that `plugin list --json` enumeration is cwd-independent, and that a record outlives its directory but is reachable from an empty directory recreated at the same path. Claude Code 2.1.240, re-run unchanged on 2.1.241, Windows. Closes #3113 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…unsafe Fresh-context verification found the classification hole. The external worktree root is SHARED — `create` places worktrees at `<root>/<owner>-<repo>-<slug>`, one root serving every repository on the machine — so "under the worktree root and not in this repository's `git worktree list`" is true of every OTHER repository's live worktree under it. Both new surfaces classified on exactly that test, which would have reported a sibling repository's active worktree as dead and handed it a destructive remedy. Registration is scoped to one repository; liveness is not. Both are now required: - `audit` Step 2b gains a `live elsewhere` bucket keyed on `git -C <path> rev-parse --is-inside-work-tree`, and a precondition that stops the whole step when the resolved worktree root itself does not resolve — the case where a detached volume makes every path under it look orphaned on identical evidence. The remaining bucket is named `candidate orphan` and is presented as two failed tests rather than as proof of death. - `cleanup`'s orphaned-directory candidate takes the same liveness test before the reap and before its `rm -rf`. - The audit's emitted remedy uses plain `mkdir`, never `mkdir -p`: it must fail on a live directory rather than no-op into the reap. Every step is now `&&`-chained, so a reap that exits 1 or 3 leaves the directory in place instead of deleting it and putting the surviving records out of reach. - `--dry-run` no longer claims a pass-through at a step it never reaches. - The cwd-independent-enumeration comment carries its basis pointer and version stamp instead of a bare "(measured)". Closes #3113 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Claude finished @kyle-sexton's task in 3m 40s —— View job Claude finished the code review
SummaryReviewed the diff against One finding posted inline — plugins/source-control/scripts/reap-project-plugin-records.sh:247-262: The pre-reap enumeration failure (line 201) correctly degrades — No other high-signal issues found — the CLI-boundary logic, path normalization (backslash/case/CRLF handling), the ordering of guards before the reap in |
|
Claude finished @kyle-sexton's task in 1m 35s —— View job Claude finished the security review
Security review — no issues foundThis PR touches genuinely security-relevant surface (new shell scripts that call Checked for, and did not find:
Nothing here rises to CRITICAL/IMPORTANT/SUGGESTION. The destructive operations ( |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: ba17c8f87f
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
|
Last security-reviewed head: |
|
Claude has reviewed this PR 4 times. The lane skips further automatic reviews after 5; deleting this comment resets the count. |
…dence bar
Second fresh-context verification: the orphaned-directory candidate was the
weak edge. It is the only candidate class with NO stranded-work row to read —
the engine enumerates strictly from `git worktree list`, so an unregistered
directory produces no row, and guard 1's closed-list rule covers an unrecognized
value, never an absent one. That left a single negative test standing between a
live directory and an unrecoverable reap plus `rm -rf`, while `audit` refused
the identical inference on the identical evidence.
The concrete miss: another repository's LIVE worktree whose main clone has been
moved, deleted, or unmounted still carries its `.git` FILE while
`rev-parse --is-inside-work-tree` fails. One test called it an empty husk.
An orphaned directory now has to pass all three of: not a work tree, no `.git`
entry (file or directory), and empty. The middle test is the load-bearing one
and the first does not imply it; the third is the only positive evidence in the
set, and it is what the presentation row's "(empty, no git ref)" always claimed
and never checked. `cleanup` also gains `audit`'s stop-scanning-an-unresolvable-
root precondition, and Step 2's layout list now names the configured external
root where `create` actually places every worktree — it was arguing from a
shared root it never told the scan to look at.
Also: the audit's user-pasted remedy no longer carries `${CLAUDE_PLUGIN_ROOT}`,
which is unset in a user's own shell and made the command inert at exit 127; the
`live here` test names the path normalization the record/`git worktree list`
spelling mismatch requires; and the `<owner>-<repo>-<slug>` naming is cited to
`worktree-create.sh`, which owns it, rather than to the root-key convention doc.
Closes #3113
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Claude finished @kyle-sexton's task in 1m 6s —— View job Security review (
|
|
Claude finished @kyle-sexton's task in 3m 18s —— View job Claude finished the code review
SummarySkip-gate check: HEAD has advanced from The new commit, I also checked whether No new high-signal findings in this commit. Status of the previously-flagged issue: |
…e verification pass 3 CI: the hygiene lane's machine-specific-paths check failed on two lines this change added, both illustrating the 8.3 short-name trap with a literal `C:\Users\<name>\...`. The gate's own remedy is placeholders or relative paths, so both now name only the user-directory component (`ALICE~1` vs `AliceExample`) — the shape the trap is actually about — with no absolute path. The invented names were never an operator's; the gate flags the path shape regardless, which is why the excluded files in ci.yml are excluded. Third fresh-context verification returned PASS; its four non-blocking findings are folded in here: - A native symlink under the shared root passed all three tests, because `find <path> -mindepth 1` does not descend a symlinked start point and reports a busy directory as empty — while the reap, which resolves cwd through the link, would act on the TARGET's records. A symlink is never a husk this action created: it is now disqualifying, making the gate four tests, and every surface that states the count agrees. - The empty test now says to read `find`'s OUTPUT, not its exit status — `find … | head -1` exits 0 either way, so a status check calls every directory empty. - The `rm -rf` block, which sits ~90 lines above the qualification, now says up front that absence from `git worktree list` is not on its own a licence to run it. - `audit`'s per-row caveat listed only the unmounted-volume case. It now lists all three live things that fail both of its tests, including the one `cleanup` calls load-bearing: another lane's live worktree whose main clone was moved, deleted, or unmounted. - The `tr -s '/'` comment claimed the squeeze "can only ever make two spellings of one path agree". A UNC `//server/share/x` squeezes to `/server/share/x`, so the claim is now qualified rather than absolute. Closes #3113 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Claude finished @kyle-sexton's task in 4m 35s —— View job Claude finished the code review
SummarySkip-gate check: this lane's last review covered This commit adds a fourth qualifying test — not a symlink — to the orphaned-directory candidate check, closing a real hole: One finding posted inline — plugins/source-control/skills/worktree/context/cleanup.md:167: The new Status of the previously-flagged issue: No other issues found in this delta — the CHANGELOG wording matches the code/doc changes, and the symlink-comment edits to the script are comment-only with no functional change. |
|
Claude finished @kyle-sexton's task in 1m 47s —— View job Security review (
|
Three review threads on #3116, all correct, all fixed. **Threads 1 and 2 (same defect).** The script verifies its own pass by re-enumerating after the uninstall calls. That second enumeration's failure was absorbed — `survivors="$(collect_matches)" || survivors=""` — so the script printed `ok: every … is gone` and exited 0 having confirmed nothing, while the IDENTICAL pre-reap failure a few lines above already degraded with `warn:` and exit 3. The asymmetry was the bug: both mean "unknown outcome", and only one said so. This was the script's most dangerous possible failure, because exit 0 is precisely the outcome a caller acts on by deleting the directory — the reap's whole purpose is that the directory is the last place those records are reachable from. A post-reap enumeration failure now reports `surviving UNKNOWN`, states the pass is UNVERIFIED, names how many calls reported success, and exits 3. The exit-code contract now says exit 3 covers both enumerations. **Thread 3.** The symlink disqualifier added last commit — itself the fix for verification pass 3's escape — was defeated by a single trailing character. POSIX resolves a trailing-slash path THROUGH a symlink to a directory, so `test -L "link/"` answers about the target. Measured on this host with native symlinks, and now pinned as three assertions rather than asserted in prose: test -L link => TRUE test -L "link/" => FALSE find link -mindepth 1 => (empty) `find` not descending a symlinked start point is the other half: with a trailing separator a symlink to a busy directory reads as an empty non-symlink and passes all four qualifying tests into `rm -rf`. `cleanup.md` Step 4b now normalizes the path before all four tests and carries the measurement; `audit.md`'s mirrored "check it the way cleanup does" guidance carries the same rule. Suite is 40 cases (was 32): the post-reap degrade is driven by a stub whose `plugin list` succeeds on call 1 and fails on call 2, and the trailing-separator rule by a real symlink fixture that skips honestly where the platform cannot create one. Closes #3113 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Claude finished @kyle-sexton's task in 2m 16s —— View job Security review (
|
|
Claude finished @kyle-sexton's task in 1m 48s —— View job Claude finished the code review
SummarySkip-gate check: this lane's last review covered This commit is explicitly a fix-up for the three open threads on this PR (this lane's, Codex's, and this lane's trailing-slash finding). I verified each fix rather than restating the author's claims:
I did not find a new issue in this delta. One low-signal observation, below the bar for this lane (not filed as a finding): the illustrative snippet at cleanup.md:171-173 shows only No new findings — no inline comments posted. Both previously-flagged threads (the post-reap swallow and the trailing-slash symlink gap) are now fixed and covered by tests I read and traced by hand. |
…#3111) The rebase conflict resolution for #3116 dropped the blank line separating the resolved region from the next release heading, which MD022/MD032 rejected — CI's `hygiene` job lints markdown repo-wide, while the scoped local run I had been using never read this file. Content unchanged; the local lint is now run over `plugins/**`, `docs/**`, and the repo root instead. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…3111) (#3115) Closes #3111 ## Summary An **unversioned** claim — `claude plugin install --config` "only applies on a fresh install (ignored once installed)" — reached 25 setup skills and the README options generator by template sweep, and prescribed `claude plugin uninstall` + reinstall as the headless way to change an option. That prescription is destructive: uninstalling drops the plugin's entire stored `pluginConfigs` entry, resetting every declared option to its manifest default — **35** options on `source-control`, **21** on `guardrails`. On **Claude Code 2.1.240**, a plain `claude plugin install … --config` against an *already-installed* plugin prints `already installed` **and still writes the value**, verified by writing a non-default value to an installed plugin and restoring it. The short-circuit message is about the install, not the config write. **What this PR does not claim.** The claim landed 2026-07-18 in `fe28ffa70` with no version stamp, and Claude Code 2.1.221 changed install-activation behaviour in this same area — so "the claim was always false" and "it was true when written and has since been fixed" are not distinguishable from the available evidence, and nothing here asserts either. The defect reported and fixed is narrower and provable: *an unversioned harness claim, propagated by template, prescribed a destructive uninstall for a case where a plain `install --config` demonstrably works on 2.1.240.* Every replacement claim carries the CLI version it was verified against and the conditions it covers — a **non-sensitive** option at **`user`** scope — and names `sensitive` options and `project`/`local` scope as **not** covered. `dometrain` and `miro`, whose options are `sensitive: true`, therefore keep `/plugin configure` as their sole prescribed rotation path; the observation appears there as prose with an explicit "do not rely on it for a credential". House style follows `plugins/claude-ops/skills/plugins/context/scope-semantics.md`. The stamp is **2.1.240**, not the 2.1.238 pinned in `package.json` — 2.1.240 is the version the observation was made on, and a stamp records the observation, not the CI pin. ## Fix - **Generator** (`scripts/sync-plugin-options-docs.py`) — route 2 no longer implies install-time-only and carries the stamp. Two "Upstream documentation" links resolved to empty backward-compatibility `<span id=…>` stubs on the settings page: live enough for lychee's fragment check, blank space for a human reader. They now point at the headings that hold the content (`settings-reference#plugins-and-skills`, `settings#settings-files-and-who-they-affect`), and a link to the `--config` flag's own reference entry was added. **All 33 READMEs were regenerated by running the script — no generated section was hand-edited.** - **25 setup skills swept.** The 18 named in the issue, plus `actionlint` (a softened variant that still prescribed the uninstall) and `session-flow` (the full claim, line-wrapped so a single-line grep missed it), plus `ai-briefing`, `bug-report`, `discipline`, `dometrain`, `education`, `miro`. - **Uninstall hazard prose: kept, demoted.** It remains true for a genuine uninstall, so deleting it would remove real information — but it stops being the prescribed path and becomes the reason not to take it. Three counts inside that prose were already stale (`guardrails` said "twenty" against 21 declared, `source-control` "twenty-nine" against 35, `desktop-notification` "four"); rather than correct numbers that rot, each now points at the README's generated Options reference table. - **Verify-it-landed.** The setup contract's `apply` now ends by reading the effective value back and reporting it, instead of asserting an unobserved change — with the fresh-session caveat preserved wherever a `${user_config.*}` value is injected at skill load. Five plugins are check-only (no `apply` action): there the step lives in `check`, and their changelog entries say so rather than repeating the template. - **Two new setup skills** — `context-budget` and `repo-hygiene`. Each earns a real `check` on criterion (b), an external prerequisite the native configuration prompt cannot see: `node`/`claude`-CLI/Agent-SDK, plus the exec-form hook whose silent launch failure makes a `true` toggle inert; and `git` (required by *every* clean tier via `git ls-files --error-unmatch`, not only the git-named ones) with optional `ghq`. - **A third was written for `visualization` and deliberately dropped.** Its lone `medium` option is **trivial** by `PLUGIN-PHILOSOPHY.md`'s own test — a self-contained scalar with a default preserving zero-config behavior, and its out-of-set values are documented as falling back to that default, a case the definition names explicitly — and the plugin has no external prerequisite and no consumer-project configuration surface. None of criteria (a), (b), (c) holds. Its warrant rested only on the coupling clause (`artifact` is correct only where that surface is reachable), which is thinner than the test it has to beat, so shipping it would have been the blanket ceremony that section forbids. Recorded in the plugin's CHANGELOG under "Unchanged, deliberately" so a reader can tell it was decided, not forgotten. - **12 new `evals/evals.json`**, required by `scripts/check-changed-skills.sh --require-evals` for any new or modified `SKILL.md`. Three existing eval sets (`guardrails`, `knowledge`, `skill-quality`) graded *against* the old claim and would have failed the corrected skills; they were corrected too. - **Version bumps + CHANGELOG entries** for all 33 touched plugins. ### Review round — three threads, all addressed - **`bash-format/skills/setup/SKILL.md:110` (P2) — a same-session read-back reports the stale value.** This landed directly on the verify-it-landed clause this PR was adding. Followed literally in the session that issued the write, "rerun `check` and report the observed effective value" reports the OLD value and reads as a failed write: the rendered `${user_config.*}` is injected at skill load, and each hook receives its `CLAUDE_PLUGIN_OPTION_*` from a process environment fixed at session start. The clause now keeps the two claims apart — the write is issued and the stored value is what was passed; the RUNNING session's behavior is not — names both mechanisms, and sends verification to a fresh session. Applied at the converged wording across 18 setup skills plus `discipline` and `skill-quality`, whose variants would otherwise have been left behind, and at the generated README block so a human reader hits it too. - **`scripts/sync-plugin-options-docs.py:105` (P2) — the generator prescribed an unverified credential rotation.** The reconfiguration text was emitted unconditionally, including for `dometrain` and `miro`, whose only option is `sensitive` — the exact case the 2.1.240 observation does not cover. Fixed at the SSOT rather than in the two READMEs, which would have regenerated away. `render()` now partitions on `spec.sensitive`: sensitive-only plugins get a block that routes rotation to `/plugin configure` and says plainly that post-install `--config` is unverified for a sensitive value; a mixed manifest keeps the claim, draws its example key from the non-sensitive set, and names the keys it does not cover (no plugin is mixed today — the branch exists so the next one does not silently inherit the wrong text). `first` now comes from the non-sensitive set, so the shell example and the `pluginConfigs` snippet stop leading with a credential. Regenerating touched exactly the two intended READMEs. - **`plugins/claude-ops/README.md:315` (P1) — README contradicts the skill beside it.** Answered, not fixed: that skill is the parallel claude-ops workstream's fence. See the forced-exception section below for why the README could not simply be reverted instead. The contradiction is transient and fails safe — the README warns against the destructive uninstall, and the skill is the surface that still prescribes it. ### Rebased onto `af3fea480` (#3116) #3116 landed `source-control` **0.55.0** (a deliberate minor: `cleanup` gained a new side effect). Both sides bumped `plugins/source-control/.claude-plugin/plugin.json` and added a `CHANGELOG.md` entry. Resolved by taking the higher version and keeping **both** entries: this PR's setup-skill change now ships as **0.55.1**, stacked above #3116's `## [0.55.0]`, which is unmodified and unrenumbered. A further patch bump on top of 0.55.0 is warranted because this is a separate user-visible documentation change to the same plugin, and `check-changelog-parity.sh --check-bump` requires a change set that touches a plugin's shipped files to carry its own new entry. ### One forced fence exception `plugins/claude-ops/` belongs to a parallel workstream. Its README still had to be regenerated (one template, every plugin), and `check-changelog-parity.sh` rejects a change set that modifies a plugin's shipped files while reusing its published version — while reverting the README would fail the generator's own `--check`. The second commit here is therefore the minimum the gate accepts: a patch bump and a release entry **scoped to the README regeneration alone**. claude-ops's `setup` skill is untouched here and still carries the old claim, by design — #3112 owns it. Expect a version conflict with that PR; resolve by taking the higher version and keeping both entries. The independent verifier confirmed this was forced rather than convenient, against the gate's source: `scripts/check-changelog-parity.sh:619-624` fires `PUBLISHED VERSION REUSE` when a plugin has shipped-file changes and `head_version == fork_version`, and `plugins/claude-ops/README.md` matches the `shipped_changed` path arm at `:462-464`. ### Adjacent drift, deliberately not fixed here The claim's canonical home is out of this PR's fence and still asserts it: `docs/extensibility-contract-smoke-tests.md:21,110-111` (Test C, correctly stamped 2026-07-12 / CC 2.1.207 — its *observation* stands, its *inference* is what propagated), restated unstamped at `docs/PLUGIN-PHILOSOPHY.md:262-263` and `docs/MIGRATION-PLAYBOOK.md:1339,1391-1392`. No CI gate ties those to the skills (`check-contract-clause-coverage.py` and `check-cross-plugin-source-drift.sh` both pass), so they are reported rather than silently changed. "Change the contract once rather than every consumer" is **not achievable inside this fence** for the same reason — the contract is defined in `PLUGIN-PHILOSOPHY.md § Setup is explicit and repeatable`; convergence here was achieved by giving all 25 skills identical wording instead. Five further items. The first is **resolved and landed here**; the rest are surfaced only. 1. **Coverage rule vs doctrine — resolved.** The working rule "declares `userConfig` ⇒ ships a setup skill" is *broader* than `PLUGIN-PHILOSOPHY.md`, which requires setup iff (a) a consumer-project config surface, (b) an external prerequisite, or (c) **non-trivial** `userConfig`, and exempts a trivial-option plugin as "blanket ceremony". Under the blunt rule a plugin whose whole manifest is one kill switch reads as a coverage gap, and closing that gap ships the ceremony the doctrine forbids — which is exactly what the `visualization` skill would have been. The refined rule now lives in `PLUGIN-PHILOSOPHY.md` § "Setup is explicit and repeatable", stated as fleet coverage: *a plugin declaring `userConfig` ships a `setup` skill unless every declared option is trivial by the test above and neither (a) nor (b) holds.* All three plugins from this change set are named there as the worked example, so the next reader does not re-derive the blunter version. This is the one `docs/` edit in the diff and it is additive — it does not touch the `--config` prose at `:262-263` that a separate workstream owns. 2. **Evals doctrine vs the evals gate.** `MIGRATION-PLAYBOOK.md` makes evals an explicit **skip** for a skill in a hook plugin, yet `check-changed-skills.sh` passes `--require-evals` for *any* touched `SKILL.md` and hard-FAILs without them. Nine of the thirteen eval sets added here are for exactly those hook-plugin setup skills. The gate won because CI must be green; one of the two surfaces should move. 3. **PR-body sections.** `.claude/source-control.md` declares `pr_body_required_sections` as Summary / Test plan / Related, but `pr-issue-linkage` requires `## Fix` and `## Verification`. This body carries all five; the two surfaces disagree. 4. **Org-agnosticism enforcement is split.** `validate-plugin-contracts.mjs` gates the no-org-reference rule for `autonomy` only (`:245-247`); `github` enforces the same class itself in `plugins/github/github.test.sh` (D4). A fleet-wide changelog sweep therefore trips two different mechanisms in two different jobs. Both are handled here, but the rule has no single home. 5. **Not a conflict after all — correcting an earlier claim of mine.** I initially reported the five check-only setup skills (`dometrain`, `miro`, `bug-report`, `education`, `discipline`) as violating the mandatory `check` + `apply` contract. They do not: `scripts/validate-plugin-contracts.mjs:56-62` sanctions a "check-only userConfig-only carve-out" for exactly this shape, and each of the five declares it. The doctrine and the validator agree; I had read the contract sentence without the carve-out. Retracted rather than left standing. ## Verification Every result below is from this branch's HEAD, run from the repo root. - `node scripts/validate-plugin-contracts.mjs` — **PASS** (50 setup skills, 2832 plugin files) - `python3 scripts/sync-plugin-options-docs.py --check` — **PASS** (up to date) - `node scripts/generate-catalog.mjs --check` — **PASS** · `node scripts/generate-cheatsheet.mjs --check` — **PASS** (setup skills are excluded from the sheet by rule, so the three new ones do not disturb it) - `scripts/check-changed-skills.sh origin/main` — **27 skills checked, 0 failed** - `plugins/skill-quality/scripts/check-evals-quality.sh` over the touched eval sets — **PASS**; the single Q6 WARN on `guardrails` (cases 2/4/5 share a prompt) is pre-existing and advisory - `check-jsonschema` against `plugins/skill-quality/reference/evals.schema.json` — **PASS** for all 12 new sets - `scripts/check-changelog-parity.sh` in all four modes (`--check`, `--check-bump`, `--check-preserved`, `--check-order`) — **PASS** - `scripts/check-skill-count-claims.sh --check` — **PASS**; the new skills falsified "One skill" claims in `repo-hygiene` and `visualization`, reworded to name the skills rather than count them (the exemptions file's prescribed treatment), and `context-budget`'s `## Skill` section is now `## Skills` - `scripts/check-skill-leaf-names.sh --check`, `check-skill-portability.sh`, `check-shell-portability.sh`, `check-contract-clause-coverage.py`, `check-cross-plugin-source-drift.sh --check`, `check-silent-skips.sh`, `check-hook-wiring-liveness.sh`, `check-stale-base-overlap.sh --check` — **all PASS** - `bash plugins/github/github.test.sh` — **PASS=36 FAIL=0** - `markdownlint-cli2@0.23.2` with the repo config over 208 plugin CHANGELOGs, READMEs, and setup skills — **0 issues** - `scripts/run-ruff.sh check scripts/sync-plugin-options-docs.py` — **PASS**. `ruff format --check` reports one pre-existing reformat at lines 245-246, outside this diff; left alone. - The four upstream links in the generated block were fetched and their anchors checked against the rendered headings. That pass also confirmed the **official docs are silent** on `--config` against an already-installed plugin — the `plugin install` reference documents the flag only as "Set a `userConfig` option declared in the plugin's manifest", and `plugin update` has no `--config` at all. Nothing upstream contradicts the correction; nothing upstream supported the original claim either, which is why the replacement is stamped rather than cited. **Scripted-edit damage, caught and reverted before push — recorded because it nearly shipped.** The per-plugin changelog bullets were rewritten by script across 33 files, and three passes went wrong in ways every gate stayed green through: `textwrap` defaults to `break_on_hyphens=True` and split inside link URLs; the repair for that stripped the hyphens it meant to rejoin (`melodic-software` → `melodicsoftware`); and reading fork-point blobs through subprocess TEXT mode decoded them with the console codepage, mojibake'ing every em dash in already-released sections. A bullet-end regex using `[^.]*` also ran past a blank line and ate a `## [0.` heading prefix. All reverted by reconstructing every touched CHANGELOG from the fork-point blob as **bytes**; `git diff` against the fork point now shows **zero deletion lines** across all 33, so released history is byte-identical and this change set is a pure addition. The lesson generalizes past this PR: a scripted edit over many files needs an invariant asserted afterward, not a spot-check. **Two defects CI and the verifier caught that local checks had not**, both now fixed and both worth knowing about: 1. `plugins/github/CHANGELOG.md` carried a full org-qualified issue URL, violating `github.test.sh`'s D4 agnosticism check — `plugin-gate` was red on commit 2. Fixed in commit 3 (bare `#3111`). The shared validator only enforces this class for `autonomy`, which is conflict 4 above. 2. Five changelog entries claimed `apply` reads the effective value back on plugins that have **no `apply` action** (`dometrain`, `miro`, `bug-report`, `education`, `discipline`), and `dometrain`/`miro` additionally called the `--config` rerun "the documented route" when both deliberately keep `/plugin configure` for their `sensitive` option. Template boilerplate asserting something unverified for its target — the same defect class this PR exists to correct. Fixed in commit 4, with every remaining plugin's `apply`-readback claim re-checked against its actual `SKILL.md`. **Independent verification.** A fresh-context agent that did not author the changes judged the final state against per-criterion pass/fail conditions and returned **PASS**: (a) every replacement claim version-stamped in the passage that makes it, with no "always false" / "fixed since" overclaim and the sensitive-option prose saying *unverified* rather than re-voicing the old claim; (b) no generated README hunk crosses a BEGIN/END marker, boundary-checked per file; (c) the new setup skills conformant and lint-clean (`CHECK-SKILL setup: PASS — 0 errors, 1 advisory warning` each), with the `visualization` warrant flagged as too thin against the trivial test — that flag is why the skill is no longer in this PR; (d) nothing outside the fence beyond the forced claude-ops bump, verified against the gate's source; (e) validators green. **Why that step earned its cost.** Defect 2 above is the strongest argument for it. The correction to 25 skills was applied from one template, and the template pushed an "`apply` reads the effective value back" sentence into five plugins that have **no `apply` action**, and told `dometrain`/`miro` the `--config` rerun was "the documented route" when both deliberately keep `/plugin configure` for a `sensitive` option the evidence never covered. That is precisely the defect this PR exists to correct — an unverified claim propagated by template into surfaces it was never checked against — reproduced one level down, by the change fixing it, and invisible from inside the context that wrote it. Every local gate was green over it. A fresh context reading the final state is what caught it. ## Test plan - CI on this PR is the primary gate; the commands above are the same ones its jobs run. - To reproduce the empirical basis by hand, on an already-installed plugin: read `~/.claude/settings.json` → `pluginConfigs["<plugin>@<marketplace>"].options`, run `claude plugin install <plugin>@<marketplace> -s user --config <key>=<non-default>`, re-read the file, then restore the original value the same way. Expect `already installed` on stdout and a changed stored value. - To review the doc surface efficiently: `python3 scripts/sync-plugin-options-docs.py --check` proves the generated blocks match the generator, so review effort belongs on `scripts/sync-plugin-options-docs.py` and the hand prose in the 25 setup skills, not on the 33 regenerated blocks. ## Related - #3111 — the tracking issue: carries the empirical proof, the stamp-don't-delete framing constraint, the coverage rule, and the verify-it-landed item. - #3112 — claude-ops:plugins audit remediation; owns `plugins/claude-ops/` and applies this same `--config` correction to that plugin's own setup skill, which is why that one file sits outside this fence. Conflicts with the forced claude-ops bump described above. - #3113 — source-control worktree-teardown record reaping; touches `plugins/source-control/` alongside this PR's edit to that plugin's `skills/setup/`. - `fe28ffa70` — "feat: adopt the uniform setup contract across 17 shape-B plugins (#360)", the 2026-07-18 template sweep that propagated the unversioned claim. - #2193, #1360 — prior fleet-wide sweeps of the same generated options block; the bump-every-touched-plugin convention used here follows theirs. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
…3148) Closes #3112 ## Summary Remediates the post-use plugin-quality audit of `claude-ops:plugins`' `sync` action — all 15 findings, plus a catalog-version pre-filter worth more than the rest combined. Every harness-behaviour claim added or changed here is version-stamped against **Claude Code 2.1.240**, the CLI it was verified on. Three silent-failure paths closed, all of the same shape: correct-looking output from a path that did not run. - **F1 (high)** — Step 2, the self-described "primary value path", no-opped invisibly. `fleet-state.sh` computed `currentProject` as a correct tri-state, but `null` covers "user-scope record", "no `projectPath`", *and* "no project context resolved at all", so a run from `$HOME` and a run inside a repo with no in-repo installs produced an identical report. New top-level `project_root` carries the distinction; the report gains a fixed `In-repo:` row that must state a skip. - **F4 (medium-high)** — `sync all` refreshed every marketplace, then ran install/update/enable maintenance against exactly one. Steps 2–5 are now the per-marketplace loop body, every `--ids` call carries `--marketplace`, and a new `--marketplaces` mode enumerates the names CR-free so the loop needs no hand-written `jq`. - **F2 (high)** — every actionable divergence routed to a `converge` command that cannot execute, because `-s project` has no path flag and the recorded `projectPath` no longer exists. Those rows now get their own report section, **outside** the Divergences count, and `converge` emits them as blocked rather than runnable. **Design decision on F2, which the brief left to me:** stale project records are *classified and reported*, never converged and never reaped. No `claude plugin` verb removes an install record by path, editing `installed_plugins.json` is outside this skill's boundary, and claude-ops must not read another plugin's `userConfig` to decide whose directories these were — so the wording is deliberately generic about "a tool that owns those directories' lifecycle" rather than naming one. `projectPathPresent` is advisory and may never filter: an unmounted volume, an offline share, and unplugged removable media are indistinguishable from a deleted worktree to a directory test, so the report says "not present on this machine", never "dead". ## Fix **Catalog-version pre-filter (the performance finding).** `marketplace.json` entries carry no version — which is why Step 3 called `claude plugin update` for every user-scope install and let the CLI no-op. Each plugin's version does exist in the marketplace checkout at `<installLocation>/<entry.source>/.claude-plugin/plugin.json`. `fleet-state.sh` now exposes it as `catalog_versions`, and `--ids update-candidates-user` withholds only ids it positively proved already sit at the catalog version. On this machine's already-current fleet: **66 update calls → 0**. It **fails open by construction, and that is the dominant path, not an edge case.** Measured across nine registered marketplaces: resolves fully for five, partially for two (13/53, 1/15), not at all for two. Any unreadable version yields a candidate, exactly as if no pre-filter existed. The test asserts the degradation as a byte-equality against `installed-user`, not a spot check. The pre-filter is also disabled — falling back to the unfiltered `installed-user` — for any marketplace whose Step 1 refresh failed, since a stale checkout cannot prove an id current; and `audit` mode, which never refreshes, reports its prediction as a lower bound carrying the catalog's `lastUpdated`. **The manifest must sit inside the checkout, enforced physically.** `source` is third-party content and the only unsafe direction is *withholding* an update. A lexical `../` refusal is insufficient — a symlink inside the checkout is reached by an ordinary `./name` source no string check can see — so the resolved manifest path is required to sit under the resolved checkout root with symlinks followed. Also fixed: `pluginConfigs` read-path claim (user / `--settings` / managed only; project and local ignored since v2.1.207, while `enabledPlugins` still honors them); `/reload-plugins --force` restated as the docs' two-step with prompt-cache invalidation as the trigger; divergence count split into run-caused vs pre-existing; self-update row when the sweep updates `claude-ops` itself; TOCTOU wording matched to the implementation with the inert detector replaced; `versionsMatch` reduced to one origin plus pointers; `user_scope_orphans` for the structurally-invisible single-scope orphans. **`setup` — now load-bearing, not tidiness.** #3115 regenerated this plugin's README with the corrected reconfiguration guidance while `skills/setup/SKILL.md` still prescribed the destructive uninstall/reinstall cycle — a contradiction live on `main` that #3115's own reviewer flagged and could not fix, because that file is in this change set's fence. This PR closes it, matching the landed fleet-wide wording verbatim apart from this plugin's option list, including the distinction that the **write** lands while the **running session's** behavior does not (`${user_config.*}` is injected at skill load and `CLAUDE_PLUGIN_OPTION_*` comes from an environment fixed at session start, so a same-session `check` still reports the old value and reading that as a failed write is wrong). Also corrects an inherited miscount: 15 options and 8 `*_audit_enabled` toggles, not 14 and 7. **Deferred, with reasons in the CHANGELOG:** `--run-log` in `fleet-state.sh` (conflicts with the script's read-only contract; F12's cheapest tier landed instead), a fourth `install_new` value (needs durable state), F8's `--selfcheck`, F14 (the repo's own `check-changed-skills.sh` enforces trigger-keyword preservation, and the finding is cosmetic since the skill sets `disable-model-invocation: true`), and the upstream issue for the missing record-reaping verb. ## Verification Two independent fresh-context verifier agents reviewed the final state against the sealed audit packet. Both initially returned **FAIL**; both sets of defects were fixed and re-verified. Round 1 found the change had made Step 3 catalog-dependent without extending the stale-catalog deferral rule — a stale checkout could withhold an id as "already current". Fixed at the decision point, plus three unstamped claims and an over-flattering CHANGELOG figure. Round 2 found the symlink-containment hole above, and that `audit` mode never triggers the stale-catalog fallback. Both fixed. Worth recording: the symlink regression test initially failed for the wrong reason — Git Bash's `ln -s` silently deep-**copies** instead of linking, so the fixture was asserting against a real in-checkout directory and `9.9.9` was the correct answer for what was on disk. The test now creates a genuine symlink via `MSYS=winsymlinks:nativestrict`, gates on `[[ -L ]]`, and skips honestly where the platform yields no real symlink. Verified against real symlinks from both `ln -s` and `cmd mklink /D`. ## Test plan - `fleet-state.test.sh`: **45 → 73 cases, 0 failed**, covering every behaviour change — `catalog_versions` and all four fail-open inputs, the fail-open byte-equality proof, `update-candidates-user` (equal/behind/ahead/project-scope/partial), symlink containment plus an in-checkout control, the production `installLocation` branch (which bypasses the fixture override), `projectPathPresent` including the `false`-is-not-`null` guard and the never-filters guarantee, `user_scope_orphans`, `project_root` (including that every `--all` block carries it), `--marketplaces` (enumeration, empty, standalone-flag rejection order-independence, and a CR regression under a CRLF-emitting `jq` stub), and selector help-text drift across all six selectors. - `check-changed-skills.sh` — 2 skills, 0 errors. `validate-plugin-contracts.mjs` — 2813 files. `check-shell-portability.sh`, `check-skill-portability.sh`, `check-evals-quality.sh`, `check-changelog-parity.sh` (`--check`, `--check-order`, `--check-bump`, `--check-preserved`), `shellcheck`, `typos`, `markdownlint-cli2`, `check-fixture-git-isolation.sh`, `claude plugin validate`, catalog/cheatsheet/plugin-options sync — all pass. - Live read-only smoke independently reproduced the audit's own numbers before any test was written: `user_scope_orphans` returned exactly the four ids the corrected packet names, and all 45 project-scope records came back `projectPathPresent: false`. ## Related - Closes #3112 (workstream B of the audit remediation). - **#3145 — a concurrent duplicate implementation of the same issue**, opened by an autonomous work-items lane ~35 minutes before this PR. This PR is a strict file-superset of it (its ten files plus `skills/setup/SKILL.md`) and covers all 15 audit findings rather than the issue's summary of 8. Two of its mechanisms were **adopted with attribution in the commit message**: the `--marketplaces` enumeration mode, which closes a real gap here, and the snake_case `project_root` spelling, which matches the existing top-level key convention where this branch had used camelCase. Two were deliberately not taken — its `stale-user` selector name reads as an authoritative stale list rather than a candidate superset, and its `plugins/<name>` layout fallback guesses a path the catalog did not declare, which can withhold an update on an unverified assumption. #3145 also lacks the traversal and symlink containment checks. - #3111 / #3115 — workstream A, **merged** as `1f7525fe6`. This branch is rebased on it; the predicted two-file conflict was resolved by keeping `0.36.0` and stacking both CHANGELOG entries with `## [0.35.4]` unmodified (`--check-preserved` confirms all 107 headings survive). - #3116 — workstream C, merged as `af3fea480`. C makes `source-control` drop project-scope records at worktree teardown, the producer-side counterpart to this PR's classify-and-report handling. This PR deliberately does not name or depend on `source-control`. - Audit findings are sealed in a `plugin-quality` evidence packet; `evidence-3.md` re-scopes F5 and `evidence-2.md` corrects a tally, both applied here. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Closes #3182 ## Summary Post-merge verification of #3139 (merged as `ef4d53959`) found three defects in the prose it shipped, each the same class that PR existed to correct: **a claim or rule reaching past what backs it.** They were filed rather than quietly patched because the content was already on `main`. This fixes those three plus the smaller items #3182 lists, in 46 added lines across three files. ## Fix **1. The fetch prohibition's stated cause did not entail its stated rule.** `PLUGIN-PHILOSOPHY.md` sanctioned "a documentation URL", then condemned any skill instructed to fetch it as having "made the publisher a runtime dependency". Fetching `code.claude.com` creates no such dependency, and a practice already shipping in the tree was condemned by it. The prohibition now turns on the target's owner and reaches publisher-owned targets only. For those targets, distinguishing an instruction to fetch from a citation offered for a reader is genuinely hard, and the statement says so rather than implying it has been settled — `plugins/architecture/reference/topic-docs.md` is named as the open case, and no ticket owns it (#3136 is enforcement-site consolidation, not this). **2. The `evidence-bearing` bullet was unsatisfiable as worded.** It required setup to report "the effective value it observed", while the same section pins *effective value* to running-session behaviour and directs verification to a fresh session. A same-session run can only observe the **stored** value. One word: `effective` → `stored`. **3. A narrowing presented as a faithful clarification.** The hook-plugin eval skip stated its rationale as "no model-facing skill at all" where the prior text said "no model-**invoked** skill". Neither works: a `setup` skill sets `disable-model-invocation: true`, so either phrasing is satisfied by a plugin that ships one — admitting as skips exactly the plugins the rest of the rule excludes. The defect was stating the condition in terms of invocation mode at all. It now reads **"no skill carrying a judgment-bearing contract"**, the test the warrant rule two sentences above already uses. Outcome unchanged: 19 hook plugins ship a setup skill, all 19 carry setup evals. **Smaller items.** Both paired reconfiguration sites in `MIGRATION-PLAYBOOK.md` now name the readback location and agree in substance, including the sensitive-value limit — an asymmetry between them would have sent a reader reconfiguring a sensitive option at project scope to look in user settings, find nothing, and report a failed write, which is the false failure `PLUGIN-PHILOSOPHY.md` exists to prevent. Their provenance cites seam 1, which documents both halves, rather than smoke-test C, which explicitly disclaims covering a sensitive option. The "step 3 above" cross-reference — which pointed from inside Reintegration's step 1 at Reintegration's own step 3, about verify-before-retiring — is replaced by a direct citation of seam 1. The `github.test.sh` sweep's wider/narrower axes are named, and "both steps of the same job" is corrected to "each running in its own step". The workflow header's gate description is corrected: the pinned reusable requires four sections, not a closing keyword plus `## Related`. ## Verification Local gates at the final commit: `markdownlint-cli2` 0 issues; `check-contract-clause-coverage.py` exit 0; `lychee --offline` 0 errors across 103 unique links; `zizmor` no findings. The workflow change is comment-only, confirmed by diff. Six fresh-context verification passes, each given the bounded criteria plus an unbounded criterion instructing it to hunt for claims reaching past their evidence anywhere in the touched paragraphs. **All six returned FAIL**, and each round's fixes introduced at least one new instance of the defect being repaired. The sixth pass found two, both in the single paragraph this PR had to *write* rather than cut, and both repairs were deletions: a tracker-wide "no ticket owns that question" that the tracker contradicts (#432 carries an accepted ruling on it, and `scripts/skill-portability-tokens.txt` stages a lint class blocked on that ruling), and a hedge that denied the statement its own preceding clause had just made. Every deletion from the prior round verified clean against the tree. Reviewing where the findings came from settled the approach. Items 2 and 3 were clean from round three onward; essentially every finding from round two on landed in material added *beyond* what #3182 asked for — an enumeration of nonconforming instances, a paragraph grounding the prohibition against the tree, a rewritten security rationale, a sensitive-value carve-out. Each was written to close the previous round's finding and opened one or two of its own. The final revision deletes those elaborations rather than repairing them again, which is why the diff is 46 lines rather than the 2,926 it peaked at. Twenty-five instances of the defect class were found across the five rounds. One was caught by the author re-reading their own writing, four by the review bots, and the rest by fresh-context verification. **None by self-review.** Two things generalise. A verifier is bounded by its criteria, so a defect nobody names survives any number of green passes — every round's findings came from the unbounded criterion, not the checklist. And under-claiming is not the safe direction: round four's findings were mostly repairs to what round three's *removals* broke. Both directions are the same failure to say exactly what the evidence supports. One pre-existing defect is deliberately left alone and filed as #3184: the workflow header's security rationale ("reads PR body metadata from the event payload only") is false against the pinned reusable, which live-refetches. It is outside #3182's scope, the `zizmor` suppression it backs is independently sound, and three separate rewrites of that comment block each introduced a new inaccuracy. ## Related - #3182 — the issue this closes; its line references were verified against `origin/main` at `ef4d53959`. - #3139 — introduced this prose; its own post-merge verification found these defects and filed them rather than patching silently. - #3184 — the workflow-comment defects this PR deliberately did not rewrite. - #3173 — shipped part 1 into the same files; item 3's prior wording is its text. - #3136 — enforcement-site consolidation; it does not own the cite-versus-fetch question, and this PR no longer claims it does. - #3115 / #3116 / #3148 — the rest of the campaign whose doc half #3139 was. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
No linked issue ## Summary Fifth #2891 de-slop shard: purge em dashes from the `claude-config` plugin instruction surfaces, the next-worst cluster after `session-flow` (#3106), `planning` (#3105), `work-items` (#3107) and `source-control` (#3108). #2891 stays open; ranked by em-dash lines over non-vendor instruction surfaces, the next unclaimed cluster after this one is `discipline` (468), then `claude-ops` (336). ## Fix Rewrote `README.md` and all ten `SKILL.md` files under `/ai-slop:audit fix` semantics: em dashes become periods, commas, a colon before a list, or a restructured sentence. Never parentheses, en dashes, or a spaced hyphen, since each of those is the same interruption wearing a different mark. A self-review against that guardrail caught seven places where a paired em dash had become parentheses in this very diff — four frontmatter `description` values (`audit`, `audit-instructions`, `audit-automation-gaps`, `audit-prompting-postures`) and three inline spots (`setup`'s required-keys list, `audit-instructions`' I8-family list and its discover-instruction-surfaces population, `audit-pass`' exclusion-set aside). All seven were restructured to commas, a colon, or a sentence break; the net parenthesis delta across the diff is -1. Frontmatter `description` and `argument-hint` values are rewritten too. No quoted auto-invocation trigger phrase contained an em dash, so no trigger changed. `claude-config` 0.38.10. Wording only: no check, phase, gate, lane, contract, or script changed. ## Verification This repo's `.claude/ai-slop.json` disables `rule-em-dash` corpus-wide (a deliberate house-style decision, #3031), so every detector run below pins `HOME` and `CLAUDE_PROJECT_DIR` to empty directories to lift that config and force the rule on — the same isolation the detector's own test suite uses. - `detect.sh` over the 11 shard files: **482 `rule-em-dash` findings to 0**, with every other rule also reporting 0. - No en dash or spaced hyphen introduced; net parenthesis delta -1. - `scripts/check-changelog-parity.sh --check` and `--check-bump origin/main`: pass. - `CHECK_SKILL_SKIP_MARKDOWNLINT=1 bash scripts/check-changed-skills.sh origin/main`: 10 skills, 0 errors. It confirms every base-ref trigger phrase is preserved on all ten. One soft warning (`unhobble` at 201 lines against a 200-line target) is pre-existing. - `markdownlint-cli2` over the 12 changed files: 0 issues. - `node scripts/generate-cheatsheet.mjs`: already in sync — no `metadata.summary` value changed, so unlike the `work-items` shard this one needs no cheat-sheet refresh. - `python3 scripts/sync-plugin-options-docs.py --check`, `typos`, `editorconfig-checker`: pass. - `origin/main` merged into the branch before this PR; no conflicts, and the two new main commits (#3115, #3116) touch no `claude-config` file. **Pre-existing failure, not from this diff.** Three `audit-permission-state` script suites fail in this environment: `permission-merge` 19/51, `automode-entry-diff` 3/63, `managed-conformance` 1/37. They fail with identical counts on a clean `origin/main` worktree, and this shard touches no script. Flagging rather than fixing, since diagnosing them is out of scope here. ## Related - Refs #2891 — the de-slop campaign this shard advances; stays open for the remaining clusters. - Refs #3105, #3106, #3107, #3108 — the sibling instruction-surface shards. - Refs #3031 — the measurement and decision that disabled `rule-em-dash` corpus-wide, which is why the verification runs force the rule back on rather than trusting a default run. **Deferred finding, deliberately not fixed here: manifest `description` fields are outside the campaign's scope definition.** Review flagged that `plugins/claude-config/.claude-plugin/plugin.json` still carries 5 em dashes in its marketplace-facing `description`. That is correct, and it is a gap in #2891's own scoping rather than in this shard: the issue defines the target set as "every `SKILL.md`, plugin READMEs, `AGENTS.md`, root `README`", which does not include `plugin.json`. The merged `work-items` shard (#3107) touched its manifest for the version bump only, so every shipped shard carries the same gap. Measured across the marketplace: **47 plugins** have em dashes in their manifest `description`, led by `discipline` (14), `claude-ops` (12) and `session-flow` (11). Fixing only `claude-config` here would leave the shard series internally inconsistent while resolving 5 of ~120 occurrences, so this belongs to a campaign-level decision on #2891 about whether manifest descriptions join the target set, not to this PR. Recorded here so it is not lost. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01Tu5t8rYWv2kDzRcdmLE2ro --- _Generated by [Claude Code](https://claude.ai/code/session_01Tu5t8rYWv2kDzRcdmLE2ro)_ --- _Generated by [Claude Code](https://claude.ai/code)_
Closes #3113
Summary
Removing a git worktree left behind every project-scope plugin install record that pointed into it, forever. Claude Code keys a project-scope install to a literal
projectPathin~/.claude/plugins/installed_plugins.json, and nothing reaps that record when the path goes away — so a directory tree this plugin's ownbabysit_worktree_rootoption calls ephemeral accumulates permanent state.Measured on the author's machine: 108 project-scope records across 8 marketplaces, all 108 naming one worktree directory that no longer exists, and 1 distinct
projectPathmachine-wide — every project-scope record on that machine an orphan.D:\worktreesis exactly this plugin's configuredworktree_root, so these are not configuration drift; they are normal lifecycle garbage from a directory this plugin itself created and destroyed. The worktree skill had zero awareness of install records: the cleanup path removed the directory and stopped.Fix
cleanupreaps at teardown. New Step 4b runsscripts/reap-project-plugin-records.shfrom inside the candidate, after the stranded-work and carried-ignored-file guards clear and beforegit worktree remove. It is ordered last of the three because it is the only one that cannot be undone: it must not fire for a worktree the guards are about to save.The boundary is the teardown event, never path liveness. A project-scope record for a live repository on an unmounted network share or a detached external volume is indistinguishable from a dead worktree to a bare existence check. The helper refuses unless
--worktree-pathnames the directory it is already standing in — the cwd constraint rendered in code rather than in prose, so it structurally cannot act on any other path.Everything goes through the documented CLI. Enumeration via
claude plugin list --json, removal viaclaude plugin uninstall <id> -s project. Never-s user— the CLI's own failure text for an id with no project record here readsUse --scope user to uninstall, and following that would uninstall the plugin fleet-wide. Never--prune(it reaches into shared auto-installed dependencies).installed_plugins.jsonis read, never written: it is Claude Code's internal state, not a published contract.auditStep 2b reports pre-existing orphans and never reaps them (task-4 decision). Records left by worktrees removed before this step existed are unreachable by it, so audit makes them visible in four buckets — live here, live elsewhere, candidate orphan, other project records (information only, no remedy). Reaching a vanished path requires recreating a directory, which is precisely the mechanism most capable of the harm above; the audit emits the commands for the user and stops.Two hazards found by verification, fixed in commits 2 and 3
Both are the same mistake — inferring deadness from a negative — and the fix's safety rests on them.
createplaces worktrees at<root>/<owner>-<repo>-<slug>, one root serving every repository on the machine, so "under the root and not in this repository'sgit worktree list" is true of every other repository's live worktree. Registration is scoped to one repository; liveness is not.auditgained the live elsewhere bucket keyed ongit -C <path> rev-parse --is-inside-work-tree, plus a precondition that stops the whole step when the resolved root itself does not resolve — the detached-volume case that would otherwise make every path under it qualify on identical evidence.git worktree list, so an unregistered directory produces no row, and the stranded guard's closed-list rule covers an unrecognized value, never an absent one. A live worktree of another repository whose main clone has been moved, deleted, or unmounted still carries its.gitfile whilerev-parsefails, so one negative test called it an empty husk and reaped plusrm -rf'd it. It now must pass all three of not a work tree, no.gitentry, and empty — the middle test being load-bearing, and the third the only positive evidence in the set (and what the presentation row's(empty, no git ref)always claimed and never checked).cleanupalso gained the unresolvable-root precondition, and Step 2's layout list now names the configured external root wherecreateactually places every worktree.0.55.0is a deliberate minor bump:cleanupgains a new side effect on an existing action — additive behaviour, not a repair.Verification
The load-bearing measurement.
claude plugin uninstall <id> -s projecthas no path flag and resolves strictly against the resolved absolute current directory:install -s projectin a plain non-git directoryprojectPath= resolved native absolute cwd (backslash-spelled on Windows). 108 → 111plugin list --jsonfrom two unrelated cwdscksum) — enumeration is cwd-independentuninstall -s projectfrom a different cwduninstall -s projectfrom the recorded directory--scope userSix arms in throwaway
$TEMPdirectories with the record store read but never written by the probe, plus a full end-to-end run of the shipped helper: 108 → 110 → 111 → refuse (unchanged) → dry-run (unchanged) → 109 → 108, wrong-cwd refusal, other-path record untouched, idempotent zero case. The record store was returned to exactly its 108-record baseline; no real record was touched. Claude Code 2.1.240, re-run unchanged on 2.1.241, Windows/Git Bash. Recorded with its as-of stamp and recheck trigger inskills/worktree/fixtures/README.md; re-runnable asfixtures/project-scope-reap-probe.sh.Three fresh-context opus verifications, none by the author. Passes 1 and 2 each returned FAIL on the hazards above; commits 2 and 3 are those verdicts acted on. Pass 3 over the final state is running as this is written — any further finding lands as another commit on this branch, and this PR is not to be merged before it reports.
Pass 2's break attempts against
audit, none of which got through: live worktree of another repo, bare repo, submodule, plain non-git directory, mounted-but-slow share, unmounted share, detached volume, symlinked/junctioned root, dangling symlink, path-is-a-file.Test plan
plugins/source-control/scripts/reap-project-plugin-records.test.sh— 32/32, new. Stubs theclaudeCLI to reproduce the measured contract; pins the cwd-equality refusal (a refused run makes zero CLI calls), that only this directory's records are removed while another path's survive, that-s userand--prunenever appear in any invocation, that a CLI no-op is reported and never escalated, that--dry-runmutates nothing, and that a degrade is visible (exit 3). One case caught a real Windows-only defect:@tsvescapes backslashes and the${p//\\//}parameter expansion does not replace them, so the matcher silently matched nothing on the only platform whereprojectPathis backslash-spelled.skills/worktree/nesting-invariant-ssot.test.sh— 18/18.scripts/validate-plugin-contracts.mjs— clean (48 setup skills, 2805 files).shellcheck -x --rcfile .shellcheckrcon all three new.sh— silent.scripts/check-changelog-parity.sh --check,check-orphaned-fixtures.sh,check-silent-skips.sh,check-shell-portability.sh,check-skill-portability.sh,check-drive-root-litter.sh,sync-plugin-options-docs.py --check,generate-cheatsheet.mjs --check— all clean.CHECK_SKILL_SKIP_MARKDOWNLINT=1 scripts/check-changed-skills.sh origin/main— PASS, 0 errors (2 pre-existing warnings).markdownlint-cli2— 0 issues.worktree-root-doctor.test.shfails 9 andworktree-add-containment-gate.test.shfails 2, both from POSIX-vs-native path form andincludeIf gitdirmatching. Unrelated to this change.Related
claude-ops:pluginsclassifies and reports these stale project records instead of routing them to aconvergethat cannot run. That is the reporting half; this is the half that stops them accumulating at the source.claude-opsdeliberately does not reach into another plugin'suserConfigto decide what is ephemeral, and does not own the lifecycle —source-controlcreates the worktree, knowsworktree_root, and destroys the directory.plugins/source-control/skills/setup/plus a version bump on this plugin's.claude-plugin/plugin.jsonandCHANGELOG.md. A rebase conflict on exactly those two files is expected. Resolve by taking the higher version and keeping BOTH CHANGELOG entries — never by dropping either side's. No other file overlaps: this PR never touchesskills/setup/.🤖 Generated with Claude Code