# NousResearch/hermes-agent · Curator LLM agent skill deletion and protection fixes

## Open now

- Nothing open on GitHub.

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.

## TL;DR

- 9 member(s): 0 open issue(s), 2 closed issue(s), 0 open PR(s), 2 merged PR(s), 5 PR(s) closed without merging.
- A fix was merged to main in PR #52849. Verify it addresses your case.
- **Recommended:** read PR #52849 (and any competing PRs below) before filing. Confirm the merged change covers your symptom.

## Maintainer resolution verdict

- Internal-tracker verdict over this cluster's issue members: `partially_resolved` · deciding PR(s): `pr-52849`. This is distinct from the fix status above. Weigh it yourself.

## How this cluster was grouped

- Link evidence (`STRUCTURAL_PARTIAL`): some members are connected by structural evidence (shared changed files, or a closing/superseded reference); the rest are joined by an automated relevance judgement over their text.
- Members outside that structural core may belong to a different underlying problem.
- Pairwise link density `0.20`: the share of member pairs that carry a link, counting a structural link in full and a relevance-only link at 0.36. `1.00` means every pair is linked.

## Closed / duplicate issues

- #26655: fix(curator): LLM consolidation pass uses skill_manage delete instead of terminal mv, bypassing .archive/ recovery
- #47688: \[Bug\]: Curator's LLM consolidation agent mutates/deletes skills in external_dirs; per-profile pins can't protect shared skills

## Prior & competing PRs

- **#52849: fix(curator): protect skills.external_dirs from autonomous curation (#47688)**. merged to main · fix tier `verified`
  - maintainer direction: category `merged`; implication `superseded`
  - files: `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:
  - `agent/curator.py`

    ```diff
    @@ -377,8 +377,10 @@ def apply_automatic_transitions(now: Optional[datetime] = None) -> Dict[str, int
         "bodies + `references/`, `templates/`, and `scripts/` subfiles for "
         "session-specific detail — not one-session-one-skill micro-entries.\n\n"
         "Hard rules — do not violate:\n"
    -    "1. DO NOT touch bundled or hub-installed skills. The candidate list "
    -    "below is already filtered to agent-created skills only.\n"
    +    "1. DO NOT touch bundled, hub-installed, or external-dir skills "
    +    "(`skills.external_dirs`). The candidate list below is already filtered "
    +    "to local curator-managed skills only; external skills are externally "
    +    "owned and read-only to this background curator.\n"
         "2. DO NOT delete any skill. Archiving (moving the skill's directory "
         "into ~/.hermes/skills/.archive/) is the maximum destructive action. "
         "Archives are recoverable; deletion is not.\n"
    ```

    ```diff
    @@ -469,8 +471,9 @@ def apply_automatic_transitions(now: Optional[datetime] = None) -> Dict[str, int
         "skill, or `absorbed_into=\"\"` when you're truly pruning with no "
         "forwarding target. This drives cron-job skill-reference migration — "
         "guessing from your YAML summary after the fact is fragile.\n"
    -    "  - terminal                       — mv a sibling into the archive "
    -    "OR move its content into a support subfile\n\n"
    +    "  - terminal                       — move LOCAL candidate content into "
    +    "a support subfile when package integrity requires it; never mv, cp, rm, "
    +    "patch, or rewrite bundled, hub-installed, or external-dir skills\n\n"
         "'keep' is a legitimate decision ONLY when the skill is already a "
         "class-level umbrella and none of the proposed merges would improve "
         "discoverability. 'This is narrow but distinct from its siblings' "
    ```

    ```diff
    @@ -1843,6 +1846,14 @@ def _run_llm_review(prompt: str) -> Dict[str, Any]:
             # Disable recursive nudges — the curator must never spawn its own review.
             review_agent._memory_nudge_interval = 0
             review_agent._skill_nudge_interval = 0
    +        # Tag this fork as autonomous background curation so skill_manage's
    +        # background-review write guard fires. Without this the fork inherits
    +        # the default "assistant_tool" origin, is_background_review() is False,
    +        # and the external/bundled/hub-installed skill_manage guards never
    +        # trigger during the curation pass they exist to protect against.
    +        # turn_context.py binds this onto the write-origin ContextVar at turn
    +        # start (see agent/turn_context.py).
    +        review_agent._memory_write_origin = "background_review"
     
             # Redirect the forked agent's stdout/stderr to /dev/null while it
             # runs so its tool-call chatter doesn't pollute the foreground
    ```
  - `agent/skill_utils.py`

    ```diff
    @@ -507,6 +507,34 @@ def get_all_skills_dirs() -> List[Path]:
         return dirs
     
     
    +def _resolve_for_skill_ownership(path) -> Path:
    +    path_obj = path if isinstance(path, Path) else Path(str(path))
    +    try:
    +        return path_obj.expanduser().resolve()
    +    except (OSError, RuntimeError):
    +        return path_obj.expanduser().absolute()
    +
    +
    +def is_external_skill_path(path) -> bool:
    +    """Return True when ``path`` lives under a configured external skills dir.
    +
    +    ``skills.external_dirs`` are externally owned: Hermes can discover and view
    +    their skills, and foreground user-directed tool calls may still edit them,
    +    but autonomous lifecycle maintenance must treat them as read-only. This
    +    helper centralizes the ownership boundary so curator/reporting/tool paths do
    +    not each need to re-interpret the config.
    +    """
    +    candidate = _resolve_for_skill_ownership(path)
    +    for root in get_external_skills_dirs():
    +        resolved_root = _resolve_for_skill_ownership(root)
    +        try:
    +            candidate.relative_to(resolved_root)
    +            return True
    +        except ValueError:
    +            continue
    +    return False
    +
    +
     # ── Condition extraction ──────────────────────────────────────────────────
     
     
    ```
  - `scripts/release.py`

    ```diff
    @@ -1644,7 +1644,7 @@
         "[redacted:email]": "emonty",
         "[redacted:email]": "rodrigoeqnit",
         "[redacted:email]": "jonpol01",
    -    "[redacted:email]": "yu-xin-c",  # PR #32122 (Docker audio bridge notes)
    +    "[redacted:email]": "yu-xin-c",  # PR #51875 salvage (protect external_dirs skills from curator)
         "[redacted:email]": "BaxBit",  # PR #30200 (Svix webhook signature validation)
         "[redacted:email]": "duyua9",  # PR #10949 (render object config values structurally)
         "ethie@nous": "ethernet8023",  # PR #29342 (TUI clipboard copy on linux/wayland)
    ```
  - `tests/agent/test_curator.py`

    ```diff
    @@ -1119,3 +1119,54 @@ def test_curator_slot_is_canonical_aux_task():
     
         # 4. web/src/pages/ModelsPage.tsx is checked at build time; the tsx
         #    array and this tuple share a ``Must match _AUX_TASK_SLOTS`` comment.
    +
    +
    +def test_review_fork_runs_under_background_review_origin(curator_env, monkeypatch):
    +    """The curator LLM fork must tag itself as background_review.
    +
    +    This is the keystone that makes skill_manager_tool's
    +    ``_background_review_write_guard`` fire during a curation pass. Without
    +    ``_memory_write_origin = "background_review"`` on the fork, the agent
    +    inherits the default ``assistant_tool`` origin, ``is_background_review()``
    +    stays False, and the external/bundled/hub-installed skill_manage guards
    +    never trigger — leaving the LLM agent free to mutate skills.external_dirs
    +    skills (GH-47688). turn_context.py binds this attribute onto the
    +    write-origin ContextVar at turn start, so asserting it is set is the
    +    enforceable invariant linking the fork to the guard.
    +    """
    +    curator = curator_env["curator"]
    +
    +    # The curator_env fixture stubs out _run_llm_review wholesale; this test
    +    # exercises the real implementation, so reload the module to restore it.
    +    import importlib
    +    importlib.reload(curator)
    +    monkeypatch.setattr(curator, "_load_config", lambda: {})
    +
    +    captured = {}
    +
    +    class _StubAgent:
    +        def __init__(self, *args, **kwargs):
    +            # AIAgent.__init__ normally sets this default; mirror it so the
    +            # production assignment in _run_llm_review is what flips it.
    +            self._memory_write_origin = "assistant_tool"
    +            self._memory_nudge_interval = 10
    +            self._skill_nudge_interval = 10
    +            self.platform = kwargs.get("platform")
    +            self._session_messages = []
    +
    +        def run_conversation(self, user_message=None, **kwargs):
    +            # Capture the origin AT RUN TIME — i.e. after _run_llm_review has
    +            # finished configuring the fork, which is exactly when it matters.
    +            captured["write_origin"] = self._memory_write_origin
    +            return {"final_response": "no change"}
    +
    +    monkeypatch.setattr("run_agent.AIAgent", _StubAgent)
    +
    +    meta = curator._run_llm_review("review prompt")
    +
    +    assert meta.get("error") is None, meta.get("error")
    +    assert captured.get("write_origin") == "background_review", (
    +        "curator review fork did not set _memory_write_origin to "
    +        "'background_review' — the skill_manage background-review write "
    +        "guard would not fire (GH-47688 regression)"
    +    )
    ```
  - `tests/agent/test_skill_utils.py`

    ```diff
    @@ -7,6 +7,7 @@
         get_disabled_skill_names,
         get_external_skills_dirs,
         is_excluded_skill_path,
    +    is_external_skill_path,
         is_skill_support_path,
         iter_skill_index_files,
         resolve_skill_config_values,
    ```

    ```diff
    @@ -168,6 +169,28 @@ def test_skill_config_raw_cache_invalidates_on_config_edit(tmp_path, monkeypatch
         os.utime(config_path, None)
     
         assert get_disabled_skill_names() == {"new-skill"}
    +
    +
    +def test_is_external_skill_path_matches_configured_external_dir(tmp_path, monkeypatch):
    +    from agent import skill_utils
    +
    +    hermes_home = tmp_path / ".hermes"
    +    local_skills = hermes_home / "skills"
    +    external = tmp_path / "external-skills"
    +    local_skills.mkdir(parents=True)
    +    external.mkdir()
    +    (hermes_home / "config.yaml").write_text(
    +        f"skills:\n  external_dirs:\n    - {external}\n",
    +        encoding="utf-8",
    +    )
    +
    +    monkeypatch.setenv("HERMES_HOME", str(hermes_home))
    +    skill_utils._external_dirs_cache_clear()
    +
    +    assert is_external_skill_path(external / "team-skill" / "SKILL.md") is True
    +    assert is_external_skill_path(local_skills / "local-skill" / "SKILL.md") is False
    +
    +
     def test_iter_skill_index_files_prunes_skill_support_dirs(tmp_path):
         """Archived package SKILL.md files under support dirs are not active skills."""
         real = tmp_path / "umbrella"
    ```
  - `tests/tools/test_skill_manager_tool.py`

    ```diff
    @@ -600,6 +600,35 @@ def test_delete_via_dispatcher_rejects_missing_absorbed_target(self, tmp_path):
             assert result["success"] is False
             assert "does not exist" in result["error"]
     
    +    def test_background_review_delete_refuses_bundled_even_with_absorbed_into(self, tmp_path):
    +        from tools.skill_provenance import (
    +            BACKGROUND_REVIEW,
    +            reset_current_write_origin,
    +            set_current_write_origin,
    +        )
    +
    +        token = [redacted:secret-assignment]
    +        try:
    +            with _skill_dir(tmp_path), \
    +                 patch("tools.skill_usage.is_protected_builtin", return_value=False), \
    +                 patch("tools.skill_usage.is_hub_installed", return_value=False), \
    +                 patch("tools.skill_usage.is_bundled",
    +                       side_effect=lambda skill_name: skill_name == "bundled"):
    +                skill_manage(action="create", name="umbrella", content=VALID_SKILL_CONTENT)
    +                skill_manage(action="create", name="bundled", content=VALID_SKILL_CONTENT)
    +                raw = skill_manage(
    +                    action="delete",
    +                    name="bundled",
    +                    absorbed_into="umbrella",
    +                )
    +        finally:
    +            reset_current_write_origin(token)
    +
    +        result = json.loads(raw)
    +        assert result["success"] is False
    +        assert "bundled" in result["error"].lower()
    +        assert (tmp_path / "bundled" / "SKILL.md").exists()
    +
     
     class TestSecurityScanGate:
         """_security_scan_skill is gated by skills.guard_agent_created config flag."""
    ```

    ```diff
    @@ -849,6 +878,40 @@ def test_create_still_writes_to_local_root(self, tmp_path):
             assert (local / "fresh-skill" / "SKILL.md").exists()
             assert not (external / "fresh-skill").exists()
     
    +    def test_background_review_refuses_to_patch_external_skill(self, tmp_path):
    +        """Autonomous curator runs treat skills.external_dirs as read-only."""
    +        from tools.skill_provenance import (
    +            BACKGROUND_REVIEW,
    +            reset_current_write_origin,
    +            set_current_write_origin,
    +        )
    +
    +        local = tmp_path / "local"
    +        external = tmp_path / "vault"
    +        local.mkdir(); external.mkdir()
    +        skill_dir = _write_external_skill(external)
    +
    +        token = [redacted:secret-assignment]
    +        try:
    +            with _two_roots(local, external), patch(
    +                "agent.skill_utils.get_external_skills_dirs",
    +                return_value=[external.resolve()],
    +            ):
    +                raw = skill_manage(
    +                    action="patch",
    +                    name="ext-skill",
    +                    old_string="OLD_MARKER",
    +                    new_string="NEW_MARKER",
    +                )
    +        finally:
    +            reset_current_write_origin(token)
    +
    +        result = json.loads(raw)
    +        assert result["success"] is False
    +        assert "external" in result["error"].lower()
    +        assert "OLD_MARKER" in (skill_dir / "SKILL.md").read_text()
    +        assert "NEW_MARKER" not in (skill_dir / "SKILL.md").read_text()
    +
     
     
     # ---------------------------------------------------------------------------
    ```
  - `tests/tools/test_skill_usage.py`

    ```diff
    @@ -341,6 +341,29 @@ def test_agent_created_skips_archive_and_hub_dirs(skills_home):
         assert "old-skill" not in names
     
     
    +def test_agent_created_excludes_external_dir_even_with_stale_agent_record(skills_home, monkeypatch):
    +    from tools.skill_usage import (
    +        agent_created_report,
    +        is_agent_created,
    +        list_agent_created_skill_names,
    +        save_usage,
    +    )
    +
    +    skills_dir = skills_home / "skills"
    +    external = skills_dir / "shared-vault"
    +    _write_skill(external, "external-skill")
    +    save_usage({"external-skill": {"created_by": "agent"}})
    +
    +    monkeypatch.setattr(
    +        "agent.skill_utils.get_external_skills_dirs",
    +        lambda: [external.resolve()],
    +    )
    +
    +    assert "external-skill" not in list_agent_created_skill_names()
    +    assert "external-skill" not in {r["name"] for r in agent_created_report()}
    +    assert is_agent_created("external-skill") is False
    +
    +
     # ---------------------------------------------------------------------------
     # Archive / restore
     # ---------------------------------------------------------------------------
    ```

    ```diff
    @@ -384,6 +407,23 @@ def test_archive_refuses_hub_skill(skills_home):
         assert not ok
     
     
    +def test_archive_refuses_external_skill(skills_home, monkeypatch):
    +    from tools.skill_usage import archive_skill
    +
    +    skills_dir = skills_home / "skills"
    +    external = skills_dir / "shared-vault"
    +    skill_dir = _write_skill(external, "external-skill")
    +    monkeypatch.setattr(
    +        "agent.skill_utils.get_external_skills_dirs",
    +        lambda: [external.resolve()],
    +    )
    +
    +    ok, msg = archive_skill("external-skill")
    +    assert not ok
    +    assert "external" in msg.lower()
    +    assert skill_dir.exists()
    +
    +
     def test_archive_missing_skill_returns_error(skills_home):
         from tools.skill_usage import archive_skill
         ok, msg = archive_skill("nonexistent")
    ```
  - `tools/skill_manager_tool.py`

    ```diff
    @@ -235,6 +235,79 @@ def _pinned_guard(name: str) -> Optional[str]:
         return None
     
     
    +def _background_review_write_guard(
    +    name: str,
    +    skill_dir: Path,
    +    action: str,
    +) -> Optional[Dict[str, Any]]:
    +    """Refuse autonomous curator writes to externally owned skills.
    +
    +    Foreground agents may still perform user-directed edits to external,
    +    bundled, or hub-installed skills. The background review fork is different:
    +    it is autonomous lifecycle maintenance, so its write surface is restricted
    +    to local curator-owned sediment.
    +    """
    +    try:
    +        from tools.skill_provenance import is_background_review
    +        if not is_background_review():
    +            return None
    +    except Exception:
    +        return None
    +
    +    try:
    +        from agent.skill_utils import is_external_skill_path
    +        if is_external_skill_path(skill_dir):
    +            return {
    +                "success": False,
    +                "error": (
    +                    f"Refusing background curator {action} for skill '{name}': "
    +                    "the skill lives in skills.external_dirs, which are "
    +                    "externally owned and read-only to autonomous curation."
    +                ),
    +            }
    +    except Exception:
    +        logger.debug("external skill guard lookup failed for %s", name, exc_info=True)
    +
    +    try:
    +        from tools import skill_usage
    +        if skill_usage.is_protected_builtin(name):
    +            return {
    +                "success": False,
    +                "error": (
    +                    f"Refusing background curator {action} for protected "
    +                    f"built-in skill '{name}'."
    +                ),
    +            }
    +        if skill_usage.is_hub_installed(name):
    +            return {
    +                "success": False,
    +                "error": (
    +                    f"Refusing background curator {action} for hub-installed "
    +                    f"skill '{name}'."
    +                ),
    +            }
    +        if skill_usage.is_bundled(name):
    +            return {
    +                "success": False,
    +                "error": (
    +                    f"Refusing background curator {action} for bundled "
    +                    f"skill '{name}'."
    +                ),
    +            }
    +    except Exception:
    +        logger.debug("owned skill guard lookup failed for %s", name, exc_info=True)
    +    return None
    +
    +
    +def _background_review_preflight(action: str, name: str) -> Optional[Dict[str, Any]]:
    +    if action not in {"edit", "patch", "delete", "write_file", "remove_file"}:
    +        return None
    +    existing = _find_skill(name)
    +    if not existing:
    +        return None
    +    return _background_review_write_guard(name, existing["path"], action)
    +
    +
     MAX_SKILL_CONTENT_CHARS = 100_000   # ~36k tokens at 2.75 chars/token
     MAX_SKILL_FILE_BYTES = 1_048_576    # 1 MiB per supporting file
     
    ```

    ```diff
    @@ -637,6 +710,9 @@ def _edit_skill(name: str, content: str) -> Dict[str, Any]:
         existing = _find_skill(name)
         if not existing:
             return {"success": False, "error": _skill_not_found_error(name)}
    +    guard = _background_review_write_guard(name, existing["path"], "edit")
    +    if guard:
    +        return guard
     
         skill_md = existing["path"] / "SKILL.md"
         # Back up original content for rollback
    ```

    ```diff
    @@ -690,6 +766,9 @@ def _patch_skill(
             return {"success": False, "error": _skill_not_found_error(name)}
     
         skill_dir = existing["path"]
    +    guard = _background_review_write_guard(name, skill_dir, "patch")
    +    if guard:
    +        return guard
     
         if file_path:
             # Patching a supporting file
    ```

    ```diff
    @@ -783,6 +862,9 @@ def _delete_skill(name: str, absorbed_into: Optional[str] = None) -> Dict[str, A
         existing = _find_skill(name)
         if not existing:
             return {"success": False, "error": _skill_not_found_error(name)}
    +    guard = _background_review_write_guard(name, existing["path"], "delete")
    +    if guard:
    +        return guard
     
         pinned_err = _pinned_guard(name)
         if pinned_err:
    ```

    ```diff
    @@ -858,6 +940,9 @@ def _write_file(name: str, file_path: str, file_content: str) -> Dict[str, Any]:
         existing = _find_skill(name)
         if not existing:
             return {"success": False, "error": _skill_not_found_error(name, " Create it first with action='create'.")}
    +    guard = _background_review_write_guard(name, existing["path"], "write_file")
    +    if guard:
    +        return guard
     
         target, err = _resolve_skill_target(existing["path"], file_path)
         if err:
    ```

    ```diff
    @@ -894,6 +979,9 @@ def _remove_file(name: str, file_path: str) -> Dict[str, Any]:
             return {"success": False, "error": _skill_not_found_error(name)}
     
         skill_dir = existing["path"]
    +    guard = _background_review_write_guard(name, skill_dir, "remove_file")
    +    if guard:
    +        return guard
     
         target, err = _resolve_skill_target(skill_dir, file_path)
         if err:
    ```

    ```diff
    @@ -1016,6 +1104,10 @@ def skill_manage(
     
         Returns JSON string with results.
         """
    +    preflight = _background_review_preflight(action, name)
    +    if preflight is not None:
    +        return json.dumps(preflight, ensure_ascii=False)
    +
         # Approval gate: when on, stages the write for review (skills are too large
         # to review inline, so they always stage regardless of origin); when off
         # (default) passes straight through. The gate is bypassed when this call is
    ```
  - `tools/skill_usage.py`

    ```diff
    @@ -34,7 +34,7 @@
     from typing import Any, Dict, List, Optional, Set, Tuple
     
     from hermes_constants import get_hermes_home
    -from agent.skill_utils import is_excluded_skill_path
    +from agent.skill_utils import is_excluded_skill_path, is_external_skill_path
     
     logger = logging.getLogger(__name__)
     
    ```

    ```diff
    @@ -352,6 +352,10 @@ def list_agent_created_skill_names() -> List[str]:
             # Skip Hermes metadata, VCS, virtualenv/dependency, and cache dirs
             if is_excluded_skill_path(skill_md):
                 continue
    +        # External skill dirs can be mounted below the local skills tree.
    +        # Discovery may see them, but autonomous lifecycle curation must not.
    +        if is_external_skill_path(skill_md):
    +            continue
             try:
                 skill_md.relative_to(base)
             except ValueError:
    ```

    ```diff
    @@ -415,7 +419,12 @@ def _read_skill_name(skill_md: Path, fallback: str) -> str:
     def is_agent_created(skill_name: str) -> bool:
         """Whether *skill_name* is neither bundled nor hub-installed."""
         off_limits = _read_bundled_manifest_names() | _read_hub_installed_names()
    -    return skill_name not in off_limits
    +    if skill_name in off_limits:
    +        return False
    +    return not (
    +        _find_skill_dir(skill_name) is None
    +        and _find_external_skill_dir(skill_name) is not None
    +    )
     
     
     def is_hub_installed(skill_name: str) -> bool:
    ```

    ```diff
    @@ -428,21 +437,36 @@ def is_bundled(skill_name: str) -> bool:
         return skill_name in _read_bundled_manifest_names()
     
     
    -def is_curation_eligible(skill_name: str) -> bool:
    +def _external_read_only_message(skill_name: str) -> str:
    +    return (
    +        f"skill '{skill_name}' lives in skills.external_dirs; "
    +        "external skills are read-only to the curator"
    +    )
    +
    +
    +def is_curation_eligible(skill_name: str, skill_path: Optional[Path] = None) -> bool:
         """Whether the curator may track/archive *skill_name*.
     
         Agent-created skills are always eligible. Bundled built-ins become eligible
    -    only when ``curator.prune_builtins`` is enabled. Hub-installed skills are
    -    NEVER eligible — they have an external upstream owner. Protected built-ins
    -    (``PROTECTED_BUILTIN_SKILLS``) are NEVER eligible regardless of any flag —
    -    they back load-bearing UX and must never be archived or consolidated.
    +    only when ``curator.prune_builtins`` is enabled. Hub-installed and external
    +    skill-dir skills are NEVER eligible — they have an external upstream owner.
    +    Protected built-ins (``PROTECTED_BUILTIN_SKILLS``) are NEVER eligible
    +    regardless of any flag — they back load-bearing UX and must never be
    +    archived or consolidated.
         """
    +    if skill_path is not None and is_external_skill_path(skill_path):
    +        return False
         if is_protected_builtin(skill_name):
             return False
         if is_hub_installed(skill_name):
             return False
         if is_bundled(skill_name):
             return _prune_builtins_enabled()
    +    local_dir = _find_skill_dir(skill_name)
    +    if local_dir is not None:
    +        return not is_external_skill_path(local_dir)
    +    if _find_external_skill_dir(skill_name) is not None:
    +        return False
         return True
     
     
    ```

    ```diff
    @@ -677,7 +701,11 @@ def archive_skill(skill_name: str) -> Tuple[bool, str]:
         when one is archived, its name is added to the suppression list so the
         update-time re-seeder leaves it archived instead of restoring it.
         """
    -    if not is_curation_eligible(skill_name):
    +    local_skill_dir = _find_skill_dir(skill_name)
    +    if local_skill_dir is None and _find_external_skill_dir(skill_name) is not None:
    +        return False, _external_read_only_message(skill_name)
    +
    +    if not is_curation_eligible(skill_name, local_skill_dir):
             if is_protected_builtin(skill_name):
                 return False, (
                     f"skill '{skill_name}' is a protected built-in; it backs "
    ```

    ```diff
    @@ -690,9 +718,11 @@ def archive_skill(skill_name: str) -> Tuple[bool, str]:
                 "curator.prune_builtins to allow pruning it"
             )
     
    -    skill_dir = _find_skill_dir(skill_name)
    +    skill_dir = local_skill_dir
         if skill_dir is None:
             return False, f"skill '{skill_name}' not found"
    +    if is_external_skill_path(skill_dir):
    +        return False, _external_read_only_message(skill_name)
     
         archive_root = _archive_dir()
         try:
    ```

    ```diff
    @@ -811,11 +841,28 @@ def _find_skill_dir(skill_name: str) -> Optional[Path]:
         for skill_md in base.rglob("SKILL.md"):
             if is_excluded_skill_path(skill_md):
                 continue
    +        if is_external_skill_path(skill_md):
    +            continue
             if _read_skill_name(skill_md, fallback=skill_md.parent.name) == skill_name:
                 return skill_md.parent
         return None
     
     
    +def _find_external_skill_dir(skill_name: str) -> Optional[Path]:
    +    """Locate a skill under configured external dirs by frontmatter name."""
    +    from agent.skill_utils import get_all_skills_dirs
    +
    +    for base in get_all_skills_dirs()[1:]:
    +        if not base.exists():
    +            continue
    +        for skill_md in base.rglob("SKILL.md"):
    +            if is_excluded_skill_path(skill_md):
    +                continue
    +            if _read_skill_name(skill_md, fallback=skill_md.parent.name) == skill_name:
    +                return skill_md.parent
    +    return None
    +
    +
     # ---------------------------------------------------------------------------
     # Reporting — for the curator CLI / slash command
     # ---------------------------------------------------------------------------
    ```
- **#53935: fix(curator): fail closed on unverified skill deletes during consolidation**. merged to main · fix tier `verified`
  - maintainer direction: category `merged`; implication `superseded`
  - files: `tests/tools/test_skill_manager_tool.py`, `tools/skill_manager_tool.py`
- **#26688: fix(curator): keep consolidation on archive moves**. closed without merge
  - maintainer direction: category `superseded_by_pr`; implication `superseded`; refs `#53935`
- **#47752: fix(curator): protect external_dirs skills from curator LLM agent mutations**. closed without merge
  - maintainer direction: category `superseded_by_pr`; implication `superseded`; refs `#52849`, `#51875`, `#51875`
- **#51875: fix(curator): protect external skills from background curation**. closed without merge
  - maintainer direction: category `merged_via`; implication `superseded`; refs `96bc524a7`, `#52849`
- **#52174: fix(curator): prevent permanent skill deletion in LLM consolidation pass**. closed without merge
  - maintainer direction: category `superseded_by_pr`; implication `superseded`; refs `#53935`, `#53935`, `#26655`, `#29912`
- **#52847: fix(anthropic): salvage text-embedded tool calls not promoted to tool_use**. closed without merge
  - maintainer direction: category `uncategorized`; implication `needs_llm`; refs `#47472`

## Fix status

- Merged to main in PR #52849 (cited above). No release data is tracked. Verify downstream.
- Fix last verified (merged) `2026-06-28`.
- Subsystem: `skills`
