Skill Sync had no default base URL, so a user with no `sync.base_url` in
config.yaml and no HERMES_SYNC_BASE_URL got:
sync inert: no sync base URL configured (config.yaml sync.base_url
or HERMES_SYNC_BASE_URL).
Every sync command was unusable out of the box. The URL was left unset
because the plane did not exist yet when the client was written; it does now.
- Adds DEFAULT_SYNC_BASE_URL = "https://gateway-gateway.nousresearch.com" and
returns it as the last step of resolve_sync_base_url().
- Resolution order is unchanged otherwise: HERMES_SYNC_BASE_URL ->
config.yaml sync.base_url -> production default. The env var and config key
now exist to point a dev/staging build at another plane rather than to make
the feature work at all.
- Follows the existing precedent for production endpoints in this codebase
(DEFAULT_NOUS_PORTAL_URL in hermes_cli/auth.py, HERMES_DIAGNOSTICS_BASE_URL
in diagnostics_upload.py): a module constant with env/config override.
The "no sync base URL configured" guards are kept — they are now unreachable
in practice but remain correct if the default is ever blanked.
Tests: 3 new — the default is returned when nothing is configured, config
still overrides it, and the constant is a bare https origin (no trailing
slash, no path) since the client appends /v1/sync/. 2349 passed / 0 failed
across 56 suites via scripts/run_tests.sh.
Verified against a temp HERMES_HOME with no config: resolves to the
production plane; HERMES_SYNC_BASE_URL and sync.base_url both still win, and
trailing slashes are stripped.
The client called its gate "the DEV-PHASE gate (tool_gateway_admin)", which
reads as though Skill Sync is gated on an unrelated service's admin right.
It isn't. NAS populates that claim from Permissions.ADMIN_ACCESS — the global
portal admin permission that guards /admin/* — so the gate is "is this user a
Nous admin?". The claim is simply named for its first consumer, the tool
gateway.
Renamed on this side to say what it means, while keeping the wire string
(other services read it):
- DEV_GATE_CLAIM -> NOUS_ADMIN_CLAIM (value unchanged: "tool_gateway_admin",
with a comment recording why the wire name differs).
- identity/status key dev_gate_ok -> nous_admin, across the client, the CLI
consumers, and the tests.
- The module docstring now states where the claim comes from, that the wire
name is misleading, and that this gate is pre-launch containment rather
than the shipping entitlement — admin status conflates "may administer
Nous" with "has Skill Sync enabled" and has no middle setting for a beta
cohort. Choosing the real entitlement is left as a separate decision.
Naming only — no behaviour change, and no change to which accounts can sync.
The user-facing messages stay deliberately vague ("not enabled for your
account yet") rather than telling users they need portal admin.
Verified: 2346 passed / 0 failed across 56 suites via scripts/run_tests.sh;
`hermes sync status` against a live token reports "nous_admin": true. Zero
stale dev_gate_ok / DEV_GATE_CLAIM references remain.
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.
Client leg of M2 org-shared skills (hsp-1-contract.md §11), pairing with
gateway-gateway #162 and NAS #768 (both merged).
- resolve_org_identity(): org_id + org_role from the token claims. NO
org_role claim (personal org — NAS only stamps it for multi-member orgs)
=> SyncInertError => every org surface is inert; personal M1 sync
untouched (contract §11.1 REFINED). org_sync_available() for callers.
- pull_org_skills(): materialize the org canonical set (refs/org/<org_id>/
HEAD) into ~/.hermes/skills/_org/<org_id>/ — fast-forward only, no client
merge on the org path (design.md §2.6); read-only mirror by convention
(§7.1: a local edit is a personal fork until proposed). maybe_pull_org_
skills() best-effort hook (never raises; inert without the claim).
- propose_skill(): snapshot the LOCAL skill dir, splice it into the org
HEAD's skill-tree map (per-skill delta, never wholesale replace), upload
?scope=org, CAS the org HEAD. ADMIN => direct merge; MEMBER => server
converts to a proposal — cas_ref now surfaces 202 as
{proposal_pending: True, proposal_id, ref} (success-shaped, NEVER
presented as live). Non-interactive by design for the future automated
submitter (Ben's trajectory note).
- put_objects(org_scope=True) adds ?scope=org (contract §11.5).
- is_sync_eligible(): skills under _org/ are excluded from PERSONAL sync —
enterprise content never rides a personal push (§11.11).
- CLI: hermes skills propose <name> [-m msg] — prints 'pending admin
review' for 202, 'merged' for admin, and a plain 'org sync unavailable'
for personal orgs instead of a raw 403.
Tests: 56 in the sync client suite (+10 org: identity gate both ways, _org/
personal-sync exclusion, admin direct merge, member 202 w/ HEAD untouched +
proposal ref parked + never-merged, splice-not-replace root, pull mirror
materialization, no-head noop, org-feature gate, maybe_pull inert). Mock
server extended (org feature flag, member-CAS→202). 103 across skills
suites. Live CLI smoke: propose --help + personal-org inert path verified.
Commit author.device was an opaque uuid4 hex, so the sync console showed a hash
per device. Make it human-friendly:
- Default: seed new devices from the short hostname + a short random suffix
(e.g. bens-macbook-a1b2c3) instead of a bare uuid. Existing .sync_device_id
files are honored verbatim (a machine keeps its id) — backward-compatible.
- hermes sync device [--name N]: show or set an explicit label
(set_device_name(), written to ~/.hermes/skills/.sync_device_id). New commits
use it; past commits keep their old label (author.device is immutable).
- Hermes Cloud: HERMES_SYNC_DEVICE_NAME env seeds the first-use label so a
hosted instance shows a recognizable name with no CLI call. Precedence:
explicit .sync_device_id file > HERMES_SYNC_DEVICE_NAME env > hostname default.
Env seeds first-use only, then persists, so a later --name still wins locally.
Tests: 46 pass (+5: hostname default, file-wins precedence, env first-use seed +
persistence, set/trim, empty-name reject). CLI verb smoke-verified end-to-end.
Two changes:
1) Rename the client-local head-bookkeeping file .sync_manifest -> .sync_state
(read_sync_state/write_sync_state) to remove the name collision with the
§2.8 plane 'sync-manifest' OBJECT. read_sync_state migrates an existing
.sync_manifest on first read so no device loses its head record.
2) Make the knobs a Hermes Cloud instance needs env-configurable, so an
instance can be set up to use sync BY DEFAULT with no config.yaml edit or
per-skill CLI call. Precedence: HERMES_SYNC_* env -> config.yaml sync.* ->
built-in default (mirrors the existing HERMES_SYNC_BASE_URL bridge).
- HERMES_SYNC_ENABLED -> sync.enabled (master on/off; def off)
gated in maybe_push/maybe_pull alongside the dev-gate + base_url.
- HERMES_SYNC_DEFAULT_OPT_IN -> sync.default_opt_in (M1-D policy; def off)
opt-out mode: every eligible skill syncs unless usage rec says sync:false;
'your skills follow you with no setup' — the Cloud default. opt-in mode
(default) unchanged: only sync:true skills sync.
sync_status() + 'hermes sync status' surface feature_enabled/default_opt_in.
Cross-repo byte-compat re-verified: client manifest bytes still parse cleanly
through gateway-gateway parseSyncManifest.
Tests: 41 pass (+7: env precedence, opt-out/opt-in policy, rename migration).
Replace the device-local opt-in model (`.usage.json` `sync` flag as the sole
source of truth) with the §2.8 content model: a root-level `sync-manifest` blob
in the tree at `refs/user/<owner>/HEAD` recording per-skill {name, enabled},
matching gateway-gateway src/sync/manifest.ts byte-for-byte.
- build/parse_sync_manifest: canonical {type,version:1,skills:[{name,enabled}]},
strict parse (malformed != empty).
- snapshot_profile embeds the manifest as a root-level blob alongside skill
subtrees; the skill walk skips it (blob, not a SKILL.md-bearing tree).
- pull reconciles local opt-in intent FROM the plane manifest, so a skill opted
in on one device becomes opted in on the others (opt-in is now cross-device,
not per-device). Never silently disables a locally-enabled skill on pull.
- .usage.json `sync` flag kept as the editable local intent; plane manifest is
authoritative.
Cross-repo byte-compat verified: the Python client's manifest bytes parse
cleanly through gateway-gateway's real parseSyncManifest (tsx harness).
Tests: 34 pass incl. 5 new (roundtrip, wire shape, strict-reject, root-blob
embed, pull adopts opt-in from manifest).