{"repo":"NousResearch/hermes-agent","public_id":"17782","display_label":"Skill usage counter never called, curator prunes active skills","summary":"A bug where bump_use() had no production call sites meant use_count stayed at zero, causing the curator to treat actively-used skills as stale. These issues and PRs address wiring bump_use() into skill invocation, preload, and cron job paths—including before wake-gate checks—and preventing the curator from archiving cron-referenced skills.","subsystem":"cron","counts":{"members":13,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":3,"merged_prs":3,"attempt_prs":7},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":17932},"resolution":{"state":"partially_resolved","by":"pr-17932,pr-19433"},"freshness":{"state":"verified","last_verified_at":"2026-06-28T22:10:22+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_PARTIAL","link_density":0.14205128205128206},"fix_edges":[{"issue_number":17782,"pr_number":17932,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true},{"issue_number":18810,"pr_number":19433,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true},{"issue_number":42303,"pr_number":54443,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false}],"members":[{"kind":"issue","number":17782,"title":"Curator: `bump_use()` has zero production call sites — `use_count` always 0","url":null,"state":"closed"},{"kind":"issue","number":18810,"title":"fix(curator): bump skill usage when cron jobs load skills","url":null,"state":"closed"},{"kind":"issue","number":42303,"title":"[Bug]: bump_use skipped on wakeAgent=false short-circuit and on script:-only skill use — script-gated cron skills look stale to the curator","url":null,"state":"closed"},{"kind":"pr","number":17818,"title":"fix(skills): wire bump_use() into skill invocation and preload paths (#17782)","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#17932","number":17932}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":17865,"title":"fix: wire bump_use() into skill loading paths","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#17818","number":17818}],"category":"duplicate_of","implication":"duplicate"},"merged":false},{"kind":"pr","number":17866,"title":"fix(skills): wire bump_use() into skill invocation and preload paths (#17782)","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#17818","number":17818},{"kind":"pr","value":"#17932","number":17932},{"kind":"pr","value":"#17782","number":17782}],"category":"duplicate_of","implication":"duplicate"},"merged":false},{"kind":"pr","number":17932,"title":"fix(skills): wire bump_use() into skill invocation, preload, and skill_view tool (#17782 salvage)","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["agent/skill_commands.py","tools/skills_tool.py"]},{"kind":"pr","number":18811,"title":"fix(cron): bump skill usage when cron jobs load skills","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#19433","number":19433}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":19433,"title":"fix(cron): bump skill usage when cron jobs load skills","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["cron/scheduler.py","tests/cron/test_scheduler.py"],"fix_hunks":[{"file":"cron/scheduler.py","hunks":["@@ -782,6 +782,7 @@ def _build_job_prompt(job: dict, prerun_script: Optional[tuple] = None) -> str:\n         return prompt\n \n     from tools.skills_tool import skill_view\n+    from tools.skill_usage import bump_use\n \n     parts = []\n     skipped: list[str] = []","@@ -793,6 +794,12 @@ def _build_job_prompt(job: dict, prerun_script: Optional[tuple] = None) -> str:\n             skipped.append(skill_name)\n             continue\n \n+        # Bump usage so the curator sees this skill as actively used.\n+        try:\n+            bump_use(skill_name)\n+        except Exception:\n+            logger.debug(\"Cron job: failed to bump skill usage for '%s'\", skill_name, exc_info=True)\n+\n         content = str(loaded.get(\"content\") or \"\").strip()\n         if parts:\n             parts.append(\"\")"]},{"file":"tests/cron/test_scheduler.py","hunks":["@@ -1857,6 +1857,54 @@ def _mixed_skill_view(name: str) -> str:\n         assert \"go\" in result\n \n \n+class TestBuildJobPromptBumpUse:\n+    \"\"\"Verify that cron jobs bump skill usage counters so the curator sees them as active.\"\"\"\n+\n+    def test_bump_use_called_for_loaded_skill(self):\n+        \"\"\"bump_use is called for each successfully loaded skill.\"\"\"\n+\n+        def _skill_view(name: str) -> str:\n+            return json.dumps({\"success\": True, \"content\": f\"Content for {name}.\"})\n+\n+        with patch(\"tools.skills_tool.skill_view\", side_effect=_skill_view), \\\n+             patch(\"tools.skill_usage.bump_use\") as mock_bump:\n+            _build_job_prompt({\"skills\": [\"alpha\", \"beta\"], \"prompt\": \"go\"})\n+\n+        assert mock_bump.call_count == 2\n+        calls = [c[0][0] for c in mock_bump.call_args_list]\n+        assert \"alpha\" in calls\n+        assert \"beta\" in calls\n+\n+    def test_bump_use_not_called_for_missing_skill(self):\n+        \"\"\"bump_use is NOT called when a skill fails to load.\"\"\"\n+\n+        def _missing_view(name: str) -> str:\n+            return json.dumps({\"success\": False, \"error\": \"not found\"})\n+\n+        with patch(\"tools.skills_tool.skill_view\", side_effect=_missing_view), \\\n+             patch(\"tools.skill_usage.bump_use\") as mock_bump:\n+            _build_job_prompt({\"skills\": [\"ghost\"], \"prompt\": \"go\"})\n+\n+        assert mock_bump.call_count == 0\n+\n+    def test_bump_failure_does_not_break_prompt(self, caplog):\n+        \"\"\"If bump_use raises, the prompt still builds — error is logged at DEBUG.\"\"\"\n+\n+        def _skill_view(name: str) -> str:\n+            return json.dumps({\"success\": True, \"content\": \"Works.\"})\n+\n+        with patch(\"tools.skills_tool.skill_view\", side_effect=_skill_view), \\\n+             patch(\"tools.skill_usage.bump_use\", side_effect=RuntimeError(\"boom\")), \\\n+             caplog.at_level(logging.DEBUG, logger=\"cron.scheduler\"):\n+            result = _build_job_prompt({\"skills\": [\"good-skill\"], \"prompt\": \"go\"})\n+\n+        # Prompt should still contain the skill content and original instruction\n+        assert \"Works.\" in result\n+        assert \"go\" in result\n+        # The error should be logged at DEBUG level, not crash\n+        assert any(\"failed to bump\" in r.message for r in caplog.records)\n+\n+\n class TestSendMediaViaAdapter:\n     \"\"\"Unit tests for _send_media_via_adapter — routes files to typed adapter methods.\"\"\"\n \n"]}]},{"kind":"pr","number":42313,"title":"fix(cron): bump skill usage before wake gate to prevent curator pruning","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"4c2961c511c523c621b8847493f032ab65bf116a","number":0},{"kind":"pr","value":"#54443","number":54443}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":42318,"title":"fix(cron): bump skill usage before wake-gate so script-gated jobs aren't seen as stale","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#42313","number":42313}],"category":"superseded_by_pr","implication":"superseded"},"merged":false,"is_fix_pr":true,"fix_tier":"suggested","fix_detail_withheld":true},{"kind":"pr","number":42463,"title":"fix(cron): bump skill usage on wakeAgent=false and empty script paths (#42303)","url":null,"state":"closed","close_reason":{"category":"uncategorized","implication":"needs_llm"},"merged":false},{"kind":"pr","number":54443,"title":"fix(curator): never archive cron-referenced skills + floor use=0 pruning","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["agent/curator.py","cron/jobs.py","tests/agent/test_curator.py"],"fix_hunks":[{"file":"agent/curator.py","hunks":["@@ -273,6 +273,21 @@ def should_run_now(now: Optional[datetime] = None) -> bool:\n # Automatic state transitions (pure function, no LLM)\n # ---------------------------------------------------------------------------\n \n+def _cron_referenced_skills() -> Set[str]:\n+    \"\"\"Skill names referenced by any cron job (incl. paused/disabled).\n+\n+    Best-effort: a cron-module import error or corrupt jobs store must never\n+    break the curator, so any failure yields an empty set (no protection,\n+    but no crash).\n+    \"\"\"\n+    try:\n+        from cron.jobs import referenced_skill_names as _refs\n+        return _refs()\n+    except Exception as e:\n+        logger.debug(\"Curator could not read cron skill references: %s\", e, exc_info=True)\n+        return set()\n+\n+\n def apply_automatic_transitions(now: Optional[datetime] = None) -> Dict[str, int]:\n     \"\"\"Walk every curator-managed skill and move active/stale/archived based on\n     the latest real activity timestamp. Pinned skills are never touched.","@@ -292,6 +307,8 @@ def apply_automatic_transitions(now: Optional[datetime] = None) -> Dict[str, int\n     stale_cutoff = now - timedelta(days=get_stale_after_days())\n     archive_cutoff = now - timedelta(days=get_archive_after_days())\n \n+    cron_referenced = _cron_referenced_skills()\n+\n     counts = {\"marked_stale\": 0, \"archived\": 0, \"reactivated\": 0, \"checked\": 0, \"seeded\": 0}\n \n     for row in _u.agent_created_report():","@@ -300,6 +317,15 @@ def apply_automatic_transitions(now: Optional[datetime] = None) -> Dict[str, int\n         if row.get(\"pinned\"):\n             continue\n \n+        # A skill referenced by any cron job (incl. paused/disabled) is in\n+        # use by definition — resuming or the next fire must find it. The\n+        # scheduler only bumps usage when a job actually fires, so jobs that\n+        # fire less often than archive_after_days, paused jobs, and far-future\n+        # one-shots would otherwise have their skills aged out from under\n+        # them. Treat referenced skills like pinned: never auto-transition.\n+        if name in cron_referenced:\n+            continue\n+\n         # First sight of a curation-eligible skill with no persisted record\n         # (e.g. a newly-eligible built-in): anchor its clock to now and defer.\n         if not row.get(\"_persisted\", True):","@@ -316,6 +342,18 @@ def apply_automatic_transitions(now: Optional[datetime] = None) -> Dict[str, int\n \n         current = row.get(\"state\", _u.STATE_ACTIVE)\n \n+        # Never-used skills (use_count == 0) get a grace floor: don't archive\n+        # one until it is at least stale_after_days old. A use=0 skill is\n+        # absence of evidence, not evidence of staleness — a skill created\n+        # recently may simply not have had its trigger come up yet.\n+        never_used = int(row.get(\"use_count\", 0) or 0) == 0\n+        if never_used and anchor > stale_cutoff:\n+            # Younger than the stale window — leave it alone entirely.\n+            if current == _u.STATE_STALE:\n+                _u.set_state(name, _u.STATE_ACTIVE)\n+                counts[\"reactivated\"] += 1\n+            continue\n+\n         if anchor <= archive_cutoff and current != _u.STATE_ARCHIVED:\n             ok, _msg = _u.archive_skill(name)\n             if ok:","@@ -390,10 +428,19 @@ def apply_automatic_transitions(now: Optional[datetime] = None) -> Dict[str, int\n     \"back load-bearing UX (slash-command entry points referenced in docs and \"\n     \"tips) and are filtered out of the candidate list below — never resurrect \"\n     \"one as an archive or absorb target.\\n\"\n+    \"3c. DO NOT archive or prune any skill marked `cron=yes` in the candidate \"\n+    \"list. A cron job depends on it and will fail to load it on its next \"\n+    \"run. You MAY still consolidate it into an umbrella — but only because \"\n+    \"the curator rewrites cron job skill references to follow consolidations; \"\n+    \"never simply prune it.\\n\"\n     \"4. DO NOT use usage counters as a reason to skip consolidation. The \"\n     \"counters are new and often mostly zero. Judge overlap on CONTENT, \"\n     \"not on use_count. 'use=0' is not evidence a skill is valuable; it's \"\n-    \"absence of evidence either way.\\n\"\n+    \"absence of evidence either way. Corollary: 'use=0' is ALSO not a \"\n+    \"reason to PRUNE a skill. Never archive a never-used skill (use=0) \"\n+    \"unless it is at least 30 days old (check last_activity / created date) \"\n+    \"AND its content is genuinely obsolete or fully absorbed elsewhere — a \"\n+    \"recently-created skill simply may not have had its trigger come up yet.\\n\"\n     \"5. DO NOT reject consolidation on the grounds that 'each skill has \"\n     \"a distinct trigger'. Pairwise distinctness is the wrong bar. The \"\n     \"right bar is: 'would a human maintainer write this as N separate \"","@@ -1413,12 +1460,14 @@ def _render_candidate_list() -> str:\n     rows = skill_usage.agent_created_report()\n     if not rows:\n         return \"No agent-created skills to review.\"\n+    cron_referenced = _cron_referenced_skills()\n     lines = [f\"Agent-created skills ({len(rows)}):\\n\"]\n     for r in rows:\n         lines.append(\n             f\"- {r['name']}  \"\n             f\"state={r['state']}  \"\n             f\"pinned={'yes' if r.get('pinned') else 'no'}  \"\n+            f\"cron={'yes' if r['name'] in cron_referenced else 'no'}  \"\n             f\"activity={r.get('activity_count', 0)}  \"\n             f\"use={r.get('use_count', 0)}  \"\n             f\"view={r.get('view_count', 0)}  \""]},{"file":"cron/jobs.py","hunks":["@@ -32,7 +32,7 @@\n from datetime import datetime, timedelta\n from pathlib import Path\n from hermes_constants import get_hermes_home\n-from typing import Optional, Dict, List, Any, Tuple, Union\n+from typing import Optional, Dict, List, Any, Set, Tuple, Union\n \n logger = logging.getLogger(__name__)\n ","@@ -1634,6 +1634,35 @@ def save_job_output(job_id: str, output: str):\n # Skill reference rewriting (curator integration)\n # =============================================================================\n \n+def referenced_skill_names() -> Set[str]:\n+    \"\"\"Return the set of skill names referenced by ANY cron job.\n+\n+    Includes paused and disabled jobs deliberately: a paused job never\n+    fires, so its skills never get a ``bump_use`` from the scheduler, yet\n+    resuming it must still find its skills present. The curator uses this\n+    set to protect referenced skills from inactivity archival — a skill a\n+    live job depends on is \"in use\" regardless of when it was last loaded.\n+\n+    Best-effort: a corrupt/unreadable jobs store returns an empty set\n+    rather than raising, so a cron issue can never break the curator.\n+    \"\"\"\n+    try:\n+        jobs = load_jobs()\n+    except Exception:\n+        logger.debug(\"referenced_skill_names: failed to load cron jobs\", exc_info=True)\n+        return set()\n+\n+    names: Set[str] = set()\n+    for job in jobs:\n+        if not isinstance(job, dict):\n+            continue\n+        for name in _normalize_skill_list(job.get(\"skill\"), job.get(\"skills\")):\n+            cleaned = str(name).strip().lstrip(\"/\")\n+            if cleaned:\n+                names.add(cleaned)\n+    return names\n+\n+\n def rewrite_skill_refs(\n     consolidated: Optional[Dict[str, str]] = None,\n     pruned: Optional[List[str]] = None,"]},{"file":"tests/agent/test_curator.py","hunks":["@@ -263,6 +263,94 @@ def test_new_skill_without_last_used_not_immediately_archived(curator_env):\n     assert (skills_dir / \"fresh\").exists()\n \n \n+def _backdate(u, name: str, days: int, *, use_count: int = 1):\n+    \"\"\"Write an agent-created usage record whose activity is *days* old.\"\"\"\n+    ts = (datetime.now(timezone.utc) - timedelta(days=days)).isoformat()\n+    data = u.load_usage()\n+    data[name] = u._empty_record()\n+    data[name][\"created_by\"] = \"agent\"\n+    data[name][\"created_at\"] = ts\n+    data[name][\"last_used_at\"] = ts if use_count else None\n+    data[name][\"last_activity_at\"] = ts if use_count else None\n+    data[name][\"use_count\"] = use_count\n+    u.save_usage(data)\n+\n+\n+def test_cron_referenced_skill_is_not_archived(curator_env, monkeypatch):\n+    \"\"\"A skill referenced by a cron job must survive inactivity archival even\n+    when its activity is well past archive_after_days. The scheduler only\n+    bumps usage when a job fires, so paused / infrequent / far-future jobs\n+    would otherwise have their skills aged out from under them.\"\"\"\n+    c = curator_env[\"curator\"]\n+    u = curator_env[\"usage\"]\n+    skills_dir = curator_env[\"home\"] / \"skills\"\n+    _write_skill(skills_dir, \"cron-dep\")\n+    _write_skill(skills_dir, \"orphan\")\n+    _backdate(u, \"cron-dep\", 200)\n+    _backdate(u, \"orphan\", 200)\n+\n+    # Pretend a (paused/infrequent) cron job references \"cron-dep\" only.\n+    monkeypatch.setattr(c, \"_cron_referenced_skills\", lambda: {\"cron-dep\"})\n+\n+    counts = c.apply_automatic_transitions()\n+\n+    assert u.get_record(\"cron-dep\")[\"state\"] == \"active\"  # protected\n+    assert (skills_dir / \"cron-dep\").exists()\n+    assert u.get_record(\"orphan\")[\"state\"] == \"archived\"  # control\n+    assert counts[\"archived\"] == 1\n+\n+\n+def test_unused_skill_not_archived_before_stale_floor(curator_env):\n+    \"\"\"A never-used skill (use_count == 0) younger than stale_after_days must\n+    not be marked stale or archived — absence of use is not evidence of\n+    staleness when the skill simply hasn't had its trigger come up yet.\"\"\"\n+    c = curator_env[\"curator\"]\n+    u = curator_env[\"usage\"]\n+    skills_dir = curator_env[\"home\"] / \"skills\"\n+    _write_skill(skills_dir, \"young-unused\")\n+    _backdate(u, \"young-unused\", 10, use_count=0)  # < 30d stale floor\n+\n+    counts = c.apply_automatic_transitions()\n+\n+    assert u.get_record(\"young-unused\")[\"state\"] == \"active\"\n+    assert counts[\"archived\"] == 0\n+    assert counts[\"marked_stale\"] == 0\n+\n+\n+def test_unused_skill_archived_past_archive_window(curator_env):\n+    \"\"\"The use=0 floor only protects YOUNG skills — a never-used skill older\n+    than archive_after_days still archives (no perpetual reprieve).\"\"\"\n+    c = curator_env[\"curator\"]\n+    u = curator_env[\"usage\"]\n+    skills_dir = curator_env[\"home\"] / \"skills\"\n+    _write_skill(skills_dir, \"old-unused\")\n+    _backdate(u, \"old-unused\", 200, use_count=0)\n+\n+    counts = c.apply_automatic_transitions()\n+\n+    assert u.get_record(\"old-unused\")[\"state\"] == \"archived\"\n+    assert counts[\"archived\"] == 1\n+\n+\n+def test_candidate_list_marks_cron_referenced_skills(curator_env, monkeypatch):\n+    \"\"\"The LLM review candidate list flags cron-referenced skills so the\n+    review pass knows not to prune them.\"\"\"\n+    c = curator_env[\"curator\"]\n+    u = curator_env[\"usage\"]\n+    skills_dir = curator_env[\"home\"] / \"skills\"\n+    _write_skill(skills_dir, \"cron-dep\")\n+    _write_skill(skills_dir, \"plain\")\n+    _backdate(u, \"cron-dep\", 1)\n+    _backdate(u, \"plain\", 1)\n+    monkeypatch.setattr(c, \"_cron_referenced_skills\", lambda: {\"cron-dep\"})\n+\n+    listing = c._render_candidate_list()\n+    cron_line = next(l for l in listing.splitlines() if l.startswith(\"- cron-dep\"))\n+    plain_line = next(l for l in listing.splitlines() if l.startswith(\"- plain\"))\n+    assert \"cron=yes\" in cron_line\n+    assert \"cron=no\" in plain_line\n+\n+\n def test_manual_skill_is_not_auto_archived(curator_env):\n     \"\"\"Manual skills can have usage records, but without the agent-created\n     marker they must stay out of curator transitions.\"\"\"\n"]}]}],"trust":{"default_tier":"verified","includes_suggested":false}}