Skip to content

fix(source-control): reap a torn-down worktree's project-scope plugin install records - #3116

Merged
kyle-sexton merged 5 commits into
mainfrom
fix/3113-worktree-teardown-plugin-records
Aug 23, 2026
Merged

fix(source-control): reap a torn-down worktree's project-scope plugin install records#3116
kyle-sexton merged 5 commits into
mainfrom
fix/3113-worktree-teardown-plugin-records

Conversation

@kyle-sexton

@kyle-sexton kyle-sexton commented Aug 23, 2026

Copy link
Copy Markdown
Contributor

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 projectPath in ~/.claude/plugins/installed_plugins.json, and nothing reaps that record when the path goes away — so a directory tree this plugin's own babysit_worktree_root option 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 projectPath machine-wide — every project-scope record on that machine an orphan. D:\worktrees is exactly this plugin's configured worktree_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

cleanup reaps at teardown. New Step 4b runs scripts/reap-project-plugin-records.sh from inside the candidate, after the stranded-work and carried-ignored-file guards clear and before git 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-path names 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 via claude plugin uninstall <id> -s project. Never -s user — the CLI's own failure text for an id with no project record here reads Use --scope user to uninstall, and following that would uninstall the plugin fleet-wide. Never --prune (it reaches into shared auto-installed dependencies). installed_plugins.json is read, never written: it is Claude Code's internal state, not a published contract.

audit Step 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.

  1. The worktree root is shared. create places worktrees at <root>/<owner>-<repo>-<slug>, one root serving every repository on the machine, so "under the root and not in this repository's git worktree list" is true of every other repository's live worktree. Registration is scoped to one repository; liveness is not. audit gained the live elsewhere bucket keyed on git -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.
  2. 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 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 .git file while rev-parse fails, so one negative test called it an empty husk and reaped plus rm -rf'd it. It now must pass all three of not a work tree, no .git entry, 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). cleanup also gained the unresolvable-root precondition, and Step 2's layout list now names the configured external root where create actually places every worktree.

0.55.0 is a deliberate minor bump: cleanup gains a new side effect on an existing action — additive behaviour, not a repair.

Verification

The load-bearing measurement. claude plugin uninstall <id> -s project has no path flag and resolves strictly against the resolved absolute current directory:

Arm Result
install -s project in a plain non-git directory writes a record with projectPath = resolved native absolute cwd (backslash-spelled on Windows). 108 → 111
plugin list --json from two unrelated cwds identical project-scope record set (same cksum) — enumeration is cwd-independent
uninstall -s project from a different cwd exit 1, count unchanged. It cannot reach another path's record
uninstall -s project from the recorded directory exit 0, record removed
directory deleted, then recreated empty at the same path record outlives the directory; uninstall from the recreated one exits 0 and removes it
no record here exit 1, no-op — same message as the wrong-path case, and it suggests --scope user

Six arms in throwaway $TEMP directories 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 in skills/worktree/fixtures/README.md; re-runnable as fixtures/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.sh32/32, new. Stubs the claude CLI 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 user and --prune never appear in any invocation, that a CLI no-op is reported and never escalated, that --dry-run mutates nothing, and that a degrade is visible (exit 3). One case caught a real Windows-only defect: @tsv escapes backslashes and the ${p//\\//} parameter expansion does not replace them, so the matcher silently matched nothing on the only platform where projectPath is 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 .shellcheckrc on 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.
  • Pre-existing on this Windows host, confirmed by stashing the change and re-running against the pristine base: worktree-root-doctor.test.sh fails 9 and worktree-add-containment-gate.test.sh fails 2, both from POSIX-vs-native path form and includeIf gitdir matching. Unrelated to this change.

Related

🤖 Generated with Claude Code

kyle-sexton and others added 2 commits August 22, 2026 21:27
… 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

claude Bot commented Aug 23, 2026

Copy link
Copy Markdown
Contributor

Claude finished @kyle-sexton's task in 3m 40s —— View job


Claude finished the code review

  • Gather context (PR body, changed files)
  • Run /review:code-review procedure
  • Post findings

Summary

Reviewed the diff against origin/main — the new reap-project-plugin-records.sh/.test.sh, the cleanup.md/audit.md procedure updates, SKILL.md, plugin.json, CHANGELOG.md, and the fixtures. This is an unusually well-substantiated change: the cwd-equality boundary, the never--s user/never---prune rules, and the shared-worktree-root "live elsewhere" classification are all backed by measured probes and a 32-case unit suite, and the second commit already fixed a real hole (shared-root liveness) that an independent verifier caught pre-merge.

One finding posted inlineplugins/source-control/scripts/reap-project-plugin-records.sh:247-262:

The pre-reap enumeration failure (line 201) correctly degrades — warn: + exit 3 — per the script's own "a degrade is reported, not swallowed" principle. The post-reap verification enumeration (line 247) reuses collect_matches but swallows a failure into an empty string (survivors="$(collect_matches)" || survivors=""), so a transient claude plugin list --json/jq hiccup on that second call falls through to ok: every … is gone with exit 0 — reporting a fully-verified reap when the verification pass never actually completed. This is inconsistent with the identical failure mode a few lines earlier and isn't covered by the test suite (which exercises the first enumeration failure via the exit-3 case, but not a failure on the second/verification call).

No other high-signal issues found — the CLI-boundary logic, path normalization (backslash/case/CRLF handling), the ordering of guards before the reap in cleanup.md, and the four-bucket classification in audit.md all held up under review.

@claude

claude Bot commented Aug 23, 2026

Copy link
Copy Markdown
Contributor

Claude finished @kyle-sexton's task in 1m 35s —— View job


Claude finished the security review

  • Gather context (PR diff, description, changed files)
  • Run /review:security-review per custom instructions
  • Post findings

Security review — no issues found

This PR touches genuinely security-relevant surface (new shell scripts that call rm -rf, git worktree remove, and claude plugin uninstall), so I read the full diff rather than skipping. Focus was plugins/source-control/scripts/reap-project-plugin-records.sh (new, blob) and the destructive command sequences it's wired into via context/cleanup.md and context/audit.md.

Checked for, and did not find:

  • Command/argument injection — every variable that flows into a claude/git/rm invocation is quoted ("$id", "$worktree_path", "$path" etc.); no eval, no unquoted expansions that could split into extra arguments.
  • Path-confusion / TOCTOU on the cwd guardreap-project-plugin-records.sh resolves both the current directory and --worktree-path via cd ... && pwd -P/pwd -W before comparing (reap-project-plugin-records.sh:96-155), so symlinks and relative-path tricks collapse to the same real path rather than bypassing the equality check.
  • Scope escalation — the script structurally never passes -s user or --prune (both would reach far past the current directory's records), and never writes installed_plugins.json directly; every mutation goes through the documented claude plugin uninstall CLI. This matches the intent explained in the PR body and is enforced, not just documented.
  • Destructive-path misclassification — the second commit's fix (shared worktree root → live elsewhere bucket in audit.md, plus the is-inside-work-tree gate before cleanup's orphaned-directory rm -rf) closes the hole where another repository's live worktree under the same shared root could be misread as dead and handed a destructive remedy. The remaining rm -rf sites are all gated behind that liveness check plus explicit user confirmation.
  • Heredoc/quoting edge cases — the <<EOF … $matches … EOF pattern in the reap script is a single parameter expansion (not re-evaluated shell text), so record IDs containing shell metacharacters can't inject commands there.
  • CI/Actions surface — this PR doesn't touch any workflow files, so no pull_request_target/permissions/supply-chain surface to assess.

Nothing here rises to CRITICAL/IMPORTANT/SUGGESTION. The destructive operations (rm -rf, plugin uninstall, worktree removal) are consistently gated behind cwd-equality checks, liveness tests, and &&-chained fail-closed sequences, matching what the PR description claims.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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".

Comment thread plugins/source-control/scripts/reap-project-plugin-records.sh Outdated
@github-actions

github-actions Bot commented Aug 23, 2026

Copy link
Copy Markdown

Last security-reviewed head: e7c39b0357b0d03ecc49c89d704c95490394dca9. On the next push, the relevance gate compares only the commits since this SHA; delete this comment to force a full re-review.

Comment thread plugins/source-control/scripts/reap-project-plugin-records.sh Outdated
@github-actions

github-actions Bot commented Aug 23, 2026

Copy link
Copy Markdown

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

claude Bot commented Aug 23, 2026

Copy link
Copy Markdown
Contributor

Claude finished @kyle-sexton's task in 1m 6s —— View job


Security review (/review:security-review)

  • Gather context (PR diff since last reviewed head, changed files)
  • Run /review:security-review procedure
  • Post findings

Skip-gate check: the last security review covered head ba17c8f. This run's relevance window is only the commits pushed since then — one new commit, d1e96a9 ("hold the acting path to the reporting path's evidence bar").

What changed since the last review

d1e96a9 touches only documentation/skill-procedure files — cleanup.md, audit.md, SKILL.md, CHANGELOG.md — no shell script content changed (reap-project-plugin-records.sh and its test file are untouched, already reviewed at ba17c8f).

The change tightens the guard in front of the destructive path, rather than loosening anything:

  • The "orphaned directory" classification that gates cleanup's rm -rf (Step 4b) previously required only a single negative test (git -C <path> rev-parse --is-inside-work-treetrue). This commit found and closed a real hole: another repository's live worktree whose main clone was moved/deleted/unmounted also fails that same test while still holding real work — so the old single test could misclassify it as an empty husk and destroy it.
  • It now requires all three: not a work tree, no .git entry (file or directory — closing exactly the miss above), and directory actually empty. audit.md gets the matching precondition (stop scanning a worktree root that doesn't resolve, so a detached volume doesn't make every path under it look orphaned).
  • The audit's pasted remedy now substitutes a resolved absolute path for the helper script instead of ${CLAUDE_PLUGIN_ROOT} (unset in a user's own shell) — a correctness fix, not a security-relevant one; the &&-chained fail-closed structure and quoting are unchanged.

Result: no security issues found

Reviewed against the same criteria as the prior pass (injection surfaces, scope escalation, destructive-path misclassification, trust boundaries) — this commit only narrows the conditions under which the destructive rm -rf / reap path fires. No new command construction, no new variable interpolation into git/claude/rm invocations, no permission or scope changes. Nothing here rises to CRITICAL/IMPORTANT/SUGGESTION.

@claude

claude Bot commented Aug 23, 2026

Copy link
Copy Markdown
Contributor

Claude finished @kyle-sexton's task in 3m 18s —— View job


Claude finished the code review

  • Gather context (PR body, changed files, prior review comments)
  • Run /review:code-review procedure
  • Post findings

Summary

Skip-gate check: HEAD has advanced from ba17c8f (last reviewed by this lane) to d1e96a9, so the prior review no longer applies as-is — re-ran the review scoped to what changed since then.

The new commit, d1e96a9 ("hold the acting path to the reporting path's evidence bar"), is documentation-onlyCHANGELOG.md, SKILL.md, audit.md, cleanup.md. No shell scripts changed. It tightens cleanup.md's orphaned-directory qualification from one test (rev-parse --is-inside-work-tree) to three (not a work tree, no .git entry, empty), adds a precondition that stops scanning an unresolvable worktree root, and fixes a ${CLAUDE_PLUGIN_ROOT}-in-a-user-pasted-command bug in audit.md's remedy. I checked the new three-test qualification for internal consistency (it correctly closes the exact gap its own commit message describes — a live worktree whose main clone was moved/unmounted still has a .git file even though rev-parse fails on it) and didn't find a correctness problem in the added prose or its example commands.

I also checked whether audit.md's Step 2b candidate orphan bucket (line 56) should have picked up the same third test — it's still only two — but that row's text is untouched by this commit (pre-existing, and the bucket already carries an explicit caveat that two negatives aren't proof of death and requires per-path user confirmation before any remedy runs), so it's out of scope for this incremental pass rather than a new issue.

No new high-signal findings in this commit.

Status of the previously-flagged issue: reap-project-plugin-records.sh:247 — the post-reap verification's survivors="$(collect_matches)" || survivors="" still swallows an enumeration failure into "0 survivors" instead of degrading like the identical pre-reap check at line 201 does — is unchanged by this commit (the script wasn't touched) and was already posted inline by both this lane's prior pass and Codex. Not re-posting a duplicate; it still applies at current HEAD.
· branch fix/3113-worktree-teardown-plugin-records

…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

claude Bot commented Aug 23, 2026

Copy link
Copy Markdown
Contributor

Claude finished @kyle-sexton's task in 4m 35s —— View job


Claude finished the code review

  • Gather context (PR body, prior reviews, changed files)
  • Run /review:code-review procedure (scoped to commit 17621ac, the delta since this lane's last reviewed head d1e96a9)
  • Post findings

Summary

Skip-gate check: this lane's last review covered d1e96a9. HEAD has advanced to 17621ac ("unbreak the machine-specific-paths gate and close verification pass 3"), so I reviewed that delta — CHANGELOG.md, reap-project-plugin-records.sh (comments only, no behavior change), SKILL.md, audit.md, cleanup.md, fixtures/README.md.

This commit adds a fourth qualifying test — not a symlink — to the orphaned-directory candidate check, closing a real hole: find <path> -mindepth 1 doesn't descend into a symlinked start point, so a symlink pointing at a busy directory would read as empty and pass the old three-test bar. I checked this fix for internal consistency across cleanup.md, audit.md, SKILL.md, and CHANGELOG.md — the numbering (0–3), the "negatives vs. the one positive" framing, and audit's candidate orphan bucket guidance all line up correctly with the new test.

One finding posted inlineplugins/source-control/skills/worktree/context/cleanup.md:167:

The new test -L "<path>" guard can itself be defeated by a trailing slash on <path> — POSIX pathname resolution follows a symlink-to-directory when the path ends in /, so test -L "link/" reports false even though link is a symlink. Nothing upstream in this doc guarantees the candidate path string is slash-free, and this is exactly the same class of "looks empty/not-a-symlink because of how the test was phrased" gap this commit set out to close for the find-doesn't-descend-symlinks case.

Status of the previously-flagged issue: reap-project-plugin-records.sh:250 — the post-reap verification's survivors="$(collect_matches)" || survivors="" still swallows an enumeration failure into "0 survivors" instead of degrading like the identical pre-reap check at line 201/204 does — is unchanged by this commit (the script's logic wasn't touched, only comments were). Already flagged inline by this lane, Codex, and the security lane on earlier heads; not re-posting a duplicate, but it still applies at current HEAD.

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.
· branch fix/3113-worktree-teardown-plugin-records

@claude

claude Bot commented Aug 23, 2026

Copy link
Copy Markdown
Contributor

Claude finished @kyle-sexton's task in 1m 47s —— View job


Security review (/review:security-review)

  • Gather context (PR diff since last reviewed head, changed files)
  • Run /review:security-review procedure
  • Post findings

Skip-gate check: the last security review covered head d1e96a9. This run's relevance window is the one new commit pushed since then, 17621ac ("unbreak the machine-specific-paths gate and close verification pass 3").

What changed since the last review

Six files, no new logic paths — this commit tightens an existing guard and clarifies comments; nothing loosens anything:

  • reap-project-plugin-records.sh (native_pwd comment, norm_path comment) — comment-only changes, no executable-line diff. They document two edge cases more precisely (an 8.3-short path component, not just a whole-path form; a UNC-path corner of the backslash-squeeze) and explicitly note the cwd guard still confines any action regardless of what the matcher returns. I checked the UNC claim: //server/share/x and /server/share/x could theoretically collide after the squeeze, but no valid Windows path spells the un-prefixed form, and — as the comment says — a false match here can only ever produce a reported no-op, never a wrong-path mutation, because the actual removal is still gated by the separate cwd-equality check earlier in the script. Confirmed, not a new surface.
  • cleanup.md / audit.md (orphaned-directory qualification) — adds a fourth test, "not a symlink" (cleanup.md:167, rule explained at cleanup.md:174-177). This closes a real gap: find <path> -mindepth 1 doesn't descend through a symlinked start point, so a symlink to a busy live directory previously reported "empty" and could pass the old three-test bar, while the reap step resolves pwd through the link and would act on the target directory's records. This is a genuine destructive-path hardening (it sits directly in front of rm -rf <path> and the reap script invocation), and I verified it closes the gap it claims to: the new test -L "<path>" runs before the other three and disqualifies the candidate outright. audit.md's parallel confirmation checklist (audit.md:76) picks up the same three checks; the fourth (not-a-work-tree) is already embedded in how a row reaches the candidate orphan bucket in the first place (audit.md:41-44), so no test is silently dropped between the two files.
  • CHANGELOG.md, SKILL.md, fixtures/README.md — prose-only propagation of the same three-test → four-test change and a wording fix to the 8.3-short-form example. No behavioral surface.

Result: no security issues found

This commit only narrows the conditions under which the destructive rm -rf / reap path fires, and the one substantive change (the symlink test) is itself a fix for a symlink-following misclassification risk — verified sound rather than just asserted. No new command construction, no new variable interpolation into git/claude/rm invocations, no scope or permission changes.

The pre-existing correctness finding on the post-reap verification swallow (reap-project-plugin-records.sh:247, survivors="$(collect_matches)" || survivors="") is unchanged by this commit — that file's only edits here are comments — and is a reporting-accuracy issue, not a security one, so it's out of scope for this lane (already tracked via the code-review lane and Codex's review).
· branch fix/3113-worktree-teardown-plugin-records

Comment thread plugins/source-control/skills/worktree/context/cleanup.md
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

claude Bot commented Aug 23, 2026

Copy link
Copy Markdown
Contributor

Claude finished @kyle-sexton's task in 2m 16s —— View job


Security review (/review:security-review)

  • Gather context (PR diff since last reviewed head, changed files)
  • Run /review:security-review procedure
  • Post findings

Skip-gate check: the last security review covered head 17621ac. This run's relevance window is the one new commit pushed since then, e7c39b0 ("an unverified reap must not report a clean one") — the commit responding to this PR's three open review threads.

What changed since the last review

  • Threads 1 & 2 (the post-reap verification swallow) — fixed correctly. reap-project-plugin-records.sh:250-268 replaces survivors="$(collect_matches)" || survivors="" with an explicit if ! survivors=...; then branch that prints surviving UNKNOWN, states the pass is UNVERIFIED, reports the reaped/no-op counts, and exits 3 — now symmetric with the pre-reap degrade at line 207. I traced the call site in cleanup.md (Step 4 report language, cleanup.md:263) and the exit-code contract comment (reap-project-plugin-records.sh:41-46) — both now correctly treat exit 3 as covering either enumeration, and the caller never deletes the directory on the strength of an unverified pass. This closes the most dangerous failure mode in the script (false "fully reaped" report used to justify a subsequent rm -rf/git worktree remove).

  • Thread 3 (trailing-separator defeats the symlink test) — fixed for the case it measures, but the shown fix is incomplete. cleanup.md:159-190 adds a normalization step ahead of the four orphaned-directory qualifying tests, with the header claiming it strips "every trailing separator." The only code shown is:

    path="${path%/}"        # and again for a Windows-style trailing backslash

    This line strips a trailing / only. The comment says to run it "again" for a trailing \, but no second line is shown, and this file is prose an executing agent follows literally (there is no compiled script backing Step 4b — audit.md:82 just says "check it the way cleanup does"). An agent that copies the one bash line shown, as the surrounding text otherwise encourages, leaves a trailing backslash unstripped. That reopens exactly the hazard this commit exists to close, on the platform this whole PR is measured against (Git Bash/Windows, where dir/Explorer-pasted paths commonly carry a trailing \): a symlink to a busy directory named with a trailing backslash would still read test -L "<path>\" as false and find as empty, sailing through all four tests into rm -rf. It's also untested — the new fixture in reap-project-plugin-records.test.sh only exercises a real symlink with a trailing /, never a trailing \.

    Suggest making the shown snippet actually strip both, e.g. path="${path%[/\\]}" (repeated if multiple trailing separators are possible), rather than leaving the second half as an unexecuted comment — and mirroring the corrected snippet in audit.md's "check it the way cleanup does" pointer.

    Fix this →

Result

One SUGGESTION-level finding (doc-completeness gap in a destructive-path guard's own fix, not an active exploit in shipped script code — the actual executable script, reap-project-plugin-records.sh, is untouched here and was already reviewed clean). No CRITICAL/IMPORTANT findings. The two previously-flagged issues (post-reap swallow, trailing-slash symlink bypass) are both substantively fixed by this commit; the residual gap above is in how completely the trailing-backslash half of the fix is spelled out for the agent that has to execute it, not a reintroduction of the original defect.
· branch fix/3113-worktree-teardown-plugin-records

@claude

claude Bot commented Aug 23, 2026

Copy link
Copy Markdown
Contributor

Claude finished @kyle-sexton's task in 1m 48s —— View job


Claude finished the code review

  • Gather context (PR body, prior reviews, changed files)
  • Run /review:code-review procedure (scoped to commit e7c39b0, the delta since this lane's last reviewed head 17621ac)
  • Post findings

Summary

Skip-gate check: this lane's last review covered 17621ac. HEAD has advanced to e7c39b0 ("an unverified reap must not report a clean one"), so I reviewed that delta — CHANGELOG.md, reap-project-plugin-records.sh, reap-project-plugin-records.test.sh, audit.md, cleanup.md.

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:

  1. Post-reap enumeration swallow (reap-project-plugin-records.sh:259-266) — previously survivors="$(collect_matches)" || survivors="" absorbed a post-reap claude plugin list failure into "0 survivors" and reported a clean reap (exit 0). Now a failure short-circuits before survivor_count is ever computed, prints surviving UNKNOWN / UNVERIFIED, names how many uninstall calls reported success vs. no-op, and exits 3 — matching the pre-reap enumeration failure's degrade path exactly (line 207-211). The script's header comment (lines 41-47) was updated to state exit 3 now covers both enumerations. This closes the finding both this lane and Codex raised, and does so correctly: the ok: every … is gone line is now unreachable on this path.
  2. Trailing-slash defeats the symlink test (cleanup.md:162-190, mirrored in audit.md:82-86) — the doc now normalizes <path> (strips trailing separator) before all four qualifying tests, not just the symlink one, and explains why a trailing slash resolves test -L through the link. This closes the finding I raised on 17621ac.
  3. Test coveragereap-project-plugin-records.test.sh gained a stub-driven case pinning the post-reap degrade (call 1 succeeds, call 2 fails → exit 3, UNVERIFIED, surviving UNKNOWN, never the clean-reap line) and a real-symlink fixture pinning the three trailing-slash assertions (test -L link → true, test -L "link/" → false, find link -mindepth 1 → empty), with an honest skip_case on platforms that can't create a symlink. Both fixtures match the mechanism they claim to test.

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 path="${path%/}" with a comment to "do it again" for a backslash, and a single application of %/ only strips one trailing separator (a path ending in // would keep one) — but this is prose an agent executes, not code that runs unattended, git worktree paths don't carry doubled trailing separators in practice, and the surrounding text already tells the reader to normalize both separator styles.

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.
· branch fix/3113-worktree-teardown-plugin-records

@kyle-sexton
kyle-sexton merged commit af3fea4 into main Aug 23, 2026
50 checks passed
@kyle-sexton
kyle-sexton deleted the fix/3113-worktree-teardown-plugin-records branch August 23, 2026 03:07
kyle-sexton added a commit that referenced this pull request Aug 23, 2026
…#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>
kyle-sexton added a commit that referenced this pull request Aug 23, 2026
…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>
kyle-sexton added a commit that referenced this pull request Aug 23, 2026
…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>
kyle-sexton added a commit that referenced this pull request Aug 23, 2026
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>
kyle-sexton added a commit that referenced this pull request Aug 23, 2026
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)_
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

fix(source-control): worktree teardown leaves project-scope plugin install records forever

1 participant