feat(hermes): MVP converter + 4 universal skills converted

Adds the conversion infrastructure for the Hermes-rollout (Nous Research):

- hermes/mapping.yaml — per-skill schema (mode: auto|manual|skip|pending,
  category, replace-rules, source override). Every skills/<name>/ has an
  explicit entry; the build fails on unmapped skills.
- scripts/build-hermes.py — Python converter. Reads mapping.yaml; for auto
  applies replace-rules to SKILL.md and copies the rest verbatim; for manual
  copies hermes/skills/<source>/ as-is; for skip / pending records to
  dist-hermes/SKIPPED.md.
- dist-hermes/ — pre-converted Hermes-flavour tree (committed; consumed by
  the recursive installer in [hermes-installer-skill]). Includes 4 universal
  skills: pulling-before-work, active-platform (with Linux+bash replacement
  rule), project-discipline, using-markitdown. Plus SKIPPED.md listing
  8 skip + 10 pending entries.
- README.md — adds "Build for Hermes" section, updates layout, mentions
  hermes/, dist-hermes/, build-hermes.py.

Mapping is the source of truth for the audit table from
.wiki/concepts/hermes-skills-rollout-design.md (Q1-Q8). Pending entries
preserve the audit decision (intended mode/category) for the follow-up
tasks: hermes-flavour-mcp-setups, hermes-installer-skill,
hermes-mvp-coverage. Security carry-forward (extraheader-pattern,
POSIX-absolute paths, semver bumps) — infrastructure ready (replace-rules +
manual mode); concrete content lands in hermes-flavour-mcp-setups.

Smoke-tests: idempotent re-run produces no diff; replace-rules verified on
active-platform (Linux+bash); strict-mapping fail-fast verified on a
synthetic unmapped skill (exit 1).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-07 00:00:00 +03:00
parent 065270de42
commit 6b36b312fa
10 changed files with 912 additions and 4 deletions

177
scripts/build-hermes.py Normal file
View File

@@ -0,0 +1,177 @@
#!/usr/bin/env python3
"""
build-hermes.py — Convert claude-skills sources to Hermes-flavour layout.
Reads: hermes/mapping.yaml
skills/<name>/ (auto mode)
hermes/skills/<source>/ (manual mode; `source:` override or /<name>/)
Writes: dist-hermes/<category>/<name>/ (auto + manual)
dist-hermes/SKIPPED.md (skip + pending log)
Exits non-zero on:
- skill in skills/ not declared in mapping.skills (forces explicit mapping)
- mode=auto but skills/<name>/ missing
- mode=manual but `source:` directory missing
- a replace-rule `find:` string not found in source SKILL.md
Design: .wiki/concepts/hermes-skills-rollout-design.md
"""
from __future__ import annotations
import argparse
import shutil
import sys
from pathlib import Path
from typing import Any
import yaml
def load_mapping(path: Path) -> dict[str, Any]:
with path.open(encoding="utf-8") as f:
return yaml.safe_load(f)
def apply_replace_rules(text: str, rules: list[dict[str, str]], skill_name: str) -> str:
for rule in rules:
find = rule["find"]
replace = rule["replace"]
if find not in text:
raise ValueError(
f"{skill_name}: replace-rule find string not present in SKILL.md: {find!r}"
)
text = text.replace(find, replace)
return text
def copy_tree(src_dir: Path, dst_dir: Path, transform_skill_md: bool, rules: list[dict[str, str]], skill_name: str) -> None:
if dst_dir.exists():
shutil.rmtree(dst_dir)
dst_dir.mkdir(parents=True)
for src_path in src_dir.rglob("*"):
rel = src_path.relative_to(src_dir)
dst_path = dst_dir / rel
if src_path.is_dir():
dst_path.mkdir(parents=True, exist_ok=True)
continue
dst_path.parent.mkdir(parents=True, exist_ok=True)
if transform_skill_md and src_path.name == "SKILL.md" and rules:
text = src_path.read_text(encoding="utf-8")
text = apply_replace_rules(text, rules, skill_name)
dst_path.write_text(text, encoding="utf-8")
else:
shutil.copyfile(src_path, dst_path)
def write_skill_auto(name: str, entry: dict[str, Any], repo_root: Path, dst_root: Path) -> None:
src_dir = repo_root / "skills" / name
if not src_dir.is_dir():
raise FileNotFoundError(f"auto skill source missing: {src_dir}")
cat = entry["category"]
rules = entry.get("replace-rules") or []
copy_tree(src_dir, dst_root / cat / name, transform_skill_md=True, rules=rules, skill_name=name)
def write_skill_manual(name: str, entry: dict[str, Any], repo_root: Path, dst_root: Path) -> None:
source = entry.get("source") or f"hermes/skills/{name}"
src_dir = repo_root / source
if not src_dir.is_dir():
raise FileNotFoundError(f"manual skill source missing: {src_dir}")
cat = entry["category"]
copy_tree(src_dir, dst_root / cat / name, transform_skill_md=False, rules=[], skill_name=name)
def render_skipped_md(skipped: list[tuple[str, str]], pending: list[tuple[str, dict, str]]) -> str:
lines: list[str] = [
"# Skipped Skills",
"",
"Auto-generated by `scripts/build-hermes.py` from `hermes/mapping.yaml`.",
"Do not edit by hand — edit the mapping and re-run the build.",
"",
"## Skipped (not applicable to Hermes)",
"",
]
if skipped:
for name, reason in sorted(skipped):
lines.append(f"- **{name}** — {reason}")
else:
lines.append("(none)")
lines.extend(["", "## Pending (deferred to follow-up tasks)", ""])
if pending:
for name, intended, reason in sorted(pending):
tail = ""
if intended:
im = intended.get("mode", "?")
ic = intended.get("category", "?")
tail = f" → intended: `mode: {im}, category: {ic}`"
lines.append(f"- **{name}** — {reason}{tail}")
else:
lines.append("(none)")
lines.append("")
return "\n".join(lines)
def main() -> int:
parser = argparse.ArgumentParser(description="Convert claude-skills sources to Hermes-flavour layout.")
parser.add_argument(
"--repo-root",
type=Path,
default=Path(__file__).resolve().parent.parent,
help="Repo root (default: parent of scripts/)",
)
parser.add_argument("--dist", type=Path, default=None, help="Output dir (default: <repo>/dist-hermes)")
parser.add_argument("--mapping", type=Path, default=None, help="Mapping file (default: <repo>/hermes/mapping.yaml)")
args = parser.parse_args()
repo_root: Path = args.repo_root.resolve()
mapping_path: Path = (args.mapping or (repo_root / "hermes" / "mapping.yaml")).resolve()
dst_root: Path = (args.dist or (repo_root / "dist-hermes")).resolve()
mapping = load_mapping(mapping_path)
skills_map: dict[str, dict[str, Any]] = mapping.get("skills") or {}
skills_dir = repo_root / "skills"
fs_skills = sorted(p.name for p in skills_dir.iterdir() if p.is_dir())
unmapped = [n for n in fs_skills if n not in skills_map]
if unmapped:
print(f"ERROR: skills/ has unmapped entries: {unmapped}", file=sys.stderr)
print(f"Add explicit `mode:` lines for each in {mapping_path.name}.", file=sys.stderr)
return 1
counts = {"auto": 0, "manual": 0, "skip": 0, "pending": 0}
skipped: list[tuple[str, str]] = []
pending: list[tuple[str, dict, str]] = []
for name, entry in sorted(skills_map.items()):
mode = entry.get("mode")
if mode == "auto":
write_skill_auto(name, entry, repo_root, dst_root)
elif mode == "manual":
write_skill_manual(name, entry, repo_root, dst_root)
elif mode == "skip":
skipped.append((name, entry.get("reason", "(no reason)")))
elif mode == "pending":
pending.append((name, entry.get("intended") or {}, entry.get("reason", "(no reason)")))
else:
print(f"ERROR: unknown mode for {name}: {mode!r}", file=sys.stderr)
return 1
counts[mode] += 1
dst_root.mkdir(parents=True, exist_ok=True)
(dst_root / "SKIPPED.md").write_text(render_skipped_md(skipped, pending), encoding="utf-8")
total = sum(counts.values())
print(f"build-hermes: {total} skills processed")
print(f" auto {counts['auto']:>3}")
print(f" manual {counts['manual']:>3}")
print(f" skip {counts['skip']:>3}")
print(f" pending {counts['pending']:>3}")
print(f" out: {dst_root}")
return 0
if __name__ == "__main__":
sys.exit(main())