24 KiB
PLAN — skill-catalog-prune (feat, ad-hoc dispatch) — r4 (after confirmation pass)
- r4 closes the confirmation pass (robustness FATAL(4)): BLOCKER 1 — the
helper lib skips any non-skill dir holding a nested SKILL.md
(browser-skills/, node_modules/, openclaw/) and
.git*/node_modulesby name, fixtureother/deep/SKILL.md; MAJOR 2 — the three existing profile/toggle suites copy lib/gstack-removed.sh into their fixture (E1b scope); MAJOR 3 — the lib removes a staledstsymlink (gstack ./setup plants~/.claude/skills/gstack -> submodule) and refuses a dst that resolves inside src, fixture case added; MAJOR 4 — update-all.sh's third copy of the block goes through the lib too (criterion 18); MINOR 5-8 — real restored count after the skip, policy message instead of the setup hint,len(x or '')+ stderr warn on fallback, E2 owns every install-plugins.sh edit (E3 no longer touches it). - date: 2026-09-28 | contract: contracts/2026-09-28-skill-catalog-prune-0554.md
- branch: feature/skill-catalog-prune
- executors: 4 feater (sonnet-pinned), parallel, disjoint file sets, same tree
- r3 closes: correctness MAJOR 1-3 / MINOR 4-9, robustness MAJOR 1-4 / MINOR
5-9, simplicity MAJOR 1 / MINOR 2-5. Named changes: whole-class gstack
helper links through one shared lib (E2),
GSTACK_REMOVEDdenylist thatgstack on/enable gstackhonor (E1b),max= union of every profile (E1a), liveset fullas an oracle (criterion 16), census test with a passing baseline then one mutant per invariant (E1a), description parser reused from lib/skill-routing-census.py (E2), synced info line dropped, stale r1 wording swept. - r2: superset =
max; full keeps the 11 redundant gstack (user rule: full ⊇ every other profile, max = everything); parked = make-pdf, diagram, 21st trio; 21st trio out of full/web/web-full/design; freeze/bin link.
Ground truth (verified on the live tree, 2026-09-28)
- Catalog: 150 skills, 53.5k chars of descriptions; the harness lists only ~19k chars of them with a description (least-invoked skills lose theirs). 78 skills are name-only in the current session.
- gstack skills are symlinked per profile (BDR-030),
fullis the default (BDR-101).profile.sh applyis ADDITIVE (enables only);setandresetpark what the profile does not list (disable_gstack_not_in,disable_externals_not_in,disable_plugins_not_in) then apply. So a name dropped from full leaves the live tree only at the nextset fullorreset; nothing runs that automatically.gstack on(enable_all_gstack) andlib/toggle-external.sh enable gstackmove EVERYskills-disabled/gstack__*back, with no denylist. - gstack skills hardcode
~/.claude/skills/gstack/<path>for shared assets, but link.sh (and a duplicate block in install-plugins.sh STEP 2) only creategstack/binandgstack/browse/dist. Census of the hardcoded paths (grep -rhoE '(~|\$HOME)/\.claude/skills/gstack/[A-Za-z0-9_./-]+' skills-external/gstack/*/SKILL.md): bin/* (dozens), scripts/jargon-list.json, ETHOS.md, browse/bin/remote-slug, browse/dist/browse, design/dist/design, extension/, lib/diagram-render/dist/diagram-render.html, make-pdf/dist/pdf, freeze/bin/check-freeze.sh, careful/bin/check-careful.sh, /sections/.md (plan--review, cso, office-hours, design-consultation, document-release), review/checklist.md, review/specialists/.md, and other skills' SKILL.md (office-hours/SKILL.md, gstack-upgrade/SKILL.md). Everything butbinandbrowse/distis unreachable today: make-pdf returns MAKE_PDF_NOT_AVAILABLE, diagram BUNDLE_MISSING, the careful/guard/freeze hooks exit 127 and never fire (LRN-096 class), cso and plan-*-review cannot read their sections. - A global
skills/gstack -> skills-external/gstacksymlink is forbidden (link.sh comment): the top-level gstack SKILL.md then lists as a duplicate skill. Skill discovery reads~/.claude/skills/<name>/SKILL.md; aSKILL.mdmust therefore never sit at~/.claude/skills/gstack/SKILL.md, and no<skill>/SKILL.mdis linked belowgstack/either (only their non-SKILL.md children), so the helper tree exposes no skill file. - gstack
shipresolves its base fromorigin/HEADon Gitea → main; it diffs and PRs against main, skipping develop.land-and-deployrunsgh pr merge --squash --delete-branchthen waits for the deploy. - security-guidance 2.0.0: SessionStart venv, UserPromptSubmit baseline,
PostToolUse regex, Stop = direct POST /v1/messages (opus-4-7, thinking
10000) on every turn that changed source, commit/push = Agent SDK review
up to 18 turns. Log 2026-09-22→28: 0 findings, 1 recorded false positive
(EVAL-024).
ENABLE_STOP_REVIEW=0is the plugin's own switch. - settings.json has NO top-level
envkey today (create it). It is the live~/.claude/settings.json: validate JSON right after the edit. - doctor.sh:
find -maxdepth 2without-L→ 34 skills;grep '^description:' | head -1→ block scalars count 0 chars. doctor.sh runs underset -euo pipefail.lib/skill-routing-census.pyalready has a testedextract_description()handling|/>blocks (hyphenated file name → load with importlib, notimport). - Specialized profiles carry exactly ONE name full lacks: pr-review-toolkit
(audit.profile; plugin deliberately out of full, audit 2026-07-02 #12,
~2.2k tokens when enabled; MANAGED_PLUGINS so
settoggles it). - Already done live (user go): brightdata disabled (
falsein settings.json), frontend-design@claude-plugins-official uninstalled (entry removed).lib/gstack-removed.shwritten by the orchestrator:GSTACK_REMOVED=(…9…)+gstack_is_removed <name>.
Approach
E1a — profiles + census suite + profile docs
Files: lib/profiles/{full,dev,backend,web,web-full,design}.profile, lib/profiles/max.profile (new), lib/tests/profile-census.test.sh (new), skills/profile/SKILL.md, README.md, USAGE.md.
- Remove the 9
GSTACK_REMOVEDentries (ship land-and-deploy setup-deploy autoplan context-save learn careful guard design-shotgun) from EVERY profile that lists them (dev, backend, web, web-full, design, full). Comment lines describing only them go too. Keepfreezeandunfreeze. full.profile: additionally remove the 5 parked entriesmake-pdf diagram 21st-ai 21st-ui-explore 21st-ui-review. The 11 redundant gstack (plan-ceo/design/devex-review, spec, review, retro, investigate, canary, qa, open-gstack-browser, setup-browser-cookies) STAY. Rewrite# DESC:: default profile; carries everything every other profile carries (user rule 2026-09-28, one exception: pr-review-toolkit); no broken or doctrine-breaking gstack (see lib/gstack-removed.sh); parked tools live inmax. The pr-review-toolkit comment block stays.web,web-full,design: also remove the 21st trio lines.- New
lib/profiles/max.profile: header# DESC: Everything — full + the parked tools (make-pdf, diagram, 21st-ai/ui-explore/ui-review) + pr-review-toolkit; switch here when one of them is wantedand the marker line# SUPERSET-OF: full. Content = new full + the 5 parked names in their original sections +pr-review-toolkit plugin@claude-code-plugins(the audit.profile line). NEVER aGSTACK_REMOVEDname. Invariant: max ⊇ union(every profile) − GSTACK_REMOVED. lib/tests/profile-census.test.sh(hermetic,set -u,checkhelper andtrap 'rm -rf "$WORK"' EXITlike lib/tests/skill-routing-census.test.sh):- Top of file:
source "$ROOT/lib/gstack-removed.sh"(REMOVED comes from the single source),PARKED=(make-pdf diagram 21st-ai 21st-ui-explore 21st-ui-review), andFULL_EXCEPTIONS=(pr-review-toolkit)with the reason on comment lines ABOVE the array (names only inside it: contract criterion 15 parses the parentheses). - ONE assertion function
census_check <profiles_dir>that runs an inline python3 heredoc taking the dir and the three lists as argv. Entry = first whitespace token of a non-blank line whose first non-blank char is not#(whatread_profilelinks). It prints ONE reason code per violation on stdout —REMOVED_LISTED:<profile>:<name>,NO_SUPERSET/MANY_SUPERSETS,SUPERSET_GAP:<name>(full or a parked/exception name missing from the# SUPERSET-OF: fullprofile),MAX_GAP:<name>(a name some profile carries that max lacks),FULL_GAP:<name>(a name a non-max profile carries that full lacks, minus REMOVED, the 21st trio and FULL_EXCEPTIONS) — and returns 0 iff no violation. - Live part:
census_check "$ROOT/lib/profiles"must return 0 (check T1-live-clean). - Fixture part under
mktemp -d:baseline/= a minimal profiles dir (full.profile withalpha,beta; qa.profile withalpha; max.profile with the marker +alpha,beta,parked-x; PARKED and FULL_EXCEPTIONS overridden for the fixture through the argv lists). The baseline MUST return 0 → printFIXTURE_BASELINE_OK. Then three mutants, each a copy of baseline with ONE change, each MUST return non-zero AND print its own code:shipadded to qa.profile →REMOVED_LISTED:qa:ship→FIXTURE_REMOVED_DETECTED;betadeleted from max.profile →SUPERSET_GAP:beta→FIXTURE_SUPERSET_DETECTED;gammaadded to qa.profile only →FULL_GAP:gamma→FIXTURE_FULLGAP_DETECTED. A detected mutant counts as a PASS of the test (it is the positive control); a mutant that returns 0 is a FAIL. - Summary
PASS=n FAIL=m, rc 1 on any FAIL. Shell helpers ≤ 25 logic lines; the python heredoc is one function per invariant.
- Top of file:
- Docs: skills/profile/SKILL.md table gains the
maxrow ("Everything — full + parked tools (make-pdf, diagram, 21st generation trio) + pr-review-toolkit") and full's row reads "Default — everything the other profiles carry, minus broken or doctrine-breaking gstack"; README line 175 and 357-360 listmax; README line 179 dropscontext-save, context-restorefrom its example list; USAGE line 166 listsmax.
E1b — denylist honored by every "gstack back" path
Files: lib/profile.sh, lib/toggle-external.sh, lib/tests/gstack-removed.test.sh (new), lib/tests/{profile-default,profile-set-managed,toggle-external-repo-resolution}.test.sh (fixture copy line only).
- lib/profile.sh:
source "$(dirname "${BASH_SOURCE[0]}")/gstack-removed.sh"next to the other top-level definitions.enable_all_gstack: skip a parked entry whose namegstack_is_removed(leave it parked,info "skipped (removed by policy, lib/gstack-removed.sh): $name").enable_skillgstack branch: refuse a removed name withwarnand return 0 (a profile listing one is a census failure, not a crash). - lib/toggle-external.sh
enable gstackloop (thefor entry in "$DISABLED_DIR"/gstack__*block): same skip, same source line (source "$(dirname "$0")/gstack-removed.sh"; note toggle-external resolves REPO from$0, keep that idiom). enable_all_gstackechoes the REAL restored count on stdout (skipped names excluded);cmd_gstack onprints that count, not the pre-computedparked_gstack_count(profile.sh ~651-655). toggle-external.sh: when the loop skipped ≥ 1 removed name and moved 0, print "only policy-removed skills remain parked (lib/gstack-removed.sh)" and NOT the "re-run gstack setup" hint (setup would relink the 9 removed skills).- The three existing suites copy only profile.sh / toggle-external.sh into
their fixture
lib/and would die on the newsource: addcp "$ROOT/lib/gstack-removed.sh" "$FX/lib/"(same for$SANDBOX/repo/lib/) in lib/tests/profile-default.test.sh (:27), lib/tests/profile-set-managed.test.sh (:22), lib/tests/toggle-external-repo-resolution.test.sh (:19). No other change to those suites. lib/tests/gstack-removed.test.sh: fixture repo under mktemp (thePROFILE_REPO_OVERRIDE/TOGGLE_EXTERNAL_REPO_OVERRIDEharness as lib/tests/profile-default.test.sh and toggle-external-repo-resolution.test.sh use it — read them first):skills-disabled/gstack__shipandgstack__browsepresent →profile.sh gstack onrestores browse only, ship stays parked, output names the skip; same fortoggle-external.sh enable gstack;gstack_is_removedpositive + negative.PASS=n FAIL=m.
E2 — wiring: shared gstack helper links + doctor catalog stats
Files: lib/gstack-links.sh (new), link.sh, install-plugins.sh (STEP 2 helper block, STEP 5 comment blocks, summary lines — E2 owns EVERY edit of this file), update-all.sh (its helper-link block), lib/doctor-skills.sh (new), doctor.sh, lib/tests/gstack-links.test.sh (new), lib/tests/doctor-skills.test.sh (new).
lib/gstack-links.sh—link_gstack_helpers <src> <dst>(split into small helpers, each ≤ 25 logic lines, noset -e, fallback ok/warn/info like lib/vendor-skills.sh): a. Guard: if$dstis a symlink →rm -f "$dst"+ info (gstack ./setup plants~/.claude/skills/gstack -> skills-external/gstackwhen the dir is absent; link.sh's old stale-link removal moves here). Then ifrealpath -m "$dst"is insiderealpath "$src"→ warn, return 1 (never write into the submodule).mkdir -p "$dst". b. For every top-level entry E of$src, skip by name.git*,node_modules,SKILL.md. If E is a dir holding its ownSKILL.md(a gstack skill) →mkdir -p "$dst/E"andln -sfneach child of E exceptSKILL.md. Else if E is a dir andfind -L "$src/E" -name SKILL.md -print -quitis non-empty (browser-skills/, openclaw/ …) → skip with info (would expose a nested skill). Else (file, or a clean non-skill dir such as bin, scripts, lib, design, extension, ETHOS.md) →ln -sfn "$src/E" "$dst/E". c. Idempotent (ln -sfn), removes nothing but the stale dst symlink, echoes the number of links created THIS run on stdout (callers add it to CHANGED) and prints oneoksummary on stderr. Rationale comment: the census above + why no SKILL.md is ever exposed under<dst>.- link.sh: replace the stale-global-link removal AND the
bin/browse/distblocks (lines ~55-91) withsource "$REPO/lib/gstack-links.sh"- one call
n=$(link_gstack_helpers "$REPO/skills-external/gstack" "$CLAUDE/skills/gstack")guarded by[ -d "$REPO/skills-external/gstack" ],CHANGED=$((CHANGED + n)); keep the "submodule not found" warning; the comment says the helper tree replaces the hand-made links and exposes no SKILL.md.
- one call
- install-plugins.sh STEP 2, the duplicate
GSTACK_DSTblock (lines ~375-390): replace with the same source + call (after ./setup, so the lib's guard removes the global link setup may have planted). update-all.sh, its helper-link block (~106-116,ln -sfwithout-n→ would nestsrc/bin/binon a re-run): same source + call, right after the submodule update. STEP 5 comment blocks + summary lines: E2 does them (moved from E3, see E3.2 text below — same wording). lib/doctor-skills.sh—skill_catalog_stats <skills_dir>: prints<count> <desc_chars>on stdout, ALWAYS exits 0 (prints0 0when the dir is absent or python fails). Implementation: onepython3 -call that loadslib/skill-routing-census.pyviaimportlib.util.spec_from_file_location(hyphenated name), globs<dir>/*/SKILL.md(glob follows symlinks), sumslen(extract_description(path) or '')(the function takes a PATH and returns None when there is no description). On any python failure print0 0to stdout AND onewarnline to stderr (doctor shows the failure instead of a healthy-looking zero). No awk parser.- doctor.sh token block: replace the loop +
findwithread -r SKILL_COUNT SKILL_DESC_CHARS < <(skill_catalog_stats "$HOME/.claude/skills")aftersource "$REPO/lib/doctor-skills.sh". Constants: DELETE the gstack (2750), context7 (200) and graphifyy (300) lines — their skills sit in~/.claude/skillsand are counted by the stats (one comment line says so); superpowers 800 → 1500 (~900 t session-start injection + ~600 t of 15 descriptions, measured 2026-09-28); ui-ux-pro-max 400 → 670 (7 descriptions, 2 669 chars, measured 2026-09-28). The "measured ~11.4k post-audit, LRN-088" note → "(re-measure after a catalog change; LRN-088)". NO synced-bucket line (dropped: out of the request's scope; noted as a TODO follow-up by the orchestrator). - Tests.
lib/tests/gstack-links.test.sh: fixture src under mktemp withbin/x,ETHOS.md,SKILL.md,browse/{SKILL.md,dist/browse},review/{SKILL.md,checklist.md,specialists/a.md},.git/HEAD,other/deep/SKILL.md,node_modules/pkg/SKILL.md→ afterlink_gstack_helpers,dst/bin,dst/ETHOS.md,dst/browse/dist,dst/review/checklist.md,dst/review/specialistsresolve;find -L dst -name SKILL.mdis EMPTY (so nodst/SKILL.md,dst/browse/SKILL.md,dst/other,dst/node_modules),dst/.gitabsent; second run echoes 0 and changes nothing (idempotent); a dst that is a symlink to src → the symlink is removed, dst becomes a real dir, andfind src -type lstays EMPTY (nothing written into src); a dst path inside src (src/helpers) → warn + rc 1, nothing created.lib/tests/doctor-skills.test.sh: fixture skills dir with an inline description, a|block scalar, a>-block, a SKILL.md with no description, a symlinked skill dir → count and chars equal the hand-computed sum; absent dir →0 0rc 0.PASS=n FAIL=msummaries.
E3 — config + doctrine + docs
Files: settings.json, agents/plugin-advisor.md, CLAUDE.global.md, skills/deploy/SKILL.md, CHANGELOG.md. (install-plugins.sh is E2's: the E3.2 wording below is what E2 writes there.)
- settings.json: CREATE the top-level key
"env": {"ENABLE_STOP_REVIEW": "0"}(it does not exist), keep the two enabledPlugins states already written live. Immediately validate:python3 -c 'import json;json.load(open("settings.json"))'. - [DONE BY E2, wording kept here] install-plugins.sh STEP 5: after the ui-ux-pro-max block, a comment block
"frontend-design@claude-plugins-official — NEVER installed: byte-identical
to the skills-external copy Step 8b syncs from the example-skills cache;
uninstalled 2026-09-28 (skill-catalog prune)" and "brightdata-plugin@synced
— account-synced from claude.ai, kept
falsein settings.json: every skill needs a Bright Data account and its bright-data-mcp skill orders WebFetch/ WebSearch replaced 'no exceptions' (would hijack /seo /geo /harden)". Summary line "security-guidance — PreToolUse security hook (0 tokens)" → "security-guidance — regex hints on Edit/Write + out-of-band LLM reviews on commit/push (Stop review off via ENABLE_STOP_REVIEW=0; quota, not context) [claude-code-plugins]". The frontend-design summary line stays (the managed copy stays). - agents/plugin-advisor.md: the
security-guidance ↔ anyrow → "Hooks + out-of-band LLM reviews (agentic review on commit/push; Stop diff review disabled by ENABLE_STOP_REVIEW=0). No context injection unless a regex hits."; the "> security-guidance and rtk are ALWAYS ON (0 tokens)" note → "> rtk is always on at 0 context tokens; security-guidance is always on and costs quota out of band (LLM reviews), not context — both omitted from the estimates". - CLAUDE.global.md Skill routing: "- Ship / PR → ship (ship-feature if
gstack off); deploy → deploy (runbook, the user runs it)" → "- Ship / PR →
ship-feature (never gstack ship: it takes
origin/HEAD= main as base and skips develop); deploy → deploy (runbook, the user runs it)". The gstack-OFF line lists "(investigate, qa, review, health, retro, office-hours…)". Design work "Review / audit" line: drop "+ 21st-ui-review" and add, at the end of the 21st sentence in that section, "21st-ai / ui-explore / ui-review aremax-profile only." Keep every line ≤ 80 chars. - skills/deploy/SKILL.md table: drop the
/land-and-deployand/setup-deployrows; add one row "Merge a finished branch |gitflow finishon an explicit human signal (skills/gitflow)". - CHANGELOG
[Unreleased]: Removed (brightdata synced plugin disabled, frontend-design official plugin uninstalled, 9 gstack skills out of every profile with reasons,GSTACK_REMOVEDdenylist honored bygstack on/enable gstack), Changed (full = everything the other profiles carry,max= full + parked + pr-review-toolkit, 21st trio max-only, security-guidance Stop review off, doctor constants), Fixed (gstack helper tree: make-pdf, diagram, sections, jargon list, ETHOS, freeze hook now fires —/unfreezeclears~/.gstack/freeze-dir.txt; doctor.sh undercount; "0 tokens" claims; Ship/PR routing), Known residual (kept gstack skills still carry upstream prose routing to /ship, /land-and-deploy, /context-save, /autoplan, /design-shotgun; 21st-ui-build and 21st-cli-use point to the max-only trio — a Skill call on a parked name fails and the doctrine routing applies).
Orchestrator steps after the executors
- Criterion 16 runs
bash lib/profile.sh set fulllive (parks the 14 names, re-applies full) and checks none of them resolves under~/.claude/skills. - Post-merge (user):
make link(helper tree) andbash lib/profile.sh set fullon any other machine.
Edge cases
- A profile line may carry a trailing label column (
personal,external,# gstack); entries match on the first token only. design.profile# GATE-BLOCK:lines name frontend-design, ui-ux-pro-max, emil-design-eng, design-html, design-motion-principles, design-review, design-consultation, the 21st CLI, 21st-ui-build: none of the removed or parked names → no gate edit (grep before editing).- link.sh / install-plugins.sh run on machines without the gstack
submodule: the call is guarded by
[ -d skills-external/gstack ]. skill_catalog_statsnever breaks doctor.sh'sset -euo pipefail: it always exits 0 and always prints two integers.- The helper tree never contains a
SKILL.mdat any depth: skill dirs expose only their non-SKILL.md children, non-skill dirs with a nested SKILL.md (browser-skills/, node_modules/, openclaw/) are skipped (asserted by the gstack-links test and criterion 3). ~/.claude/skills/gstackmay be a symlink to the submodule (planted by gstack ./setup on a fresh machine): the lib removes it before linking and never writes when dst resolves inside src.- Kept gstack skills still name removed/parked skills in their upstream prose: documented as a known residual (CHANGELOG), not patched (machine- owned submodule files).
Tests
- lib/tests/profile-census.test.sh, gstack-removed.test.sh,
gstack-links.test.sh, doctor-skills.test.sh (new, hermetic; SUITES glob
picks
lib/tests/*.test.shup automatically). - make test suite=lib/tests/doctrine-citers.test.sh (routing text changed); existing profile-default / profile-set-managed / toggle-external suites must stay green (profile.sh and toggle-external.sh changed).
- shellcheck link.sh doctor.sh install-plugins.sh update-all.sh lib/.sh lib/tests/.test.sh.
- Full
make testby the orchestrator at the end (2 pre-existing T16a gitleaks failures are known).
Disposition (RELATED MEMORY, read-before)
- honors BDR-030 / BDR-101 — gstack via profiles, full default: every drop
is a profile edit; the live tree follows through
set full. - honors BDR-025 — GATE-BLOCK single source untouched.
- honors BDR-093 — 21st pack stays installed; its generation/review trio leaves the four design-bearing profiles and lives in max; BDR amendment noted in registries.
- honors BDR-023 — close alias untouched.
- honors BDR-080 — investigate stays explicit-only and stays in full/dev/ backend (user rule).
- honors BDR-095 — static deny beats
ask: careful/guard removal loses no live protection (their hooks never fired). - honors LRN-088 — measured before cutting: the gain is routing quality and no broken 100 KB body invoked, not listing chars.
- honors LRN-022 / BLK-005 — profiles audited with the skill change (census).
- honors LRN-096 — vacuous guard class: helper tree makes the freeze hook real; careful/guard are removed rather than left vacuous.
- honors BDR-070 — no rival SEO tooling: brightdata seo-audit stays off.
- does NOT touch BDR-104 (MengTo pack) nor the 2026-07-05 frontend-design + impeccable "both" decision (the managed copy stays).