fix(sync): wire org skill pull into the runtime; scrub internal jargon

Two defects found by manual testing on the branch.

1. ORG SYNC NEVER RAN. The org pull/mirror/gating machinery was fully
   implemented and unit-tested but had ZERO runtime callers —
   maybe_pull_org_skills() was referenced only inside a comment, and
   `hermes sync` had no org path at all. Every code path fell through to
   personal sync (refs/user/<sub>/), so org skills never loaded and the
   feature looked like 'everything syncs to my personal org' even with a
   valid org token. The unit tests could not catch this: they invoked the
   functions directly, which is exactly the gap they left open.

   - cli.py session startup now calls maybe_pull_org_skills() alongside the
     personal maybe_pull_skills(), fail-quiet.
   - Auto-pull is gated on real org membership: resolve_org_identity()
     requires an org role on the token, only issued for multi-member orgs,
     so a solo account never reaches the network.
   - `hermes sync pull` refreshes the org mirror too (one pull, both
     surfaces) and reports what it refreshed.
   - `hermes sync status` exposes org_available/org_id/org_role/org_skills
     plus a plain-language summary, so a user can tell whether the org
     workflow applies instead of it being invisible.

2. INTERNAL JARGON LEAKED TO USERS. Help text and errors exposed internal
   milestone/spec coordinates: 'Propose a skill ... (M2)', 'Personal skill
   sync (HSP/1)', 'DEV-PHASE gate closed: your token lacks
   tool_gateway_admin', 'contract §4.3', and an inert message describing our
   internal personal-vs-multi-member design split. All rewritten in user
   language. Feature-local comments/docstrings lost their internal
   coordinates (§N, M1/M2, design.md, PR numbers) while keeping the
   explanatory prose. Pre-existing issue references elsewhere in the tree
   were deliberately left untouched.

Tests: 4 new guards, including two that assert the CALL SITES exist so the
org pull cannot silently become dead code again (verified failing when the
wiring is removed) and one that fails if user-facing help leaks jargon.
344 passed across the sync/skills/prompt suites.

Verified against live staging with a real org token: sync status reports
org_available=true, org_role=OWNER; sync pull performs the org refresh; the
.active_org marker is written with the org id from the token.
This commit is contained in:
Ben Barclay 2026-07-27 16:40:16 +10:00
parent 78598d091a
commit 797c52b571
7 changed files with 201 additions and 68 deletions

View file

@ -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/<org_id>/. 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

18
cli.py
View file

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

View file

@ -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 <skill> Opt a skill into sync (M1-D opt-in)\n"
" enable <skill> Opt a skill into sync\n"
" disable <skill> Opt a skill out of sync\n"
" device [--name N] Show or set this device's sync label",
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":

View file

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

View file

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

View file

@ -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()}"
)

View file

@ -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:<hex>`` 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/<owner>/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/<org_id>/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/).