feat(curator): surface unmanaged skills and add curator adopt

`hermes curator status` reported only the skills it manages, staying silent
about curation-eligible skills it can never touch. On a 237-skill library that
meant 112 skills were invisible to every automatic transition with no signal
anywhere — the curator looked broken when it was working as designed.

A skill becomes curator-managed only when `created_by: agent` lands on its
usage record, and only the background review fork writes that marker. Two
populations therefore never qualify: records written before the marker existed
(no key at all, authorship unknowable) and every foreground
`skill_manage(create)` (unset by design — those skills belong to the user).

- skill_usage: `list_unmanaged_skill_names()` / `unmanaged_report()` enumerate
  the blind spot, tagging each row with `has_provenance_key` so the two causes
  are distinguishable. `adopt_skill()` writes the marker on user declaration
  and refuses bundled, hub-installed, external, and protected built-ins.
  Adoption never resets the inactivity clock.
- curator CLI: status prints an `unmanaged (no provenance marker)` block on
  BOTH the managed and no-managed-skills paths; new `adopt` verb takes names or
  `--all-unmanaged`, with `--dry-run` and a confirmation prompt on bulk.
- skills_sync: `_backfill_optional_provenance()` matched candidates by
  repo-derived path only, so a skill installed at `mlops/chroma` that upstream
  later moved to `mlops/vector-databases/chroma` was skipped forever and
  `hermes skills repair-optional` could not fix it. Falls back to an
  unambiguous name match, still gated on identical content, and records the
  ACTUAL install path.

Provenance stays a declaration, never an inference: a high patch count proves
the agent MAINTAINS a skill, not that it authored one, since Hermes edits
user-written skills on the user's behalf routinely. An "looks agent-made"
heuristic would eventually archive hand-written work.

Validation: 347 targeted tests pass. Each new test verified via sabotage run
(revert the fix, confirm the test goes red) — one initially passed for the
wrong reason because the fixture pinned `prune_builtins` off, masking the guard
under test; fixed to force the shipped default on.
This commit is contained in:
Teknium 2026-07-25 17:01:22 -07:00
parent b9fedab47a
commit 72de75c0ab
7 changed files with 733 additions and 1 deletions

View file

@ -36,6 +36,35 @@ def _fmt_ts(ts: Optional[str]) -> str:
return f"{secs // 86400}d ago"
def _print_unmanaged_summary() -> None:
"""Report curation-eligible skills that carry no provenance marker.
A skill only becomes curator-managed once ``created_by: agent`` lands on
its usage record, which happens ONLY for background-review creations.
Skills predating that marker, plus every foreground
``skill_manage(create)``, are eligible but unmanaged no automatic
transition ever considers them. Printing just the managed count made a
large library look fully curated while a big slice was untouchable.
"""
from tools import skill_usage
try:
unmanaged = skill_usage.unmanaged_report()
except Exception:
return
if not unmanaged:
return
legacy = sum(1 for r in unmanaged if not r.get("has_provenance_key"))
foreground = len(unmanaged) - legacy
print(f"\nunmanaged (no provenance marker): {len(unmanaged)} total")
print(f" pre-dates marker {legacy}")
print(f" foreground-created {foreground}")
print(
" never auto-staled or archived — "
"`hermes curator adopt <name>` hands one over"
)
def _cmd_status(args) -> int:
from agent import curator
from tools import skill_usage
@ -85,6 +114,7 @@ def _cmd_status(args) -> int:
rows = skill_usage.curated_report()
if not rows:
print("\nno curator-managed skills")
_print_unmanaged_summary()
return 0
by_state = {"active": [], "stale": [], "archived": []}
@ -111,6 +141,9 @@ def _cmd_status(args) -> int:
if pinned:
print(f"\npinned ({len(pinned)}): {', '.join(pinned)}")
# Surface the curation blind spot on the managed path too.
_print_unmanaged_summary()
# Show top 5 least-recently-active skills. Views and edits are activity too:
# curator should not report a skill as "never used" right after skill_view()
# or skill_manage() touched it.
@ -280,6 +313,62 @@ def _cmd_unpin(args) -> int:
return 0
def _cmd_adopt(args) -> int:
"""Hand unmanaged skills to the curator by explicit user declaration.
Provenance cannot be inferred from telemetry: a high patch count proves
the agent MAINTAINS a skill, not that it AUTHORED it (the agent edits
user-written skills on the user's behalf constantly). So adoption is never
automatic the user names what they're handing over, or passes
``--all-unmanaged`` to hand over every eligible skill at once.
"""
from tools import skill_usage
names = list(getattr(args, "skill", None) or [])
adopt_all = bool(getattr(args, "all_unmanaged", False))
if adopt_all:
if names:
print("curator: pass either skill names or --all-unmanaged, not both")
return 1
names = skill_usage.list_unmanaged_skill_names()
if not names:
print("curator: no unmanaged skills to adopt")
return 0
if not names:
print("curator: name a skill to adopt, or pass --all-unmanaged")
return 1
dry_run = bool(getattr(args, "dry_run", False))
if dry_run:
print(f"curator: would adopt {len(names)} skill(s) (dry run):")
for n in names:
print(f" + {n}")
return 0
# Bulk adoption is a real lifecycle change (adopted skills become
# archivable), so confirm unless the caller opted out.
if adopt_all and not bool(getattr(args, "yes", False)):
print(f"curator: adopt {len(names)} unmanaged skill(s) into curator management?")
print(" they become eligible for automatic staleness + archival")
try:
reply = input(" proceed? [y/N] ").strip().lower()
except (EOFError, KeyboardInterrupt):
reply = ""
if reply not in {"y", "yes"}:
print("curator: aborted")
return 1
failed = 0
for n in names:
ok, msg = skill_usage.adopt_skill(n)
print(f"curator: {msg}")
if not ok:
failed += 1
if len(names) > 1:
print(f"curator: adopted {len(names) - failed}/{len(names)}")
return 1 if failed else 0
def _cmd_restore(args) -> int:
from tools import skill_usage
ok, msg = skill_usage.restore_skill(args.skill)
@ -627,6 +716,28 @@ def register_cli(parent: argparse.ArgumentParser) -> None:
p_unpin.add_argument("skill", help="Skill name")
p_unpin.set_defaults(func=_cmd_unpin)
p_adopt = subs.add_parser(
"adopt",
help="Hand unmanaged skills to the curator (provenance is a user declaration)",
)
p_adopt.add_argument(
"skill", nargs="*",
help="Skill name(s) to adopt. Omit when using --all-unmanaged.",
)
p_adopt.add_argument(
"--all-unmanaged", action="store_true",
help="Adopt every curation-eligible skill that has no provenance marker",
)
p_adopt.add_argument(
"--dry-run", action="store_true",
help="List what would be adopted without writing anything",
)
p_adopt.add_argument(
"--yes", action="store_true",
help="Skip the confirmation prompt for --all-unmanaged",
)
p_adopt.set_defaults(func=_cmd_adopt)
p_restore = subs.add_parser("restore", help="Restore an archived skill")
p_restore.add_argument("skill", help="Skill name")
p_restore.set_defaults(func=_cmd_restore)

View file

@ -200,3 +200,145 @@ def test_status_marks_missing_last_report_path(monkeypatch, capsys, tmp_path):
out = capsys.readouterr().out
assert f"last report: {missing_report} (missing)" in out
# ---------------------------------------------------------------------------
# Unmanaged blind spot + adopt verb
# ---------------------------------------------------------------------------
def test_status_surfaces_unmanaged_skills(curator_status_env):
"""A skill with no provenance marker is invisible to every automatic
transition, so status must SAY so rather than reporting only the managed
count otherwise a large library looks fully curated while much of it is
untouchable."""
env = curator_status_env
env["make_skill"]("managed-one")
env["make_skill"]("unmanaged-one")
env["skill_usage"].mark_agent_created("managed-one")
out = _capture_status(env["curator_cli"])
assert "unmanaged (no provenance marker): 1 total" in out
assert "curator adopt" in out
def test_status_reports_unmanaged_even_with_no_managed_skills(curator_status_env):
"""The early 'no curator-managed skills' return must not swallow the
unmanaged summary that combination is exactly the confusing case."""
env = curator_status_env
env["make_skill"]("unmanaged-one")
out = _capture_status(env["curator_cli"])
assert "no curator-managed skills" in out
assert "unmanaged (no provenance marker): 1 total" in out
def test_status_omits_unmanaged_section_when_none(curator_status_env):
env = curator_status_env
env["make_skill"]("managed-one")
env["skill_usage"].mark_agent_created("managed-one")
out = _capture_status(env["curator_cli"])
assert "unmanaged (no provenance marker)" not in out
def test_adopt_names_a_skill(curator_status_env):
env = curator_status_env
env["make_skill"]("legacy-one")
cli = env["curator_cli"]
rc = cli._cmd_adopt(SimpleNamespace(
skill=["legacy-one"], all_unmanaged=False, dry_run=False, yes=False,
))
assert rc == 0
assert env["skill_usage"].get_record("legacy-one").get("created_by") == "agent"
def test_adopt_dry_run_writes_nothing(curator_status_env):
env = curator_status_env
env["make_skill"]("legacy-one")
cli = env["curator_cli"]
rc = cli._cmd_adopt(SimpleNamespace(
skill=[], all_unmanaged=True, dry_run=True, yes=False,
))
assert rc == 0
assert env["skill_usage"].get_record("legacy-one").get("created_by") != "agent"
def test_adopt_all_unmanaged_requires_confirmation(curator_status_env, monkeypatch):
"""Bulk adoption makes skills archivable, so it must confirm by default."""
env = curator_status_env
env["make_skill"]("legacy-one")
cli = env["curator_cli"]
monkeypatch.setattr("builtins.input", lambda *_a: "n")
rc = cli._cmd_adopt(SimpleNamespace(
skill=[], all_unmanaged=True, dry_run=False, yes=False,
))
assert rc == 1
assert env["skill_usage"].get_record("legacy-one").get("created_by") != "agent"
def test_adopt_all_unmanaged_with_yes_skips_prompt(curator_status_env):
env = curator_status_env
env["make_skill"]("legacy-one")
env["make_skill"]("legacy-two")
cli = env["curator_cli"]
rc = cli._cmd_adopt(SimpleNamespace(
skill=[], all_unmanaged=True, dry_run=False, yes=True,
))
assert rc == 0
for n in ("legacy-one", "legacy-two"):
assert env["skill_usage"].get_record(n).get("created_by") == "agent"
def test_adopt_rejects_names_combined_with_all_unmanaged(curator_status_env):
env = curator_status_env
env["make_skill"]("legacy-one")
cli = env["curator_cli"]
rc = cli._cmd_adopt(SimpleNamespace(
skill=["legacy-one"], all_unmanaged=True, dry_run=False, yes=True,
))
assert rc == 1
def test_adopt_with_no_target_is_an_error(curator_status_env):
cli = curator_status_env["curator_cli"]
rc = cli._cmd_adopt(SimpleNamespace(
skill=[], all_unmanaged=False, dry_run=False, yes=False,
))
assert rc == 1
def test_adopt_subcommand_is_registered():
"""The verb must be reachable through the real argparse tree, not just as a
callable a handler nobody can dispatch to is dead code."""
import argparse
import hermes_cli.curator as curator_cli
parser = argparse.ArgumentParser()
curator_cli.register_cli(parser)
args = parser.parse_args(["adopt", "--all-unmanaged", "--dry-run"])
assert args.func is curator_cli._cmd_adopt
assert args.all_unmanaged is True
assert args.dry_run is True
assert args.skill == []
named = parser.parse_args(["adopt", "alpha", "beta"])
assert named.skill == ["alpha", "beta"]
assert named.all_unmanaged is False

View file

@ -813,3 +813,181 @@ def test_usage_report_covers_all_provenance(skills_home):
for n in rows:
assert rows[n]["use_count"] == 1
assert rows[n]["_persisted"] is True
# ---------------------------------------------------------------------------
# Unmanaged enumeration + adoption
#
# A skill only becomes curator-managed when ``created_by: agent`` lands on its
# usage record, and that only happens for background-review creations. Records
# written before the marker existed carry no key at all, and every foreground
# `skill_manage(create)` leaves it unset — both are curation-eligible yet
# invisible to every automatic transition. These tests pin the contract that
# the blind spot is enumerable and that adoption is an explicit declaration:
# never inferred from telemetry, never silently reached by the curator.
# ---------------------------------------------------------------------------
def _seed_usage(skills_dir: Path, records: dict) -> None:
(skills_dir / ".usage.json").write_text(
json.dumps(records, indent=1), encoding="utf-8"
)
def test_unmanaged_lists_eligible_skills_without_provenance(skills_home):
from tools.skill_usage import list_unmanaged_skill_names
skills_dir = skills_home / "skills"
_write_skill(skills_dir, "legacy") # record with NO created_by key
_write_skill(skills_dir, "foreground") # created_by present but unset
_write_skill(skills_dir, "managed") # real provenance
_seed_usage(skills_dir, {
"legacy": {"use_count": 3, "patch_count": 40},
"foreground": {"created_by": None, "use_count": 1},
"managed": {"created_by": "agent"},
})
names = list_unmanaged_skill_names()
assert "legacy" in names
assert "foreground" in names
assert "managed" not in names
def test_unmanaged_excludes_externally_owned_skills(skills_home):
from tools.skill_usage import list_unmanaged_skill_names
skills_dir = skills_home / "skills"
_write_skill(skills_dir, "bundled-one")
_write_skill(skills_dir, "hub-one")
_write_skill(skills_dir, "mine")
(skills_dir / ".bundled_manifest").write_text("bundled-one:abc\n", encoding="utf-8")
hub = skills_dir / ".hub"
hub.mkdir()
(hub / "lock.json").write_text(
json.dumps({"installed": {"hub-one": {}}}), encoding="utf-8",
)
names = list_unmanaged_skill_names()
# Bundled and hub skills have an owner other than the user; adoption is not
# the mechanism that governs them.
assert "bundled-one" not in names
assert "hub-one" not in names
assert "mine" in names
def test_unmanaged_report_distinguishes_legacy_from_foreground(skills_home):
from tools.skill_usage import unmanaged_report
skills_dir = skills_home / "skills"
_write_skill(skills_dir, "legacy")
_write_skill(skills_dir, "foreground")
_seed_usage(skills_dir, {
"legacy": {"use_count": 1},
"foreground": {"created_by": None},
})
rows = {r["name"]: r for r in unmanaged_report()}
# No created_by key at all => predates the mechanism, authorship unknowable.
assert rows["legacy"]["has_provenance_key"] is False
# Key present but unset => a foreground create under the current policy.
assert rows["foreground"]["has_provenance_key"] is True
def test_adopt_marks_skill_curator_managed(skills_home):
from tools.skill_usage import adopt_skill, curated_report, list_unmanaged_skill_names
skills_dir = skills_home / "skills"
_write_skill(skills_dir, "legacy")
_seed_usage(skills_dir, {"legacy": {"use_count": 2, "patch_count": 9}})
assert "legacy" in list_unmanaged_skill_names()
ok, _msg = adopt_skill("legacy")
assert ok is True
assert "legacy" in {r["name"] for r in curated_report()}
assert "legacy" not in list_unmanaged_skill_names()
def test_adopt_preserves_the_inactivity_clock(skills_home):
"""Adoption must not reset staleness — it hands over an EXISTING history.
If adopting re-anchored the clock to now, every legacy skill would buy a
fresh archive_after_days window, which is the opposite of what the user
wants when they hand over a library they already stopped using.
"""
from tools.skill_usage import adopt_skill, get_record, latest_activity_at
skills_dir = skills_home / "skills"
_write_skill(skills_dir, "legacy")
_seed_usage(skills_dir, {
"legacy": {
"use_count": 5,
"patch_count": 7,
"last_used_at": "2026-04-29T00:00:00+00:00",
"created_at": "2026-04-28T00:00:00+00:00",
}
})
before = latest_activity_at(get_record("legacy"))
ok, _msg = adopt_skill("legacy")
assert ok is True
rec = get_record("legacy")
assert latest_activity_at(rec) == before
assert rec["use_count"] == 5
assert rec["patch_count"] == 7
def test_adopt_is_idempotent(skills_home):
from tools.skill_usage import adopt_skill
skills_dir = skills_home / "skills"
_write_skill(skills_dir, "mine")
assert adopt_skill("mine")[0] is True
ok, msg = adopt_skill("mine")
assert ok is True
assert "already" in msg
@pytest.mark.parametrize("kind", ["bundled", "hub", "protected", "missing"])
def test_adopt_refuses_skills_the_user_does_not_own(skills_home, monkeypatch, kind):
"""Adoption writes a provenance claim, so it must refuse anything with an
external owner rather than stamping a lie onto the record.
``prune_builtins`` is forced ON here the shipped default because that
is the configuration in which a bundled skill is otherwise curation-
eligible. With it off, ``mark_agent_created``'s own eligibility gate would
block the write and this test would pass without exercising adopt's guard
at all.
"""
from tools import skill_usage
from tools.skill_usage import adopt_skill, load_usage
monkeypatch.setattr(skill_usage, "_prune_builtins_enabled", lambda: True)
skills_dir = skills_home / "skills"
if kind == "bundled":
name = "bundled-one"
_write_skill(skills_dir, name)
(skills_dir / ".bundled_manifest").write_text(f"{name}:abc\n", encoding="utf-8")
elif kind == "hub":
name = "hub-one"
_write_skill(skills_dir, name)
hub = skills_dir / ".hub"
hub.mkdir()
(hub / "lock.json").write_text(
json.dumps({"installed": {name: {}}}), encoding="utf-8",
)
elif kind == "protected":
name = sorted(skill_usage.PROTECTED_BUILTIN_SKILLS)[0]
_write_skill(skills_dir, name)
else:
name = "no-such-skill"
ok, _msg = adopt_skill(name)
assert ok is False
assert load_usage().get(name, {}).get("created_by") != "agent"
def test_adopt_rejects_empty_name(skills_home):
from tools.skill_usage import adopt_skill
assert adopt_skill("")[0] is False

View file

@ -906,6 +906,87 @@ class TestSyncSkills:
assert result["optional_provenance_backfilled"] == []
assert not (skills_dir / ".hub" / "lock.json").exists()
def test_backfills_optional_provenance_for_relocated_skill(self, tmp_path):
"""Upstream recategorization must not blind provenance repair.
When a skill was installed at ``mlops/chroma`` and upstream later moved
it to ``mlops/vector-databases/chroma``, the repo-derived install path
no longer exists in the active tree. A path-only lookup skips it
forever, so `hermes skills repair-optional` can never fix it. The
recorded install_path must be the ACTUAL location, not the repo's.
"""
bundled = self._setup_bundled(tmp_path)
optional = tmp_path / "optional-skills"
optional_skill = optional / "mlops" / "vector-databases" / "chroma"
optional_skill.mkdir(parents=True)
(optional_skill / "SKILL.md").write_text("---\nname: chroma\n---\n# Chroma\n")
skills_dir = tmp_path / "user_skills"
manifest_file = skills_dir / ".bundled_manifest"
# Installed under the OLD category path.
active = skills_dir / "mlops" / "chroma"
active.mkdir(parents=True)
(active / "SKILL.md").write_text("---\nname: chroma\n---\n# Chroma\n")
with self._patches(bundled, skills_dir, manifest_file):
with patch("tools.skills_sync._get_optional_dir", return_value=optional):
result = sync_skills(quiet=True)
assert result["optional_provenance_backfilled"] == ["chroma"]
data = json.loads((skills_dir / ".hub" / "lock.json").read_text())
entry = data["installed"]["chroma"]
assert entry["source"] == "official"
assert entry["install_path"] == "mlops/chroma"
def test_relocated_backfill_still_requires_identical_content(self, tmp_path):
"""The name fallback must not weaken the content check.
Finding the skill by name is only a locator; a locally-edited copy is
still the user's and must not be claimed as official.
"""
bundled = self._setup_bundled(tmp_path)
optional = tmp_path / "optional-skills"
optional_skill = optional / "mlops" / "vector-databases" / "chroma"
optional_skill.mkdir(parents=True)
(optional_skill / "SKILL.md").write_text("---\nname: chroma\n---\n# upstream\n")
skills_dir = tmp_path / "user_skills"
manifest_file = skills_dir / ".bundled_manifest"
active = skills_dir / "mlops" / "chroma"
active.mkdir(parents=True)
(active / "SKILL.md").write_text("---\nname: chroma\n---\n# LOCALLY EDITED\n")
with self._patches(bundled, skills_dir, manifest_file):
with patch("tools.skills_sync._get_optional_dir", return_value=optional):
result = sync_skills(quiet=True)
assert result["optional_provenance_backfilled"] == []
def test_relocated_backfill_refuses_ambiguous_names(self, tmp_path):
"""Two installed dirs sharing a name give no basis to pick one.
Guessing would write official provenance onto the wrong skill, so the
fallback must decline rather than choose.
"""
bundled = self._setup_bundled(tmp_path)
optional = tmp_path / "optional-skills"
optional_skill = optional / "cat" / "dupe"
optional_skill.mkdir(parents=True)
(optional_skill / "SKILL.md").write_text("---\nname: dupe\n---\n# D\n")
skills_dir = tmp_path / "user_skills"
manifest_file = skills_dir / ".bundled_manifest"
for parent in ("x", "y"):
d = skills_dir / parent / "dupe"
d.mkdir(parents=True)
(d / "SKILL.md").write_text("---\nname: dupe\n---\n# D\n")
with self._patches(bundled, skills_dir, manifest_file):
with patch("tools.skills_sync._get_optional_dir", return_value=optional):
result = sync_skills(quiet=True)
assert result["optional_provenance_backfilled"] == []
def test_repair_official_optional_restores_reorganized_skill_with_backup(self, tmp_path):
bundled = self._setup_bundled(tmp_path)
optional = tmp_path / "optional-skills"

View file

@ -485,6 +485,128 @@ def _is_curator_managed_record(record: Any) -> bool:
return record.get("created_by") == "agent" or record.get("agent_created") is True
def list_unmanaged_skill_names() -> List[str]:
"""Enumerate curation-ELIGIBLE skills that carry no provenance marker.
These are skills the curator *could* manage (they are not hub-installed,
not external, not protected built-ins) but never will, because nothing
ever wrote ``created_by: agent`` onto their usage record. Two ways a skill
lands here:
* It predates the provenance mechanism entirely records written before
``created_by`` existed carry no key at all, so their authorship is
unknowable from the record alone.
* It was created by a FOREGROUND ``skill_manage(action="create")`` call,
which deliberately does not mark provenance (skills a user asks for
belong to the user).
Either way the skill is invisible to ``curated_report()`` and therefore to
every automatic transition. ``hermes curator status`` surfaces this count
so the blind spot is legible instead of silent, and ``hermes curator
adopt`` lets the user hand specific skills over explicitly.
Provenance is a DECLARATION, never an inference: this function only
reports, and callers must not auto-adopt what it returns. Heavy patch or
use counts are evidence of maintenance, not of authorship the agent
edits user-authored skills on the user's behalf routinely.
"""
base = _skills_dir()
if not base.exists():
return []
hub = _read_hub_installed_names()
bundled = _read_bundled_manifest_names()
usage = load_usage()
names: List[str] = []
for skill_md in base.rglob("SKILL.md"):
if is_excluded_skill_path(skill_md) or is_external_skill_path(skill_md):
continue
try:
skill_md.relative_to(base)
except ValueError:
continue
name = _read_skill_name(skill_md, fallback=skill_md.parent.name)
# Anything with an external owner or a bundled/protected identity is
# outside the adoption question entirely.
if name in hub or name in bundled or is_protected_builtin(name):
continue
if _is_curator_managed_record(usage.get(name)):
continue
if not is_curation_eligible(name, skill_md):
continue
names.append(name)
return sorted(set(names))
def unmanaged_report() -> List[Dict[str, Any]]:
"""Rows for every skill :func:`list_unmanaged_skill_names` returns.
Each row carries the usual activity fields plus ``has_provenance_key``:
False when the record has no ``created_by`` key at all (pre-dates the
mechanism), True when the key is present but unset (a foreground create
under the current policy). The distinction matters for explaining WHY a
skill is unmanaged; it is not a signal to adopt on.
"""
usage = load_usage()
rows: List[Dict[str, Any]] = []
for name in list_unmanaged_skill_names():
raw = usage.get(name)
rec: Dict[str, Any] = dict(raw) if isinstance(raw, dict) else _empty_record()
for k, v in _empty_record().items():
rec.setdefault(k, v)
row = {"name": name, **rec}
row["has_provenance_key"] = isinstance(raw, dict) and "created_by" in raw
row["has_record"] = isinstance(raw, dict)
row["last_activity_at"] = latest_activity_at(row)
row["activity_count"] = activity_count(row)
rows.append(row)
return rows
def adopt_skill(skill_name: str) -> Tuple[bool, str]:
"""Hand *skill_name* to the curator by user declaration.
Writes the same ``created_by: agent`` marker the background review fork
writes, so the skill joins ``curated_report()`` and the automatic
transition walk. The inactivity clock is NOT reset: the skill's existing
``last_activity_at`` still governs staleness, so adopting something idle
for months does not buy it a fresh window (nor does it archive it on the
spot the state machine decides on the next pass).
Returns (ok, message). Refuses hub-installed, external, and protected
built-in skills, which have an owner other than the user.
"""
if not skill_name:
return False, "no skill name given"
if is_protected_builtin(skill_name):
return False, f"'{skill_name}' is a protected built-in; the curator never manages it"
if is_hub_installed(skill_name):
return False, f"'{skill_name}' is hub-installed; its upstream owns it"
if is_bundled(skill_name):
# Bundled skills already fall under the curator via
# ``curator.prune_builtins``; stamping created_by=agent on one would
# claim Hermes' own shipped skill was agent-authored and change nothing
# about its eligibility.
return False, (
f"'{skill_name}' is a bundled built-in — it is governed by "
"curator.prune_builtins, not by adoption"
)
skill_dir = _find_skill_dir(skill_name)
if skill_dir is None:
if _find_external_skill_dir(skill_name) is not None:
return False, f"'{skill_name}' lives in skills.external_dirs and is read-only to the curator"
return False, f"skill '{skill_name}' not found"
if is_external_skill_path(skill_dir):
return False, _external_read_only_message(skill_name)
usage = load_usage()
if _is_curator_managed_record(usage.get(skill_name)):
return True, f"'{skill_name}' is already curator-managed"
mark_agent_created(skill_name)
if not _is_curator_managed_record(load_usage().get(skill_name)):
return False, f"could not mark '{skill_name}' as curator-managed"
return True, f"adopted '{skill_name}' into curator management"
# ---------------------------------------------------------------------------
# Sidecar I/O
# ---------------------------------------------------------------------------

View file

@ -402,6 +402,36 @@ def restore_official_optional_skill(name: str, *, restore: bool = False) -> dict
}
def _find_installed_skill_dir_by_name(skill_dir_name: str) -> Optional[Path]:
"""Locate an installed skill directory by its directory name.
Used only as a fallback when the repo-derived install path doesn't exist in
the active tree (upstream recategorized the skill after it was installed).
Returns None when there is no match, or when the name is AMBIGUOUS two
skills sharing a directory name give us no basis to pick one, and guessing
would write provenance onto the wrong skill. The caller still verifies a
byte-identical content hash before recording anything.
"""
if not skill_dir_name or not SKILLS_DIR.exists():
return None
matches: List[Path] = []
for skill_md in SKILLS_DIR.rglob("SKILL.md"):
if is_excluded_skill_path(skill_md):
continue
candidate = skill_md.parent
if candidate.name != skill_dir_name:
continue
# Never reach outside the skills tree (symlinked/external dirs).
try:
candidate.resolve().relative_to(SKILLS_DIR.resolve())
except (OSError, ValueError):
continue
matches.append(candidate)
if len(matches) != 1:
return None
return matches[0]
def _backfill_optional_provenance(quiet: bool = False) -> List[str]:
"""Mark already-present official optional skills as hub-installed.
@ -440,7 +470,22 @@ def _backfill_optional_provenance(quiet: bool = False) -> List[str]:
continue
dest = SKILLS_DIR / Path(*install_path.split("/"))
if not dest.exists() or not dest.is_dir():
continue
# The active tree may hold the same skill under a DIFFERENT
# category path than the repo uses — categories get reorganized
# upstream (e.g. mlops/chroma → mlops/vector-databases/chroma)
# while the already-installed copy keeps its old location. A
# path-only lookup misses every one of those, so provenance repair
# silently skips them forever. Fall back to a unique
# same-directory-name match anywhere in the tree, then still
# require a byte-identical hash below before claiming provenance.
dest = _find_installed_skill_dir_by_name(src.name)
if dest is None:
continue
try:
install_path = _safe_rel_install_path(dest, SKILLS_DIR)
except ValueError as e:
logger.debug("Skipping relocated optional skill %s: %s", dest, e)
continue
if _dir_hash(dest) != _dir_hash(src):
continue

View file

@ -103,6 +103,8 @@ hermes curator pause # stop runs until resumed
hermes curator resume
hermes curator pin <skill> # never auto-transition this skill
hermes curator unpin <skill>
hermes curator adopt <skill> # hand an unmanaged skill to the curator
hermes curator adopt --all-unmanaged # hand over every unmanaged skill
hermes curator restore <skill> # move an archived skill back to active
hermes curator list-archived # list skills currently in ~/.hermes/skills/.archive/
hermes curator archive <skill> # manually archive a single skill now
@ -170,6 +172,57 @@ jurisdiction — the LLM review pass is skipped and the report will show
`Model: (not resolved) via (not resolved)` with `Duration: 0s`.
:::
### Adopting unmanaged skills
`hermes curator status` reports an **unmanaged** count alongside the managed
one:
```
curator-managed skills: 43 total (agent-created=43 bundled=0)
active 41
stale 2
archived 0
unmanaged (no provenance marker): 112 total
pre-dates marker 34
foreground-created 78
never auto-staled or archived — `hermes curator adopt <name>` hands one over
```
Those 112 are curation-*eligible* but permanently invisible to the lifecycle,
for one of two reasons:
- **pre-dates marker** — the record was written before `created_by` existed, so
it carries no provenance signal at all. Authorship is genuinely unknowable
from the record.
- **foreground-created** — a foreground `skill_manage(create)` left the marker
unset by design, since skills you ask for belong to you.
A large library can therefore look fully curated while most of it is
untouchable. `adopt` closes that gap by **declaration**:
```bash
hermes curator adopt <name> [<name> ...] # hand specific skills over
hermes curator adopt --all-unmanaged --dry-run # preview the full list
hermes curator adopt --all-unmanaged # hand over everything (prompts)
hermes curator adopt --all-unmanaged --yes # skip the prompt
```
Adoption writes the same `created_by: agent` marker the background review fork
writes. It does **not** reset the inactivity clock — an adopted skill keeps its
existing `last_activity_at`, so handing over a library you already stopped
using does not buy it a fresh 90-day window. Expect adopted long-idle skills to
go `stale` (or `archived`) on the next pass; that is the point.
:::note Provenance is declared, never inferred
Adoption is deliberately manual. Telemetry cannot establish authorship: a skill
with thousands of patches proves the agent **maintains** it, not that the agent
**wrote** it — Hermes edits user-authored skills on your behalf constantly. An
automatic "looks agent-made, adopt it" heuristic would eventually archive
something you hand-wrote. `adopt` refuses bundled, hub-installed, external, and
protected built-in skills, which have an owner other than you.
:::
Skills that ARE agent-created follow the full lifecycle:
- `active` → (30d unused) `stale` → (90d unused) `archived`