* fix(curator): defer first run and add --dry-run preview (#18373) Curator was meant to run 7 days after install, not on the very first gateway tick. On a fresh install (no .curator_state), should_run_now() returned True immediately because last_run_at was None — so the gateway cron ticker fired Curator against a fresh skill library moments after 'hermes update'. Combined with the binary 'agent-created' provenance model (anything not bundled and not hub-installed), this consolidated hand-authored user workflow skills without consent. Changes: - should_run_now(): first observation seeds last_run_at='now' and returns False. The next real pass fires one full interval_hours later (7 days by default), matching the original design intent. - hermes curator run --dry-run: produces the same review report without applying automatic transitions OR permitting the LLM to call skill_manage / terminal mv. A DRY-RUN banner is prepended to the prompt and the caller skips apply_automatic_transitions. State is NOT advanced so a preview doesn't defer the next scheduled real pass. - hermes update: prints a one-liner on fresh installs pointing at --dry-run, pause, and the docs. Silent on steady state. - Docs: curator.md and cli-commands.md explain the deferred first-run behavior and warn that hand-written SKILL.md files share the 'agent-created' bucket, with guidance to pin or preview before the first pass. Tests: - test_first_run_defers replaces the old 'first run always eligible' assertion — same fixture, inverted expectation. - test_maybe_run_curator_defers_on_fresh_install covers the gateway tick path end-to-end. - Three new dry-run tests cover state-advance suppression, prompt banner injection, and apply_automatic_transitions skipping. Fixes #18373. * feat(curator): pre-run backup + rollback (#18373) Every real curator pass now snapshots ~/.hermes/skills/ into ~/.hermes/skills/.curator_backups/<utc-iso>/skills.tar.gz before calling apply_automatic_transitions or the LLM review. If a run consolidates or archives something the user didn't want touched, 'hermes curator rollback' restores the tree in one command. Dry-run is skipped — no mutation means no snapshot needed. Changes: - agent/curator_backup.py (new): tar.gz snapshot + safe rollback. The snapshot excludes .curator_backups/ (would recurse) and .hub/ (managed by the skills hub). Extract refuses absolute paths and .. components, and uses tarfile's filter='data' on Python 3.12+. Rollback takes a pre-rollback safety snapshot FIRST, stages the current tree into .rollback-staging-<ts>/ so the extract lands in an empty dir, and cleans the staging dir on success. A failed extract restores the staged contents. - agent/curator.py: run_curator_review() calls curator_backup. snapshot_skills(reason='pre-curator-run') before apply_automatic_ transitions. Best-effort — a failed snapshot logs at debug and the run continues (a transient disk issue shouldn't silently disable curator forever). - hermes_cli/curator.py: new 'hermes curator backup' and 'hermes curator rollback' subcommands. rollback supports --list, --id <ts>, -y. - hermes_cli/config.py: curator.backup.{enabled, keep} config block with sane defaults (enabled=true, keep=5). - Docs: curator.md gets a 'Backups and rollback' section; cli-commands .md table gets the new rows. Tests (new file tests/agent/test_curator_backup.py, 16 cases): - snapshot creates tarball + manifest with correct counts - snapshot excludes .curator_backups/ (recursion guard) and .hub/ - snapshot disabled via config returns None without creating anything - snapshot uniquifies ids within the same second (-01 suffix) - prune honors keep count, newest-first - list_backups + _resolve_backup cover newest-default and unknown-id - rollback restores a deleted skill with content intact - rollback is itself undoable — safety snapshot shows up in list_backups - rollback with no snapshots returns an error - rollback refuses tarballs with absolute paths or .. components - real curator runs take a 'pre-curator-run' snapshot; dry-runs do not All curator tests: 210 passing locally.
This commit is contained in:
@@ -1022,6 +1022,14 @@ DEFAULT_CONFIG = {
|
||||
# Archive a skill (move to skills/.archive/) after this many days
|
||||
# without use. Archived skills are recoverable — no auto-deletion.
|
||||
"archive_after_days": 90,
|
||||
# Pre-run backup: before every real curator pass (dry-run is
|
||||
# skipped), snapshot ~/.hermes/skills/ into
|
||||
# ~/.hermes/skills/.curator_backups/<utc-iso>/skills.tar.gz so the
|
||||
# user can roll back with `hermes curator rollback`.
|
||||
"backup": {
|
||||
"enabled": True,
|
||||
"keep": 5, # retain last N regular snapshots
|
||||
},
|
||||
},
|
||||
|
||||
# Honcho AI-native memory -- reads ~/.honcho/config.json as single source of truth.
|
||||
|
||||
@@ -160,7 +160,11 @@ def _cmd_run(args) -> int:
|
||||
print("curator: disabled via config; enable with `curator.enabled: true`")
|
||||
return 1
|
||||
|
||||
print("curator: running review pass...")
|
||||
dry = bool(getattr(args, "dry_run", False))
|
||||
if dry:
|
||||
print("curator: running DRY-RUN (report only, no mutations)...")
|
||||
else:
|
||||
print("curator: running review pass...")
|
||||
|
||||
def _on_summary(msg: str) -> None:
|
||||
print(msg)
|
||||
@@ -168,17 +172,29 @@ def _cmd_run(args) -> int:
|
||||
result = curator.run_curator_review(
|
||||
on_summary=_on_summary,
|
||||
synchronous=bool(args.synchronous),
|
||||
dry_run=dry,
|
||||
)
|
||||
auto = result.get("auto_transitions", {})
|
||||
if auto:
|
||||
print(
|
||||
f"auto: checked={auto.get('checked', 0)} "
|
||||
f"stale={auto.get('marked_stale', 0)} "
|
||||
f"archived={auto.get('archived', 0)} "
|
||||
f"reactivated={auto.get('reactivated', 0)}"
|
||||
)
|
||||
if dry:
|
||||
print(
|
||||
f"auto (preview): {auto.get('checked', 0)} candidate skill(s) "
|
||||
"— no transitions applied in dry-run"
|
||||
)
|
||||
else:
|
||||
print(
|
||||
f"auto: checked={auto.get('checked', 0)} "
|
||||
f"stale={auto.get('marked_stale', 0)} "
|
||||
f"archived={auto.get('archived', 0)} "
|
||||
f"reactivated={auto.get('reactivated', 0)}"
|
||||
)
|
||||
if not args.synchronous:
|
||||
print("llm pass running in background — check `hermes curator status` later")
|
||||
if dry:
|
||||
print(
|
||||
"dry-run: no changes applied. When the report lands, read it with "
|
||||
"`hermes curator status` and run `hermes curator run` (no flag) to apply."
|
||||
)
|
||||
return 0
|
||||
|
||||
|
||||
@@ -229,6 +245,86 @@ def _cmd_restore(args) -> int:
|
||||
return 0 if ok else 1
|
||||
|
||||
|
||||
def _cmd_backup(args) -> int:
|
||||
"""Take a manual snapshot of the skills tree. Same mechanism as the
|
||||
automatic pre-run snapshot, just user-initiated."""
|
||||
from agent import curator_backup
|
||||
if not curator_backup.is_enabled():
|
||||
print(
|
||||
"curator: backups are disabled via config "
|
||||
"(`curator.backup.enabled: false`); re-enable to snapshot"
|
||||
)
|
||||
return 1
|
||||
reason = getattr(args, "reason", None) or "manual"
|
||||
snap = curator_backup.snapshot_skills(reason=reason)
|
||||
if snap is None:
|
||||
print("curator: snapshot failed — check logs (backup disabled or IO error)")
|
||||
return 1
|
||||
print(f"curator: snapshot created at ~/.hermes/skills/.curator_backups/{snap.name}")
|
||||
return 0
|
||||
|
||||
|
||||
def _cmd_rollback(args) -> int:
|
||||
"""Restore the skills tree from a snapshot. Defaults to newest.
|
||||
|
||||
``--list`` prints available snapshots and exits. ``--id <stamp>`` picks
|
||||
a specific one. Without ``-y``, prompts for confirmation. A safety
|
||||
snapshot of the current tree is always taken first, so rollbacks are
|
||||
themselves undoable.
|
||||
"""
|
||||
from agent import curator_backup
|
||||
|
||||
if getattr(args, "list", False):
|
||||
print(curator_backup.summarize_backups())
|
||||
return 0
|
||||
|
||||
backup_id = getattr(args, "backup_id", None)
|
||||
target_path = curator_backup._resolve_backup(backup_id)
|
||||
if target_path is None:
|
||||
rows = curator_backup.list_backups()
|
||||
if not rows:
|
||||
print(
|
||||
"curator: no snapshots exist yet. Take one with "
|
||||
"`hermes curator backup` or wait for the next curator run."
|
||||
)
|
||||
else:
|
||||
print(
|
||||
f"curator: no snapshot matching "
|
||||
f"{'id ' + repr(backup_id) if backup_id else 'your query'}."
|
||||
)
|
||||
print("Available:")
|
||||
print(curator_backup.summarize_backups())
|
||||
return 1
|
||||
|
||||
manifest = curator_backup._read_manifest(target_path)
|
||||
print(f"Rollback target: {target_path.name}")
|
||||
if manifest:
|
||||
print(f" reason: {manifest.get('reason', '?')}")
|
||||
print(f" created_at: {manifest.get('created_at', '?')}")
|
||||
print(f" skill files: {manifest.get('skill_files', '?')}")
|
||||
print(
|
||||
"\nThis will replace the current ~/.hermes/skills/ tree (a safety "
|
||||
"snapshot of the current state is taken first so this is undoable)."
|
||||
)
|
||||
|
||||
if not getattr(args, "yes", False):
|
||||
try:
|
||||
ans = input("Proceed? [y/N] ").strip().lower()
|
||||
except (EOFError, KeyboardInterrupt):
|
||||
print("\ncancelled")
|
||||
return 1
|
||||
if ans not in ("y", "yes"):
|
||||
print("cancelled")
|
||||
return 1
|
||||
|
||||
ok, msg, _ = curator_backup.rollback(backup_id=target_path.name)
|
||||
if ok:
|
||||
print(f"curator: {msg}")
|
||||
return 0
|
||||
print(f"curator: rollback failed — {msg}")
|
||||
return 1
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# argparse wiring (called from hermes_cli.main)
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -250,6 +346,11 @@ def register_cli(parent: argparse.ArgumentParser) -> None:
|
||||
"--sync", "--synchronous", dest="synchronous", action="store_true",
|
||||
help="Wait for the LLM review pass to finish (default: background thread)",
|
||||
)
|
||||
p_run.add_argument(
|
||||
"--dry-run", dest="dry_run", action="store_true",
|
||||
help="Report only — no state changes, no archives, no consolidation "
|
||||
"(use this to preview what curator would do)",
|
||||
)
|
||||
p_run.set_defaults(func=_cmd_run)
|
||||
|
||||
p_pause = subs.add_parser("pause", help="Pause the curator until resumed")
|
||||
@@ -270,6 +371,36 @@ def register_cli(parent: argparse.ArgumentParser) -> None:
|
||||
p_restore.add_argument("skill", help="Skill name")
|
||||
p_restore.set_defaults(func=_cmd_restore)
|
||||
|
||||
p_backup = subs.add_parser(
|
||||
"backup",
|
||||
help="Take a manual tar.gz snapshot of ~/.hermes/skills/ "
|
||||
"(curator also does this automatically before every real run)",
|
||||
)
|
||||
p_backup.add_argument(
|
||||
"--reason", default=None,
|
||||
help="Free-text label stored in manifest.json (default: 'manual')",
|
||||
)
|
||||
p_backup.set_defaults(func=_cmd_backup)
|
||||
|
||||
p_rollback = subs.add_parser(
|
||||
"rollback",
|
||||
help="Restore ~/.hermes/skills/ from a curator snapshot "
|
||||
"(defaults to the newest)",
|
||||
)
|
||||
p_rollback.add_argument(
|
||||
"--list", action="store_true",
|
||||
help="List available snapshots and exit without restoring",
|
||||
)
|
||||
p_rollback.add_argument(
|
||||
"--id", dest="backup_id", default=None,
|
||||
help="Snapshot id to restore (see `--list`); default: newest",
|
||||
)
|
||||
p_rollback.add_argument(
|
||||
"-y", "--yes", action="store_true",
|
||||
help="Skip confirmation prompt",
|
||||
)
|
||||
p_rollback.set_defaults(func=_cmd_rollback)
|
||||
|
||||
|
||||
def cli_main(argv=None) -> int:
|
||||
"""Standalone entry (also usable by hermes_cli.main fallthrough)."""
|
||||
|
||||
@@ -5433,6 +5433,45 @@ def _find_stale_dashboard_pids() -> list[int]:
|
||||
return dashboard_pids
|
||||
|
||||
|
||||
def _print_curator_first_run_notice() -> None:
|
||||
"""Print a short heads-up about the skill curator after `hermes update`.
|
||||
|
||||
Only fires when the curator is enabled AND has no recorded run yet, which
|
||||
is exactly the window where the gateway ticker used to fire Curator
|
||||
against a fresh skill library immediately after an update. We defer the
|
||||
first real pass by one ``interval_hours``; this notice tells the user how
|
||||
to preview or disable before then. Silent on steady state.
|
||||
"""
|
||||
try:
|
||||
from agent import curator
|
||||
except Exception:
|
||||
return
|
||||
try:
|
||||
if not curator.is_enabled():
|
||||
return
|
||||
state = curator.load_state()
|
||||
except Exception:
|
||||
return
|
||||
if state.get("last_run_at"):
|
||||
# Curator has run before (real or already seeded) — no notice needed.
|
||||
return
|
||||
try:
|
||||
hours = curator.get_interval_hours()
|
||||
except Exception:
|
||||
hours = 24 * 7
|
||||
days = max(1, hours // 24)
|
||||
print()
|
||||
print("ℹ Skill curator")
|
||||
print(
|
||||
f" Background skill maintenance is enabled. First pass is deferred "
|
||||
f"~{days}d after installation; only agent-created skills are in "
|
||||
f"scope and nothing is ever auto-deleted (archive is recoverable)."
|
||||
)
|
||||
print(" Preview now: hermes curator run --dry-run")
|
||||
print(" Pause it: hermes curator pause")
|
||||
print(" Docs: https://hermes-agent.nousresearch.com/docs/user-guide/features/curator")
|
||||
|
||||
|
||||
def _kill_stale_dashboard_processes(
|
||||
reason: str = "the running backend no longer matches the updated frontend",
|
||||
) -> None:
|
||||
@@ -5670,6 +5709,10 @@ def _update_via_zip(args):
|
||||
|
||||
print()
|
||||
print("✓ Update complete!")
|
||||
try:
|
||||
_print_curator_first_run_notice()
|
||||
except Exception as e:
|
||||
logger.debug("Curator first-run notice failed: %s", e)
|
||||
_kill_stale_dashboard_processes()
|
||||
|
||||
|
||||
@@ -7109,6 +7152,15 @@ def _cmd_update_impl(args, gateway_mode: bool):
|
||||
print()
|
||||
print("✓ Update complete!")
|
||||
|
||||
# Curator first-run heads-up. Only prints when curator is enabled AND
|
||||
# has never run — i.e. the window where the ticker would otherwise
|
||||
# have fired against a fresh skill library. Kept silent on steady
|
||||
# state so we don't nag.
|
||||
try:
|
||||
_print_curator_first_run_notice()
|
||||
except Exception as e:
|
||||
logger.debug("Curator first-run notice failed: %s", e)
|
||||
|
||||
# Repair RHEL-family root installs where /usr/local/bin isn't on PATH
|
||||
# for non-login interactive shells. No-op on every other platform.
|
||||
try:
|
||||
|
||||
Reference in New Issue
Block a user