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

@ -22830,15 +22830,23 @@ def _start_gateway_housekeeping(stop_event: threading.Event, adapters=None, loop
except Exception as e:
logger.debug("Curator tick error: %s", e)
# HSP skill sync — best-effort periodic pull on the same cadence.
# Inert unless the DEV-PHASE gate is open (tool_gateway_admin) and
# a sync base URL is configured; never raises.
# Skill Sync — best-effort periodic pull on the same cadence.
# Inert unless the access gate is open and a sync base URL is
# configured; never raises.
try:
from tools.skills_sync_client import maybe_pull_skills
maybe_pull_skills()
except Exception as e:
logger.debug("Sync pull tick error: %s", e)
# Org-shared skills. Gated on real org membership (the token must
# carry an org role), so a solo account never reaches the network.
try:
from tools.skills_sync_client import maybe_pull_org_skills
maybe_pull_org_skills()
except Exception as e:
logger.debug("Org sync pull tick error: %s", e)
stop_event.wait(timeout=interval)
logger.info("Gateway housekeeping stopped")

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

View file

@ -313,26 +313,4 @@ def build_skills_parser(subparsers, *, cmd_skills: Callable) -> None:
help="Interactive skill configuration — enable/disable individual skills",
)
# M2 org-shared skills (hsp-1-contract.md §11.5/§11.11): propose a local
# skill's content to the org canonical set. MEMBER → 202 proposal
# (pending admin review); ADMIN/OWNER → direct merge. Only meaningful for
# multi-member orgs — personal orgs have no org workflow (the command
# reports that instead of failing opaquely).
skills_propose = skills_subparsers.add_parser(
"propose",
help="Propose a skill to your organisation's shared skill set",
description=(
"Snapshot the local skill and submit it to the org canonical set. "
"An org admin's push merges directly; a member's push becomes a "
"proposal reviewed in the org console. Personal orgs keep simple "
"personal sync and have no proposal workflow."
),
)
skills_propose.add_argument("name", help="Skill name to propose")
skills_propose.add_argument(
"-m",
"--message",
default=None,
help="Optional proposal message (defaults to 'propose <name>')",
)
skills_parser.set_defaults(func=cmd_skills)

View file

@ -1,21 +1,21 @@
"""``hermes sync`` subcommand parser (personal skill sync).
"""``hermes sync`` subcommand parser — Skill Sync.
Cloned from ``hermes_cli/subcommands/cron.py`` same injected-handler shape
(``func=cmd_sync``) so this module does not import ``main`` (cycle avoidance).
Commands:
hermes sync status -- show gate/opt-in/head state
hermes sync pull -- pull the owner's HEAD, materialize opted-in skills
hermes sync push -- push opted-in skills to the owner's HEAD
hermes sync now -- pull then push (full reconcile)
hermes sync enable <skill> -- opt a skill into sync
hermes sync disable <skill> -- opt a skill out of sync
hermes sync device [--name] -- show or set this device's sync label
Skill Sync covers two surfaces, both under this one command for launch:
This surface is PERSONAL sync only: it moves your own skills between your own
devices via ``refs/user/<owner>/HEAD``. Sharing a skill with an organisation
is a different operation with a different destination and an approval step
see ``hermes skills propose``.
Personal your own skills, across your own devices:
hermes sync status show gate/opt-in/head state
hermes sync pull pull and materialize opted-in skills
hermes sync push push opted-in skills
hermes sync now reconcile: pull then push
hermes sync enable <skill> opt a skill into sync
hermes sync disable <skill> opt a skill out of sync
hermes sync device [--name] show or set this device's label
Organisation skills shared with your team:
hermes sync propose <skill> share a skill with your organisation
Sync is INERT unless the resolved Nous token carries the access-gate claim
AND a sync base URL is configured. The commands report that state rather than
@ -24,49 +24,48 @@ failing opaquely.
from __future__ import annotations
import argparse
from typing import Callable
def build_sync_parser(subparsers, *, cmd_sync: Callable) -> None:
"""Attach the ``sync`` subcommand (and its sub-actions) to ``subparsers``."""
import argparse
sync_parser = subparsers.add_parser(
"sync",
help="Personal skill sync across your devices",
help="Skill Sync — sync your skills across devices and with your team",
description=(
"Sync agent-created and user-authored skills across your own "
"devices."
"Skill Sync keeps your skills with you. Personal sync moves your "
"own skills between your devices; if you belong to an "
"organisation, you also get its shared skills and can propose "
"your own back to the team."
),
epilog=(
"Sharing with your team:\n"
" These commands cover your PERSONAL skills only. To share a "
"skill with your\n"
" organisation, use `hermes skills propose <skill>` instead — it "
"submits the\n"
" skill to your org's shared set (an admin approves it unless "
"you are one).\n"
" Approved org skills arrive automatically and are read-only "
"locally.\n"
"Examples:\n"
" hermes sync status what is synced, and from where\n"
" hermes sync enable my-skill include a skill in your sync\n"
" hermes sync now pull, then push\n"
" hermes sync propose my-skill share a skill with your team\n"
),
formatter_class=argparse.RawDescriptionHelpFormatter,
)
sync_sub = sync_parser.add_subparsers(dest="sync_command")
sync_sub.add_parser("status", help="Show sync gate, opt-in, and head state")
sync_sub.add_parser("pull", help="Pull the owner's HEAD and materialize opted-in skills")
sync_sub.add_parser("push", help="Push opted-in skills to the owner's HEAD")
sync_sub.add_parser("status", help="Show what is synced, and from where")
sync_sub.add_parser(
"pull", help="Pull your synced skills (and your organisation's)"
)
sync_sub.add_parser("push", help="Push your opted-in skills")
sync_sub.add_parser("now", help="Reconcile now: pull then push")
enable = sync_sub.add_parser("enable", help="Opt a skill into sync")
enable = sync_sub.add_parser("enable", help="Include a skill in your sync")
enable.add_argument("skill", help="Skill name (frontmatter name / directory name)")
disable = sync_sub.add_parser("disable", help="Opt a skill out of sync")
disable = sync_sub.add_parser("disable", help="Exclude a skill from your sync")
disable.add_argument("skill", help="Skill name (frontmatter name / directory name)")
device = sync_sub.add_parser(
"device",
help="Show or set this device's sync label (shown in the sync console)",
help="Show or set this device's label (shown in the sync console)",
)
device.add_argument(
"--name",
@ -76,4 +75,25 @@ def build_sync_parser(subparsers, *, cmd_sync: Callable) -> None:
"Omit to print the current label.",
)
# Org-shared skills. A member's submission becomes a proposal an admin
# reviews; an admin's merges straight into the shared set. Accounts that
# aren't in a shared organisation are told so plainly.
propose = sync_sub.add_parser(
"propose",
help="Share a skill with your organisation",
description=(
"Submit one of your skills to your organisation's shared set. If "
"you are an admin it is added directly; otherwise it becomes a "
"proposal for an admin to review. Accounts that aren't part of a "
"shared organisation don't have this workflow."
),
)
propose.add_argument("name", help="Skill name to share")
propose.add_argument(
"-m",
"--message",
default=None,
help="Optional message describing the change",
)
sync_parser.set_defaults(func=cmd_sync)

View file

@ -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."
)

View file

@ -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)

View file

@ -639,7 +639,7 @@ def _maybe_auto_propose_org_edit(name: str, skill_path: Path) -> Optional[str]:
return (
f"This skill is shared by your organisation. Your edit is "
f"saved locally and will not be overwritten by org updates. "
f"Run `hermes skills propose {name}` to share it back."
f"Run `hermes sync propose {name}` to share it back."
)
result = ssc.propose_skill(name)
if result.get("proposal_pending"):
@ -652,7 +652,7 @@ def _maybe_auto_propose_org_edit(name: str, skill_path: Path) -> Optional[str]:
logger.debug("auto-propose skipped for %s: %s", name, e)
return (
f"Edit saved locally. Could not submit it to your organisation "
f"right now — run `hermes skills propose {name}` to retry."
f"right now — run `hermes sync propose {name}` to retry."
)
@ -668,7 +668,7 @@ def _org_mirror_write_guard(name: str, skill_path: Path, action: str) -> Optiona
Now an edit lands in the mirror and is protected from being overwritten by
the next org pull (see the baseline sidecar in skills_sync_client). It
reaches the organisation when the user runs `hermes skills propose`, or
reaches the organisation when the user runs `hermes sync propose`, or
immediately if `sync.org_auto_propose` is on.
Deletion is still refused: the mirror is a materialized view of the org
@ -688,7 +688,7 @@ def _org_mirror_write_guard(name: str, skill_path: Path, action: str) -> Optiona
"organisation, so a local delete would just come back on "
"the next sync. Ask an org admin to remove it for "
"everyone. (Editing it IS allowed — your changes are kept "
"and can be proposed back with `hermes skills propose "
"and can be proposed back with `hermes sync propose "
f"{name}`.)"
),
}
@ -1438,7 +1438,7 @@ def apply_skill_pending(payload: Dict[str, Any]) -> str:
_skill_gate_bypass.reset(token)
# Debounce state for the HSP sync push hook. A burst of skill_manage writes
# Debounce state for the sync push hook. A burst of skill_manage writes
# (e.g. create + several write_file calls) collapses into a single push after
# a short quiet window, on a daemon timer so the agent write never blocks.
_sync_push_timer = None
@ -1447,7 +1447,7 @@ _SYNC_PUSH_DEBOUNCE_S = 5.0
def _maybe_debounced_sync_push(skill_name: str) -> None:
"""Schedule a debounced best-effort HSP push after a skill write.
"""Schedule a debounced best-effort sync push after a skill write.
Cheap fast-path: if the skill isn't opted into sync, do nothing (no auth,
no network). Otherwise (re)arm a daemon timer; the actual push runs through
@ -1586,7 +1586,7 @@ def skill_manage(
except Exception:
pass
# HSP sync push hook (debounced, best-effort). Fires only AFTER the
# Sync push hook (debounced, best-effort). Fires only AFTER the
# write gate passed (staged/unapproved writes never reach here -- the
# gate returns early above), so we never push un-reviewed content.
# Inert unless the DEV-PHASE gate is open (tool_gateway_admin on the

View file

@ -680,7 +680,7 @@ def set_pinned(skill_name: str, pinned: bool) -> None:
def set_sync(skill_name: str, sync: bool) -> None:
"""Set the HSP-sync opt-in flag on a skill's usage record (M1-D).
"""Set the sync opt-in flag on a skill's usage record.
Sync is OPT-IN: nothing propagates to the sync plane unless the user marks
a skill with ``sync: true`` here. Sits alongside ``pinned``/``created_by``
@ -695,7 +695,7 @@ def set_sync(skill_name: str, sync: bool) -> None:
def is_sync_enabled(skill_name: str) -> bool:
"""Whether a skill is opted into HSP sync (``sync: true`` in its record)."""
"""Whether a skill is opted into sync (``sync: true`` in its record)."""
return get_record(skill_name).get("sync") is True

View file

@ -1,9 +1,9 @@
#!/usr/bin/env python3
"""
HSP/1 sync client -- Hermes Sync Protocol version 1, client (personal skill sync).
Skill Sync client -- the low-level sync layer.
This is the LOW-LEVEL sync layer. It builds content-addressed HSP objects
(blob/tree/commit) from local skills, talks the HSP/1 wire contract to a sync
This is the LOW-LEVEL sync layer. It builds content-addressed objects
(blob/tree/commit) from local skills, talks the sync wire contract to a sync
plane (push objects + CAS a ref, pull the owner's HEAD, three-way merge on a
409), and is driven by:
@ -15,7 +15,7 @@ It lives beside ``tools/skills_sync.py`` (NOT under ``hermes_cli/``) so the
low-level sync layer never imports the CLI -- same rule the bundled-skills
sync module documents at ``skills_sync.py:43-50``.
Contract: ``~/src/specs/collective-wisdom/hsp-1-contract.md`` (HSP/1, frozen
Contract: the Skill Sync wire contract (version 1, frozen
for Milestone 1). Endpoint shapes, object model, canonicalization, and status
codes below all trace to that document.
@ -58,7 +58,11 @@ from typing import Any, Callable, Dict, List, Optional, Tuple
logger = logging.getLogger(__name__)
# Sync protocol constants
HSP_VERSION = "1"
# Wire protocol version. The over-the-wire names below (the `hsp_version`
# capability field and the `x-hsp-object-type` response header) are part of
# the deployed server contract and are NOT renamed with the product — the
# user-facing feature is "Skill Sync"; these are protocol identifiers.
WIRE_VERSION = "1"
DEFAULT_MAX_OBJECT_BYTES = 26214400 # 25 MiB, mirrors capabilities default
# Object kinds (sync contract)
@ -77,7 +81,7 @@ ARTIFACT_TYPE_SKILL = "skill"
# `sync-manifest` object convention (design notes).
#
# Per-skill sync opt-in ("this skill syncs / this one does not"
# opt-in state) is CONTENT inside the HSP object model, NOT a device-local flag
# opt-in state) is CONTENT inside the sync object model, NOT a device-local flag
# or a mutable preference table. An owner's synced set is a small committed blob
# named ``sync-manifest`` at the ROOT of the tree referenced by
# ``refs/user/<owner>/HEAD``, recording per-skill ``{name, enabled}``. Toggling
@ -158,14 +162,14 @@ def parse_sync_manifest(data: bytes) -> Optional[Dict[str, bool]]:
# ---------------------------------------------------------------------------
# Content addressing
#
# HSP uses the FULL 64-hex sha256 digest on the wire. This is a DIFFERENT
# The wire uses the FULL 64-hex sha256 digest. This is a DIFFERENT
# namespace from hermes-agent's local ``content_hash`` (skills_guard.py:846),
# which is a truncated 16-hex digest used for local dedup. They must never be
# conflated -- we compute full digests here.
# ---------------------------------------------------------------------------
def hsp_address(data: bytes) -> str:
"""Return ``sha256:<64-hex>`` -- the HSP wire address of ``data`` (sync contract)."""
def wire_address(data: bytes) -> str:
"""Return ``sha256:<64-hex>`` -- the wire address of ``data``."""
return "sha256:" + hashlib.sha256(data).hexdigest()
@ -279,14 +283,14 @@ def dev_gate_open() -> bool:
# ---------------------------------------------------------------------------
# Sync-plane endpoint resolution
#
# The HSP routes are mounted under /v1/sync/ (sync contract). The base URL is
# The sync routes are mounted under /v1/sync/. The base URL is
# configurable (config.yaml sync.base_url or HERMES_SYNC_BASE_URL bridge env);
# it is NOT the inference base_url. When unset, sync is inert -- there is no
# server to talk to yet (the server is being built in parallel).
# ---------------------------------------------------------------------------
def resolve_sync_base_url() -> Optional[str]:
"""Resolve the HSP sync-plane base URL, or None when unconfigured.
"""Resolve the sync-plane base URL, or None when unconfigured.
Order: HERMES_SYNC_BASE_URL env bridge -> config.yaml ``sync.base_url``.
Returns a base without a trailing slash (e.g. ``https://host``); the
@ -318,7 +322,7 @@ def resolve_sync_base_url() -> Optional[str]:
# precedence as base_url: the HERMES_SYNC_* env var wins, else config.yaml
# ``sync.*``, else a built-in default.
#
# HERMES_SYNC_BASE_URL -> sync.base_url (the HSP plane URL)
# HERMES_SYNC_BASE_URL -> sync.base_url (the sync plane URL)
# HERMES_SYNC_ENABLED -> sync.enabled (master on/off; default off)
# HERMES_SYNC_DEFAULT_OPT_IN -> sync.default_opt_in (personal sync policy; default false
# = opt-in. Set true to make
@ -383,7 +387,7 @@ def sync_org_auto_propose() -> bool:
``HERMES_SYNC_ORG_AUTO_PROPOSE`` -> ``sync.org_auto_propose`` -> False.
False (default): edits to an org-shared skill stay LOCAL until the user
runs ``hermes skills propose <skill>``. The skill keeps working with the
runs ``hermes sync propose <skill>``. The skill keeps working with the
edit applied; the organisation just doesn't see it yet.
True: every local edit to an org skill is submitted to the org as a
@ -427,7 +431,7 @@ def _skills_dir() -> Path:
def is_sync_eligible(skill_name: str) -> bool:
"""Whether *skill_name* is a candidate for HSP sync (before the opt-in check).
"""Whether *skill_name* is a candidate for sync (before the opt-in check).
Eligible = present locally under ~/.hermes/skills/, NOT bundled, NOT
hub-installed, NOT an external-dir skill, and NOT under the org mirror
@ -525,7 +529,7 @@ def _all_local_skill_names() -> List[str]:
# ---------------------------------------------------------------------------
# Object building -- turn a skill directory into HSP blob/tree/commit objects
# Object building -- turn a skill directory into blob/tree/commit objects
#
# A skill dir becomes one tree (sync contract). Each file is a blob; each
# subdir a nested tree. The profile-root tree (the sync contract: "a tree whose
@ -533,7 +537,7 @@ def _all_local_skill_names() -> List[str]:
# ---------------------------------------------------------------------------
class ObjectSet:
"""Accumulates HSP objects to push: hash -> (kind, bytes).
"""Accumulates objects to push: hash -> (kind, bytes).
Deduped by content address, so identical blobs across skills upload once.
"""
@ -542,7 +546,7 @@ class ObjectSet:
self.objects: Dict[str, Tuple[str, bytes]] = {}
def add(self, kind: str, data: bytes) -> str:
addr = hsp_address(data)
addr = wire_address(data)
self.objects.setdefault(addr, (kind, data))
return addr
@ -551,7 +555,7 @@ class ObjectSet:
def _file_mode(path: Path) -> str:
"""Return the HSP tree mode for a regular file: ``exec`` if +x else ``file``
"""Return the tree mode for a regular file: ``exec`` if +x else ``file``
(contract §2.3). No symlinks / other modes are emitted."""
try:
if path.stat().st_mode & (_stat.S_IXUSR | _stat.S_IXGRP | _stat.S_IXOTH):
@ -562,7 +566,7 @@ def _file_mode(path: Path) -> str:
def build_tree(dir_path: Path, objects: ObjectSet, *, max_object_bytes: int) -> str:
"""Recursively build HSP objects for *dir_path*; return the tree address.
"""Recursively build objects for *dir_path*; return the tree address.
Regular files become blobs; subdirectories become nested trees. Symlinks,
sockets, and other special files are skipped (contract §2.3 security: no
@ -705,22 +709,22 @@ def set_device_name(name: str) -> str:
# ---------------------------------------------------------------------------
# HSP/1 wire client
# Sync wire client
#
# Thin requests-based client for the endpoints in the sync contract- Uploads all
# new objects (batch), then CAS-es the ref. A 409 returns the actual head for
# the caller's three-way merge. Auth is the Nous bearer resolved above.
# ---------------------------------------------------------------------------
class HSPError(RuntimeError):
"""A non-recoverable HSP wire error (4xx that the client can't retry)."""
class SyncError(RuntimeError):
"""A non-recoverable wire error (4xx that the client can't retry)."""
def __init__(self, message: str, *, status: Optional[int] = None):
super().__init__(message)
self.status = status
class HSPConflict(RuntimeError):
class SyncConflict(RuntimeError):
"""CAS lost (409). ``actual`` is the current head to merge against
(contract §4.4). NOT a rejection -- pushed objects are already durable."""
@ -729,7 +733,7 @@ class HSPConflict(RuntimeError):
self.actual = actual
class HSPClient:
class SyncClient:
"""Sync client bound to a base URL + bearer (routes under
``/v1/sync/``)."""
@ -751,7 +755,7 @@ class HSPClient:
"""GET /v1/sync/capabilities (sync contract). No auth required."""
r = self._session.get(self._url("capabilities"), timeout=self.timeout)
if r.status_code != 200:
raise HSPError(f"capabilities failed: {r.status_code}", status=r.status_code)
raise SyncError(f"capabilities failed: {r.status_code}", status=r.status_code)
return r.json()
def get_refs(self, prefix: str) -> List[Dict[str, str]]:
@ -760,22 +764,22 @@ class HSPClient:
self._url("refs"), params={"prefix": prefix}, timeout=self.timeout
)
if r.status_code != 200:
raise HSPError(f"get_refs failed: {r.status_code}", status=r.status_code)
raise SyncError(f"get_refs failed: {r.status_code}", status=r.status_code)
return (r.json() or {}).get("refs", [])
def get_object(self, obj_hash: str) -> Tuple[str, bytes]:
"""GET /v1/sync/objects/:hash (sync contract). Returns (kind, bytes).
Kind comes from ``X-HSP-Object-Type`` for tree/commit; a blob response
Kind comes from the object-type response header for tree/commit; a blob
(application/octet-stream) is returned as ``blob``.
"""
r = self._session.get(self._url(f"objects/{obj_hash}"), timeout=self.timeout)
if r.status_code == 404:
raise HSPError(f"object {obj_hash} not found", status=404)
raise SyncError(f"object {obj_hash} not found", status=404)
if r.status_code == 403:
raise HSPError(f"object {obj_hash} not readable", status=403)
raise SyncError(f"object {obj_hash} not readable", status=403)
if r.status_code != 200:
raise HSPError(f"get_object failed: {r.status_code}", status=r.status_code)
raise SyncError(f"get_object failed: {r.status_code}", status=r.status_code)
kind = r.headers.get("X-HSP-Object-Type") or KIND_BLOB
return kind, r.content
@ -783,14 +787,14 @@ class HSPClient:
"""Fetch a commit object and parse its canonical JSON."""
kind, data = self.get_object(commit_hash)
if kind != KIND_COMMIT:
raise HSPError(f"{commit_hash} is {kind}, expected commit")
raise SyncError(f"{commit_hash} is {kind}, expected commit")
return json.loads(data.decode("utf-8"))
def get_tree_json(self, tree_hash: str) -> Dict[str, Any]:
"""Fetch a tree object and parse its canonical JSON."""
kind, data = self.get_object(tree_hash)
if kind != KIND_TREE:
raise HSPError(f"{tree_hash} is {kind}, expected tree")
raise SyncError(f"{tree_hash} is {kind}, expected tree")
return json.loads(data.decode("utf-8"))
# -- write -------------------------------------------------------------
@ -833,17 +837,17 @@ class HSPClient:
timeout=self.timeout,
)
if r.status_code == 413:
raise HSPError("object too large (413)", status=413)
raise SyncError("object too large (413)", status=413)
if r.status_code == 422:
raise HSPError(f"hash_mismatch (422): {r.text}", status=422)
raise SyncError(f"hash_mismatch (422): {r.text}", status=422)
if r.status_code not in (200, 201):
raise HSPError(f"put_objects failed: {r.status_code}", status=r.status_code)
raise SyncError(f"put_objects failed: {r.status_code}", status=r.status_code)
return r.json() if r.content else {}
def cas_ref(self, name: str, from_hash: Optional[str], to_hash: str) -> Dict[str, Any]:
"""POST /v1/sync/refs/:name -- atomic compare-and-swap (sync contract).
Raises :class:`HSPConflict` (carrying the actual head) on 409.
Raises :class:`SyncConflict` (carrying the actual head) on 409.
M2 (contract §11.5): a non-admin member's CAS on an org HEAD is never
rejected the server converts it to a proposal and returns
@ -862,16 +866,16 @@ class HSPClient:
return {"proposal_pending": True, **body}
if r.status_code == 409:
actual = (r.json() or {}).get("actual", "")
raise HSPConflict(actual)
raise SyncConflict(actual)
if r.status_code == 403:
raise HSPError("forbidden (403) -- owner/permission", status=403)
raise SyncError("forbidden (403) -- owner/permission", status=403)
if r.status_code != 200:
raise HSPError(f"cas_ref failed: {r.status_code}", status=r.status_code)
raise SyncError(f"cas_ref failed: {r.status_code}", status=r.status_code)
return r.json() if r.content else {}
# ---------------------------------------------------------------------------
# HSP local sync STATE (client-local head bookkeeping, FULL-digest namespace)
# Local sync STATE (client-local head bookkeeping, FULL-digest namespace)
#
# Records the last commit HEAD we pushed/pulled and, per synced skill, the tree
# hash of the on-disk content at that point. Distinct from the bundled manifest
@ -894,7 +898,7 @@ def _legacy_sync_state_path() -> Path:
def read_sync_state() -> Dict[str, Any]:
"""Read the local HSP sync state. Returns a default on missing/corrupt.
"""Read the local sync state. Returns a default on missing/corrupt.
Shape: ``{"head": "sha256:...|null", "skills": {name: {tree, commit}}}``.
``head`` is the last profile-root HEAD commit we reconciled with.
@ -933,7 +937,7 @@ def read_sync_state() -> Dict[str, Any]:
def write_sync_state(data: Dict[str, Any]) -> None:
"""Write the local HSP sync state atomically. Best-effort."""
"""Write the local sync state atomically. Best-effort."""
import tempfile
path = _sync_state_path()
@ -957,11 +961,11 @@ def write_sync_state(data: Dict[str, Any]) -> None:
# ---------------------------------------------------------------------------
# Tree materialization (pull) -- write an HSP tree back to a skill directory
# Tree materialization (pull) -- write a tree back to a skill directory
# ---------------------------------------------------------------------------
def materialize_tree(client: HSPClient, tree_hash: str, dest: Path) -> None:
"""Write the HSP tree at *tree_hash* into *dest* (created if needed).
def materialize_tree(client: SyncClient, tree_hash: str, dest: Path) -> None:
"""Write the tree at *tree_hash* into *dest* (created if needed).
Blobs become files (with +x restored for ``exec`` mode), nested trees
become subdirectories. Does NOT delete files absent from the tree -- the
@ -1017,7 +1021,7 @@ def _skill_rel_path(skill_name: str) -> Optional[PurePosixPath]:
def snapshot_profile(
skill_names: List[str], *, max_object_bytes: int = DEFAULT_MAX_OBJECT_BYTES
) -> Tuple[ObjectSet, str, Dict[str, str]]:
"""Build all HSP objects for *skill_names* + the profile-root tree.
"""Build all objects for *skill_names* + the profile-root tree.
Returns ``(objects, root_tree_hash, skill_tree_map)`` where
``skill_tree_map`` is ``{skill_name: tree_hash}``. Skills whose blobs
@ -1074,7 +1078,7 @@ def snapshot_profile(
def _build_root_tree(
node: Dict[str, Any], objects: ObjectSet, *, manifest_hash: Optional[str] = None
) -> str:
"""Recursively canonicalize the nested root structure into HSP trees.
"""Recursively canonicalize the nested root structure into trees.
``manifest_hash`` (only passed at the top level) adds a root-level
``sync-manifest`` BLOB entry (design.md §2.8) alongside the skill subtrees.
@ -1117,12 +1121,12 @@ def user_conflict_ref(owner: str, n: int) -> str:
return f"refs/user/{owner}/conflict/{n}"
def _root_tree_of_commit(client: "HSPClient", commit_hash: str) -> str:
def _root_tree_of_commit(client: "SyncClient", commit_hash: str) -> str:
"""Return the tree hash referenced by a commit."""
return client.get_commit_json(commit_hash)["tree"]
def _skill_trees_of_root(client: "HSPClient", root_tree_hash: str) -> Dict[str, str]:
def _skill_trees_of_root(client: "SyncClient", root_tree_hash: str) -> Dict[str, str]:
"""Flatten a profile-root tree into ``{posix_rel_path: skill_tree_hash}``.
A skill tree is any tree containing a ``SKILL.md`` blob entry. We walk the
@ -1150,7 +1154,7 @@ def _skill_trees_of_root(client: "HSPClient", root_tree_hash: str) -> Dict[str,
def read_manifest_of_root(
client: "HSPClient", root_tree_hash: str
client: "SyncClient", root_tree_hash: str
) -> Optional[Dict[str, bool]]:
"""Read the ``sync-manifest`` blob at the root of *root_tree_hash* into
``{name: enabled}`` (design.md §2.8), or ``None`` if there is no manifest
@ -1178,10 +1182,13 @@ def read_manifest_of_root(
def _check_version(caps: Dict[str, Any]) -> None:
"""Reject an incompatible server major version (sync contract)."""
ver = str(caps.get("hsp_version") or "")
ver = str(caps.get("hsp_version") or "") # wire field name
major = ver.split(".", 1)[0]
if major != HSP_VERSION:
raise HSPError(f"incompatible HSP version {ver!r} (client speaks {HSP_VERSION})")
if major != WIRE_VERSION:
raise SyncError(
f"this server speaks sync version {ver!r}, but this Hermes speaks "
f"{WIRE_VERSION} — update Hermes to sync with it"
)
# ---------------------------------------------------------------------------
@ -1189,7 +1196,7 @@ def _check_version(caps: Dict[str, Any]) -> None:
# ---------------------------------------------------------------------------
def push_skills(
client: Optional["HSPClient"] = None,
client: Optional["SyncClient"] = None,
*,
skill_names: Optional[List[str]] = None,
identity: Optional[Dict[str, Any]] = None,
@ -1208,7 +1215,7 @@ def push_skills(
base = resolve_sync_base_url()
if not base:
return {"ok": False, "reason": "no sync base url configured", "noop": True}
client = HSPClient(base, identity["api_key"])
client = SyncClient(base, identity["api_key"])
if skill_names is None:
skill_names = list_synced_skill_names()
@ -1245,7 +1252,7 @@ def push_skills(
manifest["root"] = root_hash
write_sync_state(manifest)
return {"ok": True, "head": commit_hash, "pushed_objects": len(objects)}
except HSPConflict as conflict:
except SyncConflict as conflict:
return _resolve_push_conflict(
client, identity, conflict.actual, root_hash, commit_hash,
objects, skill_names, message, base_head,
@ -1273,7 +1280,7 @@ def push_skills(
# ---------------------------------------------------------------------------
def _resolve_push_conflict(
client: "HSPClient",
client: "SyncClient",
identity: Dict[str, Any],
actual_head: str,
our_root: str,
@ -1321,7 +1328,7 @@ def _resolve_push_conflict(
conflict_ref = user_conflict_ref(owner, n)
try:
client.cas_ref(conflict_ref, None, our_commit)
except HSPConflict:
except SyncConflict:
pass # someone else grabbed this index; the head still exists
return {
"ok": False,
@ -1353,7 +1360,7 @@ def _resolve_push_conflict(
client.put_objects(merge_objects.objects)
try:
client.cas_ref(user_head_ref(owner), actual_head, merge_commit)
except HSPConflict as c2:
except SyncConflict as c2:
return {
"ok": False,
"conflict": True,
@ -1388,7 +1395,7 @@ def _merge_skill(base: Optional[str], ours: Optional[str], theirs: Optional[str]
def _assemble_root_from_skill_trees(
client: "HSPClient", skill_trees: Dict[str, str], objects: "ObjectSet"
client: "SyncClient", skill_trees: Dict[str, str], objects: "ObjectSet"
) -> str:
"""Build a profile-root tree object from ``{posix_rel_path: tree_hash}``.
@ -1406,11 +1413,11 @@ def _assemble_root_from_skill_trees(
return _build_root_tree(root, objects)
def _next_conflict_index(client: "HSPClient", owner: str) -> int:
def _next_conflict_index(client: "SyncClient", owner: str) -> int:
"""Pick the next free conflict ref index for the owner."""
try:
refs = client.get_refs(f"refs/user/{owner}/conflict/")
except HSPError:
except SyncError:
return 1
used = []
for r in refs:
@ -1426,7 +1433,7 @@ def _next_conflict_index(client: "HSPClient", owner: str) -> int:
# ---------------------------------------------------------------------------
def pull_skills(
client: Optional["HSPClient"] = None,
client: Optional["SyncClient"] = None,
*,
identity: Optional[Dict[str, Any]] = None,
) -> Dict[str, Any]:
@ -1445,7 +1452,7 @@ def pull_skills(
base = resolve_sync_base_url()
if not base:
return {"ok": False, "reason": "no sync base url configured", "noop": True}
client = HSPClient(base, identity["api_key"])
client = SyncClient(base, identity["api_key"])
caps = client.capabilities()
_check_version(caps)
@ -1652,7 +1659,7 @@ def list_org_skill_names() -> List[str]:
# here is inert (org_sync_available() False; pull/propose raise SyncInertError)
# and the personal personal sync experience is untouched.
#
# TRAJECTORY (Ben): `hermes skills propose` is the org sharing MVP surface; proposal is
# `hermes sync propose` is the org sharing surface; proposal is
# intended to become largely automated later (curator/background hooks driving
# the same propose_skill() path). Keep this callable non-interactive.
# ---------------------------------------------------------------------------
@ -1702,7 +1709,7 @@ def _org_dir() -> Path:
def pull_org_skills(
client: Optional["HSPClient"] = None,
client: Optional["SyncClient"] = None,
*,
identity: Optional[Dict[str, Any]] = None,
) -> Dict[str, Any]:
@ -1722,7 +1729,7 @@ def pull_org_skills(
base_url = resolve_sync_base_url()
if not base_url:
raise SyncInertError("no sync base URL configured")
client = HSPClient(base_url, identity["api_key"])
client = SyncClient(base_url, identity["api_key"])
caps = client.capabilities()
_check_version(caps)
@ -1916,7 +1923,7 @@ def _write_org_provenance(org_id: str, data: Dict[str, Any]) -> None:
def propose_skill(
skill_name: str,
client: Optional["HSPClient"] = None,
client: Optional["SyncClient"] = None,
*,
identity: Optional[Dict[str, Any]] = None,
message: Optional[str] = None,
@ -1942,7 +1949,7 @@ def propose_skill(
base_url = resolve_sync_base_url()
if not base_url:
raise SyncInertError("no sync base URL configured")
client = HSPClient(base_url, identity["api_key"])
client = SyncClient(base_url, identity["api_key"])
caps = client.capabilities()
_check_version(caps)
@ -1953,10 +1960,10 @@ def propose_skill(
# Locate the local skill directory (personal namespace, NOT _org/).
rel = _skill_rel_path(skill_name)
if rel is None:
raise HSPError(f"skill '{skill_name}' not found under the skills dir")
raise SyncError(f"skill '{skill_name}' not found under the skills dir")
skill_dir = _skills_dir() / rel
if not (skill_dir / "SKILL.md").exists():
raise HSPError(f"skill '{skill_name}' has no SKILL.md")
raise SyncError(f"skill '{skill_name}' has no SKILL.md")
# Build the proposed skill tree.
objects = ObjectSet()

View file

@ -1617,7 +1617,7 @@ def skill_view(
"Your edits are kept locally\n"
"> and are never overwritten by org updates; share "
"them back with\n"
"> `hermes skills propose` (or automatically, if your "
"> `hermes sync propose` (or automatically, if your "
"org enables it).\n\n"
)
rendered_content = header + rendered_content