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

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