refactor(skills)!: rename every skill to kvb-<category>-<name>

All 38 first-party skills move to kvb-<category>-<name> with the categories think, create, code, ci, git, check, org and skills; kvb-cleanup and kvb-help are the only uncategorized names. tests/test_naming.py pins the schema, the Agent Skills name rules and the README organigram. enable-plugins.js repoints hook commands of renamed skills in settings.local.json so a stale script path cannot block tools.

BREAKING CHANGE: the old skill names and slash commands (/commit-next, /dream, /autotune, ...) are gone; use their kvb- successors listed in the README organigram.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Kevin Veen-BirkenbachandClaude Opus 5.5 committed 2026-11-01 00:47:23 +01:00
1 parent 64403cefca
commit 624baccfc4
59 files changed
+504 -312

No files matched your search

+170
View File
@@ -0,0 +1,170 @@
#!/usr/bin/env python3
"""Fetch the failed jobs of a GitHub Actions run and cluster them by error line.
Usage:
ci_failures.py RUN_ID [--repo OWNER/REPO] [--out DIR] [--artifacts]
Prints how far HEAD moved since the run's commit, saves each finished failed
job's log (colour stripped) under DIR (default /tmp/ci-logs-RUN_ID), and
groups the jobs by the normalised error line that ended them (the last one). Jobs still running are
listed but not fetched: their API log can stop hours early. The clustering is
a heuristic; confirm a cluster's boundary against the logs.
"""
from __future__ import annotations
import argparse
import json
import re
import subprocess
import sys
from pathlib import Path
FAILED = {"failure", "cancelled", "timed_out"}
ANSI = re.compile(r"\x1b\[[0-9;]*[A-Za-z]")
STAMP = re.compile(r"^\d{4}-\d\d-\d\dT[\d:.]+Z\s?")
GENERIC = re.compile(r"failed=0\b|FAILED - RETRYING|^make(\[\d+\])?: \*\*\* \[.*\] Error \d+")
PROCESS_END = re.compile(r"##\[error\]Process completed with exit code")
ANNOTATION = re.compile(r"##\[error\]")
FATAL = re.compile(r"fatal: \[|FAILED!|Traceback|AssertionError|panic:")
LINT_AT = re.compile(r"\S+:\d+:\d+:? ((error|warning) )?[A-Z]{1,4}\d{3,4}\b")
LINT_CODE = re.compile(r"^[A-Z]{1,4}\d{3,4}\b")
LINT_ARROW = re.compile(r"^-->\s+\S+:\d+:\d+")
SIGNAL = re.compile(r"\b(ERROR|Error|FAILED|FAIL|fatal|Exception|failed)\b")
TASK = re.compile(r"^TASK \[")
REFUSAL = "terminal escape sequences"
def run(*cmd: str) -> subprocess.CompletedProcess:
return subprocess.run(list(cmd), capture_output=True, text=True, check=False)
def clean(line: str) -> str:
return STAMP.sub("", ANSI.sub("", line)).strip()
def normalise(line: str) -> str:
line = re.sub(r"\b[0-9a-f]{7,}\b", "<hex>", line)
line = re.sub(r"\d+", "<n>", line)
return line[:200]
def output_lines(text: str) -> list[tuple[str, str]]:
kept, echo, task = [], False, ""
for raw in text.splitlines():
line = clean(raw)
if PROCESS_END.search(line):
break
if line.startswith("##[group]Run "):
echo = True
elif line.startswith("##[endgroup]"):
echo = False
elif TASK.match(line):
task = line
elif line and not echo and not GENERIC.search(line):
kept.append((task, line))
return kept
def is_lint(lines: list[tuple[str, str]], index: int) -> bool:
line = lines[index][1]
if LINT_AT.search(line):
return True
following = lines[index + 1][1] if index + 1 < len(lines) else ""
return bool(LINT_CODE.match(line) and LINT_ARROW.match(following))
def signature(text: str) -> str:
lines = output_lines(text)
tiers = (
(lambda i: ANNOTATION.search(lines[i][1]), False),
(lambda i: FATAL.search(lines[i][1]), True),
(lambda i: is_lint(lines, i), False),
(lambda i: SIGNAL.search(lines[i][1]), False),
)
for matches, with_task in tiers:
for index in reversed(range(len(lines))):
if matches(index):
task, line = lines[index]
found = ANNOTATION.sub("", line).strip()
return normalise(f"{task} {found}" if with_task and task else found)
return normalise(lines[-1][1]) if lines else "<empty log>"
def cluster(jobs: list[tuple[str, str]]) -> dict[str, list[str]]:
groups: dict[str, list[str]] = {}
for job, text in jobs:
groups.setdefault(signature(text), []).append(job)
return dict(sorted(groups.items(), key=lambda item: -len(item[1])))
def behind(sha: str) -> str:
if run("git", "cat-file", "-e", f"{sha}^{{commit}}").returncode:
return "behind=unknown (run commit not in this clone)"
count = run("git", "rev-list", "--count", f"{sha}..HEAD").stdout.strip()
moved = {p.split("/")[0] for p in run("git", "diff", "--name-only", f"{sha}..HEAD").stdout.split()}
return f"behind={count} moved={','.join(sorted(moved)) or '-'}"
def main() -> int:
parser = argparse.ArgumentParser(prog="ci_failures")
parser.add_argument("run_id")
parser.add_argument("--repo")
parser.add_argument("--out")
parser.add_argument("--artifacts", action="store_true")
args = parser.parse_args()
repo = args.repo or run("gh", "repo", "view", "--json", "nameWithOwner", "-q", ".nameWithOwner").stdout.strip()
if not repo:
parser.error("no --repo and gh cannot resolve the current repository")
meta = run("gh", "api", f"/repos/{repo}/actions/runs/{args.run_id}")
# per_page=100 on a large matrix answers HTTP 502; 30 per page goes through.
listing = run("gh", "api", "--paginate", f"/repos/{repo}/actions/runs/{args.run_id}/jobs?per_page=30",
"--jq", ".jobs[] | {id, name, status, conclusion}")
if meta.returncode or listing.returncode:
print((meta.stderr or listing.stderr).strip(), file=sys.stderr)
return 1
data = json.loads(meta.stdout)
jobs = [json.loads(line) for line in listing.stdout.splitlines() if line]
out = Path(args.out or f"/tmp/ci-logs-{args.run_id}")
out.mkdir(parents=True, exist_ok=True)
print(f"run={args.run_id} repo={repo} head={data['head_sha'][:10]} status={data['status']} "
f"jobs={len(jobs)} {behind(data['head_sha'])}")
running = [j["name"] for j in jobs if j["status"] != "completed"]
if running:
print(f"running ({len(running)}, not fetched): {', '.join(running)}")
fetched, missing = [], []
for job in jobs:
if job["status"] != "completed" or job["conclusion"] not in FAILED:
continue
log = run("gh", "api", "--allow-escape-sequences", f"/repos/{repo}/actions/jobs/{job['id']}/logs")
if log.returncode or REFUSAL in log.stdout[:200]:
missing.append(f"{job['conclusion']}\t{job['name']}\t{log.stderr.strip()[:80]}")
continue
slug = re.sub(r"[^A-Za-z0-9._-]+", "_", job["name"])[:80]
path = out / f"{job['id']}-{slug}.log"
path.write_text("\n".join(ANSI.sub("", line) for line in log.stdout.splitlines()), encoding="utf-8")
text = "##[error]cancelled" if job["conclusion"] == "cancelled" else log.stdout
fetched.append((f"{job['conclusion']}\t{job['name']}\t{path}", text))
if missing:
(out / "no-log.txt").write_text("\n".join(missing) + "\n", encoding="utf-8")
counts = {c: sum(m.startswith(c) for m in missing) for c in FAILED}
summary = ", ".join(f"{n} {c}" for c, n in sorted(counts.items()) if n)
print(f"no log for {len(missing)} jobs ({summary}; never started or expired): {out / 'no-log.txt'}")
for number, (sig, jobs) in enumerate(cluster(fetched).items(), 1):
print(f"cluster {number} ({len(jobs)} jobs): {sig}")
for job in jobs[:3]:
print(f" {job}")
if len(jobs) > 3:
members = out / f"cluster-{number}.txt"
members.write_text("\n".join(jobs) + "\n", encoding="utf-8")
print(f" ... +{len(jobs) - 3} more: {members}")
if args.artifacts:
listing = run("gh", "api", f"/repos/{repo}/actions/runs/{args.run_id}/artifacts", "--paginate",
"--jq", '.artifacts[] | "\\(.name)\\t\\(.size_in_bytes)"')
print("artifacts:")
print(listing.stdout.rstrip() or " none")
return 0
if __name__ == "__main__":
sys.exit(main())