feat(kvb-skill-cleanup): delete installed skills that renames and removals left behind

legacy.py reads the skills repository's git history (renames and deletions of skills/*/SKILL.md), follows rename chains to the current skill, and classifies every installed skill directory as current (shipped or locked), project (tracked in the repository that holds the root), legacy (with its successor), removed, or unknown. kvb-skill-cleanup scans ~/.claude/skills, ~/.agents/skills, ~/.codex/skills and the projects' .claude and .agents skills, asks the operator, and deletes by re-running the installer, by rm -r where the sandbox allows, or through kvb-org-operator; a rescan verifies. tests/test_skill_cleanup.py covers a two-step rename chain, a deletion, a locked, a project and an unknown skill, and invalid input; a broken one-step resolver fails it. Operator order 2026-11-03; four descriptions trimmed for the listing budget.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Kevin Veen-BirkenbachandClaude Opus 5.5 committed 2026-11-03 13:57:14 +01:00
1 parent de8823e239
commit c47393a73b
8 files changed
+306 -15

No files matched your search

+4
View File
@@ -71,6 +71,7 @@ flowchart LR
skill --> skill_essence[kvb-skill-essence]
skill --> skill_transcend[kvb-skill-transcend]
skill --> skill_compile[kvb-skill-compile]
skill --> skill_cleanup[kvb-skill-cleanup]
none --> cleanup[kvb-cleanup]
none --> help[kvb-help]
none --> transcend_all[kvb-transcend]
@@ -149,6 +150,7 @@ flowchart LR
essence[kvb-skill-essence]
skill_transcend[kvb-skill-transcend]
compile[kvb-skill-compile]
skill_cleanup[kvb-skill-cleanup]
end
subgraph cat_none [no category]
cleanup[kvb-cleanup]
@@ -242,6 +244,8 @@ flowchart LR
skill_transcend -. deterministic moves .-> compile
essence -. procedures .-> compile
compile --> active_listening
skill_cleanup --> operator
skill_cleanup -. unknown skills .-> transcend
tune -. measurements .-> code_transcend
test --> triage
+3 -4
View File
@@ -1,10 +1,9 @@
---
name: kvb-check-principles
description: >
Analyse a repository, path or diff for violated principles: SPOT, DRY, KISS,
SRP, security at trust boundaries. Trigger on /kvb-check-principles, "sei
skeptisch", "kritische analyse", "review the architecture", or "what is
wrong with the code".
Find violated principles (SPOT, DRY, KISS, SRP, trust boundaries) in a
repository, path or diff. Trigger on /kvb-check-principles, "sei
skeptisch", "kritische analyse", or "review the architecture".
---
The stance is adversarial: the code is guilty until its structure proves
+2 -3
View File
@@ -2,9 +2,8 @@
name: kvb-code-scope-guard
description: >
Deliver exactly what was asked and nothing more. Trigger on every task that
creates files, commands or commits, on unreviewed autonomous runs, on
hand-over requests, and when the operator asks why something unrequested
exists.
creates files, commands or commits, on autonomous runs and hand-overs, and
when asked why something unrequested exists.
---
The requested scope is the deliverable. A problem you noticed behind the
+52
View File
@@ -0,0 +1,52 @@
---
name: kvb-skill-cleanup
description: >
Delete legacy skills that renamed or removed skills left in Claude, Codex,
agent and project dirs. Trigger on /kvb-skill-cleanup.
---
`${CLAUDE_SKILL_DIR}/scripts/legacy.py` reads the skills repository's git
history, where every rename and deletion of a skill is recorded, and
classifies each installed skill directory:
```bash
legacy.py --skills-repo <skills repo> --root <dir> [--root <dir> ...] [--json]
```
| Verdict | Meaning | Action |
| --- | --- | --- |
| `current` | shipped by the skills repository or its lock | keep |
| `project` | tracked in the git repository that holds the root | keep |
| `legacy` | renamed; the successor column names the current skill | delete |
| `removed` | deleted from the skills repository without a successor | delete |
| `unknown` | none of the above, for example the operator's own | keep, report |
## Step 1: roots
Every one that exists: `~/.claude/skills`, `~/.agents/skills`,
`~/.codex/skills`, and `.claude/skills` and `.agents/skills` of the current
repository and of every project the operator names.
## Step 2: scan and confirm
Run `legacy.py` over all roots. Show the `legacy` and `removed` rows in one
table (`| Root | Skill | Successor |`) and the `unknown` rows below it; the
`kvb-transcend` skill can carry an unknown one over. Then `AskUserQuestion`:
`Delete all legacy and removed` / `Pick per root` / `Delete nothing`.
## Step 3: delete
- A root that an installer rewrites as a whole (the skills repository's
`make install` for the home roots, a project's own skill install target)
is cleaned by running that installer, which also refreshes the current
skills; where the sandbox blocks it, it goes to the operator as below.
- Otherwise `rm -r <path>` per confirmed row where the sandbox lets the
agent write; every row it does not goes to the `kvb-org-operator` skill
as an operator command.
- A settings file whose hook command still points into a deleted skill
directory is reported with its line; settings files stay the operator's.
## Step 4: verify
Run `legacy.py` again: no `legacy` or `removed` row may remain in a cleaned
root. Report the before and after counts per root.
+118
View File
@@ -0,0 +1,118 @@
#!/usr/bin/env python3
"""Classify installed skills as current, project, legacy, removed or unknown.
Args:
--skills-repo: checkout of the skills repository, read with its git history.
--root: a skills directory to classify, repeatable.
--json: print the rows as JSON instead of tab-separated lines.
Exit codes:
0 done, 2 usage error.
"""
from __future__ import annotations
import argparse
import json
import subprocess
import sys
from pathlib import Path
def fail(message: str) -> None:
print(f"legacy: {message}", file=sys.stderr)
sys.exit(2)
def git(cwd: Path, *args: str) -> str | None:
result = subprocess.run(
["git", *args], cwd=cwd, capture_output=True, text=True, check=False
)
return result.stdout if result.returncode == 0 else None
def current_names(skills_repo: Path) -> set[str]:
names = {p.name for p in (skills_repo / "skills").iterdir() if (p / "SKILL.md").is_file()}
lock = json.loads((skills_repo / "skills-lock.json").read_text(encoding="utf-8"))
return names | set(lock.get("skills", {}))
def moves(skills_repo: Path) -> dict[str, str | None]:
out = git(
skills_repo,
"log", "-M", "--diff-filter=RD", "--name-status", "--format=", "--", "skills/*/SKILL.md",
)
if out is None:
fail(f"{skills_repo} is not a git repository")
found: dict[str, str | None] = {}
for line in out.splitlines():
parts = line.split("\t")
if parts[0].startswith("R") and len(parts) == 3:
found.setdefault(Path(parts[1]).parent.name, Path(parts[2]).parent.name)
elif parts[0] == "D" and len(parts) == 2:
found.setdefault(Path(parts[1]).parent.name, None)
return found
def successor(name: str, history: dict[str, str | None], current: set[str]) -> str | None:
seen = set()
while name not in current:
if name in seen or history.get(name) is None:
return None
seen.add(name)
name = history[name]
return name
def project_names(root: Path) -> set[str]:
top = git(root, "rev-parse", "--show-toplevel")
if top is None:
return set()
tracked = git(Path(top.strip()), "ls-files", "*SKILL.md") or ""
return {Path(line).parent.name for line in tracked.splitlines()}
def classify(root: Path, current: set[str], history: dict[str, str | None]) -> list[dict]:
project = project_names(root)
rows = []
for entry in sorted(p for p in root.iterdir() if (p / "SKILL.md").is_file()):
name, succ = entry.name, None
if name in current:
verdict = "current"
elif name in project:
verdict = "project"
elif name in history:
succ = successor(name, history, current)
verdict = "legacy" if succ else "removed"
else:
verdict = "unknown"
rows.append({"verdict": verdict, "successor": succ, "path": str(entry)})
return rows
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
parser.add_argument("--skills-repo", required=True)
parser.add_argument("--root", action="append", required=True)
parser.add_argument("--json", action="store_true")
args = parser.parse_args()
repo = Path(args.skills_repo)
if not (repo / "skills").is_dir() or not (repo / "skills-lock.json").is_file():
fail(f"{repo} is not a skills repository")
roots = [Path(r) for r in args.root]
for root in roots:
if not root.is_dir():
fail(f"{root} is not a directory")
current, history = current_names(repo), moves(repo)
rows = [row for root in roots for row in classify(root, current, history)]
if args.json:
print(json.dumps(rows, indent=1))
else:
for row in rows:
print(f"{row['verdict']}\t{row['successor'] or '-'}\t{row['path']}")
return 0
if __name__ == "__main__":
sys.exit(main())
+3 -4
View File
@@ -1,10 +1,9 @@
---
name: kvb-skill-dream
description: >
Consolidate the last 24 hours of work in this repository (what held, what
broke) into memory and improvement proposals.
Trigger on /kvb-skill-dream or a request to reflect on or learn from recent
work.
Consolidate the last 24 hours of this repository (what held, what broke)
into memory and proposals. Trigger on /kvb-skill-dream or a request to
learn from recent work.
---
Sleep on the work: replay what happened in this repository, separate what held
+4 -4
View File
@@ -1,10 +1,10 @@
---
name: kvb-think-criticize
description: >
Give constructive criticism of a problem, plan, design, decision, text or
code, each point hardened by dialectic. Trigger on /kvb-think-criticize,
"kritisiere", "kritisch betrachten", "konstruktive Kritik", "was haeltst du
davon", or requests for honest feedback.
Constructive criticism of a plan, design, decision, text or code, each
point hardened by dialectic. Trigger on /kvb-think-criticize, "kritisiere",
"konstruktive Kritik", "was haeltst du davon", or requests for honest
feedback.
---
The stance is critical but constructive: the goal is a better result, not a
+120
View File
@@ -0,0 +1,120 @@
"""Exercise legacy.py: renames chained through history, deletions, project and unknown skills."""
from __future__ import annotations
import json
import shutil
import subprocess
import sys
import tempfile
import unittest
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parents[1]
SCRIPT = REPO_ROOT / "skills" / "kvb-skill-cleanup" / "scripts" / "legacy.py"
@unittest.skipUnless(shutil.which("git"), "git not installed")
class TestLegacy(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
self.addCleanup(self.tmp.cleanup)
base = Path(self.tmp.name)
self.env = {
"PATH": "/usr/bin:/bin",
"HOME": str(base),
"GIT_AUTHOR_NAME": "t",
"GIT_AUTHOR_EMAIL": "t@t",
"GIT_COMMITTER_NAME": "t",
"GIT_COMMITTER_EMAIL": "t@t",
}
self.repo = base / "skills-repo"
self._skill(self.repo / "skills" / "old")
(self.repo / "skills-lock.json").write_text(
json.dumps({"skills": {"caveman": {}}}), encoding="utf-8"
)
self._git(self.repo, "init", "-q", "-b", "main")
self._commit(self.repo, "add old")
self._git(self.repo, "mv", "skills/old", "skills/mid")
self._commit(self.repo, "rename old to mid")
self._git(self.repo, "mv", "skills/mid", "skills/kvb-x-new")
self._commit(self.repo, "rename mid to kvb-x-new")
self._skill(self.repo / "skills" / "gone")
self._commit(self.repo, "add gone")
self._git(self.repo, "rm", "-q", "-r", "skills/gone")
self._commit(self.repo, "remove gone")
self.home = base / "home" / ".claude" / "skills"
for name in ("old", "mid", "gone", "kvb-x-new", "caveman", "mine"):
self._skill(self.home / name)
(self.home / "notaskill").mkdir()
self.project = base / "project"
self._skill(self.project / "skills" / "p-own")
self._git(self.project, "init", "-q", "-b", "main")
self._commit(self.project, "own skill")
self.installed = self.project / ".claude" / "skills"
for name in ("p-own", "old"):
self._skill(self.installed / name)
def _skill(self, path: Path) -> None:
path.mkdir(parents=True, exist_ok=True)
(path / "SKILL.md").write_text(
f"---\nname: {path.name}\ndescription: probe\n---\n\nBody of the probe skill.\n",
encoding="utf-8",
)
def _git(self, cwd: Path, *args: str) -> None:
subprocess.run(["git", *args], check=True, capture_output=True, env=self.env, cwd=cwd)
def _commit(self, cwd: Path, message: str) -> None:
self._git(cwd, "add", "-A")
self._git(cwd, "commit", "-q", "-m", message)
def _run(self, *args: str) -> subprocess.CompletedProcess:
return subprocess.run(
[sys.executable, str(SCRIPT), *args],
check=False,
capture_output=True,
text=True,
env=self.env,
)
def test_every_installed_skill_gets_its_verdict(self):
result = self._run(
"--skills-repo", str(self.repo),
"--root", str(self.home),
"--root", str(self.installed),
"--json",
)
self.assertEqual(result.returncode, 0, result.stderr)
home, project = str(self.home), str(self.installed)
rows = sorted(
(str(Path(row["path"]).parent), Path(row["path"]).name, row["verdict"], row["successor"])
for row in json.loads(result.stdout)
)
self.assertEqual(
rows,
sorted(
[
(home, "caveman", "current", None),
(home, "gone", "removed", None),
(home, "kvb-x-new", "current", None),
(home, "mid", "legacy", "kvb-x-new"),
(home, "mine", "unknown", None),
(home, "old", "legacy", "kvb-x-new"),
(project, "old", "legacy", "kvb-x-new"),
(project, "p-own", "project", None),
]
),
)
def test_invalid_input_exits_2(self):
missing = self._run("--skills-repo", str(self.repo), "--root", str(self.home / "nope"))
self.assertEqual(missing.returncode, 2)
no_repo = self._run("--skills-repo", str(self.home), "--root", str(self.home))
self.assertEqual(no_repo.returncode, 2)
if __name__ == "__main__":
unittest.main()