{"repo":"NousResearch/hermes-agent","public_id":"18373","display_label":"Curator safety guards, dry-run, and consent controls","summary":"These issues and PRs address concerns that the Curator component can autonomously archive or delete skills without user consent, recovery paths, or preview modes. Work spans adding dry-run support, protecting active and custom skills from unverified archival, making consolidation opt-in, and introducing review-only modes.","subsystem":"agent","counts":{"members":16,"open_issues":3,"open_issues_distinct":3,"open_prs":0,"closed_issues":3,"merged_prs":2,"attempt_prs":8},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":18389},"resolution":{"state":"partially_resolved","by":"pr-18389,pr-53935"},"freshness":{"state":"verified","last_verified_at":"2026-06-28T03:45:58+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_PARTIAL","link_density":0.09833333333333333},"fix_edges":[{"issue_number":18373,"pr_number":18389,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true},{"issue_number":26655,"pr_number":53935,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false},{"issue_number":29912,"pr_number":53935,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true}],"members":[{"kind":"issue","number":18373,"title":"Curator should not auto-archive user-created custom skills without dry-run/approval","url":null,"state":"closed"},{"kind":"issue","number":18472,"title":"feat: achievements plugin + curator dry-run proposals (filtering, CLI, export, curator --dry-run)","url":null,"state":"closed"},{"kind":"issue","number":20034,"title":"docs: document post_tool_call plugin hook as the integration seam for audit/receipt-chain plugins","url":null,"state":"open"},{"kind":"issue","number":29912,"title":"Curator may archive active skills during umbrella pass without verified consolidation (fail-open behavior)","url":null,"state":"closed"},{"kind":"issue","number":50875,"title":"[Bug]: Curator runs autonomously with hard-delete powers, no user consent gate, and no recovery path","url":null,"state":"open"},{"kind":"issue","number":54020,"title":"Feature: curator review-only mode with configurable schedule and delivery","url":null,"state":"open"},{"kind":"pr","number":18389,"title":"fix(curator): defer first run and add --dry-run preview (#18373)","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["agent/curator.py","agent/curator_backup.py","hermes_cli/config.py","hermes_cli/curator.py","hermes_cli/main.py","tests/agent/test_curator.py","tests/agent/test_curator_backup.py","website/docs/reference/cli-commands.md","website/docs/user-guide/features/curator.md"],"fix_hunks":[{"file":"agent/curator.py","hunks":["@@ -184,7 +184,16 @@ def should_run_now(now: Optional[datetime] = None) -> bool:\n     Gates:\n       - curator.enabled == True\n       - not paused\n-      - last_run_at missing, OR older than interval_hours\n+      - last_run_at present AND older than interval_hours\n+\n+    First-run behavior: when there is no ``last_run_at`` (fresh install, or\n+    install that predates the curator), we DO NOT run immediately. The\n+    curator is designed to run after at least ``interval_hours`` (7 days by\n+    default) of skill activity, not on the first background tick after\n+    ``hermes update``. On first observation we seed ``last_run_at`` to \"now\"\n+    and defer the first real pass by one full interval. Users who want to\n+    run it sooner can always invoke ``hermes curator run`` (with or without\n+    ``--dry-run``) explicitly — that path bypasses this gate.\n \n     The idle check (min_idle_hours) is applied at the call site where we know\n     whether an agent is actively running — here we only enforce the static","@@ -198,7 +207,21 @@ def should_run_now(now: Optional[datetime] = None) -> bool:\n     state = load_state()\n     last = _parse_iso(state.get(\"last_run_at\"))\n     if last is None:\n-        return True\n+        # Never run before. Seed state so we wait a full interval before the\n+        # first real pass. Report-only; do not auto-mutate the library the\n+        # very first time a gateway ticks after an update.\n+        if now is None:\n+            now = datetime.now(timezone.utc)\n+        try:\n+            state[\"last_run_at\"] = now.isoformat()\n+            state[\"last_run_summary\"] = (\n+                \"deferred first run — curator seeded, will run after one \"\n+                \"interval; use `hermes curator run --dry-run` to preview now\"\n+            )\n+            save_state(state)\n+        except Exception as e:  # pragma: no cover — best-effort persistence\n+            logger.debug(\"Failed to seed curator last_run_at: %s\", e)\n+        return False\n \n     if now is None:\n         now = datetime.now(timezone.utc)","@@ -259,6 +282,33 @@ def apply_automatic_transitions(now: Optional[datetime] = None) -> Dict[str, int\n # Review prompt for the forked agent\n # ---------------------------------------------------------------------------\n \n+CURATOR_DRY_RUN_BANNER = (\n+    \"═══════════════════════════════════════════════════════════════\\n\"\n+    \"DRY-RUN — REPORT ONLY. DO NOT MUTATE THE SKILL LIBRARY.\\n\"\n+    \"═══════════════════════════════════════════════════════════════\\n\"\n+    \"\\n\"\n+    \"This is a PREVIEW pass. Follow every instruction below EXCEPT:\\n\"\n+    \"\\n\"\n+    \"  • DO NOT call skill_manage with action=patch, create, delete, \"\n+    \"write_file, or remove_file.\\n\"\n+    \"  • DO NOT call terminal to mv skill directories into .archive/.\\n\"\n+    \"  • DO NOT call terminal to mv, cp, rm, or rewrite any file under \"\n+    \"~/.hermes/skills/.\\n\"\n+    \"  • skills_list and skill_view are FINE — read as much as you need.\\n\"\n+    \"\\n\"\n+    \"Your output IS the deliverable. Produce the exact same \"\n+    \"human-readable summary and structured YAML block you would \"\n+    \"produce on a live run — but describe the actions you WOULD take, \"\n+    \"not actions you took. A downstream reviewer will read the report \"\n+    \"and decide whether to approve a live run with \"\n+    \"`hermes curator run` (no flag).\\n\"\n+    \"\\n\"\n+    \"If you accidentally take a mutating action, say so explicitly in \"\n+    \"the summary so the reviewer can revert it.\\n\"\n+    \"═══════════════════════════════════════════════════════════════\"\n+)\n+\n+\n CURATOR_REVIEW_PROMPT = (\n     \"You are running as Hermes' background skill CURATOR. This is an \"\n     \"UMBRELLA-BUILDING consolidation pass, not a passive audit and not a \"","@@ -1072,6 +1122,7 @@ def _render_candidate_list() -> str:\n def run_curator_review(\n     on_summary: Optional[Callable[[str], None]] = None,\n     synchronous: bool = False,\n+    dry_run: bool = False,\n ) -> Dict[str, Any]:\n     \"\"\"Execute a single curator review pass.\n ","@@ -1084,9 +1135,43 @@ def run_curator_review(\n \n     If *synchronous* is True, the LLM review runs in the calling thread; the\n     default is to spawn a daemon thread so the caller returns immediately.\n+\n+    If *dry_run* is True, the automatic stale/archive transitions are SKIPPED\n+    and the LLM review pass is instructed to produce a report only — no\n+    skill_manage mutations, no terminal archive moves. The REPORT.md still\n+    gets written and ``state.last_report_path`` still records it so users\n+    can read what the curator WOULD have done.\n     \"\"\"\n     start = datetime.now(timezone.utc)\n-    counts = apply_automatic_transitions(now=start)\n+    if dry_run:\n+        # Count candidates without mutating state.\n+        try:\n+            report = skill_usage.agent_created_report()\n+            counts = {\n+                \"checked\": len(report),\n+                \"marked_stale\": 0,\n+                \"archived\": 0,\n+                \"reactivated\": 0,\n+            }\n+        except Exception:\n+            counts = {\"checked\": 0, \"marked_stale\": 0, \"archived\": 0, \"reactivated\": 0}\n+    else:\n+        # Pre-mutation snapshot — best-effort, never blocks the run. A\n+        # failed snapshot logs at debug and continues (the alternative is\n+        # that a transient disk issue silently disables curator forever,\n+        # which is worse). Users who want to require snapshots can disable\n+        # curator entirely until they can fix disk space.\n+        try:\n+            from agent import curator_backup\n+            snap = curator_backup.snapshot_skills(reason=\"pre-curator-run\")\n+            if snap is not None and on_summary:\n+                try:\n+                    on_summary(f\"curator: snapshot created ({snap.name})\")\n+                except Exception:\n+                    pass\n+        except Exception as e:\n+            logger.debug(\"Curator pre-run snapshot failed: %s\", e, exc_info=True)\n+        counts = apply_automatic_transitions(now=start)\n \n     auto_summary_parts = []\n     if counts[\"marked_stale\"]:","@@ -1098,11 +1183,16 @@ def run_curator_review(\n     auto_summary = \", \".join(auto_summary_parts) if auto_summary_parts else \"no changes\"\n \n     # Persist state before the LLM pass so a crash mid-review still records\n-    # the run and doesn't immediately re-trigger.\n+    # the run and doesn't immediately re-trigger. In dry-run we do NOT bump\n+    # last_run_at or run_count — a preview shouldn't push the next scheduled\n+    # real pass out. We still record a summary so `hermes curator status`\n+    # shows that a preview ran.\n     state = load_state()\n-    state[\"last_run_at\"] = start.isoformat()\n-    state[\"run_count\"] = int(state.get(\"run_count\", 0)) + 1\n-    state[\"last_run_summary\"] = f\"auto: {auto_summary}\"\n+    if not dry_run:\n+        state[\"last_run_at\"] = start.isoformat()\n+        state[\"run_count\"] = int(state.get(\"run_count\", 0)) + 1\n+    prefix = \"dry-run auto: \" if dry_run else \"auto: \"\n+    state[\"last_run_summary\"] = f\"{prefix}{auto_summary}\"\n     save_state(state)\n \n     def _llm_pass():","@@ -1118,7 +1208,7 @@ def _llm_pass():\n         try:\n             candidate_list = _render_candidate_list()\n             if \"No agent-created skills\" in candidate_list:\n-                final_summary = f\"auto: {auto_summary}; llm: skipped (no candidates)\"\n+                final_summary = f\"{prefix}{auto_summary}; llm: skipped (no candidates)\"\n                 llm_meta = {\n                     \"final\": \"\",\n                     \"summary\": \"skipped (no candidates)\",","@@ -1128,14 +1218,21 @@ def _llm_pass():\n                     \"error\": None,\n                 }\n             else:\n-                prompt = f\"{CURATOR_REVIEW_PROMPT}\\n\\n{candidate_list}\"\n+                if dry_run:\n+                    prompt = (\n+                        f\"{CURATOR_DRY_RUN_BANNER}\\n\\n\"\n+                        f\"{CURATOR_REVIEW_PROMPT}\\n\\n\"\n+                        f\"{candidate_list}\"\n+                    )\n+                else:\n+                    prompt = f\"{CURATOR_REVIEW_PROMPT}\\n\\n{candidate_list}\"\n                 llm_meta = _run_llm_review(prompt)\n                 final_summary = (\n-                    f\"auto: {auto_summary}; llm: {llm_meta.get('summary', 'no change')}\"\n+                    f\"{prefix}{auto_summary}; llm: {llm_meta.get('summary', 'no change')}\"\n                 )\n         except Exception as e:\n             logger.debug(\"Curator LLM pass failed: %s\", e, exc_info=True)\n-            final_summary = f\"auto: {auto_summary}; llm: error ({e})\"\n+            final_summary = f\"{prefix}{auto_summary}; llm: error ({e})\"\n             llm_meta = {\n                 \"final\": \"\",\n                 \"summary\": f\"error ({e})\","]},{"file":"agent/curator_backup.py","hunks":["@@ -0,0 +1,440 @@\n+\"\"\"Curator snapshot + rollback.\n+\n+A pre-run snapshot of ``~/.hermes/skills/`` (excluding ``.curator_backups/``\n+itself) is taken before any mutating curator pass. Snapshots are tar.gz\n+files under ``~/.hermes/skills/.curator_backups/<utc-iso>/`` with a\n+companion ``manifest.json`` describing the snapshot (reason, time, size,\n+counted skill files). Rollback picks a snapshot, moves the current\n+``skills/`` tree aside into another snapshot so even the rollback itself\n+is undoable, then extracts the chosen snapshot into place.\n+\n+The snapshot does NOT include:\n+  - ``.curator_backups/`` (would recurse)\n+  - ``.hub/`` (hub-installed skills — managed by the hub, not us)\n+\n+It DOES include:\n+  - all SKILL.md files + their directories (``scripts/``, ``references/``,\n+    ``templates/``, ``assets/``)\n+  - ``.usage.json`` (usage telemetry — needed to rehydrate state cleanly)\n+  - ``.archive/`` (so rollback restores previously-archived skills too)\n+  - ``.curator_state`` (so rolling back also restores the last-run-at\n+    pointer — otherwise the curator would immediately re-fire on the next\n+    tick)\n+  - ``.bundled_manifest`` (so protection markers stay consistent)\n+\"\"\"\n+\n+from __future__ import annotations\n+\n+import json\n+import logging\n+import os\n+import re\n+import shutil\n+import tarfile\n+import tempfile\n+import time\n+from datetime import datetime, timezone\n+from pathlib import Path\n+from typing import Any, Dict, List, Optional, Tuple\n+\n+from hermes_constants import get_hermes_home\n+\n+logger = logging.getLogger(__name__)\n+\n+\n+DEFAULT_KEEP = 5\n+\n+# Entries under skills/ that should NEVER be rolled up into a snapshot.\n+# .hub/ is managed by the skills hub; rolling it back would break lockfile\n+# invariants. .curator_backups is the backup dir itself — recursion bomb.\n+_EXCLUDE_TOP_LEVEL = {\".curator_backups\", \".hub\"}\n+\n+# Snapshot id regex: UTC ISO with colons replaced by dashes so the filename\n+# is portable (Windows-safe). An optional ``-NN`` suffix handles two\n+# snapshots landing in the same wallclock second.\n+_ID_RE = re.compile(r\"^\\d{4}-\\d{2}-\\d{2}T\\d{2}-\\d{2}-\\d{2}Z(-\\d{2})?$\")\n+\n+\n+def _backups_dir() -> Path:\n+    return get_hermes_home() / \"skills\" / \".curator_backups\"\n+\n+\n+def _skills_dir() -> Path:\n+    return get_hermes_home() / \"skills\"\n+\n+\n+def _utc_id(now: Optional[datetime] = None) -> str:\n+    \"\"\"UTC ISO-ish filesystem-safe timestamp: ``2026-05-01T13-05-42Z``.\"\"\"\n+    if now is None:\n+        now = datetime.now(timezone.utc)\n+    # isoformat → \"2026-05-01T13:05:42.123456+00:00\"; strip subseconds and tz.\n+    s = now.replace(microsecond=0).isoformat()\n+    if s.endswith(\"+00:00\"):\n+        s = s[:-6]\n+    return s.replace(\":\", \"-\") + \"Z\"\n+\n+\n+def _load_config() -> Dict[str, Any]:\n+    try:\n+        from hermes_cli.config import load_config\n+        cfg = load_config()\n+    except Exception as e:\n+        logger.debug(\"Failed to load config for curator backup: %s\", e)\n+        return {}\n+    if not isinstance(cfg, dict):\n+        return {}\n+    cur = cfg.get(\"curator\") or {}\n+    if not isinstance(cur, dict):\n+        return {}\n+    bk = cur.get(\"backup\") or {}\n+    return bk if isinstance(bk, dict) else {}\n+\n+\n+def is_enabled() -> bool:\n+    \"\"\"Default ON — the whole point of the backup is safety by default.\"\"\"\n+    return bool(_load_config().get(\"enabled\", True))\n+\n+\n+def get_keep() -> int:\n+    cfg = _load_config()\n+    try:\n+        n = int(cfg.get(\"keep\", DEFAULT_KEEP))\n+    except (TypeError, ValueError):\n+        n = DEFAULT_KEEP\n+    return max(1, n)\n+\n+\n+# ---------------------------------------------------------------------------\n+# Snapshot\n+# ---------------------------------------------------------------------------\n+\n+def _count_skill_files(base: Path) -> int:\n+    try:\n+        return sum(1 for _ in base.rglob(\"SKILL.md\"))\n+    except OSError:\n+        return 0\n+\n+\n+def _write_manifest(dest: Path, reason: str, archive_path: Path,\n+                    skills_counted: int) -> None:\n+    manifest = {\n+        \"id\": dest.name,\n+        \"reason\": reason,\n+        \"created_at\": datetime.now(timezone.utc).isoformat(),\n+        \"archive\": archive_path.name,\n+        \"archive_bytes\": archive_path.stat().st_size,\n+        \"skill_files\": skills_counted,\n+    }\n+    (dest / \"manifest.json\").write_text(\n+        json.dumps(manifest, indent=2, sort_keys=True), encoding=\"utf-8\"\n+    )\n+\n+\n+def snapshot_skills(reason: str = \"manual\") -> Optional[Path]:\n+    \"\"\"Create a tar.gz snapshot of ``~/.hermes/skills/`` and prune old ones.\n+\n+    Returns the snapshot directory path, or ``None`` if the snapshot was\n+    skipped (backup disabled, skills dir missing, or an IO error occurred —\n+    in which case we log at debug and return None so the curator never\n+    aborts a pass because of a backup failure).\n+    \"\"\"\n+    if not is_enabled():\n+        logger.debug(\"Curator backup disabled by config; skipping snapshot\")\n+        return None\n+\n+    skills = _skills_dir()\n+    if not skills.exists():\n+        logger.debug(\"No ~/.hermes/skills/ directory — nothing to back up\")\n+        return None\n+\n+    backups = _backups_dir()\n+    try:\n+        backups.mkdir(parents=True, exist_ok=True)\n+    except OSError as e:\n+        logger.debug(\"Failed to create backups dir %s: %s\", backups, e)\n+        return None\n+\n+    # Uniquify: if a snapshot with the same second already exists (can\n+    # happen if two curator runs fire in the same second), append a short\n+    # counter. Avoids clobbering and avoids timestamp collisions.\n+    base_id = _utc_id()\n+    snap_id = base_id\n+    counter = 1\n+    while (backups / snap_id).exists():\n+        snap_id = f\"{base_id}-{counter:02d}\"\n+        counter += 1\n+\n+    dest = backups / snap_id\n+    try:\n+        dest.mkdir(parents=True, exist_ok=False)\n+    except OSError as e:\n+        logger.debug(\"Failed to create snapshot dir %s: %s\", dest, e)\n+        return None\n+\n+    archive = dest / \"skills.tar.gz\"\n+    try:\n+        # Stream into the tarball — no tempdir copy needed.\n+        with tarfile.open(archive, \"w:gz\", compresslevel=6) as tf:\n+            for entry in sorted(skills.iterdir()):\n+                if entry.name in _EXCLUDE_TOP_LEVEL:\n+                    continue\n+                # arcname: store paths relative to skills/ so extraction\n+                # drops cleanly back into the skills dir.\n+                tf.add(str(entry), arcname=entry.name, recursive=True)\n+        _write_manifest(dest, reason, archive, _count_skill_files(skills))\n+    except (OSError, tarfile.TarError) as e:\n+        logger.debug(\"Curator snapshot failed: %s\", e, exc_info=True)\n+        # Clean up partial snapshot\n+        try:\n+            shutil.rmtree(dest, ignore_errors=True)\n+        except OSError:\n+            pass\n+        return None\n+\n+    _prune_old(keep=get_keep())\n+    logger.info(\"Curator snapshot created: %s (%s)\", snap_id, reason)\n+    return dest\n+\n+\n+def _prune_old(keep: int) -> List[str]:\n+    \"\"\"Delete regular snapshots beyond the newest *keep*. Returns deleted\n+    ids. Staging dirs (``.rollback-staging-*``) are implementation detail\n+    and pruned independently on every call.\"\"\"\n+    backups = _backups_dir()\n+    if not backups.exists():\n+        return []\n+    entries: List[Tuple[str, Path]] = []\n+    stale_staging: List[Path] = []\n+    for child in backups.iterdir():\n+        if not child.is_dir():\n+            continue\n+        if child.name.startswith(\".rollback-staging-\"):\n+            # Staging dirs are only supposed to exist briefly during a\n+            # rollback. If we find one here (e.g. from a crashed rollback),\n+            # clean it up opportunistically.\n+            stale_staging.append(child)\n+            continue\n+        if _ID_RE.match(child.name):\n+            entries.append((child.name, child))\n+    # Newest first (lexicographic works because the id is UTC ISO).\n+    entries.sort(key=lambda t: t[0], reverse=True)\n+    deleted: List[str] = []\n+    for _, path in entries[keep:]:\n+        try:\n+            shutil.rmtree(path)\n+            deleted.append(path.name)\n+        except OSError as e:\n+            logger.debug(\"Failed to prune %s: %s\", path, e)\n+    for path in stale_staging:\n+        try:\n+            shutil.rmtree(path)\n+        except OSError as e:\n+            logger.debug(\"Failed to clean stale staging dir %s: %s\", path, e)\n+    return deleted\n+\n+\n+# ---------------------------------------------------------------------------\n+# List + rollback\n+# ---------------------------------------------------------------------------\n+\n+def _read_manifest(snap_dir: Path) -> Dict[str, Any]:\n+    mf = snap_dir / \"manifest.json\"\n+    if not mf.exists():\n+        return {}\n+    try:\n+        return json.loads(mf.read_text(encoding=\"utf-8\"))\n+    except (OSError, json.JSONDecodeError):\n+        return {}\n+\n+\n+def list_backups() -> List[Dict[str, Any]]:\n+    \"\"\"Return all restorable snapshots, newest first. Only entries with a\n+    real ``skills.tar.gz`` tarball are listed — transient\n+    ``.rollback-staging-*`` directories created mid-rollback are\n+    implementation detail and not shown.\"\"\"\n+    backups = _backups_dir()\n+    if not backups.exists():\n+        return []\n+    out: List[Dict[str, Any]] = []\n+    for child in sorted(backups.iterdir(), reverse=True):\n+        if not child.is_dir():\n+            continue\n+        if not _ID_RE.match(child.name):\n+            continue\n+        if not (child / \"skills.tar.gz\").exists():\n+            continue\n+        mf = _read_manifest(child)\n+        mf.setdefault(\"id\", child.name)\n+        mf.setdefault(\"path\", str(child))\n+        if \"archive_bytes\" not in mf:\n+            arc = child / \"skills.tar.gz\"\n+            try:\n+                mf[\"archive_bytes\"] = arc.stat().st_size\n+            except OSError:\n+                mf[\"archive_bytes\"] = 0\n+        out.append(mf)\n+    return out\n+\n+\n+def _resolve_backup(backup_id: Optional[str]) -> Optional[Path]:\n+    \"\"\"Return the path of the requested backup, or the newest one if\n+    *backup_id* is None. Returns None if no match.\"\"\"\n+    backups = _backups_dir()\n+    if not backups.exists():\n+        return None\n+    if backup_id:\n+        target = backups / backup_id\n+        if (\n+            target.is_dir()\n+            and _ID_RE.match(backup_id)\n+            and (target / \"skills.tar.gz\").exists()\n+        ):\n+            return target\n+        return None\n+    candidates = [\n+        c for c in sorted(backups.iterdir(), reverse=True)\n+        if c.is_dir() and _ID_RE.match(c.name) and (c / \"skills.tar.gz\").exists()\n+    ]\n+    return candidates[0] if candidates else None\n+\n+\n+def rollback(backup_id: Optional[str] = None) -> Tuple[bool, str, Optional[Path]]:\n+    \"\"\"Restore ``~/.hermes/skills/`` from a snapshot.\n+\n+    Strategy:\n+      1. Resolve the target snapshot (explicit id or newest regular).\n+      2. Take a safety snapshot of the CURRENT skills tree under\n+         ``.curator_backups/pre-rollback-<ts>/`` so the rollback itself is\n+         undoable.\n+      3. Move all current top-level entries (except ``.curator_backups``\n+         and ``.hub``) into a tempdir.\n+      4. Extract the chosen snapshot into ``~/.hermes/skills/``.\n+      5. On failure during 4, move the tempdir contents back (best-effort)\n+         and return failure.\n+\n+    Returns ``(ok, message, snapshot_path)``.\n+    \"\"\"\n+    target = _resolve_backup(backup_id)\n+    if target is None:\n+        return (\n+            False,\n+            f\"no matching backup found\"\n+            + (f\" for id '{backup_id}'\" if backup_id else \"\")\n+            + \" (use `hermes curator rollback --list` to see available snapshots)\",\n+            None,\n+        )\n+    archive = target / \"skills.tar.gz\"\n+    if not archive.exists():\n+        return (False, f\"snapshot {target.name} has no skills.tar.gz — corrupted?\", None)\n+\n+    skills = _skills_dir()\n+    skills.mkdir(parents=True, exist_ok=True)\n+    backups = _backups_dir()\n+    backups.mkdir(parents=True, exist_ok=True)\n+\n+    # Step 2: safety snapshot of current state FIRST. If this fails we bail\n+    # out before touching anything — otherwise a failed extract could leave\n+    # the user with no skills.\n+    try:\n+        snapshot_skills(reason=f\"pre-rollback to {target.name}\")\n+    except Exception as e:\n+        return (False, f\"pre-rollback safety snapshot failed: {e}\", None)\n+\n+    # Additionally move current entries into an internal staging dir so\n+    # the extract happens into an empty skills tree (predictable result).\n+    # This dir is implementation detail — not listed as a restorable\n+    # backup. The safety snapshot above is the user-facing undo handle.\n+    staged = backups / f\".rollback-staging-{_utc_id()}\"\n+    try:\n+        staged.mkdir(parents=True, exist_ok=False)\n+    except OSError as e:\n+        return (False, f\"failed to create staging dir: {e}\", None)\n+\n+    moved: List[Tuple[Path, Path]] = []\n+    try:\n+        for entry in list(skills.iterdir()):\n+            if entry.name in _EXCLUDE_TOP_LEVEL:\n+                continue\n+            dest = staged / entry.name\n+            shutil.move(str(entry), str(dest))\n+            moved.append((entry, dest))\n+    except OSError as e:\n+        # Best-effort rollback of the move\n+        for orig, dest in moved:\n+            try:\n+                shutil.move(str(dest), str(orig))\n+            except OSError:\n+                pass\n+        try:\n+            shutil.rmtree(staged, ignore_errors=True)\n+        except OSError:\n+            pass\n+        return (False, f\"failed to stage current skills: {e}\", None)\n+\n+    # Step 4: extract the snapshot into skills/\n+    try:\n+        with tarfile.open(archive, \"r:gz\") as tf:\n+            # Python 3.12+ supports filter='data' for safer extraction.\n+            # Fall back to the unfiltered call for older interpreters but\n+            # still reject absolute paths and .. components defensively.\n+            for member in tf.getmembers():\n+                name = member.name\n+                if name.startswith(\"/\") or \"..\" in Path(name).parts:\n+                    raise tarfile.TarError(\n+                        f\"refusing to extract unsafe path: {name!r}\"\n+                    )\n+            try:\n+                tf.extractall(str(skills), filter=\"data\")  # type: ignore[call-arg]\n+            except TypeError:\n+                # Python < 3.12 — no filter kwarg\n+                tf.extractall(str(skills))\n+    except (OSError, tarfile.TarError) as e:\n+        # Best-effort recover: move staged contents back\n+        for orig, dest in moved:\n+            try:\n+                shutil.move(str(dest), str(orig))\n+            except OSError:\n+                pass\n+        try:\n+            shutil.rmtree(staged, ignore_errors=True)\n+        except OSError:\n+            pass\n+        return (False, f\"snapshot extract failed (state restored): {e}\", None)\n+\n+    # Extract succeeded — the staging dir has served its purpose. The\n+    # user's undo handle is the safety snapshot tarball we took earlier.\n+    try:\n+        shutil.rmtree(staged, ignore_errors=True)\n+    except OSError:\n+        pass\n+\n+    logger.info(\"Curator rollback: restored from %s\", target.name)\n+    return (True, f\"restored from snapshot {target.name}\", target)\n+\n+\n+# ---------------------------------------------------------------------------\n+# Human-readable summary for CLI\n+# ---------------------------------------------------------------------------\n+\n+def format_size(n: int) -> str:\n+    for unit in (\"B\", \"KB\", \"MB\", \"GB\"):\n+        if n < 1024 or unit == \"GB\":\n+            return f\"{n:.1f} {unit}\" if unit != \"B\" else f\"{n} B\"\n+        n /= 1024\n+    return f\"{n:.1f} GB\"\n+\n+\n+def summarize_backups() -> str:\n+    rows = list_backups()\n+    if not rows:\n+        return \"No curator snapshots yet.\"\n+    lines = [f\"{'id':<24}  {'reason':<40}  {'skills':>6}  {'size':>8}\"]\n+    lines.append(\"─\" * len(lines[0]))\n+    for r in rows:\n+        lines.append(\n+            f\"{r.get('id','?'):<24}  \"\n+            f\"{(r.get('reason','?') or '?')[:40]:<40}  \"\n+            f\"{r.get('skill_files', 0):>6}  \"\n+            f\"{format_size(int(r.get('archive_bytes', 0))):>8}\"\n+        )\n+    return \"\\n\".join(lines)"]},{"file":"hermes_cli/config.py","hunks":["@@ -1022,6 +1022,14 @@ def _ensure_hermes_home_managed(home: Path):\n         # Archive a skill (move to skills/.archive/) after this many days\n         # without use. Archived skills are recoverable — no auto-deletion.\n         \"archive_after_days\": 90,\n+        # Pre-run backup: before every real curator pass (dry-run is\n+        # skipped), snapshot ~/.hermes/skills/ into\n+        # ~/.hermes/skills/.curator_backups/<utc-iso>/skills.tar.gz so the\n+        # user can roll back with `hermes curator rollback`.\n+        \"backup\": {\n+            \"enabled\": True,\n+            \"keep\": 5,  # retain last N regular snapshots\n+        },\n     },\n \n     # Honcho AI-native memory -- reads ~/.honcho/config.json as single source of truth."]},{"file":"hermes_cli/curator.py","hunks":["@@ -160,7 +160,11 @@ def _cmd_run(args) -> int:\n         print(\"curator: disabled via config; enable with `curator.enabled: true`\")\n         return 1\n \n-    print(\"curator: running review pass...\")\n+    dry = bool(getattr(args, \"dry_run\", False))\n+    if dry:\n+        print(\"curator: running DRY-RUN (report only, no mutations)...\")\n+    else:\n+        print(\"curator: running review pass...\")\n \n     def _on_summary(msg: str) -> None:\n         print(msg)","@@ -168,17 +172,29 @@ def _on_summary(msg: str) -> None:\n     result = curator.run_curator_review(\n         on_summary=_on_summary,\n         synchronous=bool(args.synchronous),\n+        dry_run=dry,\n     )\n     auto = result.get(\"auto_transitions\", {})\n     if auto:\n-        print(\n-            f\"auto: checked={auto.get('checked', 0)} \"\n-            f\"stale={auto.get('marked_stale', 0)} \"\n-            f\"archived={auto.get('archived', 0)} \"\n-            f\"reactivated={auto.get('reactivated', 0)}\"\n-        )\n+        if dry:\n+            print(\n+                f\"auto (preview): {auto.get('checked', 0)} candidate skill(s) \"\n+                \"— no transitions applied in dry-run\"\n+            )\n+        else:\n+            print(\n+                f\"auto: checked={auto.get('checked', 0)} \"\n+                f\"stale={auto.get('marked_stale', 0)} \"\n+                f\"archived={auto.get('archived', 0)} \"\n+                f\"reactivated={auto.get('reactivated', 0)}\"\n+            )\n     if not args.synchronous:\n         print(\"llm pass running in background — check `hermes curator status` later\")\n+    if dry:\n+        print(\n+            \"dry-run: no changes applied. When the report lands, read it with \"\n+            \"`hermes curator status` and run `hermes curator run` (no flag) to apply.\"\n+        )\n     return 0\n \n ","@@ -229,6 +245,86 @@ def _cmd_restore(args) -> int:\n     return 0 if ok else 1\n \n \n+def _cmd_backup(args) -> int:\n+    \"\"\"Take a manual snapshot of the skills tree. Same mechanism as the\n+    automatic pre-run snapshot, just user-initiated.\"\"\"\n+    from agent import curator_backup\n+    if not curator_backup.is_enabled():\n+        print(\n+            \"curator: backups are disabled via config \"\n+            \"(`curator.backup.enabled: false`); re-enable to snapshot\"\n+        )\n+        return 1\n+    reason = getattr(args, \"reason\", None) or \"manual\"\n+    snap = curator_backup.snapshot_skills(reason=reason)\n+    if snap is None:\n+        print(\"curator: snapshot failed — check logs (backup disabled or IO error)\")\n+        return 1\n+    print(f\"curator: snapshot created at ~/.hermes/skills/.curator_backups/{snap.name}\")\n+    return 0\n+\n+\n+def _cmd_rollback(args) -> int:\n+    \"\"\"Restore the skills tree from a snapshot. Defaults to newest.\n+\n+    ``--list`` prints available snapshots and exits. ``--id <stamp>`` picks\n+    a specific one. Without ``-y``, prompts for confirmation. A safety\n+    snapshot of the current tree is always taken first, so rollbacks are\n+    themselves undoable.\n+    \"\"\"\n+    from agent import curator_backup\n+\n+    if getattr(args, \"list\", False):\n+        print(curator_backup.summarize_backups())\n+        return 0\n+\n+    backup_id = getattr(args, \"backup_id\", None)\n+    target_path = curator_backup._resolve_backup(backup_id)\n+    if target_path is None:\n+        rows = curator_backup.list_backups()\n+        if not rows:\n+            print(\n+                \"curator: no snapshots exist yet. Take one with \"\n+                \"`hermes curator backup` or wait for the next curator run.\"\n+            )\n+        else:\n+            print(\n+                f\"curator: no snapshot matching \"\n+                f\"{'id ' + repr(backup_id) if backup_id else 'your query'}.\"\n+            )\n+            print(\"Available:\")\n+            print(curator_backup.summarize_backups())\n+        return 1\n+\n+    manifest = curator_backup._read_manifest(target_path)\n+    print(f\"Rollback target: {target_path.name}\")\n+    if manifest:\n+        print(f\"  reason:      {manifest.get('reason', '?')}\")\n+        print(f\"  created_at:  {manifest.get('created_at', '?')}\")\n+        print(f\"  skill files: {manifest.get('skill_files', '?')}\")\n+    print(\n+        \"\\nThis will replace the current ~/.hermes/skills/ tree (a safety \"\n+        \"snapshot of the current state is taken first so this is undoable).\"\n+    )\n+\n+    if not getattr(args, \"yes\", False):\n+        try:\n+            ans = input(\"Proceed? [y/N] \").strip().lower()\n+        except (EOFError, KeyboardInterrupt):\n+            print(\"\\ncancelled\")\n+            return 1\n+        if ans not in (\"y\", \"yes\"):\n+            print(\"cancelled\")\n+            return 1\n+\n+    ok, msg, _ = curator_backup.rollback(backup_id=target_path.name)\n+    if ok:\n+        print(f\"curator: {msg}\")\n+        return 0\n+    print(f\"curator: rollback failed — {msg}\")\n+    return 1\n+\n+\n # ---------------------------------------------------------------------------\n # argparse wiring (called from hermes_cli.main)\n # ---------------------------------------------------------------------------","@@ -250,6 +346,11 @@ def register_cli(parent: argparse.ArgumentParser) -> None:\n         \"--sync\", \"--synchronous\", dest=\"synchronous\", action=\"store_true\",\n         help=\"Wait for the LLM review pass to finish (default: background thread)\",\n     )\n+    p_run.add_argument(\n+        \"--dry-run\", dest=\"dry_run\", action=\"store_true\",\n+        help=\"Report only — no state changes, no archives, no consolidation \"\n+             \"(use this to preview what curator would do)\",\n+    )\n     p_run.set_defaults(func=_cmd_run)\n \n     p_pause = subs.add_parser(\"pause\", help=\"Pause the curator until resumed\")","@@ -270,6 +371,36 @@ def register_cli(parent: argparse.ArgumentParser) -> None:\n     p_restore.add_argument(\"skill\", help=\"Skill name\")\n     p_restore.set_defaults(func=_cmd_restore)\n \n+    p_backup = subs.add_parser(\n+        \"backup\",\n+        help=\"Take a manual tar.gz snapshot of ~/.hermes/skills/ \"\n+             \"(curator also does this automatically before every real run)\",\n+    )\n+    p_backup.add_argument(\n+        \"--reason\", default=None,\n+        help=\"Free-text label stored in manifest.json (default: 'manual')\",\n+    )\n+    p_backup.set_defaults(func=_cmd_backup)\n+\n+    p_rollback = subs.add_parser(\n+        \"rollback\",\n+        help=\"Restore ~/.hermes/skills/ from a curator snapshot \"\n+             \"(defaults to the newest)\",\n+    )\n+    p_rollback.add_argument(\n+        \"--list\", action=\"store_true\",\n+        help=\"List available snapshots and exit without restoring\",\n+    )\n+    p_rollback.add_argument(\n+        \"--id\", dest=\"backup_id\", default=None,\n+        help=\"Snapshot id to restore (see `--list`); default: newest\",\n+    )\n+    p_rollback.add_argument(\n+        \"-y\", \"--yes\", action=\"store_true\",\n+        help=\"Skip confirmation prompt\",\n+    )\n+    p_rollback.set_defaults(func=_cmd_rollback)\n+\n \n def cli_main(argv=None) -> int:\n     \"\"\"Standalone entry (also usable by hermes_cli.main fallthrough).\"\"\""]},{"file":"hermes_cli/main.py","hunks":["@@ -5431,6 +5431,45 @@ def _find_stale_dashboard_pids() -> list[int]:\n     return dashboard_pids\n \n \n+def _print_curator_first_run_notice() -> None:\n+    \"\"\"Print a short heads-up about the skill curator after `hermes update`.\n+\n+    Only fires when the curator is enabled AND has no recorded run yet, which\n+    is exactly the window where the gateway ticker used to fire Curator\n+    against a fresh skill library immediately after an update. We defer the\n+    first real pass by one ``interval_hours``; this notice tells the user how\n+    to preview or disable before then. Silent on steady state.\n+    \"\"\"\n+    try:\n+        from agent import curator\n+    except Exception:\n+        return\n+    try:\n+        if not curator.is_enabled():\n+            return\n+        state = curator.load_state()\n+    except Exception:\n+        return\n+    if state.get(\"last_run_at\"):\n+        # Curator has run before (real or already seeded) — no notice needed.\n+        return\n+    try:\n+        hours = curator.get_interval_hours()\n+    except Exception:\n+        hours = 24 * 7\n+    days = max(1, hours // 24)\n+    print()\n+    print(\"ℹ Skill curator\")\n+    print(\n+        f\"  Background skill maintenance is enabled. First pass is deferred \"\n+        f\"~{days}d after installation; only agent-created skills are in \"\n+        f\"scope and nothing is ever auto-deleted (archive is recoverable).\"\n+    )\n+    print(\"  Preview now:  hermes curator run --dry-run\")\n+    print(\"  Pause it:     hermes curator pause\")\n+    print(\"  Docs:         https://hermes-agent.nousresearch.com/docs/user-guide/features/curator\")\n+\n+\n def _kill_stale_dashboard_processes(\n     reason: str = \"the running backend no longer matches the updated frontend\",\n ) -> None:","@@ -5668,6 +5707,10 @@ def _update_via_zip(args):\n \n     print()\n     print(\"✓ Update complete!\")\n+    try:\n+        _print_curator_first_run_notice()\n+    except Exception as e:\n+        logger.debug(\"Curator first-run notice failed: %s\", e)\n     _kill_stale_dashboard_processes()\n \n ","@@ -7107,6 +7150,15 @@ def _cmd_update_impl(args, gateway_mode: bool):\n         print()\n         print(\"✓ Update complete!\")\n \n+        # Curator first-run heads-up. Only prints when curator is enabled AND\n+        # has never run — i.e. the window where the ticker would otherwise\n+        # have fired against a fresh skill library. Kept silent on steady\n+        # state so we don't nag.\n+        try:\n+            _print_curator_first_run_notice()\n+        except Exception as e:\n+            logger.debug(\"Curator first-run notice failed: %s\", e)\n+\n         # Repair RHEL-family root installs where /usr/local/bin isn't on PATH\n         # for non-login interactive shells.  No-op on every other platform.\n         try:"]},{"file":"tests/agent/test_curator.py","hunks":["@@ -86,9 +86,22 @@ def test_curator_config_overrides(curator_env, monkeypatch):\n # should_run_now\n # ---------------------------------------------------------------------------\n \n-def test_first_run_always_eligible(curator_env):\n+def test_first_run_defers(curator_env):\n+    \"\"\"The FIRST observation of the curator (fresh install, no state file)\n+    must NOT trigger an immediate run. The curator is designed to run after\n+    a full ``interval_hours`` of skill activity, not on the first background\n+    tick after installation. Fixes #18373.\n+    \"\"\"\n     c = curator_env[\"curator\"]\n-    assert c.should_run_now() is True\n+    # No state file — should defer and seed last_run_at.\n+    assert c.should_run_now() is False\n+    state = c.load_state()\n+    assert state.get(\"last_run_at\") is not None, (\n+        \"first observation should seed last_run_at so the interval clock \"\n+        \"starts ticking instead of firing immediately next tick\"\n+    )\n+    # A second immediate call still returns False (seeded, not yet stale).\n+    assert c.should_run_now() is False\n \n \n def test_recent_run_blocks(curator_env):","@@ -265,6 +278,77 @@ def test_run_review_records_state(curator_env):\n     assert state[\"last_run_summary\"] is not None\n \n \n+def test_dry_run_does_not_advance_state(curator_env, monkeypatch):\n+    \"\"\"Dry-run previews must not bump last_run_at or run_count. A preview\n+    shouldn't defer the next scheduled real pass or look like a real run in\n+    `hermes curator status`. Fixes #18373.\n+    \"\"\"\n+    c = curator_env[\"curator\"]\n+    skills_dir = curator_env[\"home\"] / \"skills\"\n+    _write_skill(skills_dir, \"a\")\n+\n+    # Stub the LLM so the test doesn't need a provider.\n+    monkeypatch.setattr(\n+        c, \"_run_llm_review\",\n+        lambda prompt: {\n+            \"final\": \"\", \"summary\": \"dry preview\", \"model\": \"\", \"provider\": \"\",\n+            \"tool_calls\": [], \"error\": None,\n+        },\n+    )\n+\n+    c.run_curator_review(synchronous=True, dry_run=True)\n+    state = c.load_state()\n+    assert state.get(\"last_run_at\") is None, \"dry-run must not seed last_run_at\"\n+    assert state.get(\"run_count\", 0) == 0, \"dry-run must not bump run_count\"\n+    assert \"dry-run\" in (state.get(\"last_run_summary\") or \"\"), (\n+        \"dry-run summary should be labeled so status output is unambiguous\"\n+    )\n+\n+\n+def test_dry_run_injects_report_only_banner(curator_env, monkeypatch):\n+    \"\"\"The dry-run prompt must carry a banner instructing the LLM not to\n+    call any mutating tool. This is defense in depth — the caller also\n+    skips automatic transitions — but the LLM prompt is the only guard\n+    against the model calling skill_manage directly.\"\"\"\n+    c = curator_env[\"curator\"]\n+    skills_dir = curator_env[\"home\"] / \"skills\"\n+    _write_skill(skills_dir, \"a\")\n+\n+    captured = {}\n+    def _stub(prompt):\n+        captured[\"prompt\"] = prompt\n+        return {\"final\": \"\", \"summary\": \"s\", \"model\": \"\", \"provider\": \"\",\n+                \"tool_calls\": [], \"error\": None}\n+    monkeypatch.setattr(c, \"_run_llm_review\", _stub)\n+\n+    c.run_curator_review(synchronous=True, dry_run=True)\n+    assert \"DRY-RUN\" in captured[\"prompt\"]\n+    assert \"DO NOT\" in captured[\"prompt\"]\n+\n+\n+def test_dry_run_skips_automatic_transitions(curator_env, monkeypatch):\n+    \"\"\"Dry-run must not call apply_automatic_transitions — the auto pass\n+    archives skills deterministically, and a preview must not touch the\n+    filesystem.\"\"\"\n+    c = curator_env[\"curator\"]\n+    skills_dir = curator_env[\"home\"] / \"skills\"\n+    _write_skill(skills_dir, \"a\")\n+\n+    called = {\"n\": 0}\n+    def _explode(*_a, **_kw):\n+        called[\"n\"] += 1\n+        return {\"checked\": 0, \"marked_stale\": 0, \"archived\": 0, \"reactivated\": 0}\n+    monkeypatch.setattr(c, \"apply_automatic_transitions\", _explode)\n+    monkeypatch.setattr(\n+        c, \"_run_llm_review\",\n+        lambda p: {\"final\": \"\", \"summary\": \"s\", \"model\": \"\", \"provider\": \"\",\n+                   \"tool_calls\": [], \"error\": None},\n+    )\n+\n+    c.run_curator_review(synchronous=True, dry_run=True)\n+    assert called[\"n\"] == 0, \"dry-run must skip apply_automatic_transitions\"\n+\n+\n def test_run_review_synchronous_invokes_llm_stub(curator_env, monkeypatch):\n     c = curator_env[\"curator\"]\n     skills_dir = curator_env[\"home\"] / \"skills\"","@@ -327,12 +411,32 @@ def test_maybe_run_curator_runs_when_eligible(curator_env, monkeypatch):\n     c = curator_env[\"curator\"]\n     skills_dir = curator_env[\"home\"] / \"skills\"\n     _write_skill(skills_dir, \"a\")\n+    # Seed last_run_at far in the past so the interval gate opens — the\n+    # \"no state\" path intentionally defers the first run now (#18373).\n+    long_ago = datetime.now(timezone.utc) - timedelta(hours=c.get_interval_hours() * 2)\n+    c.save_state({\"last_run_at\": long_ago.isoformat(), \"paused\": False})\n     # Force idle over threshold\n     result = c.maybe_run_curator(idle_for_seconds=99999.0)\n     assert result is not None\n     assert \"started_at\" in result\n \n \n+def test_maybe_run_curator_defers_on_fresh_install(curator_env):\n+    \"\"\"Fresh install (no curator state file) must NOT fire the curator on\n+    the first gateway tick. The first observation seeds last_run_at and\n+    returns None. Fixes #18373.\"\"\"\n+    c = curator_env[\"curator\"]\n+    skills_dir = curator_env[\"home\"] / \"skills\"\n+    _write_skill(skills_dir, \"a\")\n+    # Infinite idle — the only thing that should block the run is the new\n+    # deferred-first-run gate.\n+    result = c.maybe_run_curator(idle_for_seconds=99999.0)\n+    assert result is None\n+    # And the next tick still defers (we seeded last_run_at to \"now\").\n+    result2 = c.maybe_run_curator(idle_for_seconds=99999.0)\n+    assert result2 is None\n+\n+\n def test_maybe_run_curator_swallows_exceptions(curator_env, monkeypatch):\n     c = curator_env[\"curator\"]\n "]},{"file":"tests/agent/test_curator_backup.py","hunks":["@@ -0,0 +1,316 @@\n+\"\"\"Tests for agent/curator_backup.py — snapshot + rollback of the skills tree.\"\"\"\n+\n+from __future__ import annotations\n+\n+import importlib\n+import json\n+import os\n+import sys\n+import tarfile\n+import tempfile\n+from pathlib import Path\n+\n+import pytest\n+\n+\n[redacted:email]\n+def backup_env(monkeypatch, tmp_path):\n+    \"\"\"Isolate HERMES_HOME + reload modules so every test starts clean.\"\"\"\n+    home = tmp_path / \".hermes\"\n+    home.mkdir()\n+    (home / \"skills\").mkdir()\n+    monkeypatch.setenv(\"HERMES_HOME\", str(home))\n+    monkeypatch.setattr(Path, \"home\", lambda: tmp_path)\n+\n+    # Reload so get_hermes_home picks up the env var fresh.\n+    import hermes_constants\n+    importlib.reload(hermes_constants)\n+    from agent import curator_backup\n+    importlib.reload(curator_backup)\n+    return {\"home\": home, \"skills\": home / \"skills\", \"cb\": curator_backup}\n+\n+\n+def _write_skill(skills_dir: Path, name: str, body: str = \"body\") -> Path:\n+    d = skills_dir / name\n+    d.mkdir(parents=True, exist_ok=True)\n+    (d / \"SKILL.md\").write_text(\n+        f\"---\\nname: {name}\\ndescription: t\\nversion: 1.0\\n---\\n\\n{body}\\n\",\n+        encoding=\"utf-8\",\n+    )\n+    return d\n+\n+\n+# ---------------------------------------------------------------------------\n+# snapshot_skills\n+# ---------------------------------------------------------------------------\n+\n+def test_snapshot_creates_tarball_and_manifest(backup_env):\n+    cb = backup_env[\"cb\"]\n+    _write_skill(backup_env[\"skills\"], \"alpha\")\n+    _write_skill(backup_env[\"skills\"], \"beta\")\n+\n+    snap = cb.snapshot_skills(reason=\"test\")\n+    assert snap is not None, \"snapshot should succeed with a populated skills dir\"\n+    assert (snap / \"skills.tar.gz\").exists()\n+    manifest = json.loads((snap / \"manifest.json\").read_text())\n+    assert manifest[\"reason\"] == \"test\"\n+    assert manifest[\"skill_files\"] == 2\n+    assert manifest[\"archive_bytes\"] > 0\n+\n+\n+def test_snapshot_excludes_backups_dir_itself(backup_env):\n+    \"\"\"The backup must NOT contain .curator_backups/ — that would recurse\n+    with every subsequent snapshot and balloon disk usage.\"\"\"\n+    cb = backup_env[\"cb\"]\n+    _write_skill(backup_env[\"skills\"], \"alpha\")\n+    snap1 = cb.snapshot_skills(reason=\"first\")\n+    assert snap1 is not None\n+    snap2 = cb.snapshot_skills(reason=\"second\")\n+    assert snap2 is not None\n+    with tarfile.open(snap2 / \"skills.tar.gz\") as tf:\n+        names = tf.getnames()\n+    assert not any(n.startswith(\".curator_backups\") for n in names), (\n+        \"second snapshot must not contain the first snapshot recursively\"\n+    )\n+\n+\n+def test_snapshot_excludes_hub_dir(backup_env):\n+    \"\"\".hub/ is managed by the skills hub. Rolling it back would break\n+    lockfile invariants, so the snapshot omits it entirely.\"\"\"\n+    cb = backup_env[\"cb\"]\n+    hub = backup_env[\"skills\"] / \".hub\"\n+    hub.mkdir()\n+    (hub / \"lock.json\").write_text(\"{}\")\n+    _write_skill(backup_env[\"skills\"], \"alpha\")\n+    snap = cb.snapshot_skills(reason=\"t\")\n+    assert snap is not None\n+    with tarfile.open(snap / \"skills.tar.gz\") as tf:\n+        names = tf.getnames()\n+    assert not any(n.startswith(\".hub\") for n in names)\n+\n+\n+def test_snapshot_disabled_returns_none(backup_env, monkeypatch):\n+    cb = backup_env[\"cb\"]\n+    monkeypatch.setattr(cb, \"is_enabled\", lambda: False)\n+    _write_skill(backup_env[\"skills\"], \"alpha\")\n+    assert cb.snapshot_skills() is None\n+    # And no backup dir should have been created\n+    assert not (backup_env[\"skills\"] / \".curator_backups\").exists()\n+\n+\n+def test_snapshot_uniquifies_when_same_second(backup_env, monkeypatch):\n+    \"\"\"Two snapshots in the same wallclock second must not clobber each\n+    other. The module appends a counter to the second snapshot's id.\"\"\"\n+    cb = backup_env[\"cb\"]\n+    _write_skill(backup_env[\"skills\"], \"alpha\")\n+    frozen = \"2026-05-01T12-00-00Z\"\n+    monkeypatch.setattr(cb, \"_utc_id\", lambda now=None: frozen)\n+    s1 = cb.snapshot_skills(reason=\"a\")\n+    s2 = cb.snapshot_skills(reason=\"b\")\n+    assert s1 is not None and s2 is not None\n+    assert s1.name == frozen\n+    assert s2.name == f\"{frozen}-01\"\n+\n+\n+def test_snapshot_prunes_to_keep_count(backup_env, monkeypatch):\n+    cb = backup_env[\"cb\"]\n+    _write_skill(backup_env[\"skills\"], \"alpha\")\n+    monkeypatch.setattr(cb, \"get_keep\", lambda: 3)\n+\n+    # Create 5 snapshots with monotonically increasing fake ids\n+    ids = [f\"2026-05-0{i}T00-00-00Z\" for i in range(1, 6)]\n+    for i, fid in enumerate(ids):\n+        monkeypatch.setattr(cb, \"_utc_id\", lambda now=None, _f=fid: _f)\n+        cb.snapshot_skills(reason=f\"n{i}\")\n+\n+    remaining = sorted(p.name for p in (backup_env[\"skills\"] / \".curator_backups\").iterdir())\n+    # Newest 3 kept (lex order == date order for this id format)\n+    assert remaining == ids[2:], f\"expected newest 3, got {remaining}\"\n+\n+\n+# ---------------------------------------------------------------------------\n+# list_backups / _resolve_backup\n+# ---------------------------------------------------------------------------\n+\n+def test_list_backups_empty(backup_env):\n+    cb = backup_env[\"cb\"]\n+    assert cb.list_backups() == []\n+\n+\n+def test_list_backups_returns_manifest_data(backup_env):\n+    cb = backup_env[\"cb\"]\n+    _write_skill(backup_env[\"skills\"], \"alpha\")\n+    cb.snapshot_skills(reason=\"m1\")\n+    rows = cb.list_backups()\n+    assert len(rows) == 1\n+    assert rows[0][\"reason\"] == \"m1\"\n+    assert rows[0][\"skill_files\"] == 1\n+\n+\n+def test_resolve_backup_newest_when_no_id(backup_env, monkeypatch):\n+    cb = backup_env[\"cb\"]\n+    _write_skill(backup_env[\"skills\"], \"alpha\")\n+    ids = [\"2026-05-01T00-00-00Z\", \"2026-05-02T00-00-00Z\"]\n+    for fid in ids:\n+        monkeypatch.setattr(cb, \"_utc_id\", lambda now=None, _f=fid: _f)\n+        cb.snapshot_skills()\n+    resolved = cb._resolve_backup(None)\n+    assert resolved is not None\n+    assert resolved.name == \"2026-05-02T00-00-00Z\", (\n+        \"resolve(None) must return newest regular snapshot\"\n+    )\n+\n+\n+def test_resolve_backup_unknown_id_returns_none(backup_env):\n+    cb = backup_env[\"cb\"]\n+    _write_skill(backup_env[\"skills\"], \"alpha\")\n+    cb.snapshot_skills()\n+    assert cb._resolve_backup(\"not-an-id\") is None\n+\n+\n+# ---------------------------------------------------------------------------\n+# rollback\n+# ---------------------------------------------------------------------------\n+\n+def test_rollback_restores_deleted_skill(backup_env):\n+    \"\"\"The whole point of this feature: user loses a skill, rollback\n+    brings it back.\"\"\"\n+    cb = backup_env[\"cb\"]\n+    skills = backup_env[\"skills\"]\n+    user_skill = _write_skill(skills, \"my-personal-workflow\", body=\"important content\")\n+    cb.snapshot_skills(reason=\"pre-simulated-curator\")\n+\n+    # Simulate curator archiving it out of existence\n+    import shutil as _sh\n+    _sh.rmtree(user_skill)\n+    assert not user_skill.exists()\n+\n+    ok, msg, _ = cb.rollback()\n+    assert ok, f\"rollback failed: {msg}\"\n+    assert user_skill.exists(), \"my-personal-workflow should be restored\"\n+    assert \"important content\" in (user_skill / \"SKILL.md\").read_text()\n+\n+\n+def test_rollback_is_itself_undoable(backup_env):\n+    \"\"\"A rollback creates its own safety snapshot before replacing the\n+    tree, so the user can undo a mistaken rollback. The safety snapshot\n+    is a real tarball with reason='pre-rollback to <id>' — it's\n+    listed by list_backups() just like any other snapshot and can be\n+    restored the same way.\"\"\"\n+    cb = backup_env[\"cb\"]\n+    skills = backup_env[\"skills\"]\n+    _write_skill(skills, \"v1\")\n+    cb.snapshot_skills(reason=\"snapshot-of-v1\")\n+\n+    # Overwrite with a new skill state\n+    import shutil as _sh\n+    _sh.rmtree(skills / \"v1\")\n+    _write_skill(skills, \"v2\")\n+\n+    ok, _, _ = cb.rollback()\n+    assert ok\n+    assert (skills / \"v1\").exists()\n+\n+    # list_backups should show a safety snapshot tagged \"pre-rollback to <target-id>\"\n+    rows = cb.list_backups()\n+    pre_rollback_entries = [r for r in rows if \"pre-rollback\" in (r.get(\"reason\") or \"\")]\n+    assert len(pre_rollback_entries) >= 1, (\n+        f\"expected a pre-rollback safety snapshot in list_backups(), got: \"\n+        f\"{[(r.get('id'), r.get('reason')) for r in rows]}\"\n+    )\n+    # And the transient staging dir must be gone (it's implementation detail)\n+    backups_dir = skills / \".curator_backups\"\n+    staging_dirs = [p for p in backups_dir.iterdir() if p.name.startswith(\".rollback-staging-\")]\n+    assert staging_dirs == [], (\n+        f\"staging dir should be cleaned up on success, got: {staging_dirs}\"\n+    )\n+\n+\n+def test_rollback_no_snapshots_returns_error(backup_env):\n+    cb = backup_env[\"cb\"]\n+    ok, msg, _ = cb.rollback()\n+    assert not ok\n+    assert \"no matching backup\" in msg.lower() or \"no snapshot\" in msg.lower()\n+\n+\n+def test_rollback_rejects_unsafe_tarball(backup_env, monkeypatch):\n+    \"\"\"Tarballs with absolute paths or .. components must be refused even\n+    if someone crafts a malicious snapshot. Defense in depth — normal\n+    curator snapshots never produce these.\"\"\"\n+    cb = backup_env[\"cb\"]\n+    skills = backup_env[\"skills\"]\n+    _write_skill(skills, \"alpha\")\n+    cb.snapshot_skills(reason=\"legit\")\n+\n+    # Hand-craft a malicious tarball replacing the legit one\n+    rows = cb.list_backups()\n+    snap_dir = Path(rows[0][\"path\"])\n+    mal = snap_dir / \"skills.tar.gz\"\n+    mal.unlink()\n+    with tarfile.open(mal, \"w:gz\") as tf:\n+        evil = tempfile.NamedTemporaryFile(delete=False, suffix=\".md\")\n+        evil.write(b\"evil\")\n+        evil.close()\n+        tf.add(evil.name, arcname=\"../../etc/evil.md\")\n+        os.unlink(evil.name)\n+\n+    ok, msg, _ = cb.rollback()\n+    assert not ok\n+    assert \"unsafe\" in msg.lower() or \"refus\" in msg.lower() or \"extract\" in msg.lower()\n+\n+\n+# ---------------------------------------------------------------------------\n+# Integration with run_curator_review\n+# ---------------------------------------------------------------------------\n+\n+def test_real_run_takes_pre_snapshot(backup_env, monkeypatch):\n+    \"\"\"A real (non-dry) curator pass must snapshot the tree before calling\n+    apply_automatic_transitions. This is the safety net #18373 asked for.\"\"\"\n+    cb = backup_env[\"cb\"]\n+    skills = backup_env[\"skills\"]\n+    _write_skill(skills, \"alpha\")\n+\n+    # Reload curator module against the freshly-env'd hermes_constants\n+    from agent import curator\n+    importlib.reload(curator)\n+\n+    # Stub out LLM review and auto transitions — we only care about the\n+    # snapshot side-effect.\n+    monkeypatch.setattr(\n+        curator, \"_run_llm_review\",\n+        lambda p: {\"final\": \"\", \"summary\": \"s\", \"model\": \"\", \"provider\": \"\",\n+                   \"tool_calls\": [], \"error\": None},\n+    )\n+    monkeypatch.setattr(\n+        curator, \"apply_automatic_transitions\",\n+        lambda now=None: {\"checked\": 1, \"marked_stale\": 0, \"archived\": 0, \"reactivated\": 0},\n+    )\n+\n+    curator.run_curator_review(synchronous=True)\n+    # Pre-run snapshot should exist\n+    rows = cb.list_backups()\n+    assert any(r.get(\"reason\") == \"pre-curator-run\" for r in rows), (\n+        f\"expected a pre-curator-run snapshot, got {[r.get('reason') for r in rows]}\"\n+    )\n+\n+\n+def test_dry_run_skips_snapshot(backup_env, monkeypatch):\n+    \"\"\"Dry-run previews must not spend disk on a snapshot — they don't\n+    mutate anything, so there's nothing to back up.\"\"\"\n+    cb = backup_env[\"cb\"]\n+    skills = backup_env[\"skills\"]\n+    _write_skill(skills, \"alpha\")\n+\n+    from agent import curator\n+    importlib.reload(curator)\n+    monkeypatch.setattr(\n+        curator, \"_run_llm_review\",\n+        lambda p: {\"final\": \"\", \"summary\": \"s\", \"model\": \"\", \"provider\": \"\",\n+                   \"tool_calls\": [], \"error\": None},\n+    )\n+\n+    curator.run_curator_review(synchronous=True, dry_run=True)\n+    rows = cb.list_backups()\n+    assert not any(r.get(\"reason\") == \"pre-curator-run\" for r in rows), (\n+        \"dry-run must not create a pre-run snapshot\"\n+    )"]},{"file":"website/docs/reference/cli-commands.md","hunks":["@@ -718,12 +718,21 @@ The curator is an auxiliary-model background task that periodically reviews agen\n |------------|-------------|\n | `status` | Show curator status and skill stats |\n | `run` | Trigger a curator review now |\n+| `run --sync` | Block until the LLM pass finishes |\n+| `run --dry-run` | Preview only — produce the review report with no mutations |\n+| `backup` | Take a manual tar.gz snapshot of `~/.hermes/skills/` (curator also snapshots automatically before every real run) |\n+| `rollback` | Restore `~/.hermes/skills/` from a snapshot (defaults to newest) |\n+| `rollback --list` | List available snapshots |\n+| `rollback --id <ts>` | Restore a specific snapshot by id |\n+| `rollback -y` | Skip the confirmation prompt |\n | `pause` | Pause the curator until resumed |\n | `resume` | Resume a paused curator |\n | `pin <skill>` | Pin a skill so the curator never auto-transitions it |\n | `unpin <skill>` | Unpin a skill |\n | `restore <skill>` | Restore an archived skill |\n \n+On a fresh install the first scheduled pass is deferred by one full `interval_hours` (7 days by default) — the gateway will not curate immediately on the first tick after `hermes update`. Use `hermes curator run --dry-run` to preview before that happens.\n+\n See [Curator](../user-guide/features/curator.md) for behavior and config.\n \n ## `hermes fallback`"]},{"file":"website/docs/user-guide/features/curator.md","hunks":["@@ -23,6 +23,12 @@ The curator is triggered by an inactivity check, not a cron daemon. On CLI sessi\n \n If both are true, it spawns a background fork of `AIAgent` — the same pattern used by the memory/skill self-improvement nudges. The fork runs in its own prompt cache and never touches the active conversation.\n \n+:::info First-run behavior\n+On a brand-new install (or the first time a pre-curator install ticks after `hermes update`), the curator **does not run immediately**. The first observation seeds `last_run_at` to \"now\" and defers the first real pass by one full `interval_hours`. This gives you a full interval to review your skill library, pin anything important, or opt out entirely before the curator ever touches it.\n+\n+If you want to see what the curator *would* do before it runs for real, run `hermes curator run --dry-run` — it produces the same review report without mutating the library.\n+:::\n+\n A run has two phases:\n \n 1. **Automatic transitions** (deterministic, no LLM). Skills unused for `stale_after_days` (30) become `stale`; skills unused for `archive_after_days` (90) are moved to `~/.hermes/skills/.archive/`.","@@ -80,6 +86,12 @@ Earlier releases used a one-off `curator.auxiliary.{provider,model}` block. That\n hermes curator status         # last run, counts, pinned list, LRU top 5\n hermes curator run            # trigger a review now (background by default)\n hermes curator run --sync     # same, but block until the LLM pass finishes\n+hermes curator run --dry-run  # preview only — report without any mutations\n+hermes curator backup         # take a manual snapshot of ~/.hermes/skills/\n+hermes curator rollback       # restore from the newest snapshot\n+hermes curator rollback --list     # list available snapshots\n+hermes curator rollback --id <ts>  # restore a specific snapshot\n+hermes curator rollback -y         # skip the confirmation prompt\n hermes curator pause          # stop runs until resumed\n hermes curator resume\n hermes curator pin <skill>    # never auto-transition this skill","@@ -87,6 +99,31 @@ hermes curator unpin <skill>\n hermes curator restore <skill>  # move an archived skill back to active\n ```\n \n+## Backups and rollback\n+\n+Before every real curator pass, Hermes takes a tar.gz snapshot of `~/.hermes/skills/` at `~/.hermes/skills/.curator_backups/<utc-iso>/skills.tar.gz`. If a pass archives or consolidates something you didn't want touched, you can undo the whole run with one command:\n+\n+```bash\n+hermes curator rollback        # restore newest snapshot (with confirmation)\n+hermes curator rollback -y     # skip the prompt\n+hermes curator rollback --list # see all snapshots with reason + size\n+```\n+\n+The rollback itself is reversible: before replacing the skills tree, Hermes takes another snapshot tagged `pre-rollback to <target-id>`, so a mistaken rollback can be undone by rolling forward to that one with `--id`.\n+\n+You can also take manual snapshots at any time with `hermes curator backup --reason \"before-refactor\"`. The `--reason` string lands in the snapshot's `manifest.json` and is shown in `--list`.\n+\n+Snapshots are pruned to `curator.backup.keep` (default 5) to keep disk usage bounded:\n+\n+```yaml\n+curator:\n+  backup:\n+    enabled: true\n+    keep: 5\n+```\n+\n+Set `curator.backup.enabled: false` to disable automatic snapshotting. The manual `hermes curator backup` command still works when backups are disabled only if you set `enabled: true` first — the flag gates both paths symmetrically so there's no way to accidentally skip the pre-run snapshot on mutating runs.\n+\n `hermes curator status` also lists the five least-recently-used skills — a quick way to see what's likely to become stale next.\n \n The same subcommands are available as the `/curator` slash command inside a running session (CLI or gateway platforms).","@@ -104,6 +141,18 @@ Everything else in `~/.hermes/skills/` is fair game for the curator. This includ\n - Skills you created manually with a hand-written `SKILL.md`.\n - Skills added via external skill directories you've pointed Hermes at.\n \n+:::warning Your hand-written skills look the same as agent-saved ones\n+Provenance here is **binary** (bundled/hub vs. everything else). The curator cannot tell a hand-authored skill you rely on for private workflows apart from a skill the self-improvement loop saved mid-session. Both land in the \"agent-created\" bucket.\n+\n+Before the first real pass (7 days after installation by default), take a moment to:\n+\n+1. Run `hermes curator run --dry-run` to see exactly what the curator would propose.\n+2. Use `hermes curator pin <name>` to fence off anything you don't want touched.\n+3. Or set `curator.enabled: false` in `config.yaml` if you'd rather manage the library yourself.\n+\n+Archives are always recoverable via `hermes curator restore <name>`, but it's easier to pin up-front than to chase down a consolidation after the fact.\n+:::\n+\n If you want to protect a specific skill from ever being touched — for example a hand-authored skill you rely on — use `hermes curator pin <name>`. See the next section.\n \n ## Pinning a skill\n"]}]},{"kind":"pr","number":30041,"title":"fix(curator): add post-archive safety guard to prevent unverified skill archival","url":null,"state":"closed","close_reason":{"category":"superseded_by_pr","implication":"superseded"},"merged":false},{"kind":"pr","number":30064,"title":"fix(curator): skip archival of skills referenced by active cron jobs (#29912)","url":null,"state":"closed","close_reason":{"category":"superseded_by_pr","implication":"superseded"},"merged":false},{"kind":"pr","number":30076,"title":"fix(curator): skip archival of skills referenced by active cron jobs (#29912)","url":null,"state":"closed","close_reason":{"category":"superseded_by_pr","implication":"superseded"},"merged":false},{"kind":"pr","number":30108,"title":"fix(curator): skip archival of skills referenced by active cron jobs (#29912)","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#53935","number":53935},{"kind":"pr","value":"#29912","number":29912},{"kind":"pr","value":"#53935","number":53935}],"category":"superseded_by_pr","implication":"superseded"},"merged":false},{"kind":"pr","number":30948,"title":"fix(curator): add post-archive safety guard to prevent unverified skill archival","url":null,"state":"closed","close_reason":{"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":31437,"title":"fix(curator): add post-archive safety guard to prevent unverified skill archival","url":null,"state":"closed","close_reason":{"category":"superseded_by_pr","implication":"superseded"},"merged":false},{"kind":"pr","number":31572,"title":"fix(curator): add post-archive safety guard to prevent unverified ski…","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#31437","number":31437},{"kind":"pr","value":"#30108","number":30108},{"kind":"pr","value":"#29912","number":29912}],"category":"duplicate_of","implication":"duplicate"},"merged":false},{"kind":"pr","number":33988,"title":"fix(curator): add post-archive safety guard to prevent unverified skill archival","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#30041","number":30041},{"kind":"pr","value":"#30948","number":30948},{"kind":"pr","value":"#31437","number":31437},{"kind":"pr","value":"#31572","number":31572},{"kind":"pr","value":"#30108","number":30108},{"kind":"pr","value":"#29912","number":29912}],"category":"duplicate_of","implication":"duplicate"},"merged":false},{"kind":"pr","number":53935,"title":"fix(curator): fail closed on unverified skill deletes during consolidation","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["tests/tools/test_skill_manager_tool.py","tools/skill_manager_tool.py"]}],"trust":{"default_tier":"verified","includes_suggested":false}}