From faefa90c2a43350336585de6d2e9d215f6e1e5bc Mon Sep 17 00:00:00 2001 From: MaximumTrainer <1376575+MaximumTrainer@users.noreply.github.com> Date: Sat, 12 Sep 2026 14:06:07 +0100 Subject: [PATCH] feat: seed skill-exchange for shared skill reuse Adds the skill-exchange skill so this repository can pull from the shared catalogue at MaximumTrainer/agent-skills and contribute general improvements back, instead of reinventing the same practices locally. Adds the mechanism only; no skills are vendored. Co-Authored-By: Claude Opus 5 (1M context) --- .claude/skills/skill-exchange/SKILL.md | 182 +++++++++ .../skills/skill-exchange/scripts/skills.py | 361 ++++++++++++++++++ agents.md | 13 + 3 files changed, 556 insertions(+) create mode 100644 .claude/skills/skill-exchange/SKILL.md create mode 100644 .claude/skills/skill-exchange/scripts/skills.py diff --git a/.claude/skills/skill-exchange/SKILL.md b/.claude/skills/skill-exchange/SKILL.md new file mode 100644 index 0000000..15e85ae --- /dev/null +++ b/.claude/skills/skill-exchange/SKILL.md @@ -0,0 +1,182 @@ +--- +name: skill-exchange +description: Check the shared skills catalogue before writing any skill, workflow or procedure from scratch, vendor in whatever already solves the problem, and send genuinely general improvements back. Use when about to write a new skill or a repeated procedure; when asked "is there a skill for X", "do we have something for this", "pull the shared skills", "update our skills", or "contribute this back"; when a vendored skill has been edited locally; and when setting up skills in a repository for the first time. The catalogue lives at MaximumTrainer/agent-skills. +license: MIT +metadata: + version: "1.0.2" +--- + +# Skill exchange + +There is a shared catalogue of skills at **`MaximumTrainer/agent-skills`**. It exists because the same practices kept being reinvented independently across repositories — outside-in TDD in nine of them, ports-and-adapters in eight, the same third-party API quirks handled in six — with each copy learning the same lessons separately and none of them sharing what it learned. + +This skill is the loop that stops that happening again. It runs in both directions: + +``` + catalogue ──── pull ────► this repo reuse what exists + catalogue ◄── contribute ─ this repo send back what generalises +``` + +The rule: + +> **Before writing a skill, a workflow document, or a procedure someone will follow twice — look in the catalogue first. After improving a vendored skill — decide whether the improvement is general, and if it is, send it back.** + +## Before you write anything + +When you are about to write a skill, a `CONTRIBUTING` section, a runbook, or an `AGENTS.md` block describing how something is done here, check first. It takes one command: + +```bash +python3 .claude/skills/skill-exchange/scripts/skills.py list +python3 .claude/skills/skill-exchange/scripts/skills.py list --search testing +``` + +The catalogue covers, broadly: + +| Area | Examples | +|---|---| +| Core practice | outside-in TDD, ports and adapters, shipping and verifying, CI triage, constant drift, deliberate decisions | +| Specification | issue specification by example, gap issues, documentation drift, white papers | +| Testing integrity | test-theatre audits, container integration tests, API quirk fixtures, live probes, synthetic data | +| Platform | Docker images, Connect IQ/Monkey C, Qt6, Three.js/R3F, screenshots | +| Integrations | Intervals.icu, MCP server tools | + +Search by the *problem*, not by the name you had in mind. "flaky" finds the CI triage skill; "fixture" finds three different ones. + +## The decision + +Having found a candidate, choose deliberately. Getting this wrong in either direction is costly: vendoring something that does not fit produces a skill nobody follows, and writing a local one that duplicates the catalogue splits the knowledge again. + +| Situation | Do | +|---|---| +| It solves the problem as written | **Pull it.** Do not copy-paste, do not rewrite it in your own words | +| It solves the problem but needs repo-specific detail | **Pull it, then add a thin local skill** that names this repo's paths, commands and conventions and defers to the shared one for the method | +| It is close but wrong in a way that would be wrong everywhere | **Pull it, fix it, contribute the fix back** | +| It is genuinely specific to this repo | **Write a local skill.** Not everything generalises, and forcing it into the catalogue makes the catalogue worse | +| Nothing matches, and the problem recurs across repos | **Write it here, then contribute it** so the next repo does not repeat the work | + +Prefer the thin-local-skill option over forking a shared skill. A local `ship.md` that says "the gate here is `./gradlew check` and `npm run verify`; follow `verify-and-ship` for the method" stays in sync for free. A forked copy with the commands spliced in drifts the moment upstream improves. + +## Pulling + +```bash +python3 .claude/skills/skill-exchange/scripts/skills.py pull outside-in-tdd hexagonal-architecture +``` + +This copies `SKILL.md` and any `references/`, `scripts/` and `assets/` into the repo's skills directory — honouring whichever convention the repo already uses (`.claude/skills/` or `.github/skills/`) — and records what was taken in `.skills-manifest.json`: the version, the ref, and a content hash per file. + +**Commit the manifest.** It is what makes `status` able to tell "someone improved this locally" apart from "upstream moved on", and without it a later sync silently discards local work. + +A vendored skill is a **copy, not a link**. It keeps working with no network, and it will not change under you mid-task. The cost is that it goes stale, which is what `status` is for. + +## Staying in sync + +```bash +python3 .claude/skills/skill-exchange/scripts/skills.py status +``` + +``` + CURRENT outside-in-tdd 1.0.0 + OUTDATED verify-and-ship 1.0.0 upstream is 1.2.0 + MODIFIED intervals-icu-api 1.0.0 local edits not in the catalogue + DIVERGED test-theatre-audit 1.0.0 local edits AND upstream is 1.1.0 +``` + +- **OUTDATED** — `pull` it. Read the diff first if the skill encodes anything you rely on. +- **MODIFIED** — someone improved it here. Decide whether it generalises, below. +- **DIVERGED** — **contribute the local change first, then pull.** Pulling first silently destroys it. This is the one state where order matters. +- **REMOVED** — the skill is gone from the catalogue. Find out why before deleting; it may have been merged into another skill. + +Run `status` when starting work in a repo you have not touched recently, and when a shared practice feels out of date. + +## Contributing back + +This is the half that usually does not happen, and the reason catalogues rot. When a vendored skill has been improved locally, the improvement is already written — the only work left is deciding where it belongs and spending five minutes on a pull request. + +### Is the change general or repo-specific? + +Apply this test honestly. **Would this change help a repository that does not share this one's stack, paths or tooling?** + +**General — contribute it:** + +- A failure mode and its cause, in any repo that uses this technology +- A sharper explanation, a better worked example, a clearer decision table +- A missing case in a checklist +- A correction — the skill said something that is not true +- A trigger phrase that should have matched and did not + +**Repo-specific — keep it local:** + +- This repo's file paths, module names, build commands, branch names +- A threshold or convention chosen for this project +- Anything naming a service, account or dataset only this repo talks to +- A workaround for a version only this repo is pinned to + +The honest split is usually "both": a general insight wrapped in local specifics. Extract the general part, phrase it without the local nouns, and contribute that — keeping the local detail in a thin local skill. A finding of the shape *"the vendor's `/wellness` endpoint returns oldest-first while `/activities` returns newest-first"* is general. *"so `DashboardSync.kt:88` reads a stale value"* is not. + +### Sending it + +```bash +python3 .claude/skills/skill-exchange/scripts/skills.py diff intervals-icu-api +python3 .claude/skills/skill-exchange/scripts/skills.py contribute intervals-icu-api +``` + +`contribute` writes a patch and prints the exact sequence — clone, branch, apply, bump the version, rebuild the catalogue, PR. Before running it: + +- **Strip the local specifics** from the patch rather than sending them upstream. +- **Say what broke.** A rule with a named failure mode survives; a rule without one gets "simplified" away by the next reader. The catalogue's own convention is that where a rule exists because something broke, the skill says what broke. +- **Bump the version deliberately** — patch for a clarification, minor for new material, major for a restructure that changes the method. +- **Run `python3 tools/build_catalogue.py`** in the catalogue repo; `catalogue.json` is generated and CI fails on drift. +- Open the PR against `main` and say which repo the improvement came from. + +After it merges, `pull` it back here so the manifest hash matches again and `status` returns to CURRENT. + +### The catalogue is served from a cache + +`raw.githubusercontent.com` responds with `Cache-Control: max-age=300`, so for up +to five minutes after a merge the catalogue still serves the previous content. +`list` will not show a newly added skill, and `status` will still say CURRENT +against the old version. + +That is the cache, not a failed contribution. Wait and re-run rather than +re-pushing. To confirm what actually landed, look at the repository rather than +the raw URL: + +```bash +gh api repos/MaximumTrainer/agent-skills/contents/catalogue.json --jq '.sha' +``` + +## Seeding a repository + +To put this skill into a repo that does not have it: + +```bash +mkdir -p .claude/skills/skill-exchange/scripts +curl -fsSL https://raw.githubusercontent.com/MaximumTrainer/agent-skills/main/skill-exchange/SKILL.md \ + -o .claude/skills/skill-exchange/SKILL.md +curl -fsSL https://raw.githubusercontent.com/MaximumTrainer/agent-skills/main/skill-exchange/scripts/skills.py \ + -o .claude/skills/skill-exchange/scripts/skills.py +python3 .claude/skills/skill-exchange/scripts/skills.py list +``` + +Then mention it in the repo's `AGENTS.md` or `CLAUDE.md` in one line, so it is found on the first session rather than the fifth: + +> Shared skills live in `MaximumTrainer/agent-skills`. Check the catalogue before writing a new skill or procedure — see `.claude/skills/skill-exchange/`. + +Where the repo already keeps skills in `.github/skills/`, the script follows that; do not move existing skills to satisfy the tool. + +## What not to do + +- **Do not copy a skill's text by hand.** It arrives without provenance, so nothing can tell you when it goes stale and nothing can send an improvement back. Use `pull`. +- **Do not edit a vendored skill to add local paths.** That guarantees a diverge on the next update. Put the local detail in a local skill that defers to the shared one. +- **Do not pull over a MODIFIED skill** without reading the diff. `pull` refuses by default for exactly this reason; `--force` discards local work. +- **Do not contribute repo-specific detail.** A catalogue full of one repo's file paths is a catalogue the other repos stop reading. +- **Do not vendor everything.** Pull what the repo actually needs. Twenty-four skills in a repo that needs three makes selection worse for the agent, not better. +- **Do not fork silently.** If a shared skill is genuinely wrong for this repo and the fix will not generalise, say so in the local skill and record it as a deliberate decision — so the next person knows the divergence was chosen. + +## Related skills + +- `deliberate-decisions` — recording a chosen divergence from a shared skill +- `docs-drift-guard` — the derived-with-a-drift-check pattern `catalogue.json` follows +- `single-source-constants` — the same argument, applied to values rather than practices +- `verify-and-ship` — landing a contribution back in the catalogue +- `spec-by-example-issue` — when a gap in the catalogue deserves an issue rather than a PR diff --git a/.claude/skills/skill-exchange/scripts/skills.py b/.claude/skills/skill-exchange/scripts/skills.py new file mode 100644 index 0000000..af6407f --- /dev/null +++ b/.claude/skills/skill-exchange/scripts/skills.py @@ -0,0 +1,361 @@ +#!/usr/bin/env python3 +""" +Pull skills from the shared catalogue, and see when a local copy has drifted. + +This script is vendored into each repository alongside the skill-exchange skill, +so it must stay self-contained: stdlib only, no local clone of the catalogue +repository required. + +Usage: + python3 skills.py list [--search TERM] what the catalogue offers + python3 skills.py pull NAME [NAME ...] vendor a skill into this repo + python3 skills.py status compare local copies to upstream + python3 skills.py diff NAME local vs upstream, unified diff + python3 skills.py contribute NAME prepare a change to send back + +Options: + --ref REF catalogue ref to read (default: main) + --source OWNER/REPO + --skills-dir DIR override auto-detection of the skills directory +""" + +import argparse +import difflib +import hashlib +import json +import subprocess +import sys +import urllib.error +import urllib.request +from datetime import date +from pathlib import Path + +DEFAULT_SOURCE = "MaximumTrainer/agent-skills" +DEFAULT_REF = "main" +MANIFEST_NAME = ".skills-manifest.json" +RAW = "https://raw.githubusercontent.com/{source}/{ref}/{path}" + + +# --- fetching ---------------------------------------------------------------- + + +def fetch(source, ref, path): + url = RAW.format(source=source, ref=ref, path=path) + request = urllib.request.Request(url, headers={"User-Agent": "skill-exchange"}) + try: + with urllib.request.urlopen(request, timeout=30) as response: + return response.read().decode("utf-8") + except urllib.error.HTTPError as e: + raise SystemExit(f"error: {url} -> HTTP {e.code}") + except urllib.error.URLError as e: + raise SystemExit(f"error: cannot reach {url}: {e.reason}") + + +def catalogue(source, ref): + data = json.loads(fetch(source, ref, "catalogue.json")) + if data.get("schema") != 1: + raise SystemExit(f"error: unsupported catalogue schema {data.get('schema')}") + return data + + +def entry_for(data, name): + for skill in data["skills"]: + if skill["name"] == name or skill["directory"] == name: + return skill + known = ", ".join(sorted(s["name"] for s in data["skills"])) + raise SystemExit(f"error: no skill named '{name}'. Available: {known}") + + +# --- local layout ------------------------------------------------------------ + + +def repo_root(): + try: + out = subprocess.run( + ["git", "rev-parse", "--show-toplevel"], + capture_output=True, encoding="utf-8", errors="replace", + ) + if out.returncode == 0: + return Path(out.stdout.strip()) + except OSError: + pass + return Path.cwd() + + +def skills_dir(override=None): + """Honour whichever convention the repo already uses.""" + if override: + return Path(override) + root = repo_root() + for candidate in (root / ".claude" / "skills", root / ".github" / "skills"): + if candidate.is_dir(): + return candidate + return root / ".claude" / "skills" + + +def load_manifest(directory): + path = directory / MANIFEST_NAME + if path.is_file(): + return json.loads(path.read_text(encoding="utf-8")) + return {"source": DEFAULT_SOURCE, "skills": {}} + + +def save_manifest(directory, manifest): + directory.mkdir(parents=True, exist_ok=True) + manifest["skills"] = dict(sorted(manifest["skills"].items())) + (directory / MANIFEST_NAME).write_text( + json.dumps(manifest, indent=2, ensure_ascii=False) + "\n", encoding="utf-8" + ) + + +def digest(text): + return hashlib.sha256(text.encode("utf-8")).hexdigest()[:16] + + +def read_local(path): + if not path.is_file(): + return None + return path.read_bytes().decode("utf-8").replace("\r\n", "\n") + + +# --- commands ---------------------------------------------------------------- + + +def cmd_list(args): + data = catalogue(args.source, args.ref) + term = (args.search or "").lower() + rows = [ + s for s in data["skills"] + if not term or term in s["name"].lower() or term in s["description"].lower() + ] + if not rows: + print(f"nothing in the catalogue matches '{args.search}'") + return 0 + print(f"{len(rows)} skill(s) in {data['source']}@{args.ref}\n") + for s in rows: + first = s["description"].split(". ")[0].rstrip(".") + print(f" {s['name']:<30} {s['version']:<8} {first[:96]}") + print("\npull one with: python3 skills.py pull ") + return 0 + + +def cmd_pull(args): + data = catalogue(args.source, args.ref) + directory = skills_dir(args.skills_dir) + manifest = load_manifest(directory) + + for name in args.names: + skill = entry_for(data, name) + target = directory / skill["directory"] + existing = manifest["skills"].get(skill["name"]) + + if existing and not args.force: + local_changed = [ + f for f, h in existing.get("files", {}).items() + if (t := read_local(target / f)) is not None and digest(t) != h + ] + if local_changed: + print(f"! {skill['name']}: locally modified ({', '.join(local_changed)}).") + print(" Resolve first: 'diff' to see it, 'contribute' to send it back,") + print(" or 'pull --force' to discard the local change.") + continue + + hashes = {} + for relative in skill["files"]: + text = fetch(args.source, args.ref, f"{skill['directory']}/{relative}") + out = target / relative + out.parent.mkdir(parents=True, exist_ok=True) + out.write_text(text, encoding="utf-8", newline="\n") + hashes[relative] = digest(text) + + manifest["source"] = args.source + manifest["skills"][skill["name"]] = { + "directory": skill["directory"], + "version": skill["version"], + "ref": args.ref, + "files": hashes, + "vendored": date.today().isoformat(), + } + verb = "updated" if existing else "added" + print(f"{verb} {skill['name']} {skill['version']} -> {target}") + + save_manifest(directory, manifest) + print(f"\nmanifest: {directory / MANIFEST_NAME}") + return 0 + + +def classify(local_state, upstream_version, recorded_version): + if local_state == "missing": + return "MISSING", "vendored but not on disk; re-pull" + upstream_newer = upstream_version != recorded_version + if local_state == "modified" and upstream_newer: + return "DIVERGED", f"local edits AND upstream is {upstream_version}" + if local_state == "modified": + return "MODIFIED", "local edits not in the catalogue" + if upstream_newer: + return "OUTDATED", f"upstream is {upstream_version}" + return "CURRENT", "" + + +def local_state_of(target, recorded_files): + seen_any = False + for relative, recorded_hash in recorded_files.items(): + text = read_local(target / relative) + if text is None: + return "missing" + seen_any = True + if digest(text) != recorded_hash: + return "modified" + return "clean" if seen_any else "missing" + + +def cmd_status(args): + directory = skills_dir(args.skills_dir) + manifest = load_manifest(directory) + if not manifest["skills"]: + print(f"no vendored skills recorded in {directory / MANIFEST_NAME}") + print("pull one with: python3 skills.py list") + return 0 + + data = catalogue(args.source, args.ref) + upstream = {s["name"]: s for s in data["skills"]} + rows, worth_acting = [], False + + for name, record in manifest["skills"].items(): + target = directory / record.get("directory", name) + if name not in upstream: + rows.append(("REMOVED", name, record["version"], "no longer in the catalogue")) + worth_acting = True + continue + state = local_state_of(target, record.get("files", {})) + status, note = classify(state, upstream[name]["version"], record["version"]) + rows.append((status, name, record["version"], note)) + worth_acting = worth_acting or status != "CURRENT" + + width = max(len(r[1]) for r in rows) + print(f"{len(rows)} vendored skill(s) against {data['source']}@{args.ref}\n") + for status, name, version, note in sorted(rows): + print(f" {status:<9} {name:<{width}} {version:<8} {note}") + + if worth_acting: + print("\n OUTDATED -> pull MODIFIED -> contribute") + print(" DIVERGED -> diff, then contribute the local change before pulling") + return 0 + + +def cmd_diff(args): + data = catalogue(args.source, args.ref) + skill = entry_for(data, args.name) + directory = skills_dir(args.skills_dir) + target = directory / skill["directory"] + shown = False + + for relative in skill["files"]: + upstream_text = fetch(args.source, args.ref, f"{skill['directory']}/{relative}") + local_text = read_local(target / relative) + if local_text is None: + print(f"--- {relative}: not present locally") + shown = True + continue + if local_text == upstream_text: + continue + shown = True + sys.stdout.writelines( + difflib.unified_diff( + upstream_text.splitlines(keepends=True), + local_text.splitlines(keepends=True), + fromfile=f"upstream/{skill['directory']}/{relative}", + tofile=f"local/{skill['directory']}/{relative}", + ) + ) + if not shown: + print(f"{skill['name']} is identical to {args.source}@{args.ref}") + return 0 + + +def cmd_contribute(args): + data = catalogue(args.source, args.ref) + skill = entry_for(data, args.name) + directory = skills_dir(args.skills_dir) + target = directory / skill["directory"] + + patch_lines = [] + for relative in skill["files"]: + upstream_text = fetch(args.source, args.ref, f"{skill['directory']}/{relative}") + local_text = read_local(target / relative) + if local_text is None or local_text == upstream_text: + continue + patch_lines.extend( + difflib.unified_diff( + upstream_text.splitlines(keepends=True), + local_text.splitlines(keepends=True), + fromfile=f"a/{skill['directory']}/{relative}", + tofile=f"b/{skill['directory']}/{relative}", + ) + ) + + if not patch_lines: + print(f"{skill['name']} matches upstream - nothing to contribute") + return 0 + + patch = Path(f"{skill['directory']}.patch").resolve() + patch.write_text("".join(patch_lines), encoding="utf-8", newline="\n") + added = sum(1 for line in patch_lines if line.startswith("+") and not line.startswith("+++")) + removed = sum(1 for line in patch_lines if line.startswith("-") and not line.startswith("---")) + + print(f"{skill['name']}: +{added} -{removed} lines vs {args.source}@{args.ref}") + print(f"patch written to {patch}\n") + print("Before sending it: is this change general, or specific to this repo?") + print("Only general improvements belong upstream. See the skill for the test.\n") + print(f" git clone https://github.com/{args.source} /tmp/agent-skills") + print(" cd /tmp/agent-skills") + print(f" git checkout -b improve/{skill['directory']}") + print(f" git apply {patch}") + print(f" # bump metadata.version in {skill['directory']}/SKILL.md") + print(" python3 tools/build_catalogue.py") + print(f" git commit -am 'feat({skill['directory']}): '") + print(" gh pr create --fill") + return 0 + + +def main(argv=None): + parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + parser.add_argument("--source", default=DEFAULT_SOURCE) + parser.add_argument("--ref", default=DEFAULT_REF) + parser.add_argument("--skills-dir", default=None) + sub = parser.add_subparsers(dest="command", required=True) + + p = sub.add_parser("list", help="what the catalogue offers") + p.add_argument("--search", default=None) + p.set_defaults(func=cmd_list) + + p = sub.add_parser("pull", help="vendor a skill into this repo") + p.add_argument("names", nargs="+") + p.add_argument("--force", action="store_true", help="overwrite local modifications") + p.set_defaults(func=cmd_pull) + + p = sub.add_parser("status", help="compare local copies to upstream") + p.set_defaults(func=cmd_status) + + p = sub.add_parser("diff", help="local vs upstream") + p.add_argument("name") + p.set_defaults(func=cmd_diff) + + p = sub.add_parser("contribute", help="prepare a change to send back") + p.add_argument("name") + p.set_defaults(func=cmd_contribute) + + # Skill text is full of em-dashes and curly quotes; on a Windows console the + # default codec is cp1252 and `diff` output comes out mangled. + for stream in (sys.stdout, sys.stderr): + try: + stream.reconfigure(encoding="utf-8", errors="replace") + except (AttributeError, ValueError): + pass + + args = parser.parse_args(argv) + return args.func(args) + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/agents.md b/agents.md index 64c1cf0..04b71c6 100644 --- a/agents.md +++ b/agents.md @@ -164,3 +164,16 @@ Before opening a pull request, verify all of the following: - [ ] No domain class imports an adapter or infrastructure type. - [ ] If a new masking strategy was added, `/docs` has been updated in this PR. - [ ] Commit message is concise and uses the imperative mood (e.g., `add BcryptMasker strategy`). + +## Shared agent skills + +Shared skills live in [MaximumTrainer/agent-skills](https://github.com/MaximumTrainer/agent-skills). Before writing a new +skill, runbook or repeated procedure, check the catalogue - and send genuinely +general improvements back so the other repositories get them too. + +```bash +python3 .claude/skills/skill-exchange/scripts/skills.py list +python3 .claude/skills/skill-exchange/scripts/skills.py status +``` + +See `.claude/skills/skill-exchange/` for the workflow.