hermes-agent/tests/agent/test_org_skill_namespace.py
Ben Barclay 4f990ec09e refactor(sync): put every Skill Sync verb under hermes sync; drop HSP naming
Encapsulates the feature behind one command for launch, and adopts the
official product name.

One command:
- `propose` moves from `hermes skills propose` to `hermes sync propose`, so
  the whole feature is one command to learn and one to document. Its handler
  moves from cmd_skills to cmd_sync accordingly.
- The `hermes sync` parser now documents both halves plainly: personal sync
  across your devices, and sharing with your organisation. Added an examples
  epilog; rewrote the verb help in user language ("Include a skill in your
  sync" rather than "Opt a skill into sync").
- Every user-facing string that pointed at `hermes skills propose` now points
  at `hermes sync propose` (8 sites, including the agent-visible guidance
  returned by skill_manage and the org provenance header).

This also clears the way for #39343, which adds its own top-level `sync` for
git-repo profile backup — that feature nests under `skills`, this one owns
`sync`.

Naming:
- HSP / "Hermes Sync Protocol" is gone from prose, docstrings, and comments.
  The feature is "Skill Sync".
- Public identifiers renamed: HSPClient -> SyncClient, HSPError -> SyncError,
  HSPConflict -> SyncConflict, hsp_address -> wire_address, HSP_VERSION ->
  WIRE_VERSION.
- The WIRE names are deliberately NOT renamed: the `hsp_version` capability
  field and the `x-hsp-object-type` response header are set by the deployed
  gateway-gateway sync plane (verified in src/sync/syncRouter.ts), so
  renaming them client-side would break sync against a live server. A comment
  at the version constant records why they differ from the product name.
- The version-mismatch error is now actionable ("this server speaks sync
  version X, but this Hermes speaks Y — update Hermes to sync with it")
  instead of leaking the protocol acronym.

Also fixes a wiring gap found on the way: the gateway housekeeping tick
pulled personal skills but never org skills — the same defect already fixed
for the CLI. Org pull now runs there too, gated on real org membership.

Tests: the jargon guard now also fails on a bare "HSP". The two tests that
asserted the old cross-command structure are replaced by three asserting the
new one (propose IS under sync, propose is NOT under skills, sync usage
lists it). 2294 passed / 0 failed across all 51 suites that import the
changed modules, via scripts/run_tests.sh.

Verified by running the real CLI: `hermes sync --help` lists all eight verbs,
`hermes skills --help` no longer mentions propose, `hermes sync propose
--help` parses, and `hermes sync status` still reports live org state.
2026-07-29 07:47:06 +10:00

389 lines
16 KiB
Python

"""M2 org-skill namespace: token-gated resolution, provenance, collisions.
Covers the design agreed 2026-07-23 (bare-name first-class org skills):
1. TOKEN-GATED discovery — only the `.active_org`-marked mirror resolves;
stale mirrors and marker-less trees never load.
2. Fail-loud collisions — a personal/org name clash lists BOTH sides flagged;
skill_view's existing multi-candidate guard refuses the bare name.
3. Load-time provenance header — org skill content announces org + author.
4. Org mirrors are read-only (skill_manage guards) and curation-exempt.
"""
import json
import pytest
from agent import skill_utils as sku
from agent.prompt_builder import _build_snapshot_entry
def _mk_skill(root, rel, name=None, body="# body\n"):
d = root
for part in rel.split("/"):
d = d / part
d.mkdir(parents=True, exist_ok=True)
(d / "SKILL.md").write_text(
f"---\nname: {name or rel.split('/')[-1]}\ndescription: d\n---\n{body}",
encoding="utf-8",
)
return d
def _mark_active(skills, org_id):
org_root = skills / sku.ORG_MIRROR_DIR_NAME
org_root.mkdir(parents=True, exist_ok=True)
(org_root / sku.ORG_ACTIVE_MARKER).write_text(org_id, encoding="utf-8")
class TestTokenGatedDiscovery:
def test_no_marker_no_org_skills(self, tmp_path):
skills = tmp_path / "skills"
_mk_skill(skills, "personal-a")
_mk_skill(skills, f"{sku.ORG_MIRROR_DIR_NAME}/org-1/shared-x", name="shared-x")
found = [p.parent.name for p in sku.iter_skill_index_files(skills, "SKILL.md")]
assert "personal-a" in found
assert "shared-x" not in found # unmarked mirror never resolves
def test_marker_gates_to_active_org_only(self, tmp_path):
skills = tmp_path / "skills"
_mk_skill(skills, f"{sku.ORG_MIRROR_DIR_NAME}/org-1/shared-x", name="shared-x")
_mk_skill(skills, f"{sku.ORG_MIRROR_DIR_NAME}/org-OLD/stale-y", name="stale-y")
_mark_active(skills, "org-1")
found = [p.parent.name for p in sku.iter_skill_index_files(skills, "SKILL.md")]
assert "shared-x" in found
assert "stale-y" not in found # stale mirror pruned at resolution
def test_switching_org_flips_resolution(self, tmp_path):
skills = tmp_path / "skills"
_mk_skill(skills, f"{sku.ORG_MIRROR_DIR_NAME}/org-1/shared-x", name="shared-x")
_mk_skill(skills, f"{sku.ORG_MIRROR_DIR_NAME}/org-2/other-z", name="other-z")
_mark_active(skills, "org-2")
found = [p.parent.name for p in sku.iter_skill_index_files(skills, "SKILL.md")]
assert found and "other-z" in found and "shared-x" not in found
def test_helpers(self, tmp_path):
skills = tmp_path / "skills"
d = _mk_skill(skills, f"{sku.ORG_MIRROR_DIR_NAME}/org-9/cat/sk", name="sk")
assert sku.is_org_mirror_path(d, skills) is True
assert sku.org_id_of_path(d, skills) == "org-9"
p = _mk_skill(skills, "plain")
assert sku.is_org_mirror_path(p, skills) is False
assert sku.read_active_org_id(skills) is None
_mark_active(skills, "org-9")
assert sku.read_active_org_id(skills) == "org-9"
class TestSnapshotEntryProvenance:
def test_org_entry_strips_prefix_and_carries_provenance(self, tmp_path):
skills = tmp_path / "skills"
d = _mk_skill(
skills, f"{sku.ORG_MIRROR_DIR_NAME}/org-1/devops/beta", name="beta"
)
(skills / sku.ORG_MIRROR_DIR_NAME / "org-1" / sku.ORG_PROVENANCE_FILE).write_text(
json.dumps(
{"author_device": "bens-macbook-a1b2c3", "author_user_id": "u1"}
),
encoding="utf-8",
)
entry = _build_snapshot_entry(d / "SKILL.md", skills, {"name": "beta"}, "d")
assert entry["org_id"] == "org-1"
assert entry["org_author"] == "bens-macbook-a1b2c3"
# Category derives from the path WITHIN the mirror, not _org/org-1/...
assert entry["category"] == "devops"
assert entry["skill_name"] == "beta"
def test_personal_entry_unchanged(self, tmp_path):
skills = tmp_path / "skills"
d = _mk_skill(skills, "devops/beta", name="beta")
entry = _build_snapshot_entry(d / "SKILL.md", skills, {"name": "beta"}, "d")
assert "org_id" not in entry
assert entry["category"] == "devops"
class TestListingCollisionsAndLabels:
def _render(self, tmp_path, monkeypatch):
from agent import prompt_builder as pb
skills = tmp_path / "skills"
skills.mkdir(parents=True, exist_ok=True)
monkeypatch.setattr(pb, "get_skills_dir", lambda: skills, raising=True)
monkeypatch.setattr(
pb, "get_all_skills_dirs", lambda: [skills], raising=True
)
monkeypatch.setattr(pb, "get_disabled_skill_names", lambda *a, **k: set())
monkeypatch.setattr(
pb, "_skills_prompt_snapshot_path", lambda: tmp_path / "snap.json"
)
pb.clear_skills_system_prompt_cache()
return skills, pb
def test_org_skill_listed_with_provenance_tag(self, tmp_path, monkeypatch):
skills, pb = self._render(tmp_path, monkeypatch)
_mk_skill(skills, "personal-a")
_mk_skill(skills, f"{sku.ORG_MIRROR_DIR_NAME}/org-1/shared-x", name="shared-x")
(skills / sku.ORG_MIRROR_DIR_NAME / "org-1" / sku.ORG_PROVENANCE_FILE).write_text(
json.dumps({"author_device": "bens-macbook"}), encoding="utf-8"
)
_mark_active(skills, "org-1")
out = pb.build_skills_system_prompt()
assert "org:org-1" in out
assert "[org-shared: by bens-macbook]" in out
assert "personal-a" in out
def test_collision_flags_both_sides(self, tmp_path, monkeypatch):
skills, pb = self._render(tmp_path, monkeypatch)
_mk_skill(skills, "k8s-debug", body="personal version\n")
_mk_skill(
skills, f"{sku.ORG_MIRROR_DIR_NAME}/org-1/k8s-debug", name="k8s-debug"
)
_mark_active(skills, "org-1")
out = pb.build_skills_system_prompt()
# BOTH entries flagged — neither silently wins.
assert out.count("[name collision") == 2
def test_no_collision_flag_when_unique(self, tmp_path, monkeypatch):
skills, pb = self._render(tmp_path, monkeypatch)
_mk_skill(skills, "personal-a")
_mk_skill(skills, f"{sku.ORG_MIRROR_DIR_NAME}/org-1/shared-x", name="shared-x")
_mark_active(skills, "org-1")
out = pb.build_skills_system_prompt()
assert "[name collision" not in out
class TestOrgSkillsAreEditableInPlace:
"""The learning loop must work ON shared skills, not around them.
Refusing edits to `_org/` froze exactly the skills the most people use:
the agent is instructed to patch a skill the moment it finds a gap, and
"fork it to a personal skill first" is not something an agent does
mid-task. So edits land in place; org updates never clobber them; the
user (or auto-propose) shares them back.
"""
def _org_skill(self, tmp_path, monkeypatch):
from tools import skill_manager_tool as smt
from agent import skill_utils as _sku
skills = tmp_path / "skills"
d = _mk_skill(
skills, f"{sku.ORG_MIRROR_DIR_NAME}/org-1/shared-x", name="shared-x"
)
_mark_active(skills, "org-1")
monkeypatch.setattr(smt, "_skills_dir", lambda: skills)
monkeypatch.setattr(
_sku, "get_all_skills_dirs", lambda: [skills], raising=True
)
return smt, skills, d
def test_patch_is_allowed_and_applied(self, tmp_path, monkeypatch):
smt, _skills, d = self._org_skill(tmp_path, monkeypatch)
result = smt._patch_skill("shared-x", "body", "improved")
assert result["success"] is True, result.get("error")
assert "improved" in (d / "SKILL.md").read_text(encoding="utf-8")
def test_edit_tells_the_user_how_to_share_it_back(self, tmp_path, monkeypatch):
smt, _skills, _d = self._org_skill(tmp_path, monkeypatch)
result = smt._patch_skill("shared-x", "body", "improved")
# Without auto-propose the edit stays local, and the tool result must
# say so AND name the command — otherwise the improvement is stranded.
assert "propose" in (result.get("org_sharing") or "")
def test_delete_is_still_refused(self, tmp_path, monkeypatch):
smt, _skills, d = self._org_skill(tmp_path, monkeypatch)
guard = smt._org_mirror_write_guard("shared-x", d, "delete")
assert guard is not None and guard["success"] is False
assert "admin" in guard["error"]
def test_curation_is_allowed(self, tmp_path, monkeypatch):
from tools import skill_usage as su
skills = tmp_path / "skills"
d = _mk_skill(
skills, f"{sku.ORG_MIRROR_DIR_NAME}/org-1/shared-x", name="shared-x"
)
monkeypatch.setattr(su, "_skills_dir", lambda: skills)
# The curator must be able to improve shared skills — they are the
# highest-leverage ones in the system.
assert su.is_curation_eligible("shared-x", d) is True
class TestOrgPullIsWiredIn:
"""Guards the integration gap that unit tests structurally cannot catch.
The org pull functions were fully implemented and unit-tested while having
ZERO runtime callers, so org skills never loaded for anyone. Testing the
functions directly could never surface that. These tests assert the CALL
SITES exist, so the feature can't silently become dead code again.
"""
def test_session_startup_calls_maybe_pull_org_skills(self):
import pathlib
cli_src = (
pathlib.Path(__file__).resolve().parents[2] / "cli.py"
).read_text(encoding="utf-8")
assert "maybe_pull_org_skills" in cli_src, (
"cli.py session startup must call maybe_pull_org_skills() — "
"without a call site the org mirror is never populated and org "
"skills never load (the function being importable is not enough)."
)
# It must sit alongside the personal pull, not replace it.
assert "maybe_pull_skills" in cli_src
def test_sync_pull_command_refreshes_org_mirror(self):
import pathlib
main_src = (
pathlib.Path(__file__).resolve().parents[2]
/ "hermes_cli"
/ "main.py"
).read_text(encoding="utf-8")
assert "maybe_pull_org_skills" in main_src, (
"`hermes sync pull` must also refresh the org mirror."
)
def test_sync_status_exposes_org_state(self):
from tools import skills_sync_client as ssc
status = ssc.sync_status()
# These keys must always be present so a user can tell whether the org
# workflow applies to them, rather than it being invisible.
for key in ("org_available", "org_id", "org_role", "org_skills"):
assert key in status, f"sync status must expose {key!r}"
def test_no_internal_jargon_in_user_facing_strings(self):
"""User-visible help/errors must not leak internal design coordinates."""
import pathlib
import re
root = pathlib.Path(__file__).resolve().parents[2]
targets = [
root / "hermes_cli" / "subcommands" / "sync.py",
root / "hermes_cli" / "subcommands" / "skills.py",
]
banned = re.compile(
r"\(M[12]\)|\bHSP\b|HSP/1|§[0-9]|DEV-PHASE|hsp-1-contract"
)
for path in targets:
for i, line in enumerate(path.read_text(encoding="utf-8").split("\n"), 1):
if "help=" in line or "description=" in line:
assert not banned.search(line), (
f"{path.name}:{i} leaks internal jargon to users: {line.strip()}"
)
class TestSkillSyncIsOneCommand:
"""Every Skill Sync verb lives under `hermes sync` for launch.
The surface is deliberately encapsulated: one command to learn, one to
document, and top-level `sync` stays free of skill-management verbs that
belong elsewhere. `propose` in particular used to sit under `hermes
skills`, which split one feature across two commands.
"""
def _src(self, *parts):
import pathlib
return (
pathlib.Path(__file__).resolve().parents[2].joinpath(*parts)
).read_text(encoding="utf-8")
def test_propose_is_a_sync_subcommand(self):
sync_src = self._src("hermes_cli", "subcommands", "sync.py")
assert '"propose"' in sync_src, (
"`propose` must be a `hermes sync` subcommand."
)
def test_propose_is_not_under_skills(self):
skills_src = self._src("hermes_cli", "subcommands", "skills.py")
assert '"propose"' not in skills_src, (
"`propose` must NOT remain under `hermes skills` — Skill Sync is "
"one command for launch."
)
def test_sync_usage_lists_propose(self):
main_src = self._src("hermes_cli", "main.py")
usage_start = main_src.index("usage: hermes sync ")
usage_block = main_src[usage_start : usage_start + 1400]
assert "propose" in usage_block, (
"`hermes sync` usage must list the propose verb."
)
class TestLocalEditsSurviveOrgUpdates:
"""Ben's requirement: local edits are never silently overwritten.
An org pull materializes the shared set. Before this, it `rmtree`'d each
skill dir and re-wrote it, so any local improvement vanished on the next
session start with no warning. Now a locally-modified skill is skipped
and reported as a conflict for the user to resolve deliberately.
"""
def _mirror(self, tmp_path, monkeypatch, body="original\n"):
from tools import skills_sync_client as ssc
skills = tmp_path / "skills"
d = _mk_skill(
skills,
f"{sku.ORG_MIRROR_DIR_NAME}/org-1/shared-x",
name="shared-x",
body=body,
)
_mark_active(skills, "org-1")
monkeypatch.setattr(ssc, "_skills_dir", lambda: skills)
monkeypatch.setattr(
ssc, "_org_dir", lambda: skills / sku.ORG_MIRROR_DIR_NAME
)
return ssc, skills, d
def test_unmodified_skill_is_not_flagged(self, tmp_path, monkeypatch):
ssc, _skills, d = self._mirror(tmp_path, monkeypatch)
ssc._write_org_baseline(
"org-1",
{"shared-x": {"fingerprint": ssc._skill_dir_fingerprint(d), "tree": "t1"}},
)
assert ssc.org_skill_is_locally_modified("shared-x", "org-1") is False
assert ssc.list_locally_modified_org_skills("org-1") == []
def test_edited_skill_is_detected(self, tmp_path, monkeypatch):
ssc, _skills, d = self._mirror(tmp_path, monkeypatch)
ssc._write_org_baseline(
"org-1",
{"shared-x": {"fingerprint": ssc._skill_dir_fingerprint(d), "tree": "t1"}},
)
(d / "SKILL.md").write_text("---\nname: shared-x\n---\nEDITED\n", encoding="utf-8")
assert ssc.org_skill_is_locally_modified("shared-x", "org-1") is True
assert ssc.list_locally_modified_org_skills("org-1") == ["shared-x"]
def test_missing_baseline_does_not_cry_wolf(self, tmp_path, monkeypatch):
ssc, _skills, _d = self._mirror(tmp_path, monkeypatch)
# Mirror pulled before baselines existed — must not be reported as
# modified (that would block every update with a phantom conflict).
assert ssc.org_skill_is_locally_modified("shared-x", "org-1") is False
def test_fingerprint_is_content_based_not_mtime(self, tmp_path, monkeypatch):
import os
import time
ssc, _skills, d = self._mirror(tmp_path, monkeypatch)
before = ssc._skill_dir_fingerprint(d)
time.sleep(0.01)
os.utime(d / "SKILL.md", None) # touch: mtime changes, content doesn't
assert ssc._skill_dir_fingerprint(d) == before
def test_auto_propose_defaults_off(self, monkeypatch):
from tools import skills_sync_client as ssc
monkeypatch.delenv("HERMES_SYNC_ORG_AUTO_PROPOSE", raising=False)
monkeypatch.setattr(
"hermes_cli.config.load_config", lambda: {}, raising=False
)
# Default must be OFF: silently pushing every agent edit to the whole
# organisation is not a safe default.
assert ssc.sync_org_auto_propose() is False
def test_auto_propose_can_be_enabled_by_env(self, monkeypatch):
from tools import skills_sync_client as ssc
monkeypatch.setenv("HERMES_SYNC_ORG_AUTO_PROPOSE", "1")
assert ssc.sync_org_auto_propose() is True