diff --git a/gateway/run.py b/gateway/run.py index e5ba9a546e8..4f610b3bf89 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -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") diff --git a/hermes_cli/main.py b/hermes_cli/main.py index ac374175b6b..43de6abe699 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -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 \n" + "usage: hermes sync " + "\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 Opt a skill into sync\n" - " disable Opt a skill out of sync\n" - " device [--name N] Show or set this device's sync label\n" + " enable Include a skill in your sync\n" + " disable 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 ", + "Shared with your team:\n" + " propose 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 `. " + f" Share them back with `hermes sync propose `. " 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 diff --git a/hermes_cli/subcommands/skills.py b/hermes_cli/subcommands/skills.py index 291697f5aff..4eb68a01b1c 100644 --- a/hermes_cli/subcommands/skills.py +++ b/hermes_cli/subcommands/skills.py @@ -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 ')", - ) skills_parser.set_defaults(func=cmd_skills) diff --git a/hermes_cli/subcommands/sync.py b/hermes_cli/subcommands/sync.py index bdc771d282d..48eb1334072 100644 --- a/hermes_cli/subcommands/sync.py +++ b/hermes_cli/subcommands/sync.py @@ -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 -- opt a skill into sync - hermes sync disable -- 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//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 opt a skill into sync + hermes sync disable 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 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 ` 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) diff --git a/tests/agent/test_org_skill_namespace.py b/tests/agent/test_org_skill_namespace.py index b86d9a7dad9..b2f61e3ece8 100644 --- a/tests/agent/test_org_skill_namespace.py +++ b/tests/agent/test_org_skill_namespace.py @@ -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 " - ) - 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." ) diff --git a/tests/tools/test_skills_sync_client.py b/tests/tools/test_skills_sync_client.py index 0553dcb6e93..e51ebc6860a 100644 --- a/tests/tools/test_skills_sync_client.py +++ b/tests/tools/test_skills_sync_client.py @@ -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) diff --git a/tools/skill_manager_tool.py b/tools/skill_manager_tool.py index 5064b25a048..6d5f2a5fba4 100644 --- a/tools/skill_manager_tool.py +++ b/tools/skill_manager_tool.py @@ -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 diff --git a/tools/skill_usage.py b/tools/skill_usage.py index 7ea57d5480a..eeb0591a430 100644 --- a/tools/skill_usage.py +++ b/tools/skill_usage.py @@ -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 diff --git a/tools/skills_sync_client.py b/tools/skills_sync_client.py index 0bc604ac12e..1bfa822a4f5 100644 --- a/tools/skills_sync_client.py +++ b/tools/skills_sync_client.py @@ -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//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 ``. The skill keeps working with the + runs ``hermes sync propose ``. 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() diff --git a/tools/skills_tool.py b/tools/skills_tool.py index b310cda8a46..9943db8160b 100644 --- a/tools/skills_tool.py +++ b/tools/skills_tool.py @@ -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