From c47393a73beac6f1c1ab5a9d4611d2b6811a6a3b Mon Sep 17 00:00:00 2001 From: Kevin Veen-Birkenbach Date: Tue, 3 Nov 2026 13:24:56 +0100 Subject: [PATCH] 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 --- README.md | 4 + skills/kvb-check-principles/SKILL.md | 7 +- skills/kvb-code-scope-guard/SKILL.md | 5 +- skills/kvb-skill-cleanup/SKILL.md | 52 +++++++++ skills/kvb-skill-cleanup/scripts/legacy.py | 118 ++++++++++++++++++++ skills/kvb-skill-dream/SKILL.md | 7 +- skills/kvb-think-criticize/SKILL.md | 8 +- tests/test_skill_cleanup.py | 120 +++++++++++++++++++++ 8 files changed, 306 insertions(+), 15 deletions(-) create mode 100644 skills/kvb-skill-cleanup/SKILL.md create mode 100755 skills/kvb-skill-cleanup/scripts/legacy.py create mode 100644 tests/test_skill_cleanup.py diff --git a/README.md b/README.md index a168623..f3e3d70 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/skills/kvb-check-principles/SKILL.md b/skills/kvb-check-principles/SKILL.md index fb5f434..482bdee 100644 --- a/skills/kvb-check-principles/SKILL.md +++ b/skills/kvb-check-principles/SKILL.md @@ -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 diff --git a/skills/kvb-code-scope-guard/SKILL.md b/skills/kvb-code-scope-guard/SKILL.md index 4c6a355..d3be253 100644 --- a/skills/kvb-code-scope-guard/SKILL.md +++ b/skills/kvb-code-scope-guard/SKILL.md @@ -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 diff --git a/skills/kvb-skill-cleanup/SKILL.md b/skills/kvb-skill-cleanup/SKILL.md new file mode 100644 index 0000000..cc5a4ef --- /dev/null +++ b/skills/kvb-skill-cleanup/SKILL.md @@ -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 --root [--root ...] [--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 ` 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. diff --git a/skills/kvb-skill-cleanup/scripts/legacy.py b/skills/kvb-skill-cleanup/scripts/legacy.py new file mode 100755 index 0000000..3950394 --- /dev/null +++ b/skills/kvb-skill-cleanup/scripts/legacy.py @@ -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()) diff --git a/skills/kvb-skill-dream/SKILL.md b/skills/kvb-skill-dream/SKILL.md index f678fce..a6d6a68 100644 --- a/skills/kvb-skill-dream/SKILL.md +++ b/skills/kvb-skill-dream/SKILL.md @@ -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 diff --git a/skills/kvb-think-criticize/SKILL.md b/skills/kvb-think-criticize/SKILL.md index 08cc66c..7a82777 100644 --- a/skills/kvb-think-criticize/SKILL.md +++ b/skills/kvb-think-criticize/SKILL.md @@ -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 diff --git a/tests/test_skill_cleanup.py b/tests/test_skill_cleanup.py new file mode 100644 index 0000000..3dd3667 --- /dev/null +++ b/tests/test_skill_cleanup.py @@ -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()