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:
Ben Barclay 2026-07-29 07:47:06 +10:00
parent 981feb6730
commit 4f990ec09e
10 changed files with 240 additions and 230 deletions

View file

@ -4440,26 +4440,27 @@ def cmd_cron(args):
def cmd_sync(args):
"""HSP/1 personal skill sync management (status/pull/push/now/enable/disable)."""
"""Skill Sync — personal sync across devices, plus sharing with your org."""
import json as _json
sub = getattr(args, "sync_command", None)
if sub in {None, ""}:
print(
"usage: hermes sync <status|pull|push|now|enable|disable|device>\n"
"usage: hermes sync "
"<status|pull|push|now|enable|disable|device|propose>\n"
"\n"
" status Show sync gate, opt-in, and head state\n"
" pull Pull the owner's HEAD, materialize opted-in skills\n"
" push Push opted-in skills to the owner's HEAD\n"
"Your skills, across your devices:\n"
" status Show what is synced, and from where\n"
" pull Pull your synced skills\n"
" push Push your opted-in skills\n"
" now Reconcile now: pull then push\n"
" enable <skill> Opt a skill into sync\n"
" disable <skill> Opt a skill out of sync\n"
" device [--name N] Show or set this device's sync label\n"
" enable <skill> Include a skill in your sync\n"
" disable <skill> Exclude a skill from your sync\n"
" device [--name N] Show or set this device's label\n"
"\n"
"These cover your PERSONAL skills, across your own devices.\n"
"To share a skill with your organisation instead:\n"
" hermes skills propose <skill>",
"Shared with your team:\n"
" propose <skill> Share a skill with your organisation",
file=sys.stderr,
)
return 1
@ -4485,6 +4486,28 @@ def cmd_sync(args):
print(ssc.stable_device_id())
return 0
if sub == "propose":
from tools import skills_sync_client as ssc
name = args.name
try:
result = ssc.propose_skill(name, message=args.message)
except ssc.SyncInertError as e:
print(f"cannot share this skill: {e}", file=sys.stderr)
return 1
except ssc.SyncError as e:
print(f"could not share '{name}': {e}", file=sys.stderr)
return 1
if result.get("proposal_pending"):
print(
f"Shared '{name}' with your organisation — an admin needs to "
f"approve it (proposal #{result.get('proposal_id')}). It is "
f"not live for the team until then."
)
else:
print(f"Added '{name}' to your organisation's shared skills.")
return 0
if sub in {"enable", "disable"}:
from tools.skill_usage import set_sync, is_curation_eligible
@ -4519,7 +4542,7 @@ def cmd_sync(args):
print(
f" {len(modified)} with local edits not yet shared: "
f"{', '.join(modified)}\n"
f" Share them back with `hermes skills propose <skill>`. "
f" Share them back with `hermes sync propose <skill>`. "
f"Org updates will not overwrite them.",
file=sys.stderr,
)
@ -4603,7 +4626,7 @@ def cmd_sync(args):
else:
print(f"Unknown sync subcommand: {sub}", file=sys.stderr)
return 1
except ssc.HSPError as e:
except ssc.SyncError as e:
print(f"sync failed: {e}", file=sys.stderr)
return 1
@ -13942,34 +13965,6 @@ def cmd_skills(args):
from hermes_cli.skills_config import skills_command as skills_config_command
skills_config_command(args)
elif getattr(args, "skills_action", None) == "propose":
# M2 org-shared skills (hsp-1-contract.md §11.5): propose a local
# skill to the org canonical set. 202 => pending review (NEVER shown
# as live); direct merge for admins. Personal orgs have no org
# workflow — say so plainly instead of a raw 403.
from tools import skills_sync_client as ssc
name = args.name
try:
result = ssc.propose_skill(name, message=args.message)
except ssc.SyncInertError as e:
print(f"org sync unavailable: {e}", file=sys.stderr)
return 1
except ssc.HSPError as e:
print(f"propose failed: {e}", file=sys.stderr)
return 1
if result.get("proposal_pending"):
print(
f"proposed '{name}' — pending admin review "
f"(proposal #{result.get('proposal_id')}). Not live for the "
f"org until approved."
)
else:
print(
f"merged '{name}' into the org set "
f"(head {str(result.get('head', ''))[:19]}…)."
)
return 0
else:
from hermes_cli.skills_hub import skills_command