From 355a63fc430fba828da60a3b6267580cf7dbff9f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Doubravsk=C3=BD?= Date: Wed, 7 Oct 2026 17:42:36 +0200 Subject: [PATCH] Add doubynet skill and extend check_versions.py to cover installed skills --- CHANGELOG.md | 26 +++++ Claude/skills/doubynet/SKILL.md | 71 +++++++++++++ README.md | 3 +- check_versions.py | 173 ++++++++++++++++++++++++++------ 4 files changed, 244 insertions(+), 29 deletions(-) create mode 100644 Claude/skills/doubynet/SKILL.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 15149dd..f4adea3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,32 @@ ships documents, so it uses the same `v#` scheme as the documents themselves. Individual guideline documents carry their own `Document Version` header, and the skills carry `metadata.version`; both are independent of the revisions below. +## v9 — 2026-10-07 + +### Added +- `Claude/skills/doubynet/` — new personal skill, reconciles the DoubyNet home-network + documentation (a separate Syncthing-synced tree, not part of this repository) against + the live state of its hosts, reports discrepancies, and updates the affected `.md` + files. Versioned `v1`, following the established `metadata.version` convention. Was + already in use directly from `~/.claude/skills/doubynet/` without a master copy here; + brought in after a session surfaced several gaps (stale hardcoded path, unclear + historical-vs-live document handling, no mention of CHANGELOG.md, no guidance for + facts that can't be verified from a machine at all) that were fixed in the same pass +- `check_versions.py` now also checks the master skills in `Claude/skills//SKILL.md` + against their installed copies in `~/.claude/skills//SKILL.md` — a fixed location, + independent of `--root`. Reads `metadata.version` from the YAML frontmatter instead of + the `**Document Version:**` marker used by guideline documents. A skill missing from + `~/.claude/skills/` entirely is reported as a new status, `MISSING`, and is safe to + `--sync` like `BEHIND`. Prompted by running the tool for the first time since `doubynet` + was added: it found four of the five pre-existing skills installed without any version + header at all, and `documentation_context` missing from `~/.claude/skills/` entirely + +### Changed +- `--sync`/report wording generalised from "document(s)" to "item(s)" now that the same + machinery covers both guideline documents and skills + +--- + ## v8 — 2026-09-29 ### Fixed diff --git a/Claude/skills/doubynet/SKILL.md b/Claude/skills/doubynet/SKILL.md new file mode 100644 index 0000000..4630223 --- /dev/null +++ b/Claude/skills/doubynet/SKILL.md @@ -0,0 +1,71 @@ +--- +name: doubynet +description: Update doubynet dokumentation +metadata: + version: v1 +--- + +Tvým úkolem je zkontrolovat a případně aktualizovat dokumentaci sítě DoubyNet. + + ## 1. Načti dokumentaci + `/home/honza/Syncthing/Programy/DoubyNet/` (lokální, Syncthing-synchronizovaná + složka na Honza-PCL — žádný manuální upload/download, synchronizace na ostatní + zařízení běží na pozadí sama): + - `README.md` (index služeb + sekce "Router — port-forwarding") + - `CLAUDE.md` (konvence zápisu dokumentace — dodržuj ji při jakékoli úpravě) + - všechny `//.md` soubory + - `/CHANGELOG.md` (jeden na hosta, průběžný záznam změn — viz krok 4) + + Vytvoř si z toho kompletní obraz zdokumentovaného stavu: hosty, služby, jak se spouští, + sítě/porty, VM, autostart, propojení mezi službami (např. Caddy → cíl). + + **Pozor na historické dokumenty.** Některé `.md` soubory popisují něco, co už + neexistuje (např. staré, fyzicky rozebrané disky/pole), a mají to nahoře jasně + označené (banner typu "⚠️ pole už neexistuje, dokument je od teď čistě + historický"). Takový dokument **needituj aktuálními fakty** — zjištěné aktuální + informace patří do dokumentu, který nahrazuje popisovanou věc (banner na to + obvykle přímo odkazuje). Smysl historického dokumentu je zůstat nezměněný jako + záznam toho, co bylo. + + ## 2. Ověř realitu + Pro každé tvrzení v dokumentaci ověř skutečný stav na příslušném stroji (SSH, `systemctl`, + `virsh`, `ss -tlnp`, obsah configů apod.). Ověřuj z relevantního hlediska — např. dostupnost + webu přes DNAT/reverse proxy testuj z jiného stroje než je cíl (self-test ze stejného hostu + může dát falešně negativní výsledek kvůli tomu, jak netfilter zachází s lokálně generovaným + provozem). + + **Některá fakta nejdou ověřit žádným příkazem** — typicky cokoliv, co žije jen v UI + zařízení bez API/SSH přístupu (např. nastavení port-forwardingu na routeru). V + takovém případě si o autoritativní export/screenshot řekni uživateli přímo, místo + abys realitu odhadoval nebo nechal needokumentovanou. + + Neprováděj destruktivní ani produkci ovlivňující příkazy bez potvrzení uživatele. Pokud + narazíš na to, že nemáš potřebná oprávnění (sudo, SSH klíč), zeptej se uživatele, jak + pokračovat — nezkoušej to obcházet. + + ## 3. Porovnej a nahlas rozdíly + Sestav přehledný seznam nesrovnalostí mezi dokumentací a realitou (co dokument tvrdí vs. + co je skutečně nastavené/běžící). Tenhle přehled ukaž uživateli **před** jakoukoli úpravou + souborů. + + ## 4. Aktualizuj dokumentaci + Až uživatel potvrdí (nebo pokud jde o triviální/jednoznačné doplnění), uprav příslušné + `.md` soubory tak, aby odpovídaly realitě: + - Dodržuj šablonu a konvence z `CLAUDE.md` (formát tabulek, PascalCase názvy, atd.) + - Nikdy nezapisuj hesla/tokeny/klíče v plaintextu — nahraď je `` tokenem + - Pokud přidáváš novou službu/VM, přidej ji i do řádku v `README.md` + - Fakta, co se netýkají jedné služby na jednom hostu (např. síťová topologie, + port-forwarding na routeru), patří do vlastní sekce v `README.md` — nenuť je + do složky jedné služby jen proto, že model "jedna složka = jedna služba" jinam + nesedí + - Zapiš stručný záznam do `/CHANGELOG.md` dotčeného hosta (nejnovější + nahoře) — co se zjistilo, co se změnilo, proč + - Po úpravách ověř, že žádný relativní markdown odkaz (`](...)`) nevede na + neexistující soubor — procházka stromem a kontrola existence cíle každého odkazu + + Žádný manuální "upload" na server není potřeba — soubory žijí lokálně v Syncthing + složce, synchronizace běží sama. + + Na konci stručně shrň, co bylo nekonzistentní a co jsi opravil. + + diff --git a/README.md b/README.md index 81e5385..d149bea 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,8 @@ Dokumentace/ │ ├── check/ # /check — run tests, investigate failures │ ├── commit/ # /commit — changelog + commit message proposal │ ├── documentation/ # /documentation — write docs/ -│ └── documentation_context/ # /documentation_context — compress docs/ into CONTEXT.md +│ ├── documentation_context/ # /documentation_context — compress docs/ into CONTEXT.md +│ └── doubynet/ # /doubynet — reconcile DoubyNet network docs with reality │ ├── Python/ # Python development guidelines │ ├── DESIGN_DOCUMENT_PYTHON.md # Guidelines for Python applications diff --git a/check_versions.py b/check_versions.py index faeaf8d..ce06e10 100644 --- a/check_versions.py +++ b/check_versions.py @@ -1,18 +1,25 @@ -"""Report sibling projects whose copies of the guideline documents are out of date, and offer to sync them. +"""Report stale copies of this repository's guideline documents and skills, and offer to sync them. -This repository is the source of truth for ``CLAUDE.md``, ``AGENTS.md`` and the -``DESIGN_DOCUMENT*.md`` family. Every project keeps a committed copy in its root, so the copies -drift as soon as a document here is bumped. This script walks the sibling directories of this -repository, reads the ``Document Version`` header of every copy it finds and reports the ones that -are behind, missing, or modified locally. +This repository is the source of truth for two independent things, both checked by this script: -It also finds copies still using a pre-rename file name (``DESIGN_DOCUMENT.md`` and friends, from -before the language became part of the name) and reports them as ``OLD NAME``. +1. ``CLAUDE.md``, ``AGENTS.md`` and the ``DESIGN_DOCUMENT*.md`` family. Every project keeps a + committed copy in its root, so the copies drift as soon as a document here is bumped. This part + walks the sibling directories of this repository (or ``--root``), reads the ``Document Version`` + header of every copy it finds and reports the ones that are behind, missing, or modified locally. +2. The personal skills under ``Claude/skills//SKILL.md``, whose installed copies Claude Code + actually runs from ``~/.claude/skills//SKILL.md`` — a fixed location, independent of + ``--root``. Versions live in the YAML frontmatter as ``metadata.version``. A skill missing from + ``~/.claude/skills/`` entirely is reported as ``MISSING`` and treated like ``BEHIND``: both are + safe to install/overwrite with ``--sync``. -``--sync`` offers to overwrite the outdated copies with the master version, renaming the old names on -the way. Only ``BEHIND`` and ``OLD NAME`` copies are ever touched: a ``MODIFIED`` copy holds local -edits that overwriting would discard, an unversioned copy cannot be compared, and ``DUPLICATE`` means -both the old and the new name exist, which needs a human to decide. +It also finds guideline copies still using a pre-rename file name (``DESIGN_DOCUMENT.md`` and +friends, from before the language became part of the name) and reports them as ``OLD NAME``. + +``--sync`` offers to overwrite the outdated copies with the master version (installing missing +skills fresh), renaming the old guideline names on the way. Only ``BEHIND``, ``MISSING`` and +``OLD NAME`` copies are ever touched: a ``MODIFIED`` copy holds local edits that overwriting would +discard, an unversioned copy cannot be compared, and ``DUPLICATE`` means both the old and the new +guideline name exist, which needs a human to decide. Standard library only, so it runs without a virtual environment: @@ -20,8 +27,8 @@ Standard library only, so it runs without a virtual environment: python check_versions.py --sync # report, then offer to copy the outdated ones python check_versions.py --sync --yes # copy without asking (for scripts) python check_versions.py --sync --dry-run # show what --sync would copy, change nothing - python check_versions.py --root .. # scan a different directory - python check_versions.py --quiet # print only projects needing attention + python check_versions.py --root .. # scan a different directory for guideline copies + python check_versions.py --quiet # print only projects/skills needing attention """ from __future__ import annotations @@ -64,12 +71,21 @@ LEGACY_RUST_NAMES: dict[str, str] = {"DESIGN_DOCUMENT.md": "DESIGN_DOCUMENT_RUST VERSION_PATTERN = re.compile(r"^\*\*Document Version:\*\*\s*(v\d+)", re.MULTILINE) +# Where the master skills live in this repository, and where Claude Code actually runs them from. +# The installed location is fixed - unlike guideline copies, it does not move with --root. +SKILLS_SUBDIR = "Claude/skills" +INSTALLED_SKILLS_DIR = Path.home() / ".claude" / "skills" + +FRONTMATTER_PATTERN = re.compile(r"\A---\n(.*?)\n---", re.DOTALL) +SKILL_VERSION_PATTERN = re.compile(r"^\s*version:\s*(v\d+)\s*$", re.MULTILINE) + STATUS_OK = "ok" STATUS_BEHIND = "behind" STATUS_MODIFIED = "modified" STATUS_UNVERSIONED = "unversioned" STATUS_LEGACY = "legacy" STATUS_DUPLICATE = "duplicate" +STATUS_MISSING = "missing" @dataclass(frozen=True) @@ -91,8 +107,8 @@ class Result: @property def can_sync(self) -> bool: - """Behind and legacy copies can be replaced; anything else may hold work we would destroy.""" - return self.status in {STATUS_BEHIND, STATUS_LEGACY} + """Behind, missing and legacy copies can be replaced; anything else may hold work we would destroy.""" + return self.status in {STATUS_BEHIND, STATUS_LEGACY, STATUS_MISSING} @property def is_rename(self) -> bool: @@ -115,6 +131,19 @@ def read_version(path: Path) -> str | None: return match.group(1) if match else None +def read_skill_version(path: Path) -> str | None: + """Return the ``metadata.version`` declared in a skill's YAML frontmatter, if any. + + Deliberately restricted to the frontmatter block (between the first pair of ``---`` lines) so + the word "version" appearing in a skill's instructions is never mistaken for the real field. + """ + frontmatter = FRONTMATTER_PATTERN.match(read_text(path)) + if not frontmatter: + return None + match = SKILL_VERSION_PATTERN.search(frontmatter.group(1)) + return match.group(1) if match else None + + def version_key(version: str | None) -> int: """Order versions numerically; a missing version sorts lowest.""" return int(version[1:]) if version else -1 @@ -160,6 +189,21 @@ def sync_document(master: Path, copy: Path, target: Path) -> None: backup.unlink() +def install_or_sync(master: Path, copy: Path, target: Path) -> None: + """Install a missing file fresh, or overwrite an outdated one via :func:`sync_document`. + + A missing skill has no existing copy to detect line endings from or back up, so it is simply + written out using the master's own line endings instead of going through the backup/restore + dance that only makes sense when a previous version is there to protect. + """ + target.parent.mkdir(parents=True, exist_ok=True) + if copy.is_file(): + sync_document(master, copy, target) + else: + newline = detect_newline(master) + target.write_text(normalise(read_text(master)) + "\n", encoding="utf-8", newline=newline) + + def load_masters(repo: Path) -> dict[str, tuple[Path, str | None]]: """Map each document name to its master path and version.""" masters: dict[str, tuple[Path, str | None]] = {} @@ -224,6 +268,51 @@ def check_legacy(project: Path, masters: dict[str, tuple[Path, str | None]]) -> return results +def find_skill_masters(repo: Path) -> dict[str, tuple[Path, str | None]]: + """Map each skill name (a folder under ``Claude/skills/``) to its master ``SKILL.md`` and version.""" + masters: dict[str, tuple[Path, str | None]] = {} + skills_dir = repo / SKILLS_SUBDIR + if not skills_dir.is_dir(): + return masters + for entry in sorted(skills_dir.iterdir()): + skill_file = entry / "SKILL.md" + if entry.is_dir() and skill_file.is_file(): + masters[entry.name] = (skill_file, read_skill_version(skill_file)) + return masters + + +def check_skills(masters: dict[str, tuple[Path, str | None]], installed_dir: Path) -> list[Result]: + """Compare every master skill against its installed copy in ``~/.claude/skills/``.""" + results: list[Result] = [] + + for name, (master_path, master_version) in masters.items(): + installed_path = installed_dir / name / "SKILL.md" + + if not installed_path.is_file(): + results.append( + Result(name, "SKILL.md", None, master_version, STATUS_MISSING, installed_path, master_path, installed_path) + ) + continue + + installed_version = read_skill_version(installed_path) + + if installed_version is None or master_version is None: + status = STATUS_UNVERSIONED + elif version_key(installed_version) < version_key(master_version): + status = STATUS_BEHIND + elif normalise(read_text(installed_path)) != normalise(read_text(master_path)): + # Same version number but different content - someone edited the installed copy in place. + status = STATUS_MODIFIED + else: + status = STATUS_OK + + results.append( + Result(name, "SKILL.md", installed_version, master_version, status, installed_path, master_path, installed_path) + ) + + return results + + def find_projects(root: Path, repo: Path) -> list[Path]: """Return candidate project directories: siblings of this repository, excluding itself.""" return sorted( @@ -241,6 +330,7 @@ def format_row(result: Result) -> str: STATUS_UNVERSIONED: "NO VER. ", STATUS_LEGACY: "OLD NAME ", STATUS_DUPLICATE: "DUPLICATE", + STATUS_MISSING: "MISSING ", }[result.status] versions = f"{result.project_version or '-'} -> {result.master_version or '-'}" return f" {marker} {result.document:<50} {versions}" @@ -259,14 +349,14 @@ def confirm(prompt: str) -> bool: def run_sync(pending: list[Result], assume_yes: bool, dry_run: bool) -> int: - """Copy the masters over the outdated project copies. Returns the number of documents left stale.""" + """Copy the masters over outdated or missing copies. Returns the number of items left stale.""" renames = sum(1 for result in pending if result.is_rename) - print(f"{len(pending)} document(s) can be updated from this repository:") + print(f"{len(pending)} item(s) can be updated from this repository:") labels = {result: f"{result.project}/{result.document}" for result in pending} width = max(len(label) for label in labels.values()) for result in pending: note = " (rename)" if result.is_rename else "" - print(f" {labels[result]:<{width}} {result.project_version} -> {result.master_version}{note}") + print(f" {labels[result]:<{width}} {result.project_version or '-'} -> {result.master_version}{note}") print() if dry_run: @@ -283,17 +373,23 @@ def run_sync(pending: list[Result], assume_yes: bool, dry_run: bool) -> int: failed = 0 for result in pending: try: - sync_document(result.master_path, result.copy_path, result.target_path) + install_or_sync(result.master_path, result.copy_path, result.target_path) except OSError as error: print(f" FAILED {result.project}/{result.document}: {error}", file=sys.stderr) failed += 1 else: - verb = "renamed" if result.is_rename else "updated" + if result.is_rename: + verb = "renamed" + elif result.status == STATUS_MISSING: + verb = "installed" + else: + verb = "updated" print(f" {verb} {result.project}/{result.target_path.name} -> {result.master_version}") print() - print(f"{len(pending) - failed} document(s) updated." if not failed else f"{failed} document(s) failed to update.") - print("Review and commit the changes in each project (a sync is its own commit).") + print(f"{len(pending) - failed} item(s) updated." if not failed else f"{failed} item(s) failed to update.") + print("Review and commit the changes (a sync is its own commit; for a newly-installed skill,") + print("there is nothing in ~/.claude/skills/ to commit, but consider committing it here too if new).") return failed @@ -349,8 +445,29 @@ def main() -> int: print(f"No guideline documents: {', '.join(unmanaged)}") print() + # Second, independent check: master skills in Claude/skills/ vs. what is actually installed in + # ~/.claude/skills/. Unlike guideline copies this location is fixed, not relative to --root. + skill_masters = find_skill_masters(repo) + if skill_masters: + print(f"Skill masters in {repo.name}/{SKILLS_SUBDIR}:") + width = max(len(name) for name in skill_masters) + for name, (_, version) in sorted(skill_masters.items()): + print(f" {name:<{width}} {version or '(no version header)'}") + print() + + skill_results = check_skills(skill_masters, INSTALLED_SKILLS_DIR) + stale_skills = [result for result in skill_results if result.needs_attention] + attention.extend(stale_skills) + + if not (args.quiet and not stale_skills): + print(f"Installed in {INSTALLED_SKILLS_DIR}:") + for result in skill_results if not args.quiet else stale_skills: + print(f"{result.project}") + print(format_row(result)) + print() + if not attention: - print("All projects are up to date.") + print("Everything is up to date.") return 0 syncable = [result for result in attention if result.can_sync] @@ -360,18 +477,18 @@ def main() -> int: remaining = run_sync(syncable, args.yes, args.dry_run) + blocked else: if syncable and not args.sync: - print(f"Run with --sync to copy {len(syncable)} outdated document(s) from this repository.") + print(f"Run with --sync to copy {len(syncable)} outdated item(s) from this repository.") remaining = len(attention) if blocked: - print(f"{blocked} document(s) left alone - MODIFIED holds local edits, NO VER. cannot be compared,") + print(f"{blocked} item(s) left alone - MODIFIED holds local edits, NO VER. cannot be compared,") print("and DUPLICATE means both the old and the new file name exist; remove the old one by hand.") if remaining: - print(f"{remaining} document(s) need attention.") + print(f"{remaining} item(s) need attention.") return 1 - print("All projects are up to date.") + print("Everything is up to date.") return 0