Add doubynet skill and extend check_versions.py to cover installed skills

This commit is contained in:
2026-10-07 17:44:00 +02:00
parent 39ad01fc09
commit 355a63fc43
4 changed files with 244 additions and 29 deletions
+26
View File
@@ -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/<name>/SKILL.md`
against their installed copies in `~/.claude/skills/<name>/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
+71
View File
@@ -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 `<Hostname>/<Služba>/<Služba>.md` soubory
- `<Hostname>/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 `<PLACEHOLDER>` 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 `<Hostname>/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.
+2 -1
View File
@@ -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
+145 -28
View File
@@ -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/<name>/SKILL.md``, whose installed copies Claude Code
actually runs from ``~/.claude/skills/<name>/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