{"repo":"NousResearch/hermes-agent","public_id":"26655","display_label":"Curator LLM agent skill deletion and protection fixes","summary":"Multiple fixes addressing the curator's LLM consolidation agent improperly mutating or permanently deleting skills in external_dirs, with related changes to protect external skills from autonomous curation and preserve archive recovery paths. One unrelated fix for salvaging text-embedded tool calls is also present.","subsystem":"skills","counts":{"members":9,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":2,"merged_prs":2,"attempt_prs":5},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":52849},"resolution":{"state":"partially_resolved","by":"pr-52849"},"freshness":{"state":"verified","last_verified_at":"2026-06-28T03:45:58+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_PARTIAL","link_density":0.20444444444444446},"fix_edges":[{"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},{"issue_number":47688,"pr_number":52849,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true}],"members":[{"kind":"issue","number":26655,"title":"fix(curator): LLM consolidation pass uses skill_manage delete instead of terminal mv, bypassing .archive/ recovery","url":null,"state":"closed"},{"kind":"issue","number":47688,"title":"[Bug]: Curator's LLM consolidation agent mutates/deletes skills in external_dirs; per-profile pins can't protect shared skills","url":null,"state":"closed"},{"kind":"pr","number":26688,"title":"fix(curator): keep consolidation on archive moves","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#53935","number":53935}],"category":"superseded_by_pr","implication":"superseded"},"merged":false},{"kind":"pr","number":47752,"title":"fix(curator): protect external_dirs skills from curator LLM agent mutations","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#52849","number":52849},{"kind":"pr","value":"#51875","number":51875},{"kind":"pr","value":"#51875","number":51875}],"category":"superseded_by_pr","implication":"superseded"},"merged":false},{"kind":"pr","number":51875,"title":"fix(curator): protect external skills from background curation","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"96bc524a7","number":0},{"kind":"pr","value":"#52849","number":52849}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":52174,"title":"fix(curator): prevent permanent skill deletion in LLM consolidation pass","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#53935","number":53935},{"kind":"pr","value":"#53935","number":53935},{"kind":"pr","value":"#26655","number":26655},{"kind":"pr","value":"#29912","number":29912}],"category":"superseded_by_pr","implication":"superseded"},"merged":false},{"kind":"pr","number":52847,"title":"fix(anthropic): salvage text-embedded tool calls not promoted to tool_use","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#47472","number":47472}],"category":"uncategorized","implication":"needs_llm"},"merged":false},{"kind":"pr","number":52849,"title":"fix(curator): protect skills.external_dirs from autonomous curation (#47688)","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/skill_utils.py","scripts/release.py","tests/agent/test_curator.py","tests/agent/test_skill_utils.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":"agent/curator.py","hunks":["@@ -377,8 +377,10 @@ def apply_automatic_transitions(now: Optional[datetime] = None) -> Dict[str, int\n     \"bodies + `references/`, `templates/`, and `scripts/` subfiles for \"\n     \"session-specific detail — not one-session-one-skill micro-entries.\\n\\n\"\n     \"Hard rules — do not violate:\\n\"\n-    \"1. DO NOT touch bundled or hub-installed skills. The candidate list \"\n-    \"below is already filtered to agent-created skills only.\\n\"\n+    \"1. DO NOT touch bundled, hub-installed, or external-dir skills \"\n+    \"(`skills.external_dirs`). The candidate list below is already filtered \"\n+    \"to local curator-managed skills only; external skills are externally \"\n+    \"owned and read-only to this background curator.\\n\"\n     \"2. DO NOT delete any skill. Archiving (moving the skill's directory \"\n     \"into ~/.hermes/skills/.archive/) is the maximum destructive action. \"\n     \"Archives are recoverable; deletion is not.\\n\"","@@ -469,8 +471,9 @@ def apply_automatic_transitions(now: Optional[datetime] = None) -> Dict[str, int\n     \"skill, or `absorbed_into=\\\"\\\"` when you're truly pruning with no \"\n     \"forwarding target. This drives cron-job skill-reference migration — \"\n     \"guessing from your YAML summary after the fact is fragile.\\n\"\n-    \"  - terminal                       — mv a sibling into the archive \"\n-    \"OR move its content into a support subfile\\n\\n\"\n+    \"  - terminal                       — move LOCAL candidate content into \"\n+    \"a support subfile when package integrity requires it; never mv, cp, rm, \"\n+    \"patch, or rewrite bundled, hub-installed, or external-dir skills\\n\\n\"\n     \"'keep' is a legitimate decision ONLY when the skill is already a \"\n     \"class-level umbrella and none of the proposed merges would improve \"\n     \"discoverability. 'This is narrow but distinct from its siblings' \"","@@ -1843,6 +1846,14 @@ def _run_llm_review(prompt: str) -> Dict[str, Any]:\n         # Disable recursive nudges — the curator must never spawn its own review.\n         review_agent._memory_nudge_interval = 0\n         review_agent._skill_nudge_interval = 0\n+        # Tag this fork as autonomous background curation so skill_manage's\n+        # background-review write guard fires. Without this the fork inherits\n+        # the default \"assistant_tool\" origin, is_background_review() is False,\n+        # and the external/bundled/hub-installed skill_manage guards never\n+        # trigger during the curation pass they exist to protect against.\n+        # turn_context.py binds this onto the write-origin ContextVar at turn\n+        # start (see agent/turn_context.py).\n+        review_agent._memory_write_origin = \"background_review\"\n \n         # Redirect the forked agent's stdout/stderr to /dev/null while it\n         # runs so its tool-call chatter doesn't pollute the foreground"]},{"file":"agent/skill_utils.py","hunks":["@@ -507,6 +507,34 @@ def get_all_skills_dirs() -> List[Path]:\n     return dirs\n \n \n+def _resolve_for_skill_ownership(path) -> Path:\n+    path_obj = path if isinstance(path, Path) else Path(str(path))\n+    try:\n+        return path_obj.expanduser().resolve()\n+    except (OSError, RuntimeError):\n+        return path_obj.expanduser().absolute()\n+\n+\n+def is_external_skill_path(path) -> bool:\n+    \"\"\"Return True when ``path`` lives under a configured external skills dir.\n+\n+    ``skills.external_dirs`` are externally owned: Hermes can discover and view\n+    their skills, and foreground user-directed tool calls may still edit them,\n+    but autonomous lifecycle maintenance must treat them as read-only. This\n+    helper centralizes the ownership boundary so curator/reporting/tool paths do\n+    not each need to re-interpret the config.\n+    \"\"\"\n+    candidate = _resolve_for_skill_ownership(path)\n+    for root in get_external_skills_dirs():\n+        resolved_root = _resolve_for_skill_ownership(root)\n+        try:\n+            candidate.relative_to(resolved_root)\n+            return True\n+        except ValueError:\n+            continue\n+    return False\n+\n+\n # ── Condition extraction ──────────────────────────────────────────────────\n \n "]},{"file":"scripts/release.py","hunks":["@@ -1644,7 +1644,7 @@\n     \"[redacted:email]\": \"emonty\",\n     \"[redacted:email]\": \"rodrigoeqnit\",\n     \"[redacted:email]\": \"jonpol01\",\n-    \"[redacted:email]\": \"yu-xin-c\",  # PR #32122 (Docker audio bridge notes)\n+    \"[redacted:email]\": \"yu-xin-c\",  # PR #51875 salvage (protect external_dirs skills from curator)\n     \"[redacted:email]\": \"BaxBit\",  # PR #30200 (Svix webhook signature validation)\n     \"[redacted:email]\": \"duyua9\",  # PR #10949 (render object config values structurally)\n     \"ethie@nous\": \"ethernet8023\",  # PR #29342 (TUI clipboard copy on linux/wayland)"]},{"file":"tests/agent/test_curator.py","hunks":["@@ -1119,3 +1119,54 @@ def test_curator_slot_is_canonical_aux_task():\n \n     # 4. web/src/pages/ModelsPage.tsx is checked at build time; the tsx\n     #    array and this tuple share a ``Must match _AUX_TASK_SLOTS`` comment.\n+\n+\n+def test_review_fork_runs_under_background_review_origin(curator_env, monkeypatch):\n+    \"\"\"The curator LLM fork must tag itself as background_review.\n+\n+    This is the keystone that makes skill_manager_tool's\n+    ``_background_review_write_guard`` fire during a curation pass. Without\n+    ``_memory_write_origin = \"background_review\"`` on the fork, the agent\n+    inherits the default ``assistant_tool`` origin, ``is_background_review()``\n+    stays False, and the external/bundled/hub-installed skill_manage guards\n+    never trigger — leaving the LLM agent free to mutate skills.external_dirs\n+    skills (GH-47688). turn_context.py binds this attribute onto the\n+    write-origin ContextVar at turn start, so asserting it is set is the\n+    enforceable invariant linking the fork to the guard.\n+    \"\"\"\n+    curator = curator_env[\"curator\"]\n+\n+    # The curator_env fixture stubs out _run_llm_review wholesale; this test\n+    # exercises the real implementation, so reload the module to restore it.\n+    import importlib\n+    importlib.reload(curator)\n+    monkeypatch.setattr(curator, \"_load_config\", lambda: {})\n+\n+    captured = {}\n+\n+    class _StubAgent:\n+        def __init__(self, *args, **kwargs):\n+            # AIAgent.__init__ normally sets this default; mirror it so the\n+            # production assignment in _run_llm_review is what flips it.\n+            self._memory_write_origin = \"assistant_tool\"\n+            self._memory_nudge_interval = 10\n+            self._skill_nudge_interval = 10\n+            self.platform = kwargs.get(\"platform\")\n+            self._session_messages = []\n+\n+        def run_conversation(self, user_message=None, **kwargs):\n+            # Capture the origin AT RUN TIME — i.e. after _run_llm_review has\n+            # finished configuring the fork, which is exactly when it matters.\n+            captured[\"write_origin\"] = self._memory_write_origin\n+            return {\"final_response\": \"no change\"}\n+\n+    monkeypatch.setattr(\"run_agent.AIAgent\", _StubAgent)\n+\n+    meta = curator._run_llm_review(\"review prompt\")\n+\n+    assert meta.get(\"error\") is None, meta.get(\"error\")\n+    assert captured.get(\"write_origin\") == \"background_review\", (\n+        \"curator review fork did not set _memory_write_origin to \"\n+        \"'background_review' — the skill_manage background-review write \"\n+        \"guard would not fire (GH-47688 regression)\"\n+    )"]},{"file":"tests/agent/test_skill_utils.py","hunks":["@@ -7,6 +7,7 @@\n     get_disabled_skill_names,\n     get_external_skills_dirs,\n     is_excluded_skill_path,\n+    is_external_skill_path,\n     is_skill_support_path,\n     iter_skill_index_files,\n     resolve_skill_config_values,","@@ -168,6 +169,28 @@ def test_skill_config_raw_cache_invalidates_on_config_edit(tmp_path, monkeypatch\n     os.utime(config_path, None)\n \n     assert get_disabled_skill_names() == {\"new-skill\"}\n+\n+\n+def test_is_external_skill_path_matches_configured_external_dir(tmp_path, monkeypatch):\n+    from agent import skill_utils\n+\n+    hermes_home = tmp_path / \".hermes\"\n+    local_skills = hermes_home / \"skills\"\n+    external = tmp_path / \"external-skills\"\n+    local_skills.mkdir(parents=True)\n+    external.mkdir()\n+    (hermes_home / \"config.yaml\").write_text(\n+        f\"skills:\\n  external_dirs:\\n    - {external}\\n\",\n+        encoding=\"utf-8\",\n+    )\n+\n+    monkeypatch.setenv(\"HERMES_HOME\", str(hermes_home))\n+    skill_utils._external_dirs_cache_clear()\n+\n+    assert is_external_skill_path(external / \"team-skill\" / \"SKILL.md\") is True\n+    assert is_external_skill_path(local_skills / \"local-skill\" / \"SKILL.md\") is False\n+\n+\n def test_iter_skill_index_files_prunes_skill_support_dirs(tmp_path):\n     \"\"\"Archived package SKILL.md files under support dirs are not active skills.\"\"\"\n     real = tmp_path / \"umbrella\""]},{"file":"tests/tools/test_skill_manager_tool.py","hunks":["@@ -600,6 +600,35 @@ def test_delete_via_dispatcher_rejects_missing_absorbed_target(self, tmp_path):\n         assert result[\"success\"] is False\n         assert \"does not exist\" in result[\"error\"]\n \n+    def test_background_review_delete_refuses_bundled_even_with_absorbed_into(self, tmp_path):\n+        from tools.skill_provenance import (\n+            BACKGROUND_REVIEW,\n+            reset_current_write_origin,\n+            set_current_write_origin,\n+        )\n+\n+        token = [redacted:secret-assignment]\n+        try:\n+            with _skill_dir(tmp_path), \\\n+                 patch(\"tools.skill_usage.is_protected_builtin\", return_value=False), \\\n+                 patch(\"tools.skill_usage.is_hub_installed\", return_value=False), \\\n+                 patch(\"tools.skill_usage.is_bundled\",\n+                       side_effect=lambda skill_name: skill_name == \"bundled\"):\n+                skill_manage(action=\"create\", name=\"umbrella\", content=VALID_SKILL_CONTENT)\n+                skill_manage(action=\"create\", name=\"bundled\", content=VALID_SKILL_CONTENT)\n+                raw = skill_manage(\n+                    action=\"delete\",\n+                    name=\"bundled\",\n+                    absorbed_into=\"umbrella\",\n+                )\n+        finally:\n+            reset_current_write_origin(token)\n+\n+        result = json.loads(raw)\n+        assert result[\"success\"] is False\n+        assert \"bundled\" in result[\"error\"].lower()\n+        assert (tmp_path / \"bundled\" / \"SKILL.md\").exists()\n+\n \n class TestSecurityScanGate:\n     \"\"\"_security_scan_skill is gated by skills.guard_agent_created config flag.\"\"\"","@@ -849,6 +878,40 @@ def test_create_still_writes_to_local_root(self, tmp_path):\n         assert (local / \"fresh-skill\" / \"SKILL.md\").exists()\n         assert not (external / \"fresh-skill\").exists()\n \n+    def test_background_review_refuses_to_patch_external_skill(self, tmp_path):\n+        \"\"\"Autonomous curator runs treat skills.external_dirs as read-only.\"\"\"\n+        from tools.skill_provenance import (\n+            BACKGROUND_REVIEW,\n+            reset_current_write_origin,\n+            set_current_write_origin,\n+        )\n+\n+        local = tmp_path / \"local\"\n+        external = tmp_path / \"vault\"\n+        local.mkdir(); external.mkdir()\n+        skill_dir = _write_external_skill(external)\n+\n+        token = [redacted:secret-assignment]\n+        try:\n+            with _two_roots(local, external), patch(\n+                \"agent.skill_utils.get_external_skills_dirs\",\n+                return_value=[external.resolve()],\n+            ):\n+                raw = skill_manage(\n+                    action=\"patch\",\n+                    name=\"ext-skill\",\n+                    old_string=\"OLD_MARKER\",\n+                    new_string=\"NEW_MARKER\",\n+                )\n+        finally:\n+            reset_current_write_origin(token)\n+\n+        result = json.loads(raw)\n+        assert result[\"success\"] is False\n+        assert \"external\" in result[\"error\"].lower()\n+        assert \"OLD_MARKER\" in (skill_dir / \"SKILL.md\").read_text()\n+        assert \"NEW_MARKER\" not in (skill_dir / \"SKILL.md\").read_text()\n+\n \n \n # ---------------------------------------------------------------------------"]},{"file":"tests/tools/test_skill_usage.py","hunks":["@@ -341,6 +341,29 @@ def test_agent_created_skips_archive_and_hub_dirs(skills_home):\n     assert \"old-skill\" not in names\n \n \n+def test_agent_created_excludes_external_dir_even_with_stale_agent_record(skills_home, monkeypatch):\n+    from tools.skill_usage import (\n+        agent_created_report,\n+        is_agent_created,\n+        list_agent_created_skill_names,\n+        save_usage,\n+    )\n+\n+    skills_dir = skills_home / \"skills\"\n+    external = skills_dir / \"shared-vault\"\n+    _write_skill(external, \"external-skill\")\n+    save_usage({\"external-skill\": {\"created_by\": \"agent\"}})\n+\n+    monkeypatch.setattr(\n+        \"agent.skill_utils.get_external_skills_dirs\",\n+        lambda: [external.resolve()],\n+    )\n+\n+    assert \"external-skill\" not in list_agent_created_skill_names()\n+    assert \"external-skill\" not in {r[\"name\"] for r in agent_created_report()}\n+    assert is_agent_created(\"external-skill\") is False\n+\n+\n # ---------------------------------------------------------------------------\n # Archive / restore\n # ---------------------------------------------------------------------------","@@ -384,6 +407,23 @@ def test_archive_refuses_hub_skill(skills_home):\n     assert not ok\n \n \n+def test_archive_refuses_external_skill(skills_home, monkeypatch):\n+    from tools.skill_usage import archive_skill\n+\n+    skills_dir = skills_home / \"skills\"\n+    external = skills_dir / \"shared-vault\"\n+    skill_dir = _write_skill(external, \"external-skill\")\n+    monkeypatch.setattr(\n+        \"agent.skill_utils.get_external_skills_dirs\",\n+        lambda: [external.resolve()],\n+    )\n+\n+    ok, msg = archive_skill(\"external-skill\")\n+    assert not ok\n+    assert \"external\" in msg.lower()\n+    assert skill_dir.exists()\n+\n+\n def test_archive_missing_skill_returns_error(skills_home):\n     from tools.skill_usage import archive_skill\n     ok, msg = archive_skill(\"nonexistent\")"]},{"file":"tools/skill_manager_tool.py","hunks":["@@ -235,6 +235,79 @@ def _pinned_guard(name: str) -> Optional[str]:\n     return None\n \n \n+def _background_review_write_guard(\n+    name: str,\n+    skill_dir: Path,\n+    action: str,\n+) -> Optional[Dict[str, Any]]:\n+    \"\"\"Refuse autonomous curator writes to externally owned skills.\n+\n+    Foreground agents may still perform user-directed edits to external,\n+    bundled, or hub-installed skills. The background review fork is different:\n+    it is autonomous lifecycle maintenance, so its write surface is restricted\n+    to local curator-owned sediment.\n+    \"\"\"\n+    try:\n+        from tools.skill_provenance import is_background_review\n+        if not is_background_review():\n+            return None\n+    except Exception:\n+        return None\n+\n+    try:\n+        from agent.skill_utils import is_external_skill_path\n+        if is_external_skill_path(skill_dir):\n+            return {\n+                \"success\": False,\n+                \"error\": (\n+                    f\"Refusing background curator {action} for skill '{name}': \"\n+                    \"the skill lives in skills.external_dirs, which are \"\n+                    \"externally owned and read-only to autonomous curation.\"\n+                ),\n+            }\n+    except Exception:\n+        logger.debug(\"external skill guard lookup failed for %s\", name, exc_info=True)\n+\n+    try:\n+        from tools import skill_usage\n+        if skill_usage.is_protected_builtin(name):\n+            return {\n+                \"success\": False,\n+                \"error\": (\n+                    f\"Refusing background curator {action} for protected \"\n+                    f\"built-in skill '{name}'.\"\n+                ),\n+            }\n+        if skill_usage.is_hub_installed(name):\n+            return {\n+                \"success\": False,\n+                \"error\": (\n+                    f\"Refusing background curator {action} for hub-installed \"\n+                    f\"skill '{name}'.\"\n+                ),\n+            }\n+        if skill_usage.is_bundled(name):\n+            return {\n+                \"success\": False,\n+                \"error\": (\n+                    f\"Refusing background curator {action} for bundled \"\n+                    f\"skill '{name}'.\"\n+                ),\n+            }\n+    except Exception:\n+        logger.debug(\"owned skill guard lookup failed for %s\", name, exc_info=True)\n+    return None\n+\n+\n+def _background_review_preflight(action: str, name: str) -> Optional[Dict[str, Any]]:\n+    if action not in {\"edit\", \"patch\", \"delete\", \"write_file\", \"remove_file\"}:\n+        return None\n+    existing = _find_skill(name)\n+    if not existing:\n+        return None\n+    return _background_review_write_guard(name, existing[\"path\"], action)\n+\n+\n MAX_SKILL_CONTENT_CHARS = 100_000   # ~36k tokens at 2.75 chars/token\n MAX_SKILL_FILE_BYTES = 1_048_576    # 1 MiB per supporting file\n ","@@ -637,6 +710,9 @@ def _edit_skill(name: str, content: str) -> Dict[str, Any]:\n     existing = _find_skill(name)\n     if not existing:\n         return {\"success\": False, \"error\": _skill_not_found_error(name)}\n+    guard = _background_review_write_guard(name, existing[\"path\"], \"edit\")\n+    if guard:\n+        return guard\n \n     skill_md = existing[\"path\"] / \"SKILL.md\"\n     # Back up original content for rollback","@@ -690,6 +766,9 @@ def _patch_skill(\n         return {\"success\": False, \"error\": _skill_not_found_error(name)}\n \n     skill_dir = existing[\"path\"]\n+    guard = _background_review_write_guard(name, skill_dir, \"patch\")\n+    if guard:\n+        return guard\n \n     if file_path:\n         # Patching a supporting file","@@ -783,6 +862,9 @@ def _delete_skill(name: str, absorbed_into: Optional[str] = None) -> Dict[str, A\n     existing = _find_skill(name)\n     if not existing:\n         return {\"success\": False, \"error\": _skill_not_found_error(name)}\n+    guard = _background_review_write_guard(name, existing[\"path\"], \"delete\")\n+    if guard:\n+        return guard\n \n     pinned_err = _pinned_guard(name)\n     if pinned_err:","@@ -858,6 +940,9 @@ def _write_file(name: str, file_path: str, file_content: str) -> Dict[str, Any]:\n     existing = _find_skill(name)\n     if not existing:\n         return {\"success\": False, \"error\": _skill_not_found_error(name, \" Create it first with action='create'.\")}\n+    guard = _background_review_write_guard(name, existing[\"path\"], \"write_file\")\n+    if guard:\n+        return guard\n \n     target, err = _resolve_skill_target(existing[\"path\"], file_path)\n     if err:","@@ -894,6 +979,9 @@ def _remove_file(name: str, file_path: str) -> Dict[str, Any]:\n         return {\"success\": False, \"error\": _skill_not_found_error(name)}\n \n     skill_dir = existing[\"path\"]\n+    guard = _background_review_write_guard(name, skill_dir, \"remove_file\")\n+    if guard:\n+        return guard\n \n     target, err = _resolve_skill_target(skill_dir, file_path)\n     if err:","@@ -1016,6 +1104,10 @@ def skill_manage(\n \n     Returns JSON string with results.\n     \"\"\"\n+    preflight = _background_review_preflight(action, name)\n+    if preflight is not None:\n+        return json.dumps(preflight, ensure_ascii=False)\n+\n     # Approval gate: when on, stages the write for review (skills are too large\n     # to review inline, so they always stage regardless of origin); when off\n     # (default) passes straight through. The gate is bypassed when this call is"]},{"file":"tools/skill_usage.py","hunks":["@@ -34,7 +34,7 @@\n from typing import Any, Dict, List, Optional, Set, Tuple\n \n from hermes_constants import get_hermes_home\n-from agent.skill_utils import is_excluded_skill_path\n+from agent.skill_utils import is_excluded_skill_path, is_external_skill_path\n \n logger = logging.getLogger(__name__)\n ","@@ -352,6 +352,10 @@ def list_agent_created_skill_names() -> List[str]:\n         # Skip Hermes metadata, VCS, virtualenv/dependency, and cache dirs\n         if is_excluded_skill_path(skill_md):\n             continue\n+        # External skill dirs can be mounted below the local skills tree.\n+        # Discovery may see them, but autonomous lifecycle curation must not.\n+        if is_external_skill_path(skill_md):\n+            continue\n         try:\n             skill_md.relative_to(base)\n         except ValueError:","@@ -415,7 +419,12 @@ def _read_skill_name(skill_md: Path, fallback: str) -> str:\n def is_agent_created(skill_name: str) -> bool:\n     \"\"\"Whether *skill_name* is neither bundled nor hub-installed.\"\"\"\n     off_limits = _read_bundled_manifest_names() | _read_hub_installed_names()\n-    return skill_name not in off_limits\n+    if skill_name in off_limits:\n+        return False\n+    return not (\n+        _find_skill_dir(skill_name) is None\n+        and _find_external_skill_dir(skill_name) is not None\n+    )\n \n \n def is_hub_installed(skill_name: str) -> bool:","@@ -428,21 +437,36 @@ def is_bundled(skill_name: str) -> bool:\n     return skill_name in _read_bundled_manifest_names()\n \n \n-def is_curation_eligible(skill_name: str) -> bool:\n+def _external_read_only_message(skill_name: str) -> str:\n+    return (\n+        f\"skill '{skill_name}' lives in skills.external_dirs; \"\n+        \"external skills are read-only to the curator\"\n+    )\n+\n+\n+def is_curation_eligible(skill_name: str, skill_path: Optional[Path] = None) -> bool:\n     \"\"\"Whether the curator may track/archive *skill_name*.\n \n     Agent-created skills are always eligible. Bundled built-ins become eligible\n-    only when ``curator.prune_builtins`` is enabled. Hub-installed skills are\n-    NEVER eligible — they have an external upstream owner. Protected built-ins\n-    (``PROTECTED_BUILTIN_SKILLS``) are NEVER eligible regardless of any flag —\n-    they back load-bearing UX and must never be archived or consolidated.\n+    only when ``curator.prune_builtins`` is enabled. Hub-installed and external\n+    skill-dir skills are NEVER eligible — they have an external upstream owner.\n+    Protected built-ins (``PROTECTED_BUILTIN_SKILLS``) are NEVER eligible\n+    regardless of any flag — they back load-bearing UX and must never be\n+    archived or consolidated.\n     \"\"\"\n+    if skill_path is not None and is_external_skill_path(skill_path):\n+        return False\n     if is_protected_builtin(skill_name):\n         return False\n     if is_hub_installed(skill_name):\n         return False\n     if is_bundled(skill_name):\n         return _prune_builtins_enabled()\n+    local_dir = _find_skill_dir(skill_name)\n+    if local_dir is not None:\n+        return not is_external_skill_path(local_dir)\n+    if _find_external_skill_dir(skill_name) is not None:\n+        return False\n     return True\n \n ","@@ -677,7 +701,11 @@ def archive_skill(skill_name: str) -> Tuple[bool, str]:\n     when one is archived, its name is added to the suppression list so the\n     update-time re-seeder leaves it archived instead of restoring it.\n     \"\"\"\n-    if not is_curation_eligible(skill_name):\n+    local_skill_dir = _find_skill_dir(skill_name)\n+    if local_skill_dir is None and _find_external_skill_dir(skill_name) is not None:\n+        return False, _external_read_only_message(skill_name)\n+\n+    if not is_curation_eligible(skill_name, local_skill_dir):\n         if is_protected_builtin(skill_name):\n             return False, (\n                 f\"skill '{skill_name}' is a protected built-in; it backs \"","@@ -690,9 +718,11 @@ def archive_skill(skill_name: str) -> Tuple[bool, str]:\n             \"curator.prune_builtins to allow pruning it\"\n         )\n \n-    skill_dir = _find_skill_dir(skill_name)\n+    skill_dir = local_skill_dir\n     if skill_dir is None:\n         return False, f\"skill '{skill_name}' not found\"\n+    if is_external_skill_path(skill_dir):\n+        return False, _external_read_only_message(skill_name)\n \n     archive_root = _archive_dir()\n     try:","@@ -811,11 +841,28 @@ def _find_skill_dir(skill_name: str) -> Optional[Path]:\n     for skill_md in base.rglob(\"SKILL.md\"):\n         if is_excluded_skill_path(skill_md):\n             continue\n+        if is_external_skill_path(skill_md):\n+            continue\n         if _read_skill_name(skill_md, fallback=skill_md.parent.name) == skill_name:\n             return skill_md.parent\n     return None\n \n \n+def _find_external_skill_dir(skill_name: str) -> Optional[Path]:\n+    \"\"\"Locate a skill under configured external dirs by frontmatter name.\"\"\"\n+    from agent.skill_utils import get_all_skills_dirs\n+\n+    for base in get_all_skills_dirs()[1:]:\n+        if not base.exists():\n+            continue\n+        for skill_md in base.rglob(\"SKILL.md\"):\n+            if is_excluded_skill_path(skill_md):\n+                continue\n+            if _read_skill_name(skill_md, fallback=skill_md.parent.name) == skill_name:\n+                return skill_md.parent\n+    return None\n+\n+\n # ---------------------------------------------------------------------------\n # Reporting — for the curator CLI / slash command\n # ---------------------------------------------------------------------------\n"]}]},{"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}}