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()