{"repo":"NousResearch/hermes-agent","public_id":"19293","display_label":"Curator misclassifying hub skills with non-ASCII names","summary":"These issues and pull requests address a bug where the curator incorrectly identifies hub-installed skills as agent-created when their display names contain non-ASCII characters. The fixes center on matching hub skills by frontmatter name rather than relying solely on ASCII slug comparisons.","subsystem":null,"counts":{"members":8,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":2,"merged_prs":2,"attempt_prs":4},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":19618},"resolution":{"state":"partially_resolved","by":"pr-20194"},"freshness":{"state":"verified","last_verified_at":"2026-05-05T11:55:22+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_SPANNING","link_density":0.26285714285714284},"fix_edges":[{"issue_number":19040,"pr_number":19618,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true},{"issue_number":19293,"pr_number":19618,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false},{"issue_number":19293,"pr_number":20194,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false},{"issue_number":19641,"pr_number":20194,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true}],"members":[{"kind":"issue","number":19293,"title":"Curator misclassifies hub-installed skills with non-ASCII `name` as agent-created","url":null,"state":"closed"},{"kind":"issue","number":19641,"title":"is_agent_created() still compares SKILL.md name against ASCII hub-slug lock keys","url":null,"state":"closed"},{"kind":"pr","number":19311,"title":"fix(skills): curator no longer misclassifies hub skills with non-ASCII names","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#19297","number":19297}],"category":"superseded_by_pr","implication":"superseded"},"merged":false,"is_fix_pr":true,"fix_tier":"suggested","fix_detail_withheld":true},{"kind":"pr","number":19369,"title":"fix(skills): protect hub-installed skills with non-ASCII display names from curator","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#19311","number":19311}],"category":"superseded_by_pr","implication":"superseded"},"merged":false,"is_fix_pr":true,"fix_tier":"suggested","fix_detail_withheld":true},{"kind":"pr","number":19460,"title":"fix(curator): protect hub skills by frontmatter name","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#20194","number":20194},{"kind":"pr","value":"#19618","number":19618},{"kind":"pr","value":"#19621","number":19621}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":19618,"title":"fix(skills): keep manual skills out of curator","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["scripts/release.py","tests/agent/test_curator.py","tests/tools/test_skill_manager_tool.py","tests/tools/test_skill_usage.py","tools/skill_manager_tool.py","tools/skill_usage.py"],"fix_hunks":[{"file":"scripts/release.py","hunks":["@@ -459,6 +459,7 @@\n     \"[redacted:email]\": \"centripetal-star\",\n     \"[redacted:email]\": \"LeonSGP43\",\n     \"[redacted:email]\": \"LeonSGP43\",\n+    \"[redacted:email]\": \"LeonSGP43\",\n     \"[redacted:email]\": \"Lubrsy706\",\n     \"[redacted:email]\": \"spniyant\",\n     \"[redacted:email]\": \"olafthiele\","]},{"file":"tests/agent/test_curator.py","hunks":["@@ -154,6 +154,7 @@ def test_unused_skill_transitions_to_stale(curator_env):\n     long_ago = (datetime.now(timezone.utc) - timedelta(days=45)).isoformat()\n     data = u.load_usage()\n     data[\"old-skill\"] = u._empty_record()\n+    data[\"old-skill\"][\"created_by\"] = \"agent\"\n     data[\"old-skill\"][\"last_used_at\"] = long_ago\n     data[\"old-skill\"][\"created_at\"] = long_ago\n     u.save_usage(data)","@@ -172,6 +173,7 @@ def test_very_old_skill_gets_archived(curator_env):\n     super_old = (datetime.now(timezone.utc) - timedelta(days=120)).isoformat()\n     data = u.load_usage()\n     data[\"ancient\"] = u._empty_record()\n+    data[\"ancient\"][\"created_by\"] = \"agent\"\n     data[\"ancient\"][\"last_used_at\"] = super_old\n     data[\"ancient\"][\"created_at\"] = super_old\n     u.save_usage(data)","@@ -192,6 +194,7 @@ def test_pinned_skill_is_never_touched(curator_env):\n     super_old = (datetime.now(timezone.utc) - timedelta(days=365)).isoformat()\n     data = u.load_usage()\n     data[\"precious\"] = u._empty_record()\n+    data[\"precious\"][\"created_by\"] = \"agent\"\n     data[\"precious\"][\"last_used_at\"] = super_old\n     data[\"precious\"][\"created_at\"] = super_old\n     data[\"precious\"][\"pinned\"] = True","@@ -214,6 +217,7 @@ def test_stale_skill_reactivates_on_recent_use(curator_env):\n     recent = datetime.now(timezone.utc).isoformat()\n     data = u.load_usage()\n     data[\"revived\"] = u._empty_record()\n+    data[\"revived\"][\"created_by\"] = \"agent\"\n     data[\"revived\"][\"state\"] = \"stale\"\n     data[\"revived\"][\"last_used_at\"] = recent\n     data[\"revived\"][\"created_at\"] = recent","@@ -240,6 +244,27 @@ def test_new_skill_without_last_used_not_immediately_archived(curator_env):\n     assert (skills_dir / \"fresh\").exists()\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+    c = curator_env[\"curator\"]\n+    u = curator_env[\"usage\"]\n+    skills_dir = curator_env[\"home\"] / \"skills\"\n+    skill_dir = _write_skill(skills_dir, \"manual\")\n+\n+    super_old = (datetime.now(timezone.utc) - timedelta(days=365)).isoformat()\n+    data = u.load_usage()\n+    data[\"manual\"] = u._empty_record()\n+    data[\"manual\"][\"last_used_at\"] = super_old\n+    data[\"manual\"][\"created_at\"] = super_old\n+    u.save_usage(data)\n+\n+    counts = c.apply_automatic_transitions()\n+    assert counts[\"checked\"] == 0\n+    assert counts[\"archived\"] == 0\n+    assert skill_dir.exists()\n+\n+\n def test_bundled_skill_not_touched_by_transitions(curator_env):\n     c = curator_env[\"curator\"]\n     u = curator_env[\"usage\"]","@@ -267,8 +292,10 @@ def test_bundled_skill_not_touched_by_transitions(curator_env):\n \n def test_run_review_records_state(curator_env):\n     c = curator_env[\"curator\"]\n+    u = curator_env[\"usage\"]\n     skills_dir = curator_env[\"home\"] / \"skills\"\n     _write_skill(skills_dir, \"a\")\n+    u.mark_agent_created(\"a\")\n \n     result = c.run_curator_review(synchronous=True)\n     assert \"started_at\" in result","@@ -284,8 +311,10 @@ def test_dry_run_does_not_advance_state(curator_env, monkeypatch):\n     `hermes curator status`. Fixes #18373.\n     \"\"\"\n     c = curator_env[\"curator\"]\n+    u = curator_env[\"usage\"]\n     skills_dir = curator_env[\"home\"] / \"skills\"\n     _write_skill(skills_dir, \"a\")\n+    u.mark_agent_created(\"a\")\n \n     # Stub the LLM so the test doesn't need a provider.\n     monkeypatch.setattr(","@@ -311,8 +340,10 @@ def test_dry_run_injects_report_only_banner(curator_env, monkeypatch):\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+    u = curator_env[\"usage\"]\n     skills_dir = curator_env[\"home\"] / \"skills\"\n     _write_skill(skills_dir, \"a\")\n+    u.mark_agent_created(\"a\")\n \n     captured = {}\n     def _stub(prompt):","@@ -331,8 +362,10 @@ def test_dry_run_skips_automatic_transitions(curator_env, monkeypatch):\n     archives skills deterministically, and a preview must not touch the\n     filesystem.\"\"\"\n     c = curator_env[\"curator\"]\n+    u = curator_env[\"usage\"]\n     skills_dir = curator_env[\"home\"] / \"skills\"\n     _write_skill(skills_dir, \"a\")\n+    u.mark_agent_created(\"a\")\n \n     called = {\"n\": 0}\n     def _explode(*_a, **_kw):","@@ -351,8 +384,10 @@ def _explode(*_a, **_kw):\n \n def test_run_review_synchronous_invokes_llm_stub(curator_env, monkeypatch):\n     c = curator_env[\"curator\"]\n+    u = curator_env[\"usage\"]\n     skills_dir = curator_env[\"home\"] / \"skills\"\n     _write_skill(skills_dir, \"a\")\n+    u.mark_agent_created(\"a\")\n \n     calls = []\n     def _stub(prompt):","@@ -409,8 +444,10 @@ def test_maybe_run_curator_enforces_idle_gate(curator_env, monkeypatch):\n \n def test_maybe_run_curator_runs_when_eligible(curator_env, monkeypatch):\n     c = curator_env[\"curator\"]\n+    u = curator_env[\"usage\"]\n     skills_dir = curator_env[\"home\"] / \"skills\"\n     _write_skill(skills_dir, \"a\")\n+    u.mark_agent_created(\"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)"]},{"file":"tests/tools/test_skill_manager_tool.py","hunks":["@@ -533,8 +533,11 @@ def test_patch_without_old_string(self, tmp_path):\n     def test_full_create_via_dispatcher(self, tmp_path):\n         with _skill_dir(tmp_path):\n             raw = skill_manage(action=\"create\", name=\"test-skill\", content=VALID_SKILL_CONTENT)\n+            from tools.skill_usage import load_usage\n+            usage = load_usage()\n         result = json.loads(raw)\n         assert result[\"success\"] is True\n+        assert usage[\"test-skill\"][\"created_by\"] == \"agent\"\n \n     def test_delete_via_dispatcher_threads_absorbed_into(self, tmp_path):\n         # Dispatcher must plumb absorbed_into through to _delete_skill so the"]},{"file":"tests/tools/test_skill_usage.py","hunks":["@@ -194,10 +194,11 @@ def test_forget_removes_record(skills_home):\n # ---------------------------------------------------------------------------\n \n def test_agent_created_excludes_bundled(skills_home):\n-    from tools.skill_usage import list_agent_created_skill_names\n+    from tools.skill_usage import list_agent_created_skill_names, mark_agent_created\n     skills_dir = skills_home / \"skills\"\n     _write_skill(skills_dir, \"bundled-skill\", category=\"github\")\n     _write_skill(skills_dir, \"my-skill\")\n+    mark_agent_created(\"my-skill\")\n     # Seed a bundled manifest marking bundled-skill as upstream\n     (skills_dir / \".bundled_manifest\").write_text(\n         \"bundled-skill:abc123\\n\", encoding=\"utf-8\",","@@ -208,10 +209,11 @@ def test_agent_created_excludes_bundled(skills_home):\n \n \n def test_agent_created_excludes_hub_installed(skills_home):\n-    from tools.skill_usage import list_agent_created_skill_names\n+    from tools.skill_usage import list_agent_created_skill_names, mark_agent_created\n     skills_dir = skills_home / \"skills\"\n     _write_skill(skills_dir, \"hub-skill\")\n     _write_skill(skills_dir, \"my-skill\")\n+    mark_agent_created(\"my-skill\")\n     hub_dir = skills_dir / \".hub\"\n     hub_dir.mkdir()\n     (hub_dir / \"lock.json\").write_text(","@@ -238,9 +240,10 @@ def test_is_agent_created(skills_home):\n \n \n def test_agent_created_skips_archive_and_hub_dirs(skills_home):\n-    from tools.skill_usage import list_agent_created_skill_names\n+    from tools.skill_usage import list_agent_created_skill_names, mark_agent_created\n     skills_dir = skills_home / \"skills\"\n     _write_skill(skills_dir, \"real-skill\")\n+    mark_agent_created(\"real-skill\")\n     # Dot-prefixed dirs must be ignored even if they contain SKILL.md\n     archive = skills_dir / \".archive\" / \"old-skill\"\n     archive.mkdir(parents=True)","@@ -368,27 +371,41 @@ def test_archive_collision_gets_suffix(skills_home):\n # Reporting\n # ---------------------------------------------------------------------------\n \n-def test_agent_created_report_includes_defaults(skills_home):\n-    from tools.skill_usage import agent_created_report, bump_view\n+def test_agent_created_report_includes_marked_skills_with_defaults(skills_home):\n+    from tools.skill_usage import agent_created_report, bump_view, mark_agent_created\n     skills_dir = skills_home / \"skills\"\n     _write_skill(skills_dir, \"a\")\n     _write_skill(skills_dir, \"b\")\n+    mark_agent_created(\"a\")\n+    mark_agent_created(\"b\")\n     bump_view(\"a\")\n     rows = agent_created_report()\n     by_name = {r[\"name\"]: r for r in rows}\n     assert \"a\" in by_name and \"b\" in by_name\n     assert by_name[\"a\"][\"view_count\"] == 1\n-    # b has no usage record yet — must still appear with defaults\n+    # b has only the provenance marker — activity fields still default.\n     assert by_name[\"b\"][\"view_count\"] == 0\n     assert by_name[\"b\"][\"state\"] == \"active\"\n \n \n+def test_manual_skill_with_usage_is_not_curator_managed(skills_home):\n+    from tools.skill_usage import agent_created_report, bump_view, list_agent_created_skill_names\n+    skills_dir = skills_home / \"skills\"\n+    _write_skill(skills_dir, \"manual-skill\")\n+\n+    bump_view(\"manual-skill\")\n+\n+    assert \"manual-skill\" not in list_agent_created_skill_names()\n+    assert \"manual-skill\" not in {r[\"name\"] for r in agent_created_report()}\n+\n+\n def test_agent_created_report_excludes_bundled_and_hub(skills_home):\n-    from tools.skill_usage import agent_created_report\n+    from tools.skill_usage import agent_created_report, mark_agent_created\n     skills_dir = skills_home / \"skills\"\n     _write_skill(skills_dir, \"mine\")\n     _write_skill(skills_dir, \"bundled\")\n     _write_skill(skills_dir, \"hubbed\")\n+    mark_agent_created(\"mine\")\n     (skills_dir / \".bundled_manifest\").write_text(\"bundled:abc\\n\", encoding=\"utf-8\")\n     hub = skills_dir / \".hub\"\n     hub.mkdir()","@@ -414,6 +431,7 @@ def test_agent_created_report_derives_activity_from_view_and_patch(skills_home,\n     ])\n     monkeypatch.setattr(skill_usage, \"_now_iso\", lambda: next(timestamps))\n \n+    skill_usage.mark_agent_created(\"mine\")\n     skill_usage.bump_view(\"mine\")\n     skill_usage.bump_patch(\"mine\")\n "]},{"file":"tools/skill_manager_tool.py","hunks":["@@ -786,8 +786,10 @@ def skill_manage(\n         # that mutate an existing skill's guidance), drop the record on delete.\n         # Best-effort; telemetry failures never break the tool.\n         try:\n-            from tools.skill_usage import bump_patch, forget\n-            if action in (\"patch\", \"edit\", \"write_file\", \"remove_file\"):\n+            from tools.skill_usage import bump_patch, forget, mark_agent_created\n+            if action == \"create\":\n+                mark_agent_created(name)\n+            elif action in (\"patch\", \"edit\", \"write_file\", \"remove_file\"):\n                 bump_patch(name)\n             elif action == \"delete\":\n                 forget(name)"]},{"file":"tools/skill_usage.py","hunks":["@@ -11,8 +11,9 @@\n   - Atomic writes via tempfile + os.replace (same pattern as .bundled_manifest).\n   - All counter bumps are best-effort: failures log at DEBUG and return silently.\n     A broken sidecar never breaks the underlying tool call.\n-  - Provenance filter: \"agent-created\" == not in .bundled_manifest AND not in\n-    .hub/lock.json. The curator only ever mutates agent-created skills.\n+  - Provenance filter: curator-managed skills are explicitly marked when\n+    created through skill_manage. Bundled / hub-installed skills stay\n+    off-limits, and manually authored skills are not inferred from location.\n \n Lifecycle states:\n     active    -> default","@@ -149,11 +150,13 @@ def _read_hub_installed_names() -> Set[str]:\n \n \n def list_agent_created_skill_names() -> List[str]:\n-    \"\"\"Enumerate skills that were authored by the agent (or user), NOT by a\n-    bundled or hub-installed source.\n+    \"\"\"Enumerate skills explicitly authored by the agent.\n \n-    The curator operates exclusively on this set. Bundled / hub skills are\n-    maintained by their upstream sources and must never be pruned here.\n+    The curator operates exclusively on this set. Skills are only eligible\n+    after ``skill_manage(action=\"create\")`` marks them in ``.usage.json``;\n+    manually authored skills must not be inferred from filesystem location.\n+    Bundled / hub skills are maintained by their upstream sources and must\n+    never be pruned here.\n     \"\"\"\n     base = _skills_dir()\n     if not base.exists():","@@ -161,6 +164,7 @@ def list_agent_created_skill_names() -> List[str]:\n     bundled = _read_bundled_manifest_names()\n     hub = _read_hub_installed_names()\n     off_limits = bundled | hub\n+    usage = load_usage()\n \n     names: List[str] = []\n     # Top-level SKILL.md files (flat layout) AND nested category/skill/SKILL.md","@@ -176,6 +180,8 @@ def list_agent_created_skill_names() -> List[str]:\n         name = _read_skill_name(skill_md, fallback=skill_md.parent.name)\n         if name in off_limits:\n             continue\n+        if not _is_curator_managed_record(usage.get(name)):\n+            continue\n         names.append(name)\n     return sorted(set(names))\n ","@@ -207,12 +213,20 @@ def is_agent_created(skill_name: str) -> bool:\n     return skill_name not in off_limits\n \n \n+def _is_curator_managed_record(record: Any) -> bool:\n+    \"\"\"Return True when a usage record opts a skill into curator management.\"\"\"\n+    if not isinstance(record, dict):\n+        return False\n+    return record.get(\"created_by\") == \"agent\" or record.get(\"agent_created\") is True\n+\n+\n # ---------------------------------------------------------------------------\n # Sidecar I/O\n # ---------------------------------------------------------------------------\n \n def _empty_record() -> Dict[str, Any]:\n     return {\n+        \"created_by\": None,\n         \"use_count\": 0,\n         \"view_count\": 0,\n         \"last_used_at\": None,","@@ -287,9 +301,8 @@ def _mutate(skill_name: str, mutator) -> None:\n     \"\"\"Load, apply *mutator(record)* in place, save. Best-effort.\n \n     Bundled and hub-installed skills are NEVER recorded in the sidecar.\n-    This keeps .usage.json focused on agent-created skills (the only ones\n-    the curator considers) and prevents stale counters from hanging around\n-    for upstream-managed skills.\n+    Local manual skills may still accrue usage telemetry, but they only\n+    become curator-managed when ``created_by`` is explicitly marked.\n     \"\"\"\n     if not skill_name:\n         return","@@ -336,6 +349,17 @@ def _apply(rec: Dict[str, Any]) -> None:\n     _mutate(skill_name, _apply)\n \n \n+def mark_agent_created(skill_name: str) -> None:\n+    \"\"\"Opt a skill created by skill_manage into curator management.\n+\n+    Viewing or invoking a manually authored skill may still create telemetry,\n+    but only this explicit marker makes it eligible for automatic curation.\n+    \"\"\"\n+    def _apply(rec: Dict[str, Any]) -> None:\n+        rec[\"created_by\"] = \"agent\"\n+    _mutate(skill_name, _apply)\n+\n+\n def set_state(skill_name: str, state: str) -> None:\n     \"\"\"Set lifecycle state. No-op if *state* is invalid.\"\"\"\n     if state not in _VALID_STATES:\n"]}]},{"kind":"pr","number":19680,"title":"fix(curator): match hub-installed skills by SKILL.md name, not just slug","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#19460","number":19460}],"category":"stale_abandoned","implication":"active"},"merged":false},{"kind":"pr","number":20194,"title":"fix(curator): protect hub skills by frontmatter name","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_usage.py","tools/skill_usage.py"]}],"trust":{"default_tier":"verified","includes_suggested":false}}