"""Skill usage telemetry + provenance tracking for the Curator feature. Per-skill usage metadata lives in a sidecar JSON file (~/.hermes/skills/.usage.json) keyed by skill name. Counters are bumped by the skill tools (skill_view, skill_manage); the curator orchestrator reads the derived activity timestamp to decide lifecycle transitions. Design notes: sidecar, not frontmatter (keeps operational telemetry out of user-authored SKILL.md content and avoids conflict pressure for bundled/hub skills); atomic writes via tempfile + os.replace (same pattern as .bundled_manifest); all counter bumps are best-effort — failures log at DEBUG and return silently, so a broken sidecar never breaks the underlying tool call; and curator-managed skills are explicitly marked when created through skill_manage — bundled / hub-installed skills stay off-limits, and manually authored skills are not inferred from location. Lifecycle states: active (default) -> stale (unused > stale_after_days, config) -> archived (unused > archive_after_days, config; moved to .archive/). ``pinned`` is a boolean opt-out from auto transitions, orthogonal to state. """ from __future__ import annotations import json import logging import os import tempfile from contextlib import contextmanager, suppress from datetime import datetime, timezone from pathlib import Path from typing import Any, Callable, Dict, Iterable, Iterator, List, Optional, Set, Tuple from hermes_constants import get_hermes_home from agent.skill_utils import is_excluded_skill_path, is_external_skill_path logger = logging.getLogger(__name__) # fcntl is Unix-only; on Windows use msvcrt for file locking. msvcrt = None try: import fcntl except ImportError: # pragma: no cover - platform-specific fallback fcntl = None try: import msvcrt except ImportError: pass STATE_ACTIVE = "active" STATE_STALE = "stale" STATE_ARCHIVED = "archived" _VALID_STATES = {STATE_ACTIVE, STATE_STALE, STATE_ARCHIVED} # Load-bearing bundled built-ins the curator must NEVER archive or consolidate, regardless of # ``curator.prune_builtins``, pin state, or LLM judgment. These back advertised UX paths; silently archiving one # turns its slash command into "Unknown command" with no signal to the user. Protection is by skill ``name`` # (frontmatter ``name:``), matching the keys used throughout this module. Keep this list tiny and intentional — # it is not a substitute for ``curator.prune_builtins: false``, which exempts ALL built-ins. (``plan`` used to # live here; it is now a first-class built-in command with no skill on disk, so the set is currently empty.) PROTECTED_BUILTIN_SKILLS: Set[str] = set() def is_protected_builtin(skill_name: str) -> bool: """Whether *skill_name* is a load-bearing built-in the curator never touches. Protected built-ins are exempt from archival and consolidation on every path: the automatic state-transition walk, the LLM consolidation pass (dropped from the candidate list), and direct ``archive_skill`` calls.""" return skill_name in PROTECTED_BUILTIN_SKILLS def _skills_dir() -> Path: return get_hermes_home() / "skills" def _usage_file() -> Path: return _skills_dir() / ".usage.json" def _archive_dir() -> Path: return _skills_dir() / ".archive" def _flock(fd, lock: bool) -> None: if fcntl: fcntl.flock(fd, fcntl.LOCK_EX if lock else fcntl.LOCK_UN) else: fd.seek(0) msvcrt.locking(fd.fileno(), msvcrt.LK_LOCK if lock else msvcrt.LK_UNLCK, 1) @contextmanager def _usage_file_lock(): """Serialize .usage.json read-modify-write cycles across processes.""" lock_path = _usage_file().with_suffix(".json.lock") lock_path.parent.mkdir(parents=True, exist_ok=True) if fcntl is None and msvcrt is None: yield return if msvcrt and (not lock_path.exists() or lock_path.stat().st_size == 0): lock_path.write_text(" ", encoding="utf-8") fd = open(lock_path, "r+" if msvcrt else "a+", encoding="utf-8") try: _flock(fd, True) yield finally: with suppress(OSError, IOError): _flock(fd, False) fd.close() def _atomic_write(path: Path, prefix: str, write: Callable[[Any], None]) -> None: """Write *path* via tempfile + fsync + os.replace; the temp file is removed on failure.""" path.parent.mkdir(parents=True, exist_ok=True) fd, tmp = tempfile.mkstemp(dir=str(path.parent), prefix=prefix, suffix=".tmp") try: with os.fdopen(fd, "w", encoding="utf-8") as f: write(f) f.flush() os.fsync(f.fileno()) os.replace(tmp, path) except BaseException: with suppress(OSError): os.unlink(tmp) raise def _read_lines(path: Path, fail_log: str) -> List[str]: """Stripped, non-empty lines of a small metadata file ([] if missing/unreadable).""" if not path.exists(): return [] try: lines = path.read_text(encoding="utf-8").splitlines() except OSError as e: logger.debug(fail_log, e) return [] return [s for s in (line.strip() for line in lines) if s] def _now_iso() -> str: return datetime.now(timezone.utc).isoformat() def _parse_iso_timestamp(value: Any) -> Optional[datetime]: """Parse an ISO timestamp defensively for activity comparisons.""" if not value: return None try: parsed = datetime.fromisoformat(str(value)) except (TypeError, ValueError): return None return parsed.replace(tzinfo=timezone.utc) if parsed.tzinfo is None else parsed def latest_activity_at(record: Dict[str, Any]) -> Optional[str]: """Return the newest actual activity timestamp for a usage record. "Activity" means a skill was used, viewed, or patched. Creation time is intentionally excluded so callers can still distinguish never-active skills; lifecycle code can fall back to ``created_at`` as its own anchor.""" stamps = [ (dt, str(raw)) for raw in (record.get(k) for k in ("last_used_at", "last_viewed_at", "last_patched_at")) if (dt := _parse_iso_timestamp(raw)) is not None ] return max(stamps, key=lambda t: t[0])[1] if stamps else None def activity_count(record: Dict[str, Any]) -> int: """Return the total observed activity count across use/view/patch events.""" total = 0 for key in ("use_count", "view_count", "patch_count"): try: total += int(record.get(key) or 0) except (TypeError, ValueError): continue return total # --- Provenance — which skills are agent-created (and thus eligible for curation) --- def _read_bundled_manifest_names() -> Set[str]: """Skill names seeded from the bundled repo: ~/.hermes/skills/.bundled_manifest ("name:hash" per line). Empty set if the file is missing or unreadable.""" lines = _read_lines(_skills_dir() / ".bundled_manifest", "Failed to read bundled manifest: %s") return {n for n in (line.split(":", 1)[0].strip() for line in lines) if n} def _read_hub_installed_names() -> Set[str]: """Skill names installed via the Skills Hub, read from ~/.hermes/skills/.hub/lock.json (see tools/skills_hub.py :: HubLockFile).""" lock_path = _skills_dir() / ".hub" / "lock.json" if not lock_path.exists(): return set() try: # Tolerate non-UTF-8 bytes in the lock file. Hub descriptions can carry Windows-1252 typographic chars # (em-dash 0x97, smart quotes, bullets) written as single high bytes; a strict utf-8 read raises # UnicodeDecodeError, which is a ValueError sibling (not OSError/JSONDecodeError) so it escapes the # handler below and 500s the whole /api/skills endpoint. errors="replace" degrades the offending byte to # U+FFFD, keeping the (structurally valid) JSON — and every other skill — readable. See #68053. data = json.loads(lock_path.read_text(encoding="utf-8", errors="replace")) installed = (data.get("installed") or {}) if isinstance(data, dict) else None if not isinstance(installed, dict): return set() names = {str(k) for k in installed} skills_dir = _skills_dir() for entry in installed.values(): install_path = entry.get("install_path") if isinstance(entry, dict) else None if not isinstance(install_path, str) or not install_path.strip(): continue try: resolved = (skills_dir / install_path).resolve() resolved.relative_to(skills_dir.resolve()) except (OSError, ValueError): continue if (resolved / "SKILL.md").exists(): names.add(_read_skill_name(resolved / "SKILL.md", fallback=resolved.name)) return names except (OSError, json.JSONDecodeError) as e: logger.debug("Failed to read hub lock file: %s", e) return set() def _prune_builtins_enabled() -> bool: """Whether bundled built-in skills are eligible for curator pruning. Reads ``curator.prune_builtins`` from config (default True). Lazy import keeps this module importable without the CLI config layer (e.g. in the update/sync context); on any failure we fall back to the default. The real safety against a mass-prune is the curator's seed-on-first-sight, not this flag — built-ins only archive after a fresh inactivity window.""" try: from hermes_cli.config import load_config cfg = load_config() cur = cfg.get("curator") if isinstance(cfg, dict) else None if isinstance(cur, dict): return bool(cur.get("prune_builtins", True)) except Exception as e: # pragma: no cover — best-effort config read logger.debug("Failed to read curator.prune_builtins: %s", e) return True def read_suppressed_names() -> Set[str]: """Built-in skills the curator pruned — the re-seeder must leave archived. One skill name per line in ``~/.hermes/skills/.curator_suppressed``. This is what makes pruning a built-in durable: without it, ``hermes update`` would re-copy the bundled skill on the next sync.""" lines = _read_lines(_skills_dir() / ".curator_suppressed", "Failed to read curator suppression list: %s") return {line for line in lines if not line.startswith("#")} def _toggle_suppressed_name(skill_name: str, *, add: bool) -> None: if not skill_name or (skill_name in (names := read_suppressed_names())) == add: return (names.add if add else names.discard)(skill_name) data = "\n".join(sorted(names)) + ("\n" if names else "") try: _atomic_write(_skills_dir() / ".curator_suppressed", ".curator_suppressed_", lambda f: f.write(data)) except Exception as e: logger.debug("Failed to write curator suppression list: %s", e, exc_info=True) def add_suppressed_name(skill_name: str) -> None: """Record that a built-in skill was pruned, so sync won't restore it.""" _toggle_suppressed_name(skill_name, add=True) def remove_suppressed_name(skill_name: str) -> None: """Clear a built-in's suppression entry (e.g. on restore).""" _toggle_suppressed_name(skill_name, add=False) def _iter_skill_mds(base: Path, *, local_only: bool) -> Iterator[Tuple[str, Path]]: """Yield ``(frontmatter name, SKILL.md)`` for every skill under *base* — the flat layout AND nested category/skill/SKILL.md — skipping Hermes metadata, VCS, virtualenv/dependency, and cache dirs. With *local_only*, external skill dirs mounted below the local tree are skipped too: discovery may see them, but autonomous lifecycle curation must not.""" for skill_md in base.rglob("SKILL.md"): if not (is_excluded_skill_path(skill_md) or (local_only and is_external_skill_path(skill_md))): yield _read_skill_name(skill_md, fallback=skill_md.parent.name), skill_md def _scan_local_skills(keep: Callable[[str, Path, Set[str], Dict[str, Any]], bool]) -> List[str]: """Sorted, de-duplicated names of local skills passing *keep(name, skill_md, bundled, usage)*. Hub-installed skills are always off-limits, and protected built-ins are never curation candidates (exempt from the automatic transition walk AND the LLM consolidation pass), so neither ever reaches *keep*.""" base = _skills_dir() if not base.exists(): return [] hub, bundled, usage = _read_hub_installed_names(), _read_bundled_manifest_names(), load_usage() return sorted({ name for name, skill_md in _iter_skill_mds(base, local_only=True) if name not in hub and not is_protected_builtin(name) and keep(name, skill_md, bundled, usage) }) def list_agent_created_skill_names() -> List[str]: """Enumerate skills the curator may manage. Always includes agent-authored skills (those marked in ``.usage.json`` via ``skill_manage(action="create")``). When ``curator.prune_builtins`` is enabled, bundled built-in skills are ALSO included even though they have no agent-created usage record — their inactivity clock is anchored on first sight (see ``apply_automatic_transitions``). Hub-installed skills are never included; manually authored skills are not inferred from filesystem location.""" if not _skills_dir().exists(): return [] prune_builtins = _prune_builtins_enabled() # read once, before the walk def _keep(name: str, _skill_md: Path, bundled: Set[str], usage: Dict[str, Any]) -> bool: # Built-ins are only candidates when pruning is enabled; they never carry a curator-managed record, so # the record gate is skipped. Agent-authored (or local-manual) skills must opt in via their record. return prune_builtins if name in bundled else _is_curator_managed_record(usage.get(name)) return _scan_local_skills(_keep) def list_archived_skill_names() -> List[str]: """Enumerate skills in ``~/.hermes/skills/.archive/``. Archive layout is flat (``.archive//``) as set by ``archive_skill``, so the directory name is the skill name. Used by ``hermes curator list-archived`` to help users pass a name to ``hermes curator restore``.""" archive_root = _archive_dir() return sorted({p.name for p in archive_root.iterdir() if p.is_dir()}) if archive_root.exists() else [] def _read_skill_name(skill_md: Path, fallback: str) -> str: """Parse the `name:` field from a SKILL.md YAML frontmatter.""" try: text = skill_md.read_text(encoding="utf-8", errors="replace")[:4000] except OSError: return fallback in_frontmatter = False for stripped in (line.strip() for line in text.split("\n")): if stripped == "---": if in_frontmatter: break in_frontmatter = True elif in_frontmatter and stripped.startswith("name:"): value = stripped.split(":", 1)[1].strip().strip("\"'") if value: return value return fallback def is_agent_created(skill_name: str) -> bool: """Whether *skill_name* is neither bundled nor hub-installed.""" if skill_name in _read_bundled_manifest_names() | _read_hub_installed_names(): return False return _find_skill_dir(skill_name) is not None or _find_external_skill_dir(skill_name) is None def is_hub_installed(skill_name: str) -> bool: """Whether *skill_name* was installed via the Skills Hub.""" return skill_name in _read_hub_installed_names() def is_bundled(skill_name: str) -> bool: """Whether *skill_name* was seeded from the bundled repo skills.""" return skill_name in _read_bundled_manifest_names() def _external_read_only_message(skill_name: str) -> str: return f"skill '{skill_name}' lives in skills.external_dirs; external skills are read-only to the curator" def is_curation_eligible(skill_name: str, skill_path: Optional[Path] = None) -> bool: """Whether the curator may track/archive *skill_name*. Agent-created skills are always eligible. Bundled built-ins become eligible only when ``curator.prune_builtins`` is enabled. Hub-installed and external skill-dir skills are NEVER eligible — they have an external upstream owner. Org-shared skills ARE eligible for improvement (the curator may patch them like any other skill; edits stay local until proposed) but are protected from ARCHIVE/DELETE elsewhere — removing a shared skill is an org-admin action, not a local curation decision. Protected built-ins (``PROTECTED_BUILTIN_SKILLS``) are NEVER eligible regardless of any flag — they back load-bearing UX and must never be archived or consolidated.""" if (skill_path is not None and is_external_skill_path(skill_path)) or is_protected_builtin(skill_name) or is_hub_installed(skill_name): return False if is_bundled(skill_name): return _prune_builtins_enabled() local_dir = _find_skill_dir(skill_name) return not is_external_skill_path(local_dir) if local_dir is not None else _find_external_skill_dir(skill_name) is None def _is_curator_managed_record(record: Any) -> bool: """Return True when a usage record opts a skill into curator management. NAMING (issue #67140): the on-disk field is ``created_by``, which reads like provenance but is consumed as a **curator-management opt-in policy flag**. The two are not the same question: provenance = "who authored this file" — historical fact, unrecoverable for records written before the marker existed; management = "may autonomous curation mutate/archive this" — a policy decision the user can change at any time via ``hermes curator adopt``. ``created_by: "agent"`` therefore means "curator-managed", NOT "proof the agent wrote it". The field name is retained because it is already on disk in every user's ``.usage.json``; renaming it would strand those records. Read it as policy, and prefer ``is_curator_managed()`` at call sites so the intent is unambiguous.""" return isinstance(record, dict) and (record.get("created_by") == "agent" or record.get("agent_created") is True) def is_curator_managed(skill_name: str) -> bool: """Whether *skill_name* is opted into curator management — the policy-intent alias for the ``created_by``-marker check (see ``_is_curator_managed_record`` for the field-name story).""" return _is_curator_managed_record(load_usage().get(skill_name)) def list_unmanaged_skill_names() -> List[str]: """Enumerate curation-ELIGIBLE skills that carry no provenance marker. These are skills the curator *could* manage (not hub-installed, not external, not protected built-ins) but never will, because nothing ever wrote ``created_by: agent`` onto their usage record. Two ways a skill lands here: it predates the provenance mechanism entirely (records written before ``created_by`` existed carry no key at all, so authorship is unknowable from the record alone), or it was created by a FOREGROUND ``skill_manage(action="create")`` call, which deliberately does not mark provenance (skills a user asks for belong to the user). Either way the skill is invisible to ``curated_report()`` and therefore to every automatic transition. ``hermes curator status`` surfaces this count so the blind spot is legible instead of silent, and ``hermes curator adopt`` lets the user hand specific skills over explicitly. Provenance is a DECLARATION, never an inference: this function only reports, and callers must not auto-adopt what it returns. Heavy patch or use counts are evidence of maintenance, not of authorship — the agent edits user-authored skills on the user's behalf routinely.""" def _keep(name: str, skill_md: Path, bundled: Set[str], usage: Dict[str, Any]) -> bool: # Anything with an external owner or a bundled/protected identity is outside the adoption question. return name not in bundled and not _is_curator_managed_record(usage.get(name)) and is_curation_eligible(name, skill_md) return _scan_local_skills(_keep) def unmanaged_report() -> List[Dict[str, Any]]: """Rows for every skill :func:`list_unmanaged_skill_names` returns. Each row carries the usual activity fields plus ``has_provenance_key``: False when the record has no ``created_by`` key at all (pre-dates the mechanism), True when the key is present but unset (a foreground create under the current policy). The distinction matters for explaining WHY a skill is unmanaged; it is not a signal to adopt on.""" usage = load_usage() return [ _report_row( name, dict(raw) if isinstance(raw, dict) else None, has_provenance_key=isinstance(raw, dict) and "created_by" in raw, has_record=isinstance(raw, dict), ) for name, raw in ((n, usage.get(n)) for n in list_unmanaged_skill_names()) ] def adopt_skill(skill_name: str) -> Tuple[bool, str]: """Hand *skill_name* to the curator by user declaration. Writes the same ``created_by: agent`` marker the background review fork writes, so the skill joins ``curated_report()`` and the automatic transition walk. The inactivity clock is NOT reset: the skill's existing ``last_activity_at`` still governs staleness, so adopting something idle for months does not buy it a fresh window (nor does it archive it on the spot — the state machine decides on the next pass). Returns (ok, message). Refuses hub-installed, external, and protected built-in skills, which have an owner other than the user.""" if not skill_name: return False, "no skill name given" if is_protected_builtin(skill_name): return False, f"'{skill_name}' is a protected built-in; the curator never manages it" if is_hub_installed(skill_name): return False, f"'{skill_name}' is hub-installed; its upstream owns it" if is_bundled(skill_name): # Bundled skills already fall under the curator via ``curator.prune_builtins``; stamping created_by=agent # on one would claim Hermes' own shipped skill was agent-authored and change nothing about its eligibility. return False, f"'{skill_name}' is a bundled built-in — it is governed by curator.prune_builtins, not by adoption" skill_dir = _find_skill_dir(skill_name) if skill_dir is None: if _find_external_skill_dir(skill_name) is not None: return False, f"'{skill_name}' lives in skills.external_dirs and is read-only to the curator" return False, f"skill '{skill_name}' not found" if is_external_skill_path(skill_dir): return False, _external_read_only_message(skill_name) if _is_curator_managed_record(load_usage().get(skill_name)): return True, f"'{skill_name}' is already curator-managed" mark_agent_created(skill_name) if not _is_curator_managed_record(load_usage().get(skill_name)): return False, f"could not mark '{skill_name}' as curator-managed" return True, f"adopted '{skill_name}' into curator management" # --- Sidecar I/O --- def _empty_record() -> Dict[str, Any]: return { "created_by": None, "use_count": 0, "view_count": 0, "last_used_at": None, "last_viewed_at": None, "patch_count": 0, "patch_generation": 0, "last_reused_patch_generation": 0, "last_patched_at": None, "created_at": _now_iso(), "state": STATE_ACTIVE, "pinned": False, "archived_at": None, } def _backfilled(rec: Any) -> Dict[str, Any]: """*rec* with every missing default key filled in (a fresh record when not a dict), so callers never need to handle old files that predate newer keys.""" if not isinstance(rec, dict): return _empty_record() for k, v in _empty_record().items(): rec.setdefault(k, v) return rec def _report_row(name: str, raw: Any, **extra: Any) -> Dict[str, Any]: """Build one report row: name + backfilled record + *extra* + derived activity fields.""" row = {"name": name, **_backfilled(raw), **extra} row.update(last_activity_at=latest_activity_at(row), activity_count=activity_count(row)) return row def load_usage() -> Dict[str, Dict[str, Any]]: """Read the entire .usage.json map. Returns empty dict on missing/corrupt.""" path = _usage_file() if not path.exists(): return {} try: data = json.loads(path.read_text(encoding="utf-8")) except (OSError, json.JSONDecodeError) as e: logger.debug("Failed to read %s: %s", path, e) return {} # Defensive: drop any non-dict values return {str(k): v for k, v in data.items() if isinstance(v, dict)} if isinstance(data, dict) else {} def save_usage(data: Dict[str, Dict[str, Any]]) -> bool: """Write the usage map atomically and report whether it committed.""" path = _usage_file() try: _atomic_write(path, ".usage_", lambda f: json.dump(data, f, indent=2, sort_keys=True, ensure_ascii=False)) return True except Exception as e: logger.debug("Failed to write %s: %s", path, e, exc_info=True) return False def get_record(skill_name: str) -> Dict[str, Any]: """Return the record for *skill_name*, creating a fresh one if missing.""" return _backfilled(load_usage().get(skill_name)) def _locked_update( skill_name: str, op: Callable[[Dict[str, Dict[str, Any]]], Tuple[Any, bool]], fail_log: str, guard: Optional[Callable[[], bool]] = None, ) -> Any: """Run *op(data)* on the loaded usage map under the file lock. Best-effort. *guard* (if given) is evaluated before the lock is taken; a False result skips the update. *op* returns a (result, dirty) pair; the map is saved only when dirty. Returns the result, or None when the guard failed, the save did not land, or anything raised (logged at DEBUG via *fail_log*).""" try: if guard is not None and not guard(): return None with _usage_file_lock(): data = load_usage() result, dirty = op(data) return None if dirty and not save_usage(data) else result except Exception as e: logger.debug(fail_log, skill_name, e, exc_info=True) return None def seed_record_if_missing(skill_name: str) -> None: """Persist a baseline usage record for a curation-eligible skill. Built-ins carry no usage record until something touches them, which leaves their inactivity clock with no anchor. Seeding a record here fixes ``created_at`` to the moment the curator first sees the skill, so the archive/stale clock measures non-use FROM THEN — not from epoch. No-op when a record already exists or the skill isn't curation-eligible.""" if not skill_name or not is_curation_eligible(skill_name): return def _seed(data): if missing := not isinstance(data.get(skill_name), dict): data[skill_name] = _empty_record() return None, missing _locked_update(skill_name, _seed, "skill_usage.seed_record_if_missing(%s) failed: %s") def _mutate(skill_name: str, mutator, *, require_curation_eligible: bool = False) -> Any: """Load, apply *mutator(record)* in place, save. Best-effort. By default this records telemetry for ANY skill — bundled, hub-installed, or agent-created — because usage tracking is pure observability and is orthogonal to whether a skill is ever curated. Lifecycle mutators (``set_state``, ``set_pinned``, ``mark_agent_created``) pass ``require_curation_eligible=True`` so they never write meaningless state onto a skill the curator can't manage (e.g. an ``archived`` flag on a hub-installed skill).""" if not skill_name: return None def _apply(data): rec = data[skill_name] = data[skill_name] if isinstance(data.get(skill_name), dict) else _empty_record() return mutator(rec), True guard = (lambda: is_curation_eligible(skill_name)) if require_curation_eligible else None return _locked_update(skill_name, _apply, "skill_usage._mutate(%s) failed: %s", guard) def _set_field(skill_name: str, key: str, value: Any) -> bool: """Curation-gated single-field write; True only when the write landed.""" def _apply(rec: Dict[str, Any]) -> bool: rec[key] = value return True # non-None sentinel: _mutate propagates the mutator result return bool(_mutate(skill_name, _apply, require_curation_eligible=True)) def _non_negative_int(value: Any) -> int: if isinstance(value, bool): return 0 try: return max(0, int(value or 0)) except (TypeError, ValueError): return 0 def _bump(rec: Dict[str, Any], count_key: str, ts_key: str) -> None: rec[count_key] = _non_negative_int(rec.get(count_key)) + 1 rec[ts_key] = _now_iso() def telemetry_provenance(skill_name: str, record: Optional[Dict[str, Any]] = None) -> str: """Return the bounded provenance used by shared skill metrics.""" if is_hub_installed(skill_name) or is_bundled(skill_name): return "installed" if ":" in skill_name: with suppress(Exception): from hermes_cli.plugins import get_plugin_manager if get_plugin_manager().find_plugin_skill(skill_name) is not None: return "installed" created_by = record.get("created_by") if isinstance(record, dict) else None if created_by in ("installed", "agent"): return {"installed": "installed", "agent": "agent_created"}[created_by] if _find_external_skill_dir(skill_name) is not None: return "external" if _find_skill_dir(skill_name) is not None or isinstance(record, dict): return "local" return "unknown" def _emit_skill_lifecycle( skill_name: str, action: str, *, record: Optional[Dict[str, Any]] = None, task_id: Optional[str] = None, session_id: Optional[str] = None, **facts: Any, ) -> None: """Emit one best-effort lifecycle fact after authoritative state changes. *facts* may carry ``use_count`` / ``reused`` / ``reuse_after_patch``; absent ones are sent as None.""" try: from hermes_cli.lifecycle import has_hook, invoke_hook if not has_hook("on_skill_lifecycle"): return invoke_hook( "on_skill_lifecycle", action=action, skill_name=skill_name, provenance=telemetry_provenance(skill_name, record), task_id=task_id or "", session_id=session_id or "", use_count=facts.get("use_count"), reused=facts.get("reused"), reuse_after_patch=facts.get("reuse_after_patch"), ) except Exception: logger.debug("skill_usage lifecycle hook failed for %s/%s", skill_name, action, exc_info=True) def _mutate_and_emit(skill_name: str, action: str, mutator: Callable[[Dict[str, Any]], Dict[str, Any]], **hook_kwargs: Any) -> None: """``_mutate`` then emit *action* with the mutator's facts as the record — only if the write landed. Any ``use_count`` / ``reused`` / ``reuse_after_patch`` facts are forwarded to the hook.""" facts = _mutate(skill_name, mutator) if isinstance(facts, dict): hook_kwargs.update({k: facts[k] for k in ("use_count", "reused", "reuse_after_patch") if k in facts}) _emit_skill_lifecycle(skill_name, action, record=facts, **hook_kwargs) # --- Public counter-bump helpers — telemetry for ALL skills (observability only) --- def bump_view(skill_name: str) -> None: """Bump view_count and last_viewed_at. Called from skill_view(). Tracks every skill regardless of provenance — built-ins and hub skills included. Usage telemetry is observability, not a curation signal.""" _mutate(skill_name, lambda rec: _bump(rec, "view_count", "last_viewed_at")) def bump_use(skill_name: str, *, task_id: Optional[str] = None, session_id: Optional[str] = None) -> None: """Bump use_count and last_used_at. Called when a skill is actively used (e.g. loaded into the prompt path or referenced from an assistant turn). Tracks every skill regardless of provenance.""" def _apply(rec: Dict[str, Any]) -> Dict[str, Any]: previous_use_count = _non_negative_int(rec.get("use_count")) patch_generation = _non_negative_int(rec.get("patch_generation")) last_reused_generation = min(_non_negative_int(rec.get("last_reused_patch_generation")), patch_generation) reused = previous_use_count > 0 reuse_after_patch = reused and patch_generation > last_reused_generation rec.update( use_count=previous_use_count + 1, last_used_at=_now_iso(), patch_generation=patch_generation, last_reused_patch_generation=patch_generation if reuse_after_patch else last_reused_generation, ) return { "created_by": rec.get("created_by"), "use_count": rec["use_count"], "reused": reused, "reuse_after_patch": reuse_after_patch, } _mutate_and_emit(skill_name, "loaded", _apply, task_id=task_id, session_id=session_id) def bump_patch( skill_name: str, *, action: str = "patch", task_id: Optional[str] = None, session_id: Optional[str] = None ) -> None: """Bump patch_count and last_patched_at. Called from skill_manage (patch/edit). Tracks every skill regardless of provenance.""" def _apply(rec: Dict[str, Any]) -> Dict[str, Any]: _bump(rec, "patch_count", "last_patched_at") rec["patch_generation"] = _non_negative_int(rec.get("patch_generation")) + 1 return {"created_by": rec.get("created_by")} lifecycle_action = "patched" if action == "patch" else "edited" _mutate_and_emit(skill_name, lifecycle_action, _apply, task_id=task_id, session_id=session_id) def record_created( skill_name: str, *, agent_created: bool, task_id: Optional[str] = None, session_id: Optional[str] = None ) -> None: """Persist explicit creation provenance and emit a successful create fact.""" def _apply(rec: Dict[str, Any]) -> Dict[str, Any]: # A successful create is a new logical skill even if stale sidecar state survived an earlier deletion or # manual filesystem change. rec.clear() rec.update(_empty_record(), **({"created_by": "agent"} if agent_created else {})) return {"created_by": rec["created_by"]} _mutate_and_emit(skill_name, "created", _apply, task_id=task_id, session_id=session_id) def record_installed(skill_name: str) -> None: """Record a successful Skills Hub install without exporting its name.""" def _apply(rec: Dict[str, Any]) -> Dict[str, Any]: rec.update(created_by="installed", state=STATE_ACTIVE, archived_at=None) return {"created_by": rec["created_by"]} _mutate_and_emit(skill_name, "installed", _apply) def mark_agent_created(skill_name: str) -> None: """Opt a skill created by skill_manage into curator management. Viewing or invoking a manually authored skill may still create telemetry, but only this explicit marker makes it eligible for automatic curation.""" _set_field(skill_name, "created_by", "agent") def set_state(skill_name: str, state: str) -> None: """Set lifecycle state. No-op if *state* is invalid or the skill isn't curator-manageable (hub skills, or built-ins with pruning disabled).""" if state not in _VALID_STATES: logger.debug("set_state: invalid state %r for %s", state, skill_name) return def _apply(rec: Dict[str, Any]) -> Dict[str, Any]: previous_state = rec.get("state") facts = {"changed": previous_state != state, "created_by": rec.get("created_by")} if facts["changed"]: rec["state"] = state if state != STATE_STALE: rec["archived_at"] = _now_iso() if state == STATE_ARCHIVED else None facts["previous_state"] = previous_state return facts facts = _mutate(skill_name, _apply, require_curation_eligible=True) if not isinstance(facts, dict) or not facts.get("changed"): return restored = state == STATE_ACTIVE and facts.get("previous_state") == STATE_ARCHIVED # active<-stale emits nothing action = "restored" if restored else {STATE_ARCHIVED: "archived", STATE_STALE: "stale"}.get(state) if action is not None: _emit_skill_lifecycle(skill_name, action, record=facts) def set_pinned(skill_name: str, pinned: bool) -> bool: """Set/clear the pin flag. Returns False when the write did not land (skill not curation-eligible), True on success — so callers can report failure instead of a false success (issue #92993).""" return _set_field(skill_name, "pinned", bool(pinned)) def set_sync(skill_name: str, sync: bool) -> None: """Set the sync opt-in flag on a skill's usage record. Sync is OPT-IN: nothing propagates to the sync plane unless the user marks a skill with ``sync: true`` here. Sits alongside ``pinned``/``created_by`` on the ``.usage.json`` sidecar and is read by ``tools.skills_sync_client.list_synced_skill_names``. Gated on curation eligibility so bundled/hub/external skills (which never sync) can't be marked. Provisional per the M1-D default.""" _set_field(skill_name, "sync", bool(sync)) def is_sync_enabled(skill_name: str) -> bool: """Whether a skill is opted into sync (``sync: true`` in its record).""" return get_record(skill_name).get("sync") is True def forget(skill_name: str) -> None: """Drop a skill's usage entry entirely. Called when the skill is deleted.""" if skill_name: _locked_update(skill_name, lambda d: (None, d.pop(skill_name, None) is not None), "skill_usage.forget(%s) failed: %s") # --- Archive / restore --- def _relocate(src: Path, dest: Path, skill_name: str, action: str, **capture_kwargs: Any) -> Tuple[bool, str]: """Move *src* to *dest* for *action* ("archive" | "restore") with an audit-ledger entry around it, then apply the suppression + state side effects for that direction. Ledger pre-capture is best-effort and never blocks the move; if it is unavailable the post-move record is skipped too. The rename falls back to shutil.move across devices. Returns (ok, message).""" try: from tools import skill_ledger as _ledger _ledger_before = _ledger.capture_before(src, **capture_kwargs) except Exception: _ledger = _ledger_before = None # type: ignore[assignment] try: src.rename(dest) except OSError: import shutil try: shutil.move(str(src), str(dest)) except Exception as e: return False, f"failed to {action}: {e}" if action == "archive": # Pruning a built-in only sticks if the re-seeder is told to leave it alone. if is_bundled(skill_name): add_suppressed_name(skill_name) set_state(skill_name, STATE_ARCHIVED) else: # Restoring a pruned built-in lifts its suppression so updates can manage it. remove_suppressed_name(skill_name) set_state(skill_name, STATE_ACTIVE) with suppress(Exception): if _ledger is not None: _ledger.record_mutation( action, skill_name, before=_ledger_before if _ledger_before is not None else [], after_root=dest ) return True, f"{action}d to {dest}" def archive_skill(skill_name: str) -> Tuple[bool, str]: """Move a curator-eligible skill directory to ~/.hermes/skills/.archive/. Returns (ok, message). Never archives hub-installed skills. Bundled built-ins are only archivable when ``curator.prune_builtins`` is enabled; when one is archived, its name is added to the suppression list so the update-time re-seeder leaves it archived instead of restoring it.""" skill_dir = _find_skill_dir(skill_name) if skill_dir is None and _find_external_skill_dir(skill_name) is not None: return False, _external_read_only_message(skill_name) if not is_curation_eligible(skill_name, skill_dir): if is_protected_builtin(skill_name): return False, f"skill '{skill_name}' is a protected built-in; it backs load-bearing UX and is never archived or consolidated" if is_hub_installed(skill_name): return False, f"skill '{skill_name}' is hub-installed; never archive" return False, f"skill '{skill_name}' is a bundled built-in; enable curator.prune_builtins to allow pruning it" if skill_dir is None: return False, f"skill '{skill_name}' not found" if is_external_skill_path(skill_dir): return False, _external_read_only_message(skill_name) archive_root = _archive_dir() try: archive_root.mkdir(parents=True, exist_ok=True) except OSError as e: return False, f"failed to create archive dir: {e}" # Flatten any category nesting into a single ".archive//" so restores are simple; on collision, append # a timestamp. dest = archive_root / skill_dir.name if dest.exists(): dest = archive_root / f"{skill_dir.name}-{datetime.now(timezone.utc).strftime('%Y%m%d%H%M%S')}" # complete_package: consolidation may have re-homed support files out of the tree first, so a disk-only # capture can come back hollow; the fill from the newest curator backup keeps rollback restorable (#96962). return _relocate(skill_dir, dest, skill_name, "archive", complete_package=True, skill=skill_name) def restore_skill(skill_name: str) -> Tuple[bool, str]: """Move an archived skill back to ~/.hermes/skills/. Restores to the flat top-level layout; original category nesting is NOT reconstructed. Refuses to restore under a name that now collides with a hub-installed skill — that would shadow the upstream version. Also refuses to restore over a bundled built-in UNLESS ``curator.prune_builtins`` is enabled (in which case built-ins are curator-managed and restoring is the documented way to lift a prune). Restoring clears any suppression entry so future updates may re-seed the built-in again.""" # Hub skills always have an external upstream owner — never shadow them. if is_hub_installed(skill_name): return False, f"skill '{skill_name}' is now hub-installed; restore would shadow the upstream version" # A bundled built-in is upstream-owned UNLESS prune_builtins is on; with the flag off, restoring over it # would shadow the bundled version. if is_bundled(skill_name) and not _prune_builtins_enabled(): return False, f"skill '{skill_name}' is now bundled; restore would shadow the upstream version" archive_root = _archive_dir() if not archive_root.exists(): return False, "no archive directory" # Try exact name match first, then the timestamped-duplicate fallback. Recursive walk handles nested archive # layouts (e.g. .archive///) left behind by older archive paths or external imports. candidates = [p for p in archive_root.rglob("*") if p.is_dir() and p.name == skill_name] if not candidates: # A name collision makes archive_skill() disambiguate by appending its UTC timestamp # ("-YYYYMMDDHHMMSS", a 14-digit suffix), so only that exact shape is another copy of THIS skill. # A bare startswith(f"{skill_name}-") also swallows unrelated sibling skills — restoring "git" would # otherwise pull an archived "git-helpers" out of the archive and rename it to "git", destroying the # sibling's only copy. Require the suffix to be the timestamp archive_skill writes. prefix = f"{skill_name}-" candidates = sorted( (p for p in archive_root.rglob("*") if p.is_dir() and p.name.startswith(prefix) and len(p.name) - len(prefix) == 14 and p.name[len(prefix):].isdigit()), reverse=True, ) if not candidates: return False, f"skill '{skill_name}' not found in archive" dest = _skills_dir() / skill_name if dest.exists(): return False, f"destination already exists: {dest}" return _relocate(candidates[0], dest, skill_name, "restore") def _match_skill_dir(skill_mds: Iterable[Path], skill_name: str) -> Optional[Path]: return next((p.parent for p in skill_mds if _read_skill_name(p, fallback=p.parent.name) == skill_name), None) def _find_skill_dir(skill_name: str) -> Optional[Path]: """Locate the directory for a skill by its frontmatter `name:` field. Handles both flat (~/.hermes/skills//SKILL.md) and category-nested (~/.hermes/skills///SKILL.md) layouts. Uses the gated index iterator so M2 org mirrors resolve ONLY for the active org (stale ``_org//`` trees never match).""" base = _skills_dir() if not base.exists(): return None from agent.skill_utils import iter_skill_index_files return _match_skill_dir( (p for p in iter_skill_index_files(base, "SKILL.md") if not is_external_skill_path(p)), skill_name ) def _find_external_skill_dir(skill_name: str) -> Optional[Path]: """Locate a skill under configured external dirs by frontmatter name.""" from agent.skill_utils import get_all_skills_dirs for base in (b for b in get_all_skills_dirs()[1:] if b.exists()): found = _match_skill_dir((p for p in base.rglob("SKILL.md") if not is_excluded_skill_path(p)), skill_name) if found is not None: return found return None # --- Reporting — for the curator CLI / slash command --- def curated_report() -> List[Dict[str, Any]]: """Return a list of {name, provenance, state, pinned, last_activity_at, ...} records for every curator-managed skill. Missing usage records are backfilled with defaults so callers can always index fields. ``provenance`` is 'agent', 'bundled', or 'hub' (see :func:`provenance`). Bundled skills are only included when ``curator.prune_builtins`` is enabled. Hub-installed skills are never included. Each row carries ``_persisted``: True when a real record exists in ``.usage.json``, False when the row is a fresh backfill (e.g. a built-in seen for the first time). The curator uses this to seed the inactivity clock instead of treating an unrecorded skill as ancient.""" data = load_usage() names = set(list_agent_created_skill_names()) # Issue #92993: a successfully pinned skill must be visible in the report even when it lacks the created_by # marker (eligible-but-unmanaged), or its pin silently vanishes from `curator status`. The local-dir guard # keeps stale records for deleted skill dirs from rendering as ghost rows; `curator unpin` is the cleanup path. names.update( name for name, rec in data.items() if isinstance(rec, dict) and rec.get("pinned") and is_curation_eligible(name) and _find_skill_dir(name) is not None ) rows = [_report_row(name, data.get(name), _persisted=isinstance(data.get(name), dict)) for name in sorted(names)] for row in rows: row["provenance"] = provenance(row["name"]) return rows def provenance(skill_name: str) -> str: """Classify a skill's origin: 'hub', 'bundled', or 'agent'. 'agent' covers both agent-authored and local manually-authored skills — anything not seeded from the bundled repo or installed via the hub.""" return "hub" if is_hub_installed(skill_name) else "bundled" if is_bundled(skill_name) else "agent" def usage_report() -> List[Dict[str, Any]]: """Return usage telemetry for EVERY skill on disk, with provenance. Unlike ``curated_report()`` (which is scoped to curator-managed candidates), this surfaces all skills — bundled built-ins and hub-installed included — so callers can answer "how often is this skill used" independent of whether it's ever curated. Rows carry a ``provenance`` field ('agent' | 'bundled' | 'hub') and ``_persisted`` (whether a real ``.usage.json`` record backs the row).""" base = _skills_dir() if not base.exists(): return [] data = load_usage() rows: Dict[str, Dict[str, Any]] = {} for name, _skill_md in _iter_skill_mds(base, local_only=False): if name not in rows: rows[name] = _report_row(name, data.get(name), provenance=provenance(name), _persisted=isinstance(data.get(name), dict)) return [rows[name] for name in sorted(rows)]