mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-31 19:16:29 +00:00
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.
This commit is contained in:
parent
981feb6730
commit
4f990ec09e
10 changed files with 240 additions and 230 deletions
|
|
@ -261,7 +261,9 @@ class TestOrgPullIsWiredIn:
|
|||
root / "hermes_cli" / "subcommands" / "sync.py",
|
||||
root / "hermes_cli" / "subcommands" / "skills.py",
|
||||
]
|
||||
banned = re.compile(r"\(M[12]\)|HSP/1|§[0-9]|DEV-PHASE|hsp-1-contract")
|
||||
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:
|
||||
|
|
@ -270,41 +272,41 @@ class TestOrgPullIsWiredIn:
|
|||
)
|
||||
|
||||
|
||||
class TestOrgSharingIsDiscoverable:
|
||||
"""`hermes sync` must point users at the org-sharing command.
|
||||
class TestSkillSyncIsOneCommand:
|
||||
"""Every Skill Sync verb lives under `hermes sync` for launch.
|
||||
|
||||
Without this, there is no path from "I want to share this with my team"
|
||||
to `hermes skills propose` — sync looks like the only sharing surface
|
||||
while being personal-only.
|
||||
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 test_sync_usage_block_mentions_propose(self):
|
||||
def _src(self, *parts):
|
||||
import pathlib
|
||||
|
||||
main_src = (
|
||||
pathlib.Path(__file__).resolve().parents[2]
|
||||
/ "hermes_cli"
|
||||
/ "main.py"
|
||||
return (
|
||||
pathlib.Path(__file__).resolve().parents[2].joinpath(*parts)
|
||||
).read_text(encoding="utf-8")
|
||||
usage_start = main_src.index(
|
||||
"usage: hermes sync <status|pull|push|now|enable|disable|device>"
|
||||
)
|
||||
usage_block = main_src[usage_start : usage_start + 1200]
|
||||
assert "hermes skills propose" in usage_block, (
|
||||
"`hermes sync` usage must point at the org-sharing command."
|
||||
|
||||
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_sync_parser_epilog_mentions_propose(self):
|
||||
import pathlib
|
||||
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."
|
||||
)
|
||||
|
||||
src = (
|
||||
pathlib.Path(__file__).resolve().parents[2]
|
||||
/ "hermes_cli"
|
||||
/ "subcommands"
|
||||
/ "sync.py"
|
||||
).read_text(encoding="utf-8")
|
||||
assert "hermes skills propose" in src, (
|
||||
"`hermes sync --help` must point at the org-sharing command."
|
||||
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."
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -1,13 +1,13 @@
|
|||
"""Tests for tools/skills_sync_client.py — the HSP/1 sync client.
|
||||
"""Tests for tools/skills_sync_client.py — the Skill Sync client.
|
||||
|
||||
Covers, against the frozen contract (~/src/specs/collective-wisdom/
|
||||
hsp-1-contract.md):
|
||||
the sync wire contract):
|
||||
* content addressing (full 64-hex) + canonical JSON (§2.1, §2.5)
|
||||
* the DEV-PHASE gate (tool_gateway_admin) making sync inert
|
||||
* the M1-D opt-in default (nothing syncs without the sync flag)
|
||||
* object building (blob/tree/commit, exec mode, size limit)
|
||||
* push (upload + CAS), pull (materialize), and the three-way merge / 409
|
||||
conflict paths — all against an in-process mock HSP server.
|
||||
conflict paths — all against an in-process mock sync server.
|
||||
|
||||
The mock server implements the contract §3/§4 endpoint shapes with an
|
||||
in-memory object store + ref table. No live server, no network.
|
||||
|
|
@ -25,7 +25,7 @@ import tools.skills_sync_client as ssc
|
|||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# In-process mock HSP/1 server (contract §3-§4)
|
||||
# In-process mock sync server (read + write endpoints)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class _MockState:
|
||||
|
|
@ -237,7 +237,7 @@ def _jwt(claims: dict) -> str:
|
|||
|
||||
class TestAddressing:
|
||||
def test_full_64_hex_address(self):
|
||||
addr = ssc.hsp_address(b"")
|
||||
addr = ssc.wire_address(b"")
|
||||
# sha256 of empty is the well-known e3b0... digest, full 64 hex.
|
||||
assert addr == (
|
||||
"sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
|
||||
|
|
@ -245,9 +245,9 @@ class TestAddressing:
|
|||
assert len(addr.split(":", 1)[1]) == 64
|
||||
|
||||
def test_address_differs_from_local_truncated_namespace(self):
|
||||
# OI-5: HSP full-64-hex must NOT equal the local truncated 16-hex form.
|
||||
# The wire full-64-hex must NOT equal the local truncated 16-hex form.
|
||||
data = b"hello world"
|
||||
full = ssc.hsp_address(data)
|
||||
full = ssc.wire_address(data)
|
||||
truncated = "sha256:" + hashlib.sha256(data).hexdigest()[:16]
|
||||
assert full != truncated
|
||||
assert len(full.split(":")[1]) == 64
|
||||
|
|
@ -460,7 +460,7 @@ def synced_env(tmp_path, monkeypatch):
|
|||
class TestEndToEnd:
|
||||
def test_capabilities_version_check(self, mock_server):
|
||||
base, state = mock_server
|
||||
client = ssc.HSPClient(base, "tok")
|
||||
client = ssc.SyncClient(base, "tok")
|
||||
caps = client.capabilities()
|
||||
assert caps["hsp_version"] == "1"
|
||||
ssc._check_version(caps) # no raise
|
||||
|
|
@ -468,14 +468,14 @@ class TestEndToEnd:
|
|||
def test_version_mismatch_raises(self, mock_server):
|
||||
base, state = mock_server
|
||||
state.hsp_version = "2"
|
||||
client = ssc.HSPClient(base, "tok")
|
||||
with pytest.raises(ssc.HSPError):
|
||||
client = ssc.SyncClient(base, "tok")
|
||||
with pytest.raises(ssc.SyncError):
|
||||
ssc._check_version(client.capabilities())
|
||||
|
||||
def test_push_uploads_and_cas(self, mock_server, synced_env):
|
||||
base, state = mock_server
|
||||
home, skills, identity = synced_env
|
||||
client = ssc.HSPClient(base, identity["api_key"])
|
||||
client = ssc.SyncClient(base, identity["api_key"])
|
||||
result = ssc.push_skills(client, identity=identity)
|
||||
assert result["ok"] is True
|
||||
# HEAD ref advanced to our commit
|
||||
|
|
@ -491,7 +491,7 @@ class TestEndToEnd:
|
|||
def test_push_then_pull_materializes(self, mock_server, synced_env, tmp_path, monkeypatch):
|
||||
base, state = mock_server
|
||||
home, skills, identity = synced_env
|
||||
client = ssc.HSPClient(base, identity["api_key"])
|
||||
client = ssc.SyncClient(base, identity["api_key"])
|
||||
ssc.push_skills(client, identity=identity)
|
||||
|
||||
# Simulate a fresh device: new skills dir, same server, same opt-in.
|
||||
|
|
@ -513,7 +513,7 @@ class TestEndToEnd:
|
|||
def test_push_idempotent_reupload(self, mock_server, synced_env):
|
||||
base, state = mock_server
|
||||
home, skills, identity = synced_env
|
||||
client = ssc.HSPClient(base, identity["api_key"])
|
||||
client = ssc.SyncClient(base, identity["api_key"])
|
||||
r1 = ssc.push_skills(client, identity=identity)
|
||||
n_objects = len(state.objects)
|
||||
# push again with no local change -> same head, objects already_present
|
||||
|
|
@ -525,7 +525,7 @@ class TestEndToEnd:
|
|||
def test_conflict_nonoverlap_merges(self, mock_server, synced_env, monkeypatch):
|
||||
base, state = mock_server
|
||||
home, skills, identity = synced_env
|
||||
client = ssc.HSPClient(base, identity["api_key"])
|
||||
client = ssc.SyncClient(base, identity["api_key"])
|
||||
# First push establishes a base head we record locally.
|
||||
first = ssc.push_skills(client, identity=identity)
|
||||
# Inject a divergent server head: change beta server-side so the next
|
||||
|
|
@ -543,7 +543,7 @@ class TestEndToEnd:
|
|||
def test_conflict_true_overlap_writes_conflict_ref(self, mock_server, synced_env, monkeypatch):
|
||||
base, state = mock_server
|
||||
home, skills, identity = synced_env
|
||||
client = ssc.HSPClient(base, identity["api_key"])
|
||||
client = ssc.SyncClient(base, identity["api_key"])
|
||||
ssc.push_skills(client, identity=identity)
|
||||
|
||||
# Build a DIFFERENT server-side head for the SAME skill (alpha) so the
|
||||
|
|
@ -642,7 +642,7 @@ class TestSyncManifest:
|
|||
# plane content. Read it back via read_manifest_of_root.
|
||||
base, state = mock_server
|
||||
home, skills, identity = synced_env
|
||||
client = ssc.HSPClient(base, identity["api_key"])
|
||||
client = ssc.SyncClient(base, identity["api_key"])
|
||||
|
||||
objs, root_hash, skill_map = ssc.snapshot_profile(["alpha", "beta"])
|
||||
client.put_objects(objs.objects)
|
||||
|
|
@ -661,7 +661,7 @@ class TestSyncManifest:
|
|||
# becomes opted in locally on pull, even if this device had it disabled.
|
||||
base, state = mock_server
|
||||
home, skills, identity = synced_env
|
||||
client = ssc.HSPClient(base, identity["api_key"])
|
||||
client = ssc.SyncClient(base, identity["api_key"])
|
||||
|
||||
# Device A pushes alpha+beta (manifest enables both).
|
||||
ssc.push_skills(client, identity=identity)
|
||||
|
|
@ -857,7 +857,7 @@ class TestOrgEndToEnd:
|
|||
base, state = mock_server
|
||||
home, skills, identity = synced_env
|
||||
identity = {**identity, "org_id": "org-1", "org_role": "ADMIN"}
|
||||
client = ssc.HSPClient(base, identity["api_key"])
|
||||
client = ssc.SyncClient(base, identity["api_key"])
|
||||
result = ssc.propose_skill("alpha", client, identity=identity)
|
||||
assert result["ok"] is True
|
||||
assert result.get("merged") is True
|
||||
|
|
@ -871,7 +871,7 @@ class TestOrgEndToEnd:
|
|||
home, skills, identity = synced_env
|
||||
# Seed an org HEAD as admin first.
|
||||
admin_ident = {**identity, "org_id": "org-1", "org_role": "ADMIN"}
|
||||
client = ssc.HSPClient(base, identity["api_key"])
|
||||
client = ssc.SyncClient(base, identity["api_key"])
|
||||
seeded = ssc.propose_skill("alpha", client, identity=admin_ident)
|
||||
|
||||
# Member edits beta and proposes: server converts to 202.
|
||||
|
|
@ -896,7 +896,7 @@ class TestOrgEndToEnd:
|
|||
base, state = mock_server
|
||||
home, skills, identity = synced_env
|
||||
admin_ident = {**identity, "org_id": "org-1", "org_role": "ADMIN"}
|
||||
client = ssc.HSPClient(base, identity["api_key"])
|
||||
client = ssc.SyncClient(base, identity["api_key"])
|
||||
ssc.propose_skill("alpha", client, identity=admin_ident)
|
||||
ssc.propose_skill("beta", client, identity=admin_ident)
|
||||
|
||||
|
|
@ -913,7 +913,7 @@ class TestOrgEndToEnd:
|
|||
base, state = mock_server
|
||||
home, skills, identity = synced_env
|
||||
admin_ident = {**identity, "org_id": "org-1", "org_role": "ADMIN"}
|
||||
client = ssc.HSPClient(base, identity["api_key"])
|
||||
client = ssc.SyncClient(base, identity["api_key"])
|
||||
ssc.propose_skill("alpha", client, identity=admin_ident)
|
||||
|
||||
result = ssc.pull_org_skills(client, identity=admin_ident)
|
||||
|
|
@ -927,7 +927,7 @@ class TestOrgEndToEnd:
|
|||
base, state = mock_server
|
||||
home, skills, identity = synced_env
|
||||
ident = {**identity, "org_id": "org-1", "org_role": "MEMBER"}
|
||||
client = ssc.HSPClient(base, identity["api_key"])
|
||||
client = ssc.SyncClient(base, identity["api_key"])
|
||||
result = ssc.pull_org_skills(client, identity=ident)
|
||||
assert result["ok"] is True
|
||||
assert result["head"] is None
|
||||
|
|
@ -938,7 +938,7 @@ class TestOrgEndToEnd:
|
|||
home, skills, identity = synced_env
|
||||
state.org_feature = False
|
||||
ident = {**identity, "org_id": "org-1", "org_role": "ADMIN"}
|
||||
client = ssc.HSPClient(base, identity["api_key"])
|
||||
client = ssc.SyncClient(base, identity["api_key"])
|
||||
with pytest.raises(ssc.SyncInertError):
|
||||
ssc.propose_skill("alpha", client, identity=ident)
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue