feat(curator): prune built-in skills after inactivity + track usage for all skills (#36701)

Two related changes to the skill curator:

1. Built-in pruning. New curator.prune_builtins config (default on) lets the
   curator archive bundled built-in skills after the inactivity period, not
   just agent-created ones. A .curator_suppressed list tells the update-time
   re-seeder (tools/skills_sync) to leave pruned built-ins archived, so the
   prune is durable across `hermes update`. Built-ins are seeded with a
   baseline record on first sight, so the inactivity clock starts at upgrade
   time -- no mass-prune on the first run. Hub-installed skills are never
   pruned regardless of the flag. Restoring a built-in clears its suppression.

2. Usage tracking for all skills. Telemetry (view/use/patch) was wrongly gated
   behind curation-eligibility, so built-ins were tracked only when prunable
   and hub skills never. Telemetry is observability and is now decoupled from
   curation: every skill accrues usage counts regardless of provenance, while
   lifecycle mutators (set_state/set_pinned/mark_agent_created) stay
   curation-gated. New usage_report() + provenance() expose all skills with an
   agent/bundled/hub tag.
This commit is contained in:
Teknium
2026-06-01 02:07:32 -07:00
committed by GitHub
parent 0622a70eb4
commit 70e1571d89
8 changed files with 622 additions and 73 deletions
+125
View File
@@ -31,6 +31,11 @@ def curator_env(tmp_path, monkeypatch):
# Default: no config file → curator defaults. Tests can override.
monkeypatch.setattr(curator, "_load_config", lambda: {})
# Pin prune_builtins OFF by default so transition tests don't pick up
# built-ins unless they explicitly enable it. Both config-reading paths
# are pinned (curator reads via _load_config; skill_usage reads config
# directly). Tests opt in with _enable_prune_builtins(...).
monkeypatch.setattr(usage, "_prune_builtins_enabled", lambda: False)
return {"home": home, "curator": curator, "usage": usage}
@@ -285,6 +290,126 @@ def test_bundled_skill_not_touched_by_transitions(curator_env):
assert (skills_dir / "bundled").exists() # never moved
# ---------------------------------------------------------------------------
# prune_builtins: curator may archive bundled built-ins after inactivity
# ---------------------------------------------------------------------------
def _enable_prune_builtins(curator_env, monkeypatch):
"""Flip curator.prune_builtins on for both config-reading paths."""
c = curator_env["curator"]
u = curator_env["usage"]
monkeypatch.setattr(c, "_load_config", lambda: {"prune_builtins": True})
monkeypatch.setattr(u, "_prune_builtins_enabled", lambda: True)
def _disable_prune_builtins(curator_env, monkeypatch):
"""Flip curator.prune_builtins off for both config-reading paths."""
c = curator_env["curator"]
u = curator_env["usage"]
monkeypatch.setattr(c, "_load_config", lambda: {"prune_builtins": False})
monkeypatch.setattr(u, "_prune_builtins_enabled", lambda: False)
def test_prune_builtins_default_on(curator_env):
# Shipped default is ON: with no explicit config, built-ins are eligible.
c = curator_env["curator"]
# _load_config returns {} (fixture) → default True surfaces.
assert c.get_prune_builtins() is True
def test_prune_builtins_off_excludes_bundled(curator_env, monkeypatch):
c = curator_env["curator"]
skills_dir = curator_env["home"] / "skills"
_write_skill(skills_dir, "bundled")
(skills_dir / ".bundled_manifest").write_text("bundled:abc\n", encoding="utf-8")
# Explicitly off → bundled is not a candidate (the opt-out path).
_disable_prune_builtins(curator_env, monkeypatch)
assert c.get_prune_builtins() is False
counts = c.apply_automatic_transitions()
assert counts["checked"] == 0
assert (skills_dir / "bundled").exists()
def test_prune_builtins_seeds_clock_on_first_sight(curator_env, monkeypatch):
c = curator_env["curator"]
u = curator_env["usage"]
skills_dir = curator_env["home"] / "skills"
_write_skill(skills_dir, "bundled")
(skills_dir / ".bundled_manifest").write_text("bundled:abc\n", encoding="utf-8")
_enable_prune_builtins(curator_env, monkeypatch)
# First pass: built-in has no record yet → it's seeded, NOT archived,
# even though it's "old" on disk. The inactivity clock starts now.
counts = c.apply_automatic_transitions()
assert counts["checked"] == 1
assert counts["seeded"] == 1
assert counts["archived"] == 0
assert (skills_dir / "bundled").exists()
# A record now exists with created_at ~ now.
assert isinstance(u.load_usage().get("bundled"), dict)
def test_prune_builtins_archives_stale_bundled_and_suppresses(curator_env, monkeypatch):
c = curator_env["curator"]
u = curator_env["usage"]
skills_dir = curator_env["home"] / "skills"
_write_skill(skills_dir, "bundled")
(skills_dir / ".bundled_manifest").write_text("bundled:abc\n", encoding="utf-8")
_enable_prune_builtins(curator_env, monkeypatch)
# Seed a record whose last activity is far past the archive cutoff.
super_old = (datetime.now(timezone.utc) - timedelta(days=500)).isoformat()
data = u.load_usage()
data["bundled"] = u._empty_record()
data["bundled"]["last_used_at"] = super_old
u.save_usage(data)
counts = c.apply_automatic_transitions()
assert counts["archived"] == 1
# Directory moved into .archive/, suppression recorded so update won't restore.
assert not (skills_dir / "bundled").exists()
assert (skills_dir / ".archive" / "bundled").exists()
assert "bundled" in u.read_suppressed_names()
def test_prune_builtins_restore_clears_suppression(curator_env, monkeypatch):
u = curator_env["usage"]
skills_dir = curator_env["home"] / "skills"
_write_skill(skills_dir, "bundled")
(skills_dir / ".bundled_manifest").write_text("bundled:abc\n", encoding="utf-8")
_enable_prune_builtins(curator_env, monkeypatch)
ok, _ = u.archive_skill("bundled")
assert ok
assert "bundled" in u.read_suppressed_names()
ok, _ = u.restore_skill("bundled")
assert ok
assert (skills_dir / "bundled").exists()
assert "bundled" not in u.read_suppressed_names()
def test_prune_builtins_never_touches_hub_skills(curator_env, monkeypatch):
u = curator_env["usage"]
skills_dir = curator_env["home"] / "skills"
_write_skill(skills_dir, "hubskill")
hub_dir = skills_dir / ".hub"
hub_dir.mkdir(parents=True, exist_ok=True)
(hub_dir / "lock.json").write_text(
'{"version": 1, "installed": {"hubskill": {"install_path": "hubskill"}}}',
encoding="utf-8",
)
_enable_prune_builtins(curator_env, monkeypatch)
# Even with prune_builtins on, hub-installed skills stay off-limits.
assert u.is_curation_eligible("hubskill") is False
ok, msg = u.archive_skill("hubskill")
assert ok is False
assert "hub-installed" in msg
assert (skills_dir / "hubskill").exists()
# ---------------------------------------------------------------------------
# run_curator_review orchestration
# ---------------------------------------------------------------------------