diff --git a/agent/skill_utils.py b/agent/skill_utils.py index 2f41310f152..ea4bb2ea27d 100644 --- a/agent/skill_utils.py +++ b/agent/skill_utils.py @@ -49,7 +49,7 @@ EXCLUDED_SKILL_DIRS = frozenset( # archive workflow preserves a complete old skill package under references/. SKILL_SUPPORT_DIRS = frozenset(("references", "templates", "assets", "scripts")) -# ── M2 org-shared skills (hsp-1-contract.md §11) ─────────────────────────── +# ── Org-shared skills (sync contract) ─────────────────────────── # Org mirrors live under ~/.hermes/skills/_org//. Resolution is # TOKEN-GATED via a marker file the sync client writes after verifying the # token (skills_sync_client.pull_org_skills): only the marked org's mirror is diff --git a/cli.py b/cli.py index e556a5ac19d..043454af98e 100644 --- a/cli.py +++ b/cli.py @@ -13355,15 +13355,25 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin): except Exception: pass - # HSP skill sync — best-effort periodic pull, piggy-backing on the - # curator tick. Inert unless the DEV-PHASE gate is open - # (tool_gateway_admin) and a sync base URL is configured; swallows all - # errors so it never blocks CLI startup. + # Skill sync — best-effort periodic pull, piggy-backing on the + # curator tick. Inert unless the access gate is open and a sync base + # URL is configured; swallows all errors so it never blocks startup. try: from tools.skills_sync_client import maybe_pull_skills maybe_pull_skills() except Exception: pass + + # Org-shared skills — pull the organisation's approved set into the + # read-only mirror. Gated on real org membership: resolve_org_identity + # requires an org role on the token, which is only issued for + # multi-member organisations, so a solo account never reaches the + # network here. Fail-quiet, exactly like the personal pull above. + try: + from tools.skills_sync_client import maybe_pull_org_skills + maybe_pull_org_skills() + except Exception: + pass if self.preloaded_skills and not self._startup_skills_line_shown: skills_label = ", ".join(self.preloaded_skills) self._console_print( diff --git a/hermes_cli/main.py b/hermes_cli/main.py index 90597526cb7..86d0d5eed6e 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -4453,7 +4453,7 @@ def cmd_sync(args): " pull Pull the owner's HEAD, materialize opted-in skills\n" " push Push opted-in skills to the owner's HEAD\n" " now Reconcile now: pull then push\n" - " enable Opt a skill into sync (M1-D opt-in)\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", file=sys.stderr, @@ -4502,12 +4502,25 @@ def cmd_sync(args): if sub == "status": status = ssc.sync_status() print(_json.dumps(status, indent=2, ensure_ascii=False)) + if status.get("org_available"): + n = len(status.get("org_skills") or []) + print( + f"\nOrg skills: {n} shared skill(s) mirrored read-only from " + f"your organisation (your role: {status.get('org_role')}). " + f"They load alongside your own, labeled by origin.", + file=sys.stderr, + ) + elif status.get("logged_in"): + print( + "\nOrg skills: not applicable — this account isn't a member " + "of a shared organisation.", + file=sys.stderr, + ) if not status.get("logged_in"): print("\nNot logged into Nous Portal — sync is inert.", file=sys.stderr) elif not status.get("dev_gate_ok"): print( - "\nDEV-PHASE gate closed: your token lacks 'tool_gateway_admin'. " - "Sync is inert during the dev rollout.", + "\nSync is not enabled for your account yet.", file=sys.stderr, ) elif not status.get("feature_enabled"): @@ -4532,7 +4545,7 @@ def cmd_sync(args): return 1 if not identity.get("dev_gate_ok"): print( - "sync inert: DEV-PHASE gate closed (token lacks 'tool_gateway_admin').", + "sync unavailable: not enabled for your account yet.", file=sys.stderr, ) return 1 @@ -4547,6 +4560,16 @@ def cmd_sync(args): try: if sub == "pull": result = ssc.pull_skills(identity=identity) + # Refresh the org mirror too when this account belongs to an + # organisation (no-op otherwise), so one pull covers both. + org_result = ssc.maybe_pull_org_skills() + if org_result: + n = len(org_result.get("updated") or []) + print( + f"org: refreshed {n} shared skill(s) from your " + f"organisation.", + file=sys.stderr, + ) elif sub == "push": result = ssc.push_skills(identity=identity, message="hermes sync push") elif sub == "now": diff --git a/hermes_cli/subcommands/skills.py b/hermes_cli/subcommands/skills.py index b3c23a95a6e..291697f5aff 100644 --- a/hermes_cli/subcommands/skills.py +++ b/hermes_cli/subcommands/skills.py @@ -320,7 +320,7 @@ def build_skills_parser(subparsers, *, cmd_skills: Callable) -> None: # reports that instead of failing opaquely). skills_propose = skills_subparsers.add_parser( "propose", - help="Propose a skill to your org's shared skill set (M2)", + 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 " diff --git a/hermes_cli/subcommands/sync.py b/hermes_cli/subcommands/sync.py index b6d4d40d209..f752aa288cb 100644 --- a/hermes_cli/subcommands/sync.py +++ b/hermes_cli/subcommands/sync.py @@ -26,7 +26,7 @@ def build_sync_parser(subparsers, *, cmd_sync: Callable) -> None: """Attach the ``sync`` subcommand (and its sub-actions) to ``subparsers``.""" sync_parser = subparsers.add_parser( "sync", - help="Personal skill sync (HSP/1)", + help="Personal skill sync across your devices", description="Sync agent-created and user-authored skills across devices.", ) sync_sub = sync_parser.add_subparsers(dest="sync_command") diff --git a/tests/agent/test_org_skill_namespace.py b/tests/agent/test_org_skill_namespace.py index a37a555ee00..9a4c7783f33 100644 --- a/tests/agent/test_org_skill_namespace.py +++ b/tests/agent/test_org_skill_namespace.py @@ -174,3 +174,66 @@ class TestOrgMirrorReadOnly: d = _mk_skill(skills, f"{sku.ORG_MIRROR_DIR_NAME}/org-1/shared-x", name="shared-x") monkeypatch.setattr(su, "_skills_dir", lambda: skills) assert su.is_curation_eligible("shared-x", d) is False + + +class TestOrgPullIsWiredIn: + """Guards the integration gap that unit tests structurally cannot catch. + + The org pull functions were fully implemented and unit-tested while having + ZERO runtime callers, so org skills never loaded for anyone. Testing the + functions directly could never surface that. These tests assert the CALL + SITES exist, so the feature can't silently become dead code again. + """ + + def test_session_startup_calls_maybe_pull_org_skills(self): + import pathlib + + cli_src = ( + pathlib.Path(__file__).resolve().parents[2] / "cli.py" + ).read_text(encoding="utf-8") + assert "maybe_pull_org_skills" in cli_src, ( + "cli.py session startup must call maybe_pull_org_skills() — " + "without a call site the org mirror is never populated and org " + "skills never load (the function being importable is not enough)." + ) + # It must sit alongside the personal pull, not replace it. + assert "maybe_pull_skills" in cli_src + + def test_sync_pull_command_refreshes_org_mirror(self): + import pathlib + + main_src = ( + pathlib.Path(__file__).resolve().parents[2] + / "hermes_cli" + / "main.py" + ).read_text(encoding="utf-8") + assert "maybe_pull_org_skills" in main_src, ( + "`hermes sync pull` must also refresh the org mirror." + ) + + def test_sync_status_exposes_org_state(self): + from tools import skills_sync_client as ssc + + status = ssc.sync_status() + # These keys must always be present so a user can tell whether the org + # workflow applies to them, rather than it being invisible. + for key in ("org_available", "org_id", "org_role", "org_skills"): + assert key in status, f"sync status must expose {key!r}" + + def test_no_internal_jargon_in_user_facing_strings(self): + """User-visible help/errors must not leak internal design coordinates.""" + import pathlib + import re + + root = pathlib.Path(__file__).resolve().parents[2] + targets = [ + 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") + 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: + assert not banned.search(line), ( + f"{path.name}:{i} leaks internal jargon to users: {line.strip()}" + ) diff --git a/tools/skills_sync_client.py b/tools/skills_sync_client.py index 6b36463aa5b..a864a15133a 100644 --- a/tools/skills_sync_client.py +++ b/tools/skills_sync_client.py @@ -57,16 +57,16 @@ from typing import Any, Callable, Dict, List, Optional, Tuple logger = logging.getLogger(__name__) -# HSP/1 protocol constants (contract §1, §3.1) +# Sync protocol constants HSP_VERSION = "1" DEFAULT_MAX_OBJECT_BYTES = 26214400 # 25 MiB, mirrors capabilities default -# Object kinds (contract §2) +# Object kinds (sync contract) KIND_BLOB = "blob" KIND_TREE = "tree" KIND_COMMIT = "commit" -# Tree entry modes (contract §2.3) +# Tree entry modes (sync contract) MODE_FILE = "file" MODE_EXEC = "exec" MODE_DIR = "dir" @@ -74,9 +74,9 @@ MODE_DIR = "dir" ARTIFACT_TYPE_SKILL = "skill" # --------------------------------------------------------------------------- -# `sync-manifest` object convention (design.md §2.8). +# `sync-manifest` object convention (design notes). # -# Per-skill sync opt-in ("this skill syncs / this one does not" — the M1-D +# 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 # 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 @@ -156,7 +156,7 @@ def parse_sync_manifest(data: bytes) -> Optional[Dict[str, bool]]: # --------------------------------------------------------------------------- -# Content addressing (contract §2.1 / OI-5) +# Content addressing # # HSP uses the FULL 64-hex sha256 digest on the wire. This is a DIFFERENT # namespace from hermes-agent's local ``content_hash`` (skills_guard.py:846), @@ -165,12 +165,12 @@ def parse_sync_manifest(data: bytes) -> Optional[Dict[str, bool]]: # --------------------------------------------------------------------------- def hsp_address(data: bytes) -> str: - """Return ``sha256:<64-hex>`` -- the HSP wire address of ``data`` (contract §2.1).""" + """Return ``sha256:<64-hex>`` -- the HSP wire address of ``data`` (sync contract).""" return "sha256:" + hashlib.sha256(data).hexdigest() def canonical_json_bytes(obj: Dict[str, Any]) -> bytes: - """Canonical JSON serialization for tree/commit hashing (contract §2.5). + """Canonical JSON serialization for tree/commit hashing (sync contract). UTF-8, keys sorted lexicographically, no insignificant whitespace (``separators=(",", ":")``), no trailing newline. Arrays must already be @@ -187,7 +187,7 @@ def canonical_json_bytes(obj: Dict[str, Any]) -> bytes: # --------------------------------------------------------------------------- -# Identity & DEV-PHASE gate +# Identity & access gate # # We reuse resolve_nous_runtime_credentials() for the bearer (it honors the # cross-process file lock + portal host allowlist and refreshes as needed -- @@ -266,7 +266,7 @@ def resolve_identity() -> Dict[str, Any]: def dev_gate_open() -> bool: - """Whether the DEV-PHASE gate permits sync. Never raises.""" + """Whether the access gate permits sync. Never raises.""" try: return bool(resolve_identity().get("dev_gate_ok")) except SyncInertError: @@ -279,7 +279,7 @@ def dev_gate_open() -> bool: # --------------------------------------------------------------------------- # Sync-plane endpoint resolution # -# The HSP routes are mounted under /v1/sync/ (contract §1). The base URL is +# The HSP routes are mounted under /v1/sync/ (sync contract). 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). @@ -320,7 +320,7 @@ def resolve_sync_base_url() -> Optional[str]: # # HERMES_SYNC_BASE_URL -> sync.base_url (the HSP plane URL) # HERMES_SYNC_ENABLED -> sync.enabled (master on/off; default off) -# HERMES_SYNC_DEFAULT_OPT_IN -> sync.default_opt_in (M1-D policy; default false +# HERMES_SYNC_DEFAULT_OPT_IN -> sync.default_opt_in (personal sync policy; default false # = opt-in. Set true to make # every eligible skill sync # without per-skill enable — @@ -378,7 +378,7 @@ def sync_feature_enabled() -> bool: def sync_default_opt_in() -> bool: - """The M1-D default opt-in policy (env-first). + """The personal sync default opt-in policy (env-first). ``HERMES_SYNC_DEFAULT_OPT_IN`` -> ``sync.default_opt_in`` -> False. @@ -386,7 +386,7 @@ def sync_default_opt_in() -> bool: ``hermes sync enable`` (or a plane manifest that opted it in). True: opt-OUT — every sync-eligible skill is treated as opted in unless explicitly disabled, which is the "your skills follow you with no setup" default a - Hermes Cloud deployment wants. Per design.md §3.0 M1-D this default is + Hermes Cloud deployment wants. Per the design notes, this default is provisional and expected to flip; exposing it as env config lets the operator choose per deployment without a protocol change. """ @@ -394,7 +394,7 @@ def sync_default_opt_in() -> bool: # --------------------------------------------------------------------------- -# Local skill eligibility + the M1-D opt-in "sync" flag +# Local skill eligibility + the personal sync opt-in "sync" flag # # Only agent-created + user-authored skills under ~/.hermes/skills/ sync. # Bundled (.bundled_manifest) and hub-installed skills are excluded. Sync is @@ -413,7 +413,7 @@ def is_sync_eligible(skill_name: str) -> bool: Eligible = present locally under ~/.hermes/skills/, NOT bundled, NOT hub-installed, NOT an external-dir skill, and NOT under the org mirror (``_org/`` — enterprise-managed content pulls from the org HEAD and must - never ride a personal push; contract §11.11 / design.md §7.1). Mirrors the + never ride a personal push; the sync contract / the design notes). Mirrors the exclusion logic used by the curator (tools/skill_usage.py). """ try: @@ -508,8 +508,8 @@ def _all_local_skill_names() -> List[str]: # --------------------------------------------------------------------------- # Object building -- turn a skill directory into HSP blob/tree/commit objects # -# A skill dir becomes one tree (contract §2.3). Each file is a blob; each -# subdir a nested tree. The profile-root tree (contract §2.3: "a tree whose +# 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 # entries are category trees") is built from the set of synced skill trees. # --------------------------------------------------------------------------- @@ -565,7 +565,7 @@ def build_tree(dir_path: Path, objects: ObjectSet, *, max_object_bytes: int) -> if len(data) > max_object_bytes: raise ValueError( f"file {child} is {len(data)} bytes > max_object_bytes " - f"{max_object_bytes} (contract §4.3)" + f"{max_object_bytes}" ) blob_hash = objects.add(KIND_BLOB, data) entries.append( @@ -577,7 +577,7 @@ def build_tree(dir_path: Path, objects: ObjectSet, *, max_object_bytes: int) -> } ) # else: skip special files - # Entries sorted by name (byte order) for canonicalization (contract §2.3). + # Entries sorted by name (byte order) for canonicalization (sync contract). entries.sort(key=lambda e: e["name"]) tree_obj = {"type": KIND_TREE, "entries": entries} return objects.add(KIND_TREE, canonical_json_bytes(tree_obj)) @@ -593,7 +593,7 @@ def build_commit( objects: ObjectSet, ts: Optional[str] = None, ) -> str: - """Build a commit object (contract §2.4) and return its address. + """Build a commit object (sync contract) and return its address. ``parents``: 0 for first commit, 1 for a normal edit, 2 for a merge commit (order significant: parents[0] = base fast-forwarded from, parents[1] = @@ -632,7 +632,7 @@ def _default_device_label() -> str: def stable_device_id() -> str: """Return a stable per-device label for commit ``author.device`` (contract - §2.4 -- advisory, never an auth input). Persisted under + -- advisory, never an auth input). Persisted under ~/.hermes/skills/.sync_device_id. New devices are seeded with a HUMAN-FRIENDLY default (short hostname + a @@ -688,7 +688,7 @@ def set_device_name(name: str) -> str: # --------------------------------------------------------------------------- # HSP/1 wire client # -# Thin requests-based client for the endpoints in contract §3-§4. Uploads all +# 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. # --------------------------------------------------------------------------- @@ -711,7 +711,7 @@ class HSPConflict(RuntimeError): class HSPClient: - """HSP/1 client bound to a base URL + bearer (contract §1, routes under + """Sync client bound to a base URL + bearer (routes under ``/v1/sync/``).""" def __init__(self, base_url: str, api_key: str, *, timeout: float = 30.0): @@ -729,14 +729,14 @@ class HSPClient: # -- capability & read ------------------------------------------------- def capabilities(self) -> Dict[str, Any]: - """GET /v1/sync/capabilities (contract §3.1). No auth required.""" + """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) return r.json() def get_refs(self, prefix: str) -> List[Dict[str, str]]: - """GET /v1/sync/refs?prefix=... (contract §3.2).""" + """GET /v1/sync/refs?prefix=... (sync contract).""" r = self._session.get( self._url("refs"), params={"prefix": prefix}, timeout=self.timeout ) @@ -745,7 +745,7 @@ class HSPClient: return (r.json() or {}).get("refs", []) def get_object(self, obj_hash: str) -> Tuple[str, bytes]: - """GET /v1/sync/objects/:hash (contract §3.3). Returns (kind, bytes). + """GET /v1/sync/objects/:hash (sync contract). Returns (kind, bytes). Kind comes from ``X-HSP-Object-Type`` for tree/commit; a blob response (application/octet-stream) is returned as ``blob``. @@ -782,10 +782,10 @@ class HSPClient: *, org_scope: bool = False, ) -> Dict[str, Any]: - """POST /v1/sync/objects (contract §4.2). Batch multi-object upload. + """POST /v1/sync/objects (sync contract). Batch multi-object upload. Contract §1 requires raw object bytes on the wire (NOT base64-in-JSON), - and §4.2 specifies "a length-prefixed or multipart stream of + and specifies "a length-prefixed or multipart stream of {hash, type, bytes}". We use multipart/form-data: one part per object, the part's field name = the claimed ``sha256:`` hash, its ``filename`` carries the object ``type`` (blob|tree|commit), and the @@ -822,7 +822,7 @@ class HSPClient: 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 (contract §4.4). + """POST /v1/sync/refs/:name -- atomic compare-and-swap (sync contract). Raises :class:`HSPConflict` (carrying the actual head) on 409. @@ -856,13 +856,13 @@ class HSPClient: # # 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 -# (skills_sync.py, truncated local content_hash namespace) AND from the §2.8 +# (skills_sync.py, truncated local content_hash namespace) AND from the # `sync-manifest` OBJECT in the sync plane (the per-skill opt-in content). This # is purely local reconciliation bookkeeping. Lives at # ~/.hermes/skills/.sync_state as JSON. # # NOTE: renamed from `.sync_manifest` -> `.sync_state` to remove the collision -# with the §2.8 plane `sync-manifest`. `read_sync_state` migrates an existing +# with the plane `sync-manifest`. `read_sync_state` migrates an existing # `.sync_manifest` on first read so no local head record is lost. # --------------------------------------------------------------------------- @@ -974,9 +974,9 @@ def materialize_tree(client: HSPClient, tree_hash: str, dest: Path) -> None: # Profile snapshot -- build the objects + per-skill tree map for a push # # The profile root is a tree whose entries mirror each synced skill's relative -# path under ~/.hermes/skills/ (contract §2.3: "the profile root is a tree +# path under ~/.hermes/skills/ (the sync contract: "the profile root is a tree # whose entries are category trees"). Only opted-in, eligible skills are -# included (M1-D opt-in + eligibility). +# included (personal sync opt-in + eligibility). # --------------------------------------------------------------------------- def _skill_rel_path(skill_name: str) -> Optional[PurePosixPath]: @@ -1039,7 +1039,7 @@ def snapshot_profile( node = node.setdefault(part, {}) node[parts[-1]] = {"__tree__": tree_hash} - # §2.8 sync-manifest: record the opt-in state (the pushed set = enabled). + # sync-manifest: record the opt-in state (the pushed set = enabled). # Only skills that actually made it into the tree are recorded, keyed by the # skill NAME (matching gateway-gateway's manifest shape + the read walk that # enumerates skill subtrees by name). @@ -1087,7 +1087,7 @@ def _build_root_tree( # --------------------------------------------------------------------------- -# Ref naming (contract §2.6) +# Ref naming (sync contract) # --------------------------------------------------------------------------- def user_head_ref(owner: str) -> str: @@ -1158,7 +1158,7 @@ def read_manifest_of_root( def _check_version(caps: Dict[str, Any]) -> None: - """Reject an incompatible server major version (contract §1).""" + """Reject an incompatible server major version (sync contract).""" ver = str(caps.get("hsp_version") or "") major = ver.split(".", 1)[0] if major != HSP_VERSION: @@ -1176,7 +1176,7 @@ def push_skills( identity: Optional[Dict[str, Any]] = None, message: str = "hermes skill sync", ) -> Dict[str, Any]: - """Push opted-in skills to the owner's HEAD (contract §4). + """Push opted-in skills to the owner's HEAD (sync contract). Uploads all new objects, then CAS-es ``refs/user//HEAD``. On a 409, fetches the actual head, three-way merges, and retries once (§4.4 / M1-C). @@ -1234,7 +1234,7 @@ def push_skills( # --------------------------------------------------------------------------- -# Conflict resolution / three-way merge (contract §4.4, M1-C) +# Conflict resolution / three-way merge # # On a 409 the server hands back the actual head. We fetch it, three-way merge # per skill against the base we forked from, reusing the origin/user/incoming @@ -1297,7 +1297,7 @@ def _resolve_push_conflict( # decision == "none": skill deleted on the winning side -> drop if overlaps: - # TRUE OVERLAP -> write a conflict head and surface it (M1-C). + # TRUE OVERLAP -> write a conflict head and surface it (personal sync). n = _next_conflict_index(client, owner) conflict_ref = user_conflict_ref(owner, n) try: @@ -1447,7 +1447,7 @@ def pull_skills( root_tree = _root_tree_of_commit(client, head) remote_trees = _skill_trees_of_root(client, root_tree) - # §2.8: reconcile local opt-in intent FROM the plane manifest, so a skill the + # : reconcile local opt-in intent FROM the plane manifest, so a skill the # user opted in on another device becomes opted in here too (opt-in is # cross-device content, not a device-local flag). We only ADOPT enables from # the manifest for skills present in the remote tree; we never silently @@ -1506,7 +1506,7 @@ def _opted_in_rel_paths() -> List[str]: # # maybe_pull_skills / maybe_push_skills clone the shape of the curator's # maybe_run_curator (agent/curator.py:1998): best-effort, never raise, return -# a result dict or None. The DEV-PHASE gate is checked first -- sync is inert +# a result dict or None. The access gate is checked first -- sync is inert # (no push, no pull, no-op) unless tool_gateway_admin === true on the token. # --------------------------------------------------------------------------- @@ -1516,7 +1516,7 @@ def maybe_push_skills(*, message: str = "hermes skill sync") -> Optional[Dict[st try: identity = resolve_identity() if not identity.get("dev_gate_ok"): - return None # DEV-PHASE gate: inert without tool_gateway_admin + return None # access gate: inert without tool_gateway_admin if not sync_feature_enabled(): return None # feature off for this instance (HERMES_SYNC_ENABLED) if not resolve_sync_base_url(): @@ -1536,7 +1536,7 @@ def maybe_pull_skills() -> Optional[Dict[str, Any]]: try: identity = resolve_identity() if not identity.get("dev_gate_ok"): - return None # DEV-PHASE gate: inert without tool_gateway_admin + return None # access gate: inert without tool_gateway_admin if not sync_feature_enabled(): return None # feature off for this instance (HERMES_SYNC_ENABLED) if not resolve_sync_base_url(): @@ -1558,6 +1558,13 @@ def sync_status() -> Dict[str, Any]: "opted_in_skills": [], "local_head": None, "owner": None, + # Org-shared skills. `org_available` is False for an account that + # isn't in a shared organisation — the org workflow does not apply, + # which is different from it being broken or misconfigured. + "org_available": False, + "org_id": None, + "org_role": None, + "org_skills": [], } try: identity = resolve_identity() @@ -1573,24 +1580,55 @@ def sync_status() -> Dict[str, Any]: status["local_head"] = read_sync_state().get("head") except Exception: pass + try: + org_identity = resolve_org_identity() + status["org_available"] = True + status["org_id"] = org_identity.get("org_id") + status["org_role"] = org_identity.get("org_role") + status["org_skills"] = list_org_skill_names() + except SyncInertError: + pass + except Exception as e: + logger.debug("skills_sync_client: sync_status org lookup failed: %s", e) return status +def list_org_skill_names() -> List[str]: + """Skill names present in the local org mirror (empty when none pulled).""" + names: List[str] = [] + try: + from agent.skill_utils import read_active_org_id + + org_id = read_active_org_id(_skills_dir()) + if not org_id: + return names + root = _org_dir() / org_id + if not root.is_dir(): + return names + for skill_md in root.rglob("SKILL.md"): + rel = skill_md.parent.relative_to(root) + if rel.parts: + names.append(str(rel).replace("\\", "/")) + except Exception as e: + logger.debug("skills_sync_client: org skill listing failed: %s", e) + return sorted(names) + + # --------------------------------------------------------------------------- -# M2 org-shared skills (hsp-1-contract.md §11) — org pull + propose. +# Org-shared skills (sync contract) — org pull + propose. # # Org skills live under a DISTINCT local namespace, ~/.hermes/skills/_org/ -# (design.md §7.1: enterprise-managed skills are read-only to the runtime; a +# (the design notes: enterprise-managed skills are read-only to the runtime; a # local edit is a personal fork of record until proposed). The org canonical # set is `refs/org//HEAD` — the SAME object model as personal sync. # -# PERSONAL-ORG GATE (contract §11.1 REFINED, Ben 2026-07-23): a personal org +# PERSONAL-ORG GATE (the sync contract REFINED, Ben 2026-07-23): a personal org # has NO org workflow. The discriminator travels in the token: NAS stamps the # `org_role` claim ONLY for multi-member orgs. No claim ⇒ every org helper # here is inert (org_sync_available() False; pull/propose raise SyncInertError) -# and the personal M1 experience is untouched. +# and the personal personal sync experience is untouched. # -# TRAJECTORY (Ben): `hermes skills propose` is the M2 MVP surface; proposal is +# TRAJECTORY (Ben): `hermes skills propose` is the org sharing MVP surface; proposal is # intended to become largely automated later (curator/background hooks driving # the same propose_skill() path). Keep this callable non-interactive. # --------------------------------------------------------------------------- @@ -1611,11 +1649,10 @@ def resolve_org_identity() -> Dict[str, Any]: org_id = claims.get("org_id") org_role = claims.get("org_role") if not org_id: - raise SyncInertError("token carries no org_id") + raise SyncInertError("no organisation associated with this account") if not isinstance(org_role, str) or not org_role: raise SyncInertError( - "no org_role claim (personal org keeps the simple personal sync; " - "org workflow is multi-member-org only)" + "this account isn't a member of a shared organisation" ) identity["org_id"] = str(org_id) identity["org_role"] = org_role @@ -1636,7 +1673,7 @@ def org_head_ref(org_id: str) -> str: def _org_dir() -> Path: - """Local mirror root for org skills (read-only by convention §7.1).""" + """Local mirror root for org skills (read-only by convention ).""" return _skills_dir() / ORG_DIR_NAME @@ -1655,7 +1692,7 @@ def pull_org_skills( """ identity = identity or resolve_org_identity() if "org_id" not in identity: - raise SyncInertError("identity lacks org context; use resolve_org_identity()") + raise SyncInertError("no organisation context available") org_id = identity["org_id"] base_url = resolve_sync_base_url() if not base_url: @@ -1665,7 +1702,7 @@ def pull_org_skills( caps = client.capabilities() _check_version(caps) if "org" not in (caps.get("features") or []): - raise SyncInertError("server does not advertise the 'org' feature") + raise SyncInertError("this server does not support org-shared skills") refs = client.get_refs(f"refs/org/{org_id}/") head = next( @@ -1704,7 +1741,7 @@ def pull_org_skills( ) # Provenance sidecar for the load-time header (skill_view): the HEAD # commit's author is TOKEN-VERIFIED at push time by the plane - # (author_mismatch guard, gateway-gateway #166) — trustworthy to display. + # (author_mismatch guard, the sync plane) — trustworthy to display. _write_org_provenance( org_id, { @@ -1777,7 +1814,7 @@ def propose_skill( caps = client.capabilities() _check_version(caps) if "org" not in (caps.get("features") or []): - raise SyncInertError("server does not advertise the 'org' feature") + raise SyncInertError("this server does not support org-shared skills") max_bytes = int(caps.get("max_object_bytes") or DEFAULT_MAX_OBJECT_BYTES) # Locate the local skill directory (personal namespace, NOT _org/).