feat(curator): hint at hermes curator pin in the rename block (#23212)

Surfaces the pin command at the moment users care about it: when a
consolidation just landed against their skill library and they're
looking at the umbrella name in the curator output. Previously `hermes
curator pin` existed but had no discovery surface — users only learned
it existed by reading docs or stumbling onto `hermes curator --help`.

The hint:

    archived 3 skill(s):
      • docx-extraction → document-tools
      • pdf-extraction → document-tools
      • old-stale — pruned (stale)
    full report: hermes curator status
    keep an umbrella stable: hermes curator pin document-tools

Gated on having at least one consolidation that produced an umbrella.
Pruned-only runs (nothing surviving to pin) skip the hint. When
multiple umbrellas were produced, picks alphabetically first as a
concrete example rather than listing them all.

3 new tests in tests/agent/test_curator_classification.py covering:
consolidation produces hint with real umbrella name, pruned-only run
omits it, multi-umbrella picks one example.
This commit is contained in:
Teknium 2026-05-10 06:44:53 -07:00 committed by GitHub
parent 50f9fee988
commit 7312f7f849
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 118 additions and 1 deletions

View file

@ -899,9 +899,12 @@ def _build_rename_summary(
flaky-thing pruned (stale)
old-utility spreadsheet-ops
full report: hermes curator status
keep an umbrella stable: hermes curator pin document-tools
Cap is 10 entries so a 50-skill consolidation doesn't blow up
agent.log; the full list is always in REPORT.md.
agent.log; the full list is always in REPORT.md. The pin hint only
appears when at least one consolidation produced an umbrella worth
pinning (pruned-only runs skip it).
"""
after_by_name = {r.get("name"): r for r in after_report if isinstance(r, dict)}
after_names = set(after_by_name.keys())
@ -950,6 +953,17 @@ def _build_rename_summary(
if total > SHOW:
lines.append(f" … and {total - SHOW} more")
lines.append("full report: hermes curator status")
# Pin hint — only surface it when there's actually a destination skill
# worth pinning. The umbrella skills that absorbed content are the natural
# candidates: pinning one tells future curator runs to leave it alone.
# Pruned-only runs don't get this hint (nothing surviving to pin).
if consolidated:
umbrellas = sorted({e.get("into") for e in consolidated if e.get("into")})
if umbrellas:
example = umbrellas[0]
lines.append(
f"keep an umbrella stable: hermes curator pin {example}"
)
return "\n".join(lines)

View file

@ -1020,3 +1020,106 @@ def test_rename_summary_mixed_consolidation_and_pruning(curator_env):
assert merge_idx < drop_idx, "consolidated should render before pruned"
assert "merge-me → umbrella" in lines[merge_idx]
assert "drop-me — pruned (stale)" in lines[drop_idx]
# ---------------------------------------------------------------------------
# Pin hint — surfaces `hermes curator pin <umbrella>` in the rename block so
# users learn the command exists at the moment they care (a consolidation
# just landed against their library). The hint is gated on having at least
# one umbrella destination — pruned-only runs skip it.
# ---------------------------------------------------------------------------
def test_rename_summary_pin_hint_appears_when_consolidation_produced_umbrella(curator_env):
"""When at least one skill was absorbed into an umbrella, hint at pinning it."""
result = curator_env._build_rename_summary(
before_names={"pdf-extraction", "docx-extraction", "document-tools"},
after_report=[{"name": "document-tools", "state": "active"}],
tool_calls=[
{
"name": "skill_manage",
"arguments": json.dumps({
"action": "delete",
"name": "pdf-extraction",
"absorbed_into": "document-tools",
}),
},
{
"name": "skill_manage",
"arguments": json.dumps({
"action": "delete",
"name": "docx-extraction",
"absorbed_into": "document-tools",
}),
},
],
model_final="",
)
assert "hermes curator pin document-tools" in result
assert "keep an umbrella stable" in result
def test_rename_summary_pin_hint_skipped_for_pruned_only_runs(curator_env):
"""Pruned-only runs have nothing surviving to pin — hint should not appear."""
result = curator_env._build_rename_summary(
before_names={"old-flaky-thing", "another-stale", "keeper"},
after_report=[{"name": "keeper", "state": "active"}],
tool_calls=[
{
"name": "skill_manage",
"arguments": json.dumps({
"action": "delete",
"name": "old-flaky-thing",
"absorbed_into": "",
}),
},
{
"name": "skill_manage",
"arguments": json.dumps({
"action": "delete",
"name": "another-stale",
"absorbed_into": "",
}),
},
],
model_final="",
)
# Block still renders (skills were archived) but no pin hint.
assert "archived 2 skill(s):" in result
assert "hermes curator pin" not in result
assert "keep an umbrella stable" not in result
def test_rename_summary_pin_hint_picks_one_umbrella_when_multiple_absorbed(curator_env):
"""Multiple umbrellas → hint shows one example (alphabetically first), not a list."""
result = curator_env._build_rename_summary(
before_names={"a-skill", "b-skill", "umbrella-zeta", "umbrella-alpha"},
after_report=[
{"name": "umbrella-zeta", "state": "active"},
{"name": "umbrella-alpha", "state": "active"},
],
tool_calls=[
{
"name": "skill_manage",
"arguments": json.dumps({
"action": "delete",
"name": "a-skill",
"absorbed_into": "umbrella-zeta",
}),
},
{
"name": "skill_manage",
"arguments": json.dumps({
"action": "delete",
"name": "b-skill",
"absorbed_into": "umbrella-alpha",
}),
},
],
model_final="",
)
# Sorted picks alphabetically first.
assert "hermes curator pin umbrella-alpha" in result
# Exactly one hint line, not one per umbrella.
pin_lines = [ln for ln in result.splitlines() if "hermes curator pin" in ln]
assert len(pin_lines) == 1