feat(skills): org-skill namespace — token-gated discovery, fail-loud collisions, provenance (M2)
Implements the agreed design (2026-07-23): org skills are FIRST-CLASS (bare names) with three hard companions. 1. TOKEN-GATED RESOLUTION: _org/<org_id>/ mirrors resolve ONLY while marked active. pull_org_skills (which runs only after the token's org_id+org_role verified) writes _org/.active_org; discovery (iter_skill_index_files, _find_skill_dir, snapshot manifest) prunes every other mirror. Leave the org (verified personal token in maybe_pull_org_skills) => marker cleared => org skills stop resolving; offline => marker untouched (grace). Snapshot manifest includes the marker so org switches invalidate the prompt snapshot; _SKILLS_SNAPSHOT_VERSION bumped to 2. 2. FAIL-LOUD COLLISIONS: listing pass unified across snapshot/scan paths; a personal/org name clash flags BOTH entries '[name collision — load via category path]' — neither side silently wins (personal-wins = silent divergence from the org set; org-wins = shadowed personal work). skill_view's existing multi-candidate refusal already rejects the ambiguous bare name. 3. PROVENANCE: org entries list under an org:<org_id> category with '[org-shared: by <author>]' tags; skill_view prepends a load-time header (org, author, as-of + read-only/fork-and-propose guidance) INTO the content the model consumes, plus an org_provenance result field. Author comes from the pull-time .org-provenance.json sidecar (HEAD commit author — token-verified at push by the plane's author_mismatch guard, gg #166). 4. READ-ONLY MIRROR: skill_manage patch/edit/delete/write_file refuse org- mirror targets with fork-and-propose guidance; org skills are curation- exempt (is_curation_eligible False — the org HEAD owns them). Tests: 11 new (tests/agent/test_org_skill_namespace.py) covering gating, stale-mirror pruning, org-switch flip, snapshot provenance, listing labels, both-sides collision flags, read-only guard, curation exemption; 448 green across skills/prompt/sync suites. Live E2E (real modules, temp HERMES_HOME, mock plane): merge -> pull -> marker+sidecar -> labeled listing -> exactly-2 collision flags -> load-time header -> edit refused -> marker cleared => org skills vanish, personal survive.
This commit is contained in:
@@ -49,6 +49,52 @@ 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 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
|
||||
# scanned. No marker ⇒ no org skills load. The marker is plain data (org_id
|
||||
# string) so this module stays import-light; the VERIFICATION lives in the
|
||||
# sync client, which is the only writer. Offline grace: the marker persists,
|
||||
# so already-pulled org skills keep working without connectivity; a VERIFIED
|
||||
# org change (or personal-org token) rewrites/removes it.
|
||||
|
||||
ORG_MIRROR_DIR_NAME = "_org"
|
||||
ORG_ACTIVE_MARKER = ".active_org"
|
||||
ORG_PROVENANCE_FILE = ".org-provenance.json"
|
||||
|
||||
|
||||
def read_active_org_id(skills_dir: Path) -> Optional[str]:
|
||||
"""The org id whose mirror may resolve, or None (no org skills load)."""
|
||||
try:
|
||||
marker = skills_dir / ORG_MIRROR_DIR_NAME / ORG_ACTIVE_MARKER
|
||||
if not marker.exists():
|
||||
return None
|
||||
val = marker.read_text(encoding="utf-8").strip()
|
||||
return val or None
|
||||
except OSError:
|
||||
return None
|
||||
|
||||
|
||||
def is_org_mirror_path(path, skills_dir: Path) -> bool:
|
||||
"""True when *path* is inside the org mirror (``_org/``)."""
|
||||
try:
|
||||
rel = Path(path).resolve().relative_to(Path(skills_dir).resolve())
|
||||
except (OSError, ValueError):
|
||||
return False
|
||||
return bool(rel.parts) and rel.parts[0] == ORG_MIRROR_DIR_NAME
|
||||
|
||||
|
||||
def org_id_of_path(path, skills_dir: Path) -> Optional[str]:
|
||||
"""The ``<org_id>`` segment for a path under ``_org/<org_id>/...``."""
|
||||
try:
|
||||
rel = Path(path).resolve().relative_to(Path(skills_dir).resolve())
|
||||
except (OSError, ValueError):
|
||||
return None
|
||||
if len(rel.parts) >= 2 and rel.parts[0] == ORG_MIRROR_DIR_NAME:
|
||||
return rel.parts[1]
|
||||
return None
|
||||
|
||||
|
||||
def is_excluded_skill_path(path) -> bool:
|
||||
"""True if *path* should be skipped by active skill scanners.
|
||||
@@ -802,11 +848,24 @@ def iter_skill_index_files(skills_dir: Path, filename: str):
|
||||
scripts) can contain arbitrary markdown and even archived package
|
||||
``SKILL.md`` files, but they are progressive-disclosure data loaded through
|
||||
``skill_view(..., file_path=...)`` rather than active skill roots.
|
||||
|
||||
M2 org mirrors (``_org/``): TOKEN-GATED resolution. Only the active org's
|
||||
subdir (per the sync-client-written ``.active_org`` marker) is walked;
|
||||
every other ``_org/<id>/`` (stale mirror from a previous org, or no
|
||||
marker at all) is pruned — leave an org and its skills stop resolving,
|
||||
without any manual cleanup.
|
||||
"""
|
||||
skills_dir_str = str(skills_dir)
|
||||
active_org = read_active_org_id(skills_dir)
|
||||
org_root = os.path.join(skills_dir_str, ORG_MIRROR_DIR_NAME)
|
||||
matches: list[str] = []
|
||||
for root, dirs, files in os.walk(skills_dir_str, followlinks=True):
|
||||
has_skill_md = "SKILL.md" in files
|
||||
if root == skills_dir_str and ORG_MIRROR_DIR_NAME in dirs and active_org is None:
|
||||
dirs.remove(ORG_MIRROR_DIR_NAME)
|
||||
elif root == org_root:
|
||||
# Inside _org/: descend ONLY into the active org's mirror.
|
||||
dirs[:] = [d for d in dirs if d == active_org]
|
||||
dirs[:] = [
|
||||
d
|
||||
for d in dirs
|
||||
|
||||
Reference in New Issue
Block a user