From 14f927996c47bb3ebaa5f03da9f20a65218fc1d9 Mon Sep 17 00:00:00 2001 From: Richard Tang Date: Tue, 21 Apr 2026 18:48:22 -0700 Subject: [PATCH] feat: skill library --- core/framework/agent_loop/agent_loop.py | 8 +- core/framework/agent_loop/prompting.py | 9 +- core/framework/agent_loop/types.py | 7 + core/framework/host/colony_runtime.py | 125 +- core/framework/orchestrator/node.py | 4 + core/framework/server/app.py | 2 + core/framework/server/queen_orchestrator.py | 30 +- core/framework/server/routes_skills.py | 1131 +++++++++++++++++ core/framework/server/session_manager.py | 42 + core/framework/skills/authoring.py | 204 +++ core/framework/skills/discovery.py | 35 +- core/framework/skills/manager.py | 194 ++- core/framework/skills/overrides.py | 241 ++++ core/framework/skills/trust.py | 11 +- core/framework/tools/queen_lifecycle_tools.py | 167 +-- core/frontend/src/App.tsx | 2 + core/frontend/src/api/skills.ts | 153 +++ core/frontend/src/components/Sidebar.tsx | 8 + core/frontend/src/pages/skills-library.tsx | 979 ++++++++++++++ core/tests/conftest.py | 1 + core/tests/test_create_colony_tool.py | 16 + core/tests/test_routes_skills.py | 289 +++++ core/tests/test_skill_overrides.py | 226 ++++ 23 files changed, 3734 insertions(+), 150 deletions(-) create mode 100644 core/framework/server/routes_skills.py create mode 100644 core/framework/skills/authoring.py create mode 100644 core/framework/skills/overrides.py create mode 100644 core/frontend/src/api/skills.ts create mode 100644 core/frontend/src/pages/skills-library.tsx create mode 100644 core/tests/test_routes_skills.py create mode 100644 core/tests/test_skill_overrides.py diff --git a/core/framework/agent_loop/agent_loop.py b/core/framework/agent_loop/agent_loop.py index feb0471f..c7499b11 100644 --- a/core/framework/agent_loop/agent_loop.py +++ b/core/framework/agent_loop/agent_loop.py @@ -793,10 +793,16 @@ class AgentLoop(AgentProtocol): tools.extend(synthetic) # 6b3. Dynamic prompt refresh (phase switching / memory refresh) - if ctx.dynamic_prompt_provider is not None or ctx.dynamic_memory_provider is not None: + if ( + ctx.dynamic_prompt_provider is not None + or ctx.dynamic_memory_provider is not None + or ctx.dynamic_skills_catalog_provider is not None + ): if ctx.dynamic_prompt_provider is not None: _new_prompt = stamp_prompt_datetime(ctx.dynamic_prompt_provider()) else: + # build_system_prompt_for_context reads dynamic_skills_catalog_provider + # directly; no separate branch needed. _new_prompt = build_system_prompt_for_context(ctx) if _new_prompt != conversation.system_prompt: conversation.update_system_prompt(_new_prompt) diff --git a/core/framework/agent_loop/prompting.py b/core/framework/agent_loop/prompting.py index c89edfbb..8822c632 100644 --- a/core/framework/agent_loop/prompting.py +++ b/core/framework/agent_loop/prompting.py @@ -53,7 +53,14 @@ def build_prompt_spec( # trigger tools are present in this agent's tool list (e.g. browser_* # pulls in hive.browser-automation). Keeps non-browser agents lean. tool_names = [getattr(t, "name", "") for t in (getattr(ctx, "available_tools", None) or [])] - skills_catalog_prompt = augment_catalog_for_tools(ctx.skills_catalog_prompt or "", tool_names) + raw_catalog = ctx.skills_catalog_prompt or "" + dynamic_catalog = getattr(ctx, "dynamic_skills_catalog_provider", None) + if dynamic_catalog is not None: + try: + raw_catalog = dynamic_catalog() or "" + except Exception: + raw_catalog = ctx.skills_catalog_prompt or "" + skills_catalog_prompt = augment_catalog_for_tools(raw_catalog, tool_names) return PromptSpec( identity_prompt=ctx.identity_prompt or "", diff --git a/core/framework/agent_loop/types.py b/core/framework/agent_loop/types.py index 2e8d1aed..4bb13b7b 100644 --- a/core/framework/agent_loop/types.py +++ b/core/framework/agent_loop/types.py @@ -183,6 +183,13 @@ class AgentContext: dynamic_tools_provider: Any = None dynamic_prompt_provider: Any = None dynamic_memory_provider: Any = None + # Optional Callable[[], str]: when set, the current skills-catalog + # prompt is sourced from this provider each iteration. Lets workers + # pick up UI toggles without restarting the run. Queen agents already + # rebuild the whole prompt via dynamic_prompt_provider — this field + # is a surgical alternative used by colony workers where the rest of + # the prompt stays constant and we don't want to thrash the cache. + dynamic_skills_catalog_provider: Any = None skills_catalog_prompt: str = "" protocols_prompt: str = "" diff --git a/core/framework/host/colony_runtime.py b/core/framework/host/colony_runtime.py index 6185101a..07010d7a 100644 --- a/core/framework/host/colony_runtime.py +++ b/core/framework/host/colony_runtime.py @@ -185,6 +185,8 @@ class ColonyRuntime: protocols_prompt: str = "", skill_dirs: list[str] | None = None, pipeline_stages: list | None = None, + queen_id: str | None = None, + colony_name: str | None = None, ): from framework.pipeline.runner import PipelineRunner from framework.skills.manager import SkillsManager @@ -193,14 +195,27 @@ class ColonyRuntime: self._goal = goal self._config = config or ColonyConfig() self._runtime_log_store = runtime_log_store + self._queen_id: str | None = queen_id + # ``colony_id`` is the event-bus scope (session.id in DM sessions); + # ``colony_name`` is the on-disk identity under ~/.hive/colonies/. + # They coincide for forked colonies but diverge for queen DM + # sessions, so separate them explicitly. + self._colony_name: str | None = colony_name if pipeline_stages: self._pipeline = PipelineRunner(pipeline_stages) else: self._pipeline = self._load_pipeline_from_config() - if skills_manager_config is not None: - self._skills_manager = SkillsManager(skills_manager_config) + # Resolve per-colony override paths so UI toggles can reach this + # runtime. Callers that build their own SkillsManagerConfig stay + # in charge; bare construction auto-wires the standard paths. + _effective_cfg = skills_manager_config + if _effective_cfg is None and not (skills_catalog_prompt or protocols_prompt): + _effective_cfg = self._build_default_skills_config(colony_name, queen_id) + + if _effective_cfg is not None: + self._skills_manager = SkillsManager(_effective_cfg) self._skills_manager.load() elif skills_catalog_prompt or protocols_prompt: import warnings @@ -397,6 +412,92 @@ class ColonyRuntime: return PipelineRunner([]) return build_pipeline_from_config(stages_config) + @staticmethod + def _build_default_skills_config( + colony_name: str | None, + queen_id: str | None, + ) -> SkillsManagerConfig: + """Assemble a ``SkillsManagerConfig`` that wires in the per-colony / + per-queen override files and the ``queen_ui`` / ``colony_ui`` scope + dirs based on the standard ``~/.hive`` layout. + + ``colony_name`` must be an actual on-disk colony name + (``~/.hive/colonies/{name}/``). DM sessions where the ``colony_id`` + is a session UUID should pass ``None`` so we don't create a stray + override file under a session identifier. + """ + from framework.config import COLONIES_DIR, QUEENS_DIR + from framework.skills.discovery import ExtraScope + from framework.skills.manager import SkillsManagerConfig + + extras: list[ExtraScope] = [] + queen_overrides_path: Path | None = None + if queen_id: + queen_home = QUEENS_DIR / queen_id + queen_overrides_path = queen_home / "skills_overrides.json" + extras.append( + ExtraScope(directory=queen_home / "skills", label="queen_ui", priority=2) + ) + + colony_overrides_path: Path | None = None + if colony_name: + colony_home = COLONIES_DIR / colony_name + colony_overrides_path = colony_home / "skills_overrides.json" + # Colony-scope SKILL.md dir is the project-scope from discovery's + # point of view (colony_dir is the project_root). Add it also as + # a tagged ``colony_ui`` scope so UI-created entries resolve with + # correct provenance. + extras.append( + ExtraScope( + directory=colony_home / ".hive" / "skills", + label="colony_ui", + priority=3, + ) + ) + + return SkillsManagerConfig( + queen_id=queen_id, + queen_overrides_path=queen_overrides_path, + colony_name=colony_name, + colony_overrides_path=colony_overrides_path, + extra_scope_dirs=extras, + interactive=False, # HTTP-driven runtimes never prompt for consent + ) + + @property + def queen_id(self) -> str | None: + """The queen that owns this runtime, if known.""" + return self._queen_id + + @property + def colony_name(self) -> str | None: + """The on-disk colony name (distinct from event-bus scope ``colony_id``).""" + return self._colony_name + + @property + def skills_manager(self): + """Access the live :class:`SkillsManager` (for HTTP handlers).""" + return self._skills_manager + + async def reload_skills(self) -> dict[str, Any]: + """Rebuild the catalog after an override change; in-flight workers + pick up the new catalog on their next iteration via + ``dynamic_skills_catalog_provider``. + + Returns a small stats dict that HTTP handlers can echo back to + the UI ("applied — N skills now in catalog"). + """ + async with self._skills_manager.mutation_lock: + self._skills_manager.reload() + self.skill_dirs = self._skills_manager.allowlisted_dirs + self.batch_init_nudge = self._skills_manager.batch_init_nudge + self.context_warn_ratio = self._skills_manager.context_warn_ratio + catalog_prompt = self._skills_manager.skills_catalog_prompt + return { + "catalog_chars": len(catalog_prompt), + "skill_dirs": list(self.skill_dirs), + } + # ── Per-colony tool allowlist ─────────────────────────────── def set_tool_allowlist( @@ -800,6 +901,23 @@ class ColonyRuntime: conversation_store=worker_conv_store, ) + # Workers pick up UI-driven override changes via this provider, + # which reads the live catalog on each iteration. The db_path + # pre-activated catalog stays static because its contents are + # built for *this* worker's task (a tombstone toggle from the + # UI should not yank it mid-run). + _db_path_pre_activated = bool( + isinstance(input_data, dict) and input_data.get("db_path") + ) + # Default-bind the manager into the closure so each loop iteration + # captures the same manager instance — pyflakes B023 would flag a + # free-variable capture here. + _provider = ( + None + if _db_path_pre_activated + else (lambda mgr=self._skills_manager: mgr.skills_catalog_prompt) + ) + agent_context = AgentContext( runtime=self._make_runtime_adapter(worker_id), agent_id=worker_id, @@ -813,6 +931,7 @@ class ColonyRuntime: skills_catalog_prompt=_spawn_catalog, protocols_prompt=self.protocols_prompt, skill_dirs=_spawn_skill_dirs, + dynamic_skills_catalog_provider=_provider, execution_id=worker_id, stream_id=explicit_stream_id or f"worker:{worker_id}", ) @@ -1057,6 +1176,7 @@ class ColonyRuntime: conversation_store=overseer_conv_store, ) + _overseer_skills_mgr = self._skills_manager overseer_ctx = AgentContext( runtime=self._make_runtime_adapter(overseer_id), agent_id=overseer_id, @@ -1070,6 +1190,7 @@ class ColonyRuntime: skills_catalog_prompt=self.skills_catalog_prompt, protocols_prompt=self.protocols_prompt, skill_dirs=self.skill_dirs, + dynamic_skills_catalog_provider=lambda: _overseer_skills_mgr.skills_catalog_prompt, execution_id=overseer_id, stream_id="overseer", ) diff --git a/core/framework/orchestrator/node.py b/core/framework/orchestrator/node.py index 961ee08a..6ba3ebfc 100644 --- a/core/framework/orchestrator/node.py +++ b/core/framework/orchestrator/node.py @@ -543,6 +543,10 @@ class NodeContext: # Dynamic memory provider — when set, EventLoopNode rebuilds the # system prompt with the latest memory block each iteration. dynamic_memory_provider: Any = None # Callable[[], str] | None + # Surgical skills-catalog refresh, same contract as AgentContext's + # field of the same name. Lets workers pick up UI-driven skill + # toggles without rebuilding the full system prompt each turn. + dynamic_skills_catalog_provider: Any = None # Callable[[], str] | None # Skill system prompts — injected by the skill discovery pipeline skills_catalog_prompt: str = "" # Available skills XML catalog diff --git a/core/framework/server/app.py b/core/framework/server/app.py index 5d6fa1a9..80a7f887 100644 --- a/core/framework/server/app.py +++ b/core/framework/server/app.py @@ -338,6 +338,7 @@ def create_app(model: str | None = None) -> web.Application: from framework.server.routes_queen_tools import register_routes as register_queen_tools_routes from framework.server.routes_queens import register_routes as register_queen_routes from framework.server.routes_sessions import register_routes as register_session_routes + from framework.server.routes_skills import register_routes as register_skills_routes from framework.server.routes_workers import register_routes as register_worker_routes register_config_routes(app) @@ -354,6 +355,7 @@ def create_app(model: str | None = None) -> web.Application: register_mcp_routes(app) register_colony_worker_routes(app) register_prompt_routes(app) + register_skills_routes(app) # Static file serving — Option C production mode # If frontend/dist/ exists, serve built frontend files on / diff --git a/core/framework/server/queen_orchestrator.py b/core/framework/server/queen_orchestrator.py index 9cb2e2f9..35a7bb93 100644 --- a/core/framework/server/queen_orchestrator.py +++ b/core/framework/server/queen_orchestrator.py @@ -653,12 +653,34 @@ async def create_queen( # ---- Default skill protocols ------------------------------------- _queen_skill_dirs: list[str] = [] try: + from framework.config import QUEENS_DIR + from framework.skills.discovery import ExtraScope from framework.skills.manager import SkillsManager, SkillsManagerConfig - # Pass project_root so user-scope skills (~/.hive/skills/, ~/.agents/skills/) - # are discovered. Queen has no agent-specific project root, so we use its - # own directory — the value just needs to be non-None to enable user-scope scanning. - _queen_skills_mgr = SkillsManager(SkillsManagerConfig(project_root=Path(__file__).parent)) + # Queen home backs the queen-UI skill scope and the queen's + # override store. The directory already exists (or is created on + # demand by queen_profiles.py); treat a missing queen_name as the + # default queen to preserve backwards compatibility. + _queen_id = getattr(session, "queen_name", None) or "default" + _queen_home = QUEENS_DIR / _queen_id + _queen_skills_mgr = SkillsManager( + SkillsManagerConfig( + queen_id=_queen_id, + queen_overrides_path=_queen_home / "skills_overrides.json", + extra_scope_dirs=[ + ExtraScope( + directory=_queen_home / "skills", + label="queen_ui", + priority=2, + ) + ], + # No project_root — queen's project is her own identity; + # user-scope discovery still runs without one. + project_root=None, + skip_community_discovery=True, + interactive=False, + ) + ) _queen_skills_mgr.load() phase_state.protocols_prompt = _queen_skills_mgr.protocols_prompt phase_state.skills_catalog_prompt = _queen_skills_mgr.skills_catalog_prompt diff --git a/core/framework/server/routes_skills.py b/core/framework/server/routes_skills.py new file mode 100644 index 00000000..ea9444cc --- /dev/null +++ b/core/framework/server/routes_skills.py @@ -0,0 +1,1131 @@ +"""HTTP routes for per-queen / per-colony skill control + aggregated library. + +Three parallel surfaces: + +1. Per-queen routes + GET /api/queen/{queen_id}/skills + POST /api/queen/{queen_id}/skills + PATCH /api/queen/{queen_id}/skills/{skill_name} + PUT /api/queen/{queen_id}/skills/{skill_name}/body + DELETE /api/queen/{queen_id}/skills/{skill_name} + POST /api/queen/{queen_id}/skills/reload + +2. Per-colony routes (same shape, but keyed by colony_name) + GET / POST / PATCH / PUT / DELETE / reload + +3. Aggregated library (powers the Skills Library page) + GET /api/skills -- full catalog + inversion (visible_to.*) + GET /api/skills/{name} -- full body + file listing for drawer view + GET /api/skills/scopes -- {queens: [...], colonies: [...]} + POST /api/skills/upload -- multipart (.md or .zip) into a named scope + +Live managers are reloaded when an override mutation occurs so in-flight +queens/workers pick up the new catalog on their next iteration via the +dynamic-prompt providers wired in ``colony_runtime`` and +``queen_orchestrator``. +""" + +from __future__ import annotations + +import io +import logging +import re +import zipfile +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any + +from aiohttp import web + +from framework.config import COLONIES_DIR, QUEENS_DIR +from framework.skills.authoring import ( + build_draft, + remove_skill as authoring_remove_skill, + validate_skill_name, + write_skill, +) +from framework.skills.discovery import DiscoveryConfig, ExtraScope, SkillDiscovery +from framework.skills.manager import SkillsManager, SkillsManagerConfig +from framework.skills.overrides import ( + OverrideEntry, + Provenance, + SkillOverrideStore, + utc_now, +) +from framework.skills.parser import ParsedSkill + +logger = logging.getLogger(__name__) + +# Cap uploaded payloads to avoid the aiohttp multipart reader pulling +# megabytes into memory. 2 MB is generous for a SKILL.md + a handful +# of supporting files. +_MAX_UPLOAD_BYTES = 2 * 1024 * 1024 +_ZIP_MAGIC = b"PK\x03\x04" + + +# --------------------------------------------------------------------------- +# Scope resolution +# --------------------------------------------------------------------------- + + +@dataclass +class SkillScope: + """Everything a handler needs to mutate a scope's skill surface.""" + + kind: str # "queen" | "colony" + target_id: str # queen_id or colony_name + write_dir: Path # where SKILL.md folders live + overrides_path: Path # where the JSON store lives + store: SkillOverrideStore + # Live runtimes whose SkillsManager must be reloaded on mutation. + affected_runtimes: list = field(default_factory=list) + # The SkillsManager used by GET to enumerate skills. Queen scope + # prefers a live DM session's manager; colony scope uses the colony + # runtime. When no runtime is live we build an ad-hoc manager. + manager: SkillsManager | None = None + + +def _ensure_queens_known() -> None: + """Materialize default queen profiles so GET works on a cold install.""" + from framework.agents.queen.queen_profiles import ensure_default_queens + + try: + ensure_default_queens() + except Exception: + logger.debug("ensure_default_queens failed (non-fatal)", exc_info=True) + + +def _queen_scope(manager: Any, queen_id: str) -> SkillScope | None: + _ensure_queens_known() + queen_home = QUEENS_DIR / queen_id + if not queen_home.is_dir(): + # queen_profiles only creates dirs for *known* queen ids. An unknown + # id means the caller typed something wrong. + return None + overrides_path = queen_home / "skills_overrides.json" + store = SkillOverrideStore.load(overrides_path, scope_label=f"queen:{queen_id}") + write_dir = queen_home / "skills" + + # Prefer a live manager so reload after mutation reaches running + # sessions. Any queen-session manager is equivalent since queen-scope + # skills cascade identically to every session. + runtimes: list = [] + live_manager: SkillsManager | None = None + try: + for session in manager.iter_queen_sessions(queen_id): # type: ignore[union-attr] + phase_state = getattr(session, "phase_state", None) + if phase_state is not None: + skills_mgr = getattr(phase_state, "skills_manager", None) + if isinstance(skills_mgr, SkillsManager) and live_manager is None: + live_manager = skills_mgr + # Colonies owned by this queen also need reload when queen-scope toggles. + for colony in manager.iter_colony_runtimes(queen_id=queen_id): # type: ignore[union-attr] + runtimes.append(colony) + except Exception: + logger.debug("queen scope: live manager lookup failed", exc_info=True) + + if live_manager is None: + live_manager = _build_admin_manager(queen_id=queen_id) + + return SkillScope( + kind="queen", + target_id=queen_id, + write_dir=write_dir, + overrides_path=overrides_path, + store=store, + affected_runtimes=runtimes, + manager=live_manager, + ) + + +def _colony_scope(manager: Any, colony_name: str) -> SkillScope | None: + colony_home = COLONIES_DIR / colony_name + if not colony_home.is_dir(): + return None + # Read colony metadata to find the owning queen, so cascades and + # inherited-from-queen listing work on GET. + queen_id: str | None = None + try: + from framework.host.colony_metadata import load_colony_metadata + + meta = load_colony_metadata(colony_name) + queen_id = meta.get("queen_name") or None + except Exception: + logger.debug("colony metadata lookup failed for %s", colony_name, exc_info=True) + + overrides_path = colony_home / "skills_overrides.json" + store = SkillOverrideStore.load(overrides_path, scope_label=f"colony:{colony_name}") + write_dir = colony_home / ".hive" / "skills" + + runtimes: list = [] + live_manager: SkillsManager | None = None + try: + for colony in manager.iter_colony_runtimes(colony_name=colony_name): # type: ignore[union-attr] + runtimes.append(colony) + skills_mgr = getattr(colony, "skills_manager", None) + if isinstance(skills_mgr, SkillsManager) and live_manager is None: + live_manager = skills_mgr + except Exception: + logger.debug("colony scope: live manager lookup failed", exc_info=True) + + if live_manager is None: + live_manager = _build_admin_manager(queen_id=queen_id, colony_name=colony_name) + + return SkillScope( + kind="colony", + target_id=colony_name, + write_dir=write_dir, + overrides_path=overrides_path, + store=store, + affected_runtimes=runtimes, + manager=live_manager, + ) + + +def _build_admin_manager( + *, + queen_id: str | None = None, + colony_name: str | None = None, +) -> SkillsManager: + """Build a read-only SkillsManager for GET when no live session exists. + + Intentionally leaves ``project_root`` unset even for a colony: the + colony's ``.hive/skills/`` directory is surfaced via the ``colony_ui`` + extra scope. Also routing it through ``project_root`` would double- + scan the same dir, and last-wins collision resolution would retag the + skills as ``source_scope="project"`` — which flips the provenance + fallback to ``PROJECT_DROPPED`` and drops ``editable`` to ``False`` + for anything without an explicit override-store entry. + """ + extras: list[ExtraScope] = [] + queen_overrides_path: Path | None = None + colony_overrides_path: Path | None = None + if queen_id: + queen_home = QUEENS_DIR / queen_id + queen_overrides_path = queen_home / "skills_overrides.json" + extras.append(ExtraScope(directory=queen_home / "skills", label="queen_ui", priority=2)) + if colony_name: + colony_home = COLONIES_DIR / colony_name + colony_overrides_path = colony_home / "skills_overrides.json" + extras.append( + ExtraScope(directory=colony_home / ".hive" / "skills", label="colony_ui", priority=3) + ) + cfg = SkillsManagerConfig( + queen_id=queen_id, + queen_overrides_path=queen_overrides_path, + colony_name=colony_name, + colony_overrides_path=colony_overrides_path, + extra_scope_dirs=extras, + project_root=None, + skip_community_discovery=True, + interactive=False, + ) + mgr = SkillsManager(cfg) + mgr.load() + return mgr + + +# --------------------------------------------------------------------------- +# Serializers +# --------------------------------------------------------------------------- + + +_EDITABLE_PROVENANCE = { + Provenance.USER_UI_CREATED, + Provenance.QUEEN_CREATED, + Provenance.LEARNED_RUNTIME, +} + + +def _resolve_provenance( + skill: ParsedSkill, + queen_store: SkillOverrideStore | None, + colony_store: SkillOverrideStore | None, +) -> tuple[Provenance, OverrideEntry | None]: + """Override-store entry wins, otherwise fall back to source_scope inference. + + ``OTHER`` entries carry toggle/notes state but don't claim an + origin — we still return the inferred provenance for those so the + UI shows something meaningful instead of a generic badge. + """ + # Collect the entry (if any) before deciding which provenance to report. + # A FRAMEWORK-stamped entry on a skill that doesn't actually live in the + # framework scope was written by the old buggy PATCH handler; treat it + # like OTHER so the inference below reports an honest provenance. + store_entry: OverrideEntry | None = None + for store in (colony_store, queen_store): + if store is None: + continue + entry = store.get(skill.name) + if entry is not None: + store_entry = entry + stamped = entry.provenance + if stamped == Provenance.FRAMEWORK and skill.source_scope != "framework": + stamped = Provenance.OTHER + if stamped != Provenance.OTHER: + return stamped, entry + break + # Infer from scope label. ``colony_ui`` with no informative entry + # is only reachable via create_colony() since the UI POST path + # always stamps USER_UI_CREATED. + if skill.source_scope == "framework": + return Provenance.FRAMEWORK, store_entry + if skill.source_scope == "user": + return Provenance.USER_DROPPED, store_entry + if skill.source_scope == "queen_ui": + return Provenance.USER_UI_CREATED, store_entry + if skill.source_scope == "colony_ui": + return Provenance.QUEEN_CREATED, store_entry + return Provenance.PROJECT_DROPPED, store_entry + + +def _effective_enabled( + skill: ParsedSkill, + queen_store: SkillOverrideStore | None, + colony_store: SkillOverrideStore | None, +) -> bool: + # Colony explicit wins over queen explicit; either explicit wins over + # master switch + default. Keeps the UI's enable/disable toggle simple. + for store in (colony_store, queen_store): + if store is None: + continue + entry = store.get(skill.name) + if entry is not None and entry.enabled is not None: + return entry.enabled + for store in (colony_store, queen_store): + if store is not None and store.all_defaults_disabled and skill.source_scope == "framework": + return False + return True + + +def _serialize_skill( + skill: ParsedSkill, + *, + queen_store: SkillOverrideStore | None, + colony_store: SkillOverrideStore | None, +) -> dict[str, Any]: + provenance, entry = _resolve_provenance(skill, queen_store, colony_store) + editable = provenance in _EDITABLE_PROVENANCE + return { + "name": skill.name, + "description": skill.description, + "source_scope": skill.source_scope, + "provenance": str(provenance), + "enabled": _effective_enabled(skill, queen_store, colony_store), + "editable": editable, + "deletable": editable, + "location": skill.location, + "base_dir": skill.base_dir, + "visibility": skill.visibility, + "trust": entry.trust if entry else None, + "created_at": entry.created_at.isoformat() if (entry and entry.created_at) else None, + "created_by": entry.created_by if entry else None, + "notes": entry.notes if entry else None, + "param_overrides": dict(entry.param_overrides) if entry else {}, + } + + +# --------------------------------------------------------------------------- +# GET handlers +# --------------------------------------------------------------------------- + + +async def handle_list_queen_skills(request: web.Request) -> web.Response: + manager = request.app.get("manager") + queen_id = request.match_info["queen_id"] + scope = _queen_scope(manager, queen_id) + if scope is None: + return web.json_response({"error": f"queen '{queen_id}' not found"}, status=404) + mgr = scope.manager + assert mgr is not None + skills = [ + _serialize_skill(s, queen_store=scope.store, colony_store=None) + for s in mgr.enumerate_skills_with_source() + ] + skills.sort(key=lambda r: r["name"]) + return web.json_response( + { + "queen_id": queen_id, + "all_defaults_disabled": scope.store.all_defaults_disabled, + "skills": skills, + } + ) + + +async def handle_list_colony_skills(request: web.Request) -> web.Response: + manager = request.app.get("manager") + colony_name = request.match_info["colony_name"] + scope = _colony_scope(manager, colony_name) + if scope is None: + return web.json_response({"error": f"colony '{colony_name}' not found"}, status=404) + mgr = scope.manager + assert mgr is not None + # Queen store contributes cascade inheritance — load it so provenance / + # enabled resolution matches what the colony actually sees. + queen_store = None + queen_id: str | None = None + try: + from framework.host.colony_metadata import load_colony_metadata + + queen_id = load_colony_metadata(colony_name).get("queen_name") or None + except Exception: + queen_id = None + if queen_id: + queen_store = SkillOverrideStore.load( + QUEENS_DIR / queen_id / "skills_overrides.json", + scope_label=f"queen:{queen_id}", + ) + + all_skills = mgr.enumerate_skills_with_source() + rows = [ + _serialize_skill(s, queen_store=queen_store, colony_store=scope.store) + for s in all_skills + ] + rows.sort(key=lambda r: r["name"]) + inherited = [s.name for s in all_skills if s.source_scope == "queen_ui"] + return web.json_response( + { + "colony_name": colony_name, + "queen_id": queen_id, + "all_defaults_disabled": scope.store.all_defaults_disabled, + "skills": rows, + "inherited_from_queen": sorted(inherited), + } + ) + + +# --------------------------------------------------------------------------- +# Aggregated library +# --------------------------------------------------------------------------- + + +async def handle_list_all_skills(request: web.Request) -> web.Response: + """Global catalog: every skill in every scope + inversion ``visible_to``.""" + # Enumerate queens and colonies by walking the standard dirs. + _ensure_queens_known() + queen_ids = ( + sorted(p.name for p in QUEENS_DIR.glob("*") if (p / "profile.yaml").exists()) + if QUEENS_DIR.is_dir() + else [] + ) + colony_names: list[str] = [] + if COLONIES_DIR.is_dir(): + colony_names = sorted(p.name for p in COLONIES_DIR.iterdir() if p.is_dir()) + + # Build one admin manager that covers every scope — expensive on cold + # boot but cached thanks to the parser's per-file reads being cheap. + extras: list[ExtraScope] = [] + for qid in queen_ids: + extras.append(ExtraScope(directory=QUEENS_DIR / qid / "skills", label="queen_ui", priority=2)) + # We intentionally don't plumb every colony's project_root into one + # manager — discovery only allows a single project_root. For the + # aggregator we scan every colony's .hive/skills/ as a tagged extra + # scope instead. That keeps the xml-catalog-per-scope invariant + # intact without requiring N managers. + for cn in colony_names: + extras.append( + ExtraScope( + directory=COLONIES_DIR / cn / ".hive" / "skills", + label="colony_ui", + priority=3, + ) + ) + + # Raw discovery (no override filtering) — we apply per-scope stores + # below when computing ``visible_to``. + discovery = SkillDiscovery( + DiscoveryConfig(project_root=None, skip_framework_scope=False, extra_scopes=extras) + ) + discovered = discovery.discover() + + # Load all stores once. + queen_stores: dict[str, SkillOverrideStore] = { + qid: SkillOverrideStore.load( + QUEENS_DIR / qid / "skills_overrides.json", scope_label=f"queen:{qid}" + ) + for qid in queen_ids + } + colony_stores: dict[str, SkillOverrideStore] = {} + colony_queens: dict[str, str | None] = {} + for cn in colony_names: + colony_stores[cn] = SkillOverrideStore.load( + COLONIES_DIR / cn / "skills_overrides.json", scope_label=f"colony:{cn}" + ) + try: + from framework.host.colony_metadata import load_colony_metadata + + colony_queens[cn] = load_colony_metadata(cn).get("queen_name") or None + except Exception: + colony_queens[cn] = None + + rows: list[dict[str, Any]] = [] + # Owner mapping for queen_ui / colony_ui scopes: the dir path encodes + # which queen/colony the skill belongs to. + def _owner_for(skill: ParsedSkill) -> dict[str, str] | None: + base = Path(skill.base_dir) + parts = base.parts + try: + idx_q = parts.index("queens") + if skill.source_scope == "queen_ui" and idx_q + 1 < len(parts): + qid = parts[idx_q + 1] + return {"type": "queen", "id": qid, "name": qid} + except ValueError: + pass + try: + idx_c = parts.index("colonies") + if skill.source_scope == "colony_ui" and idx_c + 1 < len(parts): + cn = parts[idx_c + 1] + return {"type": "colony", "id": cn, "name": cn} + except ValueError: + pass + return None + + for skill in sorted(discovered, key=lambda s: s.name): + visible_queens: list[str] = [] + visible_colonies: list[str] = [] + for qid, qstore in queen_stores.items(): + if _effective_enabled(skill, qstore, None): + visible_queens.append(qid) + for cn, cstore in colony_stores.items(): + qstore = queen_stores.get(colony_queens.get(cn) or "", None) + if _effective_enabled(skill, qstore, cstore): + visible_colonies.append(cn) + + # Provenance: choose the nearest owning store's record if any. + owner = _owner_for(skill) + prov_store: SkillOverrideStore | None = None + if owner and owner["type"] == "queen": + prov_store = queen_stores.get(owner["id"]) + elif owner and owner["type"] == "colony": + prov_store = colony_stores.get(owner["id"]) + provenance, entry = _resolve_provenance(skill, prov_store, None) + editable = provenance in _EDITABLE_PROVENANCE + rows.append( + { + "name": skill.name, + "description": skill.description, + "source_scope": skill.source_scope, + "provenance": str(provenance), + "owner": owner, + "visible_to": {"queens": sorted(visible_queens), "colonies": sorted(visible_colonies)}, + "enabled_by_default": True, + "editable": editable, + "deletable": editable, + "location": skill.location, + "visibility": skill.visibility, + } + ) + + return web.json_response( + { + "skills": rows, + "queens": [{"id": q, "name": q} for q in queen_ids], + "colonies": [{"name": c, "queen_id": colony_queens.get(c)} for c in colony_names], + } + ) + + +async def handle_get_skill_detail(request: web.Request) -> web.Response: + """GET /api/skills/{skill_name} — full body for the detail drawer.""" + skill_name = request.match_info["skill_name"] + name, err = validate_skill_name(skill_name) + if err or name is None: + return web.json_response({"error": err}, status=400) + manager = _build_admin_manager() + for s in manager.enumerate_skills_with_source(): + if s.name == name: + # Re-read body so we get the freshest content (the cached + # ParsedSkill.body was stamped at load time). + try: + body = Path(s.location).read_text(encoding="utf-8") + except OSError as exc: + return web.json_response({"error": f"failed to read {s.location}: {exc}"}, status=500) + return web.json_response( + { + "name": s.name, + "description": s.description, + "source_scope": s.source_scope, + "location": s.location, + "base_dir": s.base_dir, + "body": body, + "visibility": s.visibility, + } + ) + return web.json_response({"error": f"skill '{name}' not found"}, status=404) + + +async def handle_list_scopes(request: web.Request) -> web.Response: + """GET /api/skills/scopes — enumerate queens and colonies for the UI picker.""" + _ensure_queens_known() + queens = [] + if QUEENS_DIR.is_dir(): + for p in sorted(QUEENS_DIR.glob("*/profile.yaml")): + queens.append({"id": p.parent.name, "name": p.parent.name}) + colonies = [] + if COLONIES_DIR.is_dir(): + for p in sorted(x for x in COLONIES_DIR.iterdir() if x.is_dir()): + queen_id = None + try: + from framework.host.colony_metadata import load_colony_metadata + + queen_id = load_colony_metadata(p.name).get("queen_name") or None + except Exception: + pass + colonies.append({"name": p.name, "queen_id": queen_id}) + return web.json_response({"queens": queens, "colonies": colonies}) + + +# --------------------------------------------------------------------------- +# Mutations (shared body) +# --------------------------------------------------------------------------- + + +async def _reload_scope(scope: SkillScope) -> None: + """Reload the primary manager and every live runtime affected by the scope.""" + import asyncio + + async def _reload_one(rt) -> None: + try: + await rt.reload_skills() + except Exception: + logger.warning("reload_skills failed for runtime %r", rt, exc_info=True) + + # The primary manager (often the queen's) gets reloaded too. + if scope.manager is not None: + async with scope.manager.mutation_lock: + try: + scope.manager.reload() + except Exception: + logger.warning("primary manager reload failed", exc_info=True) + # Runtimes in parallel — each has its own mutation lock. + if scope.affected_runtimes: + await asyncio.gather(*[_reload_one(rt) for rt in scope.affected_runtimes], return_exceptions=True) + + +async def _handle_create(scope: SkillScope, payload: dict[str, Any], user_id: str) -> web.Response: + draft, err = build_draft( + skill_name=payload.get("name"), + skill_description=payload.get("description"), + skill_body=payload.get("body"), + skill_files=payload.get("files"), + ) + if err or draft is None: + return web.json_response({"error": err}, status=400) + replace_existing = bool(payload.get("replace_existing", False)) + installed, wrote_err, replaced = write_skill( + draft, target_root=scope.write_dir, replace_existing=replace_existing + ) + if wrote_err is not None or installed is None: + status = 409 if "already exists" in (wrote_err or "") else 500 + return web.json_response({"error": wrote_err}, status=status) + enabled = bool(payload.get("enabled", True)) + scope.store.upsert( + draft.name, + OverrideEntry( + enabled=enabled, + provenance=Provenance.USER_UI_CREATED, + created_at=utc_now(), + created_by=user_id, + notes=payload.get("notes") if isinstance(payload.get("notes"), str) else None, + ), + ) + scope.store.save() + await _reload_scope(scope) + return web.json_response( + { + "name": draft.name, + "installed_path": str(installed), + "replaced": replaced, + "enabled": enabled, + "provenance": str(Provenance.USER_UI_CREATED), + }, + status=201, + ) + + +async def _handle_patch(scope: SkillScope, skill_name: str, payload: dict[str, Any]) -> web.Response: + name, err = validate_skill_name(skill_name) + if err or name is None: + return web.json_response({"error": err}, status=400) + # A PATCH can't know who originally authored the skill — it only + # stores the user's toggle. Use OTHER rather than stamping a guess + # (FRAMEWORK, USER_UI_CREATED, etc.) that would then outrank the + # source_scope inference on subsequent reads. + existing = scope.store.get(name) or OverrideEntry(provenance=Provenance.OTHER) + if "enabled" in payload: + enabled_raw = payload["enabled"] + if not isinstance(enabled_raw, bool): + return web.json_response({"error": "'enabled' must be a bool"}, status=400) + existing.enabled = enabled_raw + if "param_overrides" in payload: + po = payload["param_overrides"] + if not isinstance(po, dict): + return web.json_response({"error": "'param_overrides' must be an object"}, status=400) + existing.param_overrides = dict(po) + if "notes" in payload: + notes = payload["notes"] + existing.notes = notes if isinstance(notes, str) or notes is None else existing.notes + if "all_defaults_disabled" in payload and isinstance(payload["all_defaults_disabled"], bool): + scope.store.all_defaults_disabled = payload["all_defaults_disabled"] + scope.store.upsert(name, existing) + scope.store.save() + await _reload_scope(scope) + return web.json_response({"name": name, "enabled": existing.enabled, "ok": True}) + + + + +async def _handle_put_body(scope: SkillScope, skill_name: str, payload: dict[str, Any]) -> web.Response: + name, err = validate_skill_name(skill_name) + if err or name is None: + return web.json_response({"error": err}, status=400) + entry = scope.store.get(name) + provenance = entry.provenance if entry else Provenance.FRAMEWORK + if provenance not in _EDITABLE_PROVENANCE: + return web.json_response( + {"error": f"skill '{name}' is not editable (provenance={provenance})"}, status=403 + ) + description = payload.get("description") + body = payload.get("body") + if not isinstance(body, str) or not body.strip(): + return web.json_response({"error": "'body' is required"}, status=400) + # If the caller omitted description, keep whatever's in the current SKILL.md. + current_desc = None + if description is None: + current_path = scope.write_dir / name / "SKILL.md" + if current_path.exists(): + match = re.search(r"^description:\s*(.+)$", current_path.read_text(encoding="utf-8"), re.MULTILINE) + if match: + current_desc = match.group(1).strip().strip("\"'") + final_desc = description if isinstance(description, str) else (current_desc or "") + draft, derr = build_draft( + skill_name=name, + skill_description=final_desc, + skill_body=body, + skill_files=payload.get("files"), + ) + if derr or draft is None: + return web.json_response({"error": derr}, status=400) + installed, werr, _ = write_skill(draft, target_root=scope.write_dir, replace_existing=True) + if werr or installed is None: + return web.json_response({"error": werr}, status=500) + # Touch created_at? No — keep the original author. Just refresh notes/overrides. + await _reload_scope(scope) + return web.json_response({"name": name, "installed_path": str(installed)}) + + +async def _handle_delete(scope: SkillScope, skill_name: str) -> web.Response: + name, err = validate_skill_name(skill_name) + if err or name is None: + return web.json_response({"error": err}, status=400) + entry = scope.store.get(name) + provenance = entry.provenance if entry else None + if provenance is not None and provenance not in _EDITABLE_PROVENANCE: + return web.json_response( + {"error": f"skill '{name}' is not deletable (provenance={provenance})"}, status=403 + ) + removed, rerr = authoring_remove_skill(scope.write_dir, name) + if rerr: + return web.json_response({"error": rerr}, status=500) + scope.store.remove(name, tombstone=True) + scope.store.save() + await _reload_scope(scope) + return web.json_response({"name": name, "removed": removed}) + + +async def _handle_reload(scope: SkillScope) -> web.Response: + await _reload_scope(scope) + return web.json_response({"ok": True, "target": scope.target_id, "kind": scope.kind}) + + +# --------------------------------------------------------------------------- +# HTTP handlers (thin wrappers that resolve the scope then delegate) +# --------------------------------------------------------------------------- + + +def _requester_id(request: web.Request) -> str: + # The UI surfaces a user email via the shell's CLAUDE.md; production + # deployments should replace this with authenticated session state. + return request.headers.get("X-User", "ui") + + +async def handle_create_queen_skill(request: web.Request) -> web.Response: + manager = request.app.get("manager") + scope = _queen_scope(manager, request.match_info["queen_id"]) + if scope is None: + return web.json_response({"error": "queen not found"}, status=404) + try: + payload = await request.json() + except Exception: + return web.json_response({"error": "invalid JSON body"}, status=400) + return await _handle_create(scope, payload, _requester_id(request)) + + +async def handle_patch_queen_skill(request: web.Request) -> web.Response: + manager = request.app.get("manager") + scope = _queen_scope(manager, request.match_info["queen_id"]) + if scope is None: + return web.json_response({"error": "queen not found"}, status=404) + try: + payload = await request.json() + except Exception: + return web.json_response({"error": "invalid JSON body"}, status=400) + return await _handle_patch(scope, request.match_info["skill_name"], payload) + + +async def handle_put_queen_skill_body(request: web.Request) -> web.Response: + manager = request.app.get("manager") + scope = _queen_scope(manager, request.match_info["queen_id"]) + if scope is None: + return web.json_response({"error": "queen not found"}, status=404) + try: + payload = await request.json() + except Exception: + return web.json_response({"error": "invalid JSON body"}, status=400) + return await _handle_put_body(scope, request.match_info["skill_name"], payload) + + +async def handle_delete_queen_skill(request: web.Request) -> web.Response: + manager = request.app.get("manager") + scope = _queen_scope(manager, request.match_info["queen_id"]) + if scope is None: + return web.json_response({"error": "queen not found"}, status=404) + return await _handle_delete(scope, request.match_info["skill_name"]) + + +async def handle_reload_queen_skills(request: web.Request) -> web.Response: + manager = request.app.get("manager") + scope = _queen_scope(manager, request.match_info["queen_id"]) + if scope is None: + return web.json_response({"error": "queen not found"}, status=404) + return await _handle_reload(scope) + + +async def handle_create_colony_skill(request: web.Request) -> web.Response: + manager = request.app.get("manager") + scope = _colony_scope(manager, request.match_info["colony_name"]) + if scope is None: + return web.json_response({"error": "colony not found"}, status=404) + try: + payload = await request.json() + except Exception: + return web.json_response({"error": "invalid JSON body"}, status=400) + return await _handle_create(scope, payload, _requester_id(request)) + + +async def handle_patch_colony_skill(request: web.Request) -> web.Response: + manager = request.app.get("manager") + scope = _colony_scope(manager, request.match_info["colony_name"]) + if scope is None: + return web.json_response({"error": "colony not found"}, status=404) + try: + payload = await request.json() + except Exception: + return web.json_response({"error": "invalid JSON body"}, status=400) + return await _handle_patch(scope, request.match_info["skill_name"], payload) + + +async def handle_put_colony_skill_body(request: web.Request) -> web.Response: + manager = request.app.get("manager") + scope = _colony_scope(manager, request.match_info["colony_name"]) + if scope is None: + return web.json_response({"error": "colony not found"}, status=404) + try: + payload = await request.json() + except Exception: + return web.json_response({"error": "invalid JSON body"}, status=400) + return await _handle_put_body(scope, request.match_info["skill_name"], payload) + + +async def handle_delete_colony_skill(request: web.Request) -> web.Response: + manager = request.app.get("manager") + scope = _colony_scope(manager, request.match_info["colony_name"]) + if scope is None: + return web.json_response({"error": "colony not found"}, status=404) + return await _handle_delete(scope, request.match_info["skill_name"]) + + +async def handle_reload_colony_skills(request: web.Request) -> web.Response: + manager = request.app.get("manager") + scope = _colony_scope(manager, request.match_info["colony_name"]) + if scope is None: + return web.json_response({"error": "colony not found"}, status=404) + return await _handle_reload(scope) + + +# --------------------------------------------------------------------------- +# Upload handler (multipart: SKILL.md OR .zip) +# --------------------------------------------------------------------------- + + +async def handle_upload_skill(request: web.Request) -> web.Response: + """POST /api/skills/upload — accept a single SKILL.md or a .zip bundle. + + Form fields: + ``file`` (required) — the .md / .zip payload + ``scope`` (required) — "user" | "queen" | "colony" + ``target_id`` (queen|colony) — queen_id or colony_name for those scopes + ``enabled`` (optional) — "true"/"false" (defaults true) + ``replace_existing`` — "true"/"false" (defaults false) + ``name`` — optional override for single-.md uploads + """ + manager = request.app.get("manager") + if not request.content_type.startswith("multipart/"): + return web.json_response({"error": "expected multipart/form-data"}, status=400) + + reader = await request.multipart() + upload_bytes: bytes | None = None + upload_filename: str | None = None + form: dict[str, str] = {} + + while True: + part = await reader.next() + if part is None: + break + if part.name == "file": + buf = io.BytesIO() + while True: + chunk = await part.read_chunk(size=65536) + if not chunk: + break + buf.write(chunk) + if buf.tell() > _MAX_UPLOAD_BYTES: + return web.json_response( + {"error": f"upload exceeds {_MAX_UPLOAD_BYTES} bytes"}, status=413 + ) + upload_bytes = buf.getvalue() + upload_filename = part.filename or "" + else: + form[part.name or ""] = (await part.text()).strip() + + if upload_bytes is None: + return web.json_response({"error": "missing 'file' part"}, status=400) + + scope_kind = form.get("scope", "").lower() + if scope_kind not in {"user", "queen", "colony"}: + return web.json_response({"error": "scope must be user|queen|colony"}, status=400) + target_id = form.get("target_id", "").strip() + if scope_kind in {"queen", "colony"} and not target_id: + return web.json_response({"error": f"target_id required for scope={scope_kind}"}, status=400) + enabled = form.get("enabled", "true").lower() != "false" + replace_existing = form.get("replace_existing", "false").lower() == "true" + name_override = form.get("name", "").strip() or None + + # Resolve the write target + if scope_kind == "user": + write_dir = Path.home() / ".hive" / "skills" + overrides_path: Path | None = None + store: SkillOverrideStore | None = None + affected_runtimes: list = [] + elif scope_kind == "queen": + scope = _queen_scope(manager, target_id) + if scope is None: + return web.json_response({"error": "queen not found"}, status=404) + write_dir = scope.write_dir + overrides_path = scope.overrides_path + store = scope.store + affected_runtimes = scope.affected_runtimes + else: # colony + scope = _colony_scope(manager, target_id) + if scope is None: + return web.json_response({"error": "colony not found"}, status=404) + write_dir = scope.write_dir + overrides_path = scope.overrides_path + store = scope.store + affected_runtimes = scope.affected_runtimes + + # Extract into a draft + draft: Any + if upload_bytes.startswith(_ZIP_MAGIC) or (upload_filename or "").endswith(".zip"): + draft_name, draft_desc, draft_body, draft_files, err = _extract_from_zip( + upload_bytes, name_hint=name_override + ) + if err: + return web.json_response({"error": err}, status=400) + else: + draft_name, draft_desc, draft_body, draft_files, err = _extract_from_md( + upload_bytes, filename=upload_filename, name_hint=name_override + ) + if err: + return web.json_response({"error": err}, status=400) + + draft, derr = build_draft( + skill_name=draft_name, + skill_description=draft_desc, + skill_body=draft_body, + skill_files=draft_files, + ) + if derr or draft is None: + return web.json_response({"error": derr}, status=400) + + installed, werr, replaced = write_skill( + draft, target_root=write_dir, replace_existing=replace_existing + ) + if werr or installed is None: + status = 409 if "already exists" in (werr or "") else 500 + return web.json_response({"error": werr}, status=status) + + if store is not None and overrides_path is not None: + store.upsert( + draft.name, + OverrideEntry( + enabled=enabled, + provenance=Provenance.USER_UI_CREATED, + created_at=utc_now(), + created_by=_requester_id(request), + ), + ) + store.save() + + # Reload affected runtimes (queen/colony scopes only). For user + # scope, changes take effect the next time a runtime reloads — + # usually on the next queen boot. + for rt in affected_runtimes: + try: + await rt.reload_skills() + except Exception: + logger.warning("runtime reload after upload failed", exc_info=True) + + return web.json_response( + { + "name": draft.name, + "installed_path": str(installed), + "replaced": replaced, + "scope": scope_kind, + "target_id": target_id or None, + "enabled": enabled, + }, + status=201, + ) + + +def _extract_from_md( + raw: bytes, + *, + filename: str | None, + name_hint: str | None, +) -> tuple[str, str, str, list[dict], str | None]: + """Parse a lone SKILL.md upload. Returns (name, desc, body, files, error).""" + try: + text = raw.decode("utf-8") + except UnicodeDecodeError: + return "", "", "", [], "upload is not valid UTF-8" + name, desc, body, err = _parse_skill_md_text(text) + if err: + return "", "", "", [], err + if name_hint: + name = name_hint + if not name and filename: + stem = Path(filename).stem + if stem and stem.lower() != "skill": + name = stem.lower() + if not name: + return "", "", "", [], "could not determine skill name (pass 'name' form field)" + return name, desc, body, [], None + + +def _extract_from_zip( + raw: bytes, + *, + name_hint: str | None, +) -> tuple[str, str, str, list[dict], str | None]: + """Parse a .zip bundle. SKILL.md must be at root (not inside a folder).""" + try: + z = zipfile.ZipFile(io.BytesIO(raw)) + except zipfile.BadZipFile: + return "", "", "", [], "invalid zip file" + + names = z.namelist() + skill_mds = [n for n in names if n == "SKILL.md" or n.endswith("/SKILL.md")] + if not skill_mds: + return "", "", "", [], "zip must contain SKILL.md at root" + # Prefer the shallowest SKILL.md; multiple is usually an authoring mistake + skill_mds.sort(key=lambda p: p.count("/")) + root_skill_md = skill_mds[0] + root_prefix = root_skill_md[: -len("SKILL.md")] if root_skill_md != "SKILL.md" else "" + + skill_text = z.read(root_skill_md).decode("utf-8", errors="replace") + name, desc, body, err = _parse_skill_md_text(skill_text) + if err: + return "", "", "", [], err + if name_hint: + name = name_hint + if not name and root_prefix: + name = root_prefix.strip("/") + if not name: + return "", "", "", [], "could not determine skill name from zip" + + files: list[dict] = [] + for entry_name in names: + if entry_name == root_skill_md or entry_name.endswith("/"): + continue + if not entry_name.startswith(root_prefix): + continue + rel = entry_name[len(root_prefix):] + if not rel or rel == "SKILL.md": + continue + content_bytes = z.read(entry_name) + try: + content = content_bytes.decode("utf-8") + except UnicodeDecodeError: + return "", "", "", [], f"binary file '{rel}' not supported yet" + files.append({"path": rel, "content": content}) + return name, desc, body, files, None + + +def _parse_skill_md_text(text: str) -> tuple[str, str, str, str | None]: + """Lightweight frontmatter split so uploads can be validated offline. + + Full parsing happens inside :mod:`framework.skills.parser` when + discovery runs; this only needs ``name``, ``description`` and the + body so we can hand off to ``build_draft``. + """ + if not text.startswith("---"): + return "", "", "", "SKILL.md must start with '---' frontmatter" + try: + _, fm, body = text.split("---", 2) + except ValueError: + return "", "", "", "SKILL.md frontmatter is malformed" + name = "" + description = "" + for line in fm.splitlines(): + line = line.strip() + if line.startswith("name:"): + name = line.split(":", 1)[1].strip().strip("\"'") + elif line.startswith("description:"): + description = line.split(":", 1)[1].strip().strip("\"'") + return name, description, body.lstrip("\n"), None + + +# --------------------------------------------------------------------------- +# Registration +# --------------------------------------------------------------------------- + + +def register_routes(app: web.Application) -> None: + r = app.router + + # Per-queen + r.add_get("/api/queen/{queen_id}/skills", handle_list_queen_skills) + r.add_post("/api/queen/{queen_id}/skills", handle_create_queen_skill) + r.add_patch("/api/queen/{queen_id}/skills/{skill_name}", handle_patch_queen_skill) + r.add_put("/api/queen/{queen_id}/skills/{skill_name}/body", handle_put_queen_skill_body) + r.add_delete("/api/queen/{queen_id}/skills/{skill_name}", handle_delete_queen_skill) + r.add_post("/api/queen/{queen_id}/skills/reload", handle_reload_queen_skills) + + # Per-colony + r.add_get("/api/colonies/{colony_name}/skills", handle_list_colony_skills) + r.add_post("/api/colonies/{colony_name}/skills", handle_create_colony_skill) + r.add_patch("/api/colonies/{colony_name}/skills/{skill_name}", handle_patch_colony_skill) + r.add_put( + "/api/colonies/{colony_name}/skills/{skill_name}/body", + handle_put_colony_skill_body, + ) + r.add_delete("/api/colonies/{colony_name}/skills/{skill_name}", handle_delete_colony_skill) + r.add_post("/api/colonies/{colony_name}/skills/reload", handle_reload_colony_skills) + + # Aggregated library (powers the Skills Library page) + r.add_get("/api/skills", handle_list_all_skills) + r.add_get("/api/skills/scopes", handle_list_scopes) + r.add_post("/api/skills/upload", handle_upload_skill) + r.add_get("/api/skills/{skill_name}", handle_get_skill_detail) diff --git a/core/framework/server/session_manager.py b/core/framework/server/session_manager.py index b971be78..0d89f6a0 100644 --- a/core/framework/server/session_manager.py +++ b/core/framework/server/session_manager.py @@ -1516,6 +1516,12 @@ class SessionManager: tool_executor=queen_tool_executor, event_bus=session.event_bus, colony_id=session.id, + # Wire the on-disk colony name and queen id so + # ColonyRuntime auto-derives its override paths. DM sessions + # have no colony_name (session.colony_name is None), which + # keeps them out of the per-colony JSON store. + colony_name=getattr(session, "colony_name", None), + queen_id=getattr(session, "queen_name", None) or None, pipeline_stages=[], # queen pipeline runs in queen_orchestrator, not here ) @@ -1739,6 +1745,42 @@ class SessionManager: def list_sessions(self) -> list[Session]: return list(self._sessions.values()) + # ------------------------------------------------------------------ + # Skill override helpers — used by routes_skills to find every live + # SkillsManager affected by a queen- or colony-scope mutation so a + # single HTTP call can reload them all. + # ------------------------------------------------------------------ + + def iter_queen_sessions(self, queen_id: str): + """Yield live sessions whose queen matches ``queen_id``.""" + for s in self._sessions.values(): + if getattr(s, "queen_name", None) == queen_id: + yield s + + def iter_colony_runtimes( + self, + *, + queen_id: str | None = None, + colony_name: str | None = None, + ): + """Yield live ``ColonyRuntime`` instances matching the filters. + + ``queen_id`` alone → every runtime whose ``queen_id`` matches + (useful when the user toggles a queen-scope skill — all her + colonies must reload). ``colony_name`` alone → the single + runtime pinned to that colony. Both → intersection. No filters + → every live runtime (used by global ``/api/skills`` reload). + """ + for s in self._sessions.values(): + colony = getattr(s, "colony", None) + if colony is None: + continue + if queen_id is not None and getattr(colony, "queen_id", None) != queen_id: + continue + if colony_name is not None and getattr(colony, "colony_name", None) != colony_name: + continue + yield colony + # ------------------------------------------------------------------ # Cold session helpers (disk-only, no live runtime required) # ------------------------------------------------------------------ diff --git a/core/framework/skills/authoring.py b/core/framework/skills/authoring.py new file mode 100644 index 00000000..fef20305 --- /dev/null +++ b/core/framework/skills/authoring.py @@ -0,0 +1,204 @@ +"""Shared skill authoring primitives. + +Validates and materializes a skill folder. Used by three callers: + +1. Queen's ``create_colony`` tool (``queen_lifecycle_tools.py``) — inline + content passed by the queen during colony creation. +2. HTTP POST / PUT routes under ``/api/**/skills`` — UI-driven creation. +3. Future ``create_learned_skill`` tool — runtime learning. + +Keeping the validators and writer here ensures the three paths share one +authority; changes to the name regex or frontmatter layout happen in one +place. +""" + +from __future__ import annotations + +import logging +import re +import shutil +from dataclasses import dataclass, field +from pathlib import Path + +logger = logging.getLogger(__name__) + +# Framework skill names include dots (``hive.note-taking``), so the +# validator needs to allow them even though the queen's ``create_colony`` +# tool historically forbade dots. User-created skills without dots still +# pass; the dot cap just prevents us from rejecting existing framework +# names when the UI toggles them via ``validate_skill_name``. +_SKILL_NAME_RE = re.compile(r"^[a-z0-9.-]+$") +_MAX_NAME_LEN = 64 +_MAX_DESC_LEN = 1024 + + +@dataclass +class SkillFile: + """Supporting file bundled with a skill (relative path + content).""" + + rel_path: Path + content: str + + +@dataclass +class SkillDraft: + """Validated skill content ready to be written to disk.""" + + name: str + description: str + body: str + files: list[SkillFile] = field(default_factory=list) + + @property + def skill_md_text(self) -> str: + """Assemble the final SKILL.md text (frontmatter + body).""" + body_norm = self.body.rstrip() + "\n" + return f"---\nname: {self.name}\ndescription: {self.description}\n---\n\n{body_norm}" + + +def validate_skill_name(raw: str) -> tuple[str | None, str | None]: + """Return ``(normalized_name, error)``. Either side may be None.""" + name = (raw or "").strip() if isinstance(raw, str) else "" + if not name: + return None, "skill_name is required" + if not _SKILL_NAME_RE.match(name): + return None, f"skill_name '{name}' must match [a-z0-9-] pattern" + if name.startswith("-") or name.endswith("-") or "--" in name: + return None, f"skill_name '{name}' has leading/trailing/consecutive hyphens" + if len(name) > _MAX_NAME_LEN: + return None, f"skill_name '{name}' exceeds {_MAX_NAME_LEN} chars" + return name, None + + +def validate_description(raw: str) -> tuple[str | None, str | None]: + desc = (raw or "").strip() if isinstance(raw, str) else "" + if not desc: + return None, "skill_description is required" + if len(desc) > _MAX_DESC_LEN: + return None, f"skill_description must be 1–{_MAX_DESC_LEN} chars" + # Frontmatter descriptions are line-oriented — the parser reads one value. + if "\n" in desc or "\r" in desc: + return None, "skill_description must be a single line (no newlines)" + return desc, None + + +def validate_files(raw: list[dict] | None) -> tuple[list[SkillFile] | None, str | None]: + if not raw: + return [], None + if not isinstance(raw, list): + return None, "skill_files must be an array" + out: list[SkillFile] = [] + for entry in raw: + if not isinstance(entry, dict): + return None, "each skill_files entry must be an object with 'path' and 'content'" + rel_raw = entry.get("path") + content = entry.get("content") + if not isinstance(rel_raw, str) or not rel_raw.strip(): + return None, "skill_files entry missing non-empty 'path'" + if not isinstance(content, str): + return None, f"skill_files entry '{rel_raw}' missing string 'content'" + rel_stripped = rel_raw.strip() + # Allow './foo' but reject '/foo' — relativizing absolute paths silently + # has bitten other tools; make the intent loud instead. + if rel_stripped.startswith("./"): + rel_stripped = rel_stripped[2:] + rel_path = Path(rel_stripped) + if rel_stripped.startswith("/") or rel_path.is_absolute() or ".." in rel_path.parts: + return None, f"skill_files path '{rel_raw}' must be relative and inside the skill folder" + if rel_path.as_posix() == "SKILL.md": + return None, "skill_files must not contain SKILL.md — pass skill_body instead" + out.append(SkillFile(rel_path=rel_path, content=content)) + return out, None + + +def build_draft( + *, + skill_name: str, + skill_description: str, + skill_body: str, + skill_files: list[dict] | None = None, +) -> tuple[SkillDraft | None, str | None]: + """Validate all inputs and return an immutable draft ready for writing.""" + name, err = validate_skill_name(skill_name) + if err or name is None: + return None, err + desc, err = validate_description(skill_description) + if err or desc is None: + return None, err + body = skill_body if isinstance(skill_body, str) else "" + if not body.strip(): + return None, ( + "skill_body is required — the operational procedure the " + "colony worker needs to run this job unattended" + ) + files, err = validate_files(skill_files) + if err or files is None: + return None, err + return SkillDraft(name=name, description=desc, body=body, files=list(files)), None + + +def write_skill( + draft: SkillDraft, + *, + target_root: Path, + replace_existing: bool = True, +) -> tuple[Path | None, str | None, bool]: + """Write the draft under ``target_root/{draft.name}/``. + + ``target_root`` is the parent scope dir (e.g. + ``~/.hive/agents/queens/{id}/skills`` or + ``{colony_dir}/.hive/skills``). The function creates it if needed. + + Returns ``(installed_path, error, replaced)``. On success ``error`` is + ``None``; on failure ``installed_path`` is ``None`` and the target is + left as it was before the call (best-effort). + + When ``replace_existing=False`` and the target dir already exists, + the write is refused with a non-fatal error (caller decides whether + to surface it as a 409 or a warning). + """ + try: + target_root.mkdir(parents=True, exist_ok=True) + except OSError as e: + return None, f"failed to create skills root: {e}", False + + target = target_root / draft.name + replaced = False + try: + if target.exists(): + if not replace_existing: + return None, f"skill '{draft.name}' already exists", False + # Remove the old dir outright so stale files from a prior + # version don't linger alongside the new ones. + replaced = True + shutil.rmtree(target) + target.mkdir(parents=True, exist_ok=False) + (target / "SKILL.md").write_text(draft.skill_md_text, encoding="utf-8") + for sf in draft.files: + full_path = target / sf.rel_path + full_path.parent.mkdir(parents=True, exist_ok=True) + full_path.write_text(sf.content, encoding="utf-8") + except OSError as e: + return None, f"failed to write skill folder {target}: {e}", replaced + return target, None, replaced + + +def remove_skill(target_root: Path, skill_name: str) -> tuple[bool, str | None]: + """Rm-tree the skill directory under ``target_root/{skill_name}/``. + + Returns ``(removed, error)``. ``removed=False, error=None`` means + the directory didn't exist (idempotent). Name is validated on the + way in so an attacker with UI access can't traverse out of the + scope root. + """ + name, err = validate_skill_name(skill_name) + if err or name is None: + return False, err + target = target_root / name + if not target.exists(): + return False, None + try: + shutil.rmtree(target) + except OSError as e: + return False, f"failed to remove skill folder {target}: {e}" + return True, None diff --git a/core/framework/skills/discovery.py b/core/framework/skills/discovery.py index cd0ab6eb..04b1bc3f 100644 --- a/core/framework/skills/discovery.py +++ b/core/framework/skills/discovery.py @@ -7,7 +7,7 @@ locations. Resolves name collisions deterministically. from __future__ import annotations import logging -from dataclasses import dataclass +from dataclasses import dataclass, field from pathlib import Path from framework.skills.parser import ParsedSkill, parse_skill_md @@ -33,13 +33,33 @@ _SKIP_DIRS = frozenset( _SCOPE_PRIORITY = { "framework": 0, "user": 1, - "project": 2, + "queen_ui": 2, + "colony_ui": 3, + "project": 4, } # Within the same scope, Hive-specific paths override cross-client paths. # We encode this by scanning cross-client first, then Hive-specific (later wins). +@dataclass +class ExtraScope: + """Additional scope dir to scan beyond the standard five. + + Used by :class:`framework.skills.manager.SkillsManager` to surface + per-queen (``queen_ui``) and per-colony (``colony_ui``) skill + directories created through the UI. The ``label`` feeds + :attr:`ParsedSkill.source_scope` so downstream consumers (trust + gate, UI provenance resolver) can distinguish scope origins. + """ + + directory: Path + label: str + # Kept for forward-compat with the priority table; discovery itself + # relies on scan order for last-wins resolution. + priority: int = 0 + + @dataclass class DiscoveryConfig: """Configuration for skill discovery.""" @@ -49,6 +69,10 @@ class DiscoveryConfig: skip_framework_scope: bool = False max_depth: int = 4 max_dirs: int = 2000 + # Additional scope dirs scanned between user and project scopes, + # in the order they are provided. Use ``ExtraScope`` to tag each + # with its logical label (``queen_ui`` / ``colony_ui``). + extra_scopes: list[ExtraScope] = field(default_factory=list) class SkillDiscovery: @@ -105,6 +129,13 @@ class SkillDiscovery: self._scanned_dirs.append(user_hive) all_skills.extend(self._scan_scope(user_hive, "user")) + # Extra scopes (queen_ui / colony_ui), scanned between user and project + # so colony overrides beat queen overrides, and both beat user-scope. + for extra in self._config.extra_scopes: + if extra.directory.is_dir(): + self._scanned_dirs.append(extra.directory) + all_skills.extend(self._scan_scope(extra.directory, extra.label)) + # Project scope (highest precedence) if self._config.project_root: root = self._config.project_root diff --git a/core/framework/skills/manager.py b/core/framework/skills/manager.py index 54249cab..521246b7 100644 --- a/core/framework/skills/manager.py +++ b/core/framework/skills/manager.py @@ -23,6 +23,7 @@ Typical usage — **bare** (exported agents, SDK users):: from __future__ import annotations +import asyncio import logging from dataclasses import dataclass, field from pathlib import Path @@ -44,6 +45,18 @@ class SkillsManagerConfig: even when ``project_root`` is set. interactive: Whether trust gating can prompt the user interactively. When ``False``, untrusted project skills are silently skipped. + queen_id: Optional queen identifier. When set, enables the + ``queen_ui`` scope and per-queen override file. + queen_overrides_path: Path to + ``~/.hive/agents/queens/{queen_id}/skills_overrides.json``. + When set, the store is loaded and its entries override + discovery results (disable skills, record provenance). + colony_name: Optional colony identifier; mirrors ``queen_id`` for + the ``colony_ui`` scope. + colony_overrides_path: Per-colony override file path. + extra_scope_dirs: Extra scope dirs scanned between user and + project scopes. Typically populated by the caller with the + queen/colony UI skill directories. """ skills_config: SkillsConfig = field(default_factory=SkillsConfig) @@ -51,6 +64,15 @@ class SkillsManagerConfig: skip_community_discovery: bool = False interactive: bool = True + # Override support + queen_id: str | None = None + queen_overrides_path: Path | None = None + colony_name: str | None = None + colony_overrides_path: Path | None = None + # Typed at the call site as ``list[ExtraScope]`` — not imported here + # to keep this module free of discovery-layer dependencies. + extra_scope_dirs: list = field(default_factory=list) + class SkillsManager: """Unified skill lifecycle: discovery → loading → prompt renderation. @@ -65,13 +87,21 @@ class SkillsManager: self._config = config or SkillsManagerConfig() self._loaded = False self._catalog: object = None # SkillCatalog, set after load() + self._all_skills: list = [] # list[ParsedSkill], pre-override-filter self._catalog_prompt: str = "" self._protocols_prompt: str = "" self._allowlisted_dirs: list[str] = [] self._default_mgr: object = None # DefaultSkillManager, set after load() + # Override stores (loaded lazily in _do_load). Queen-scope and + # colony-scope are read together; colony entries win on collision. + self._queen_overrides: object = None # SkillOverrideStore | None + self._colony_overrides: object = None # SkillOverrideStore | None # Hot-reload state self._watched_dirs: list[str] = [] + self._watched_files: list[str] = [] self._watcher_task: object = None # asyncio.Task, set by start_watching() + # Serializes in-process mutations (HTTP handlers + create_colony). + self._mutation_lock = asyncio.Lock() # ------------------------------------------------------------------ # Factory for backwards-compat bridge @@ -119,6 +149,7 @@ class SkillsManager: from framework.skills.catalog import SkillCatalog from framework.skills.defaults import DefaultSkillManager from framework.skills.discovery import DiscoveryConfig, SkillDiscovery + from framework.skills.overrides import SkillOverrideStore skills_config = self._config.skills_config @@ -128,12 +159,13 @@ class SkillsManager: DiscoveryConfig( project_root=self._config.project_root, skip_framework_scope=False, + extra_scopes=list(self._config.extra_scope_dirs or []), ) ) discovered = discovery.discover() self._watched_dirs = discovery.scanned_directories - # Trust-gate project-scope skills (AS-13) + # Trust-gate project-scope skills (AS-13). UI scopes bypass. if self._config.project_root is not None and not self._config.skip_community_discovery: from framework.skills.trust import TrustGate @@ -141,6 +173,33 @@ class SkillsManager: discovered, project_dir=self._config.project_root ) + # 1b. Load per-scope override stores. Missing files → empty stores. + queen_store = None + if self._config.queen_overrides_path is not None: + queen_store = SkillOverrideStore.load( + self._config.queen_overrides_path, + scope_label=f"queen:{self._config.queen_id or ''}", + ) + colony_store = None + if self._config.colony_overrides_path is not None: + colony_store = SkillOverrideStore.load( + self._config.colony_overrides_path, + scope_label=f"colony:{self._config.colony_name or ''}", + ) + self._queen_overrides = queen_store + self._colony_overrides = colony_store + self._watched_files = [ + str(p) + for p in (self._config.queen_overrides_path, self._config.colony_overrides_path) + if p is not None + ] + + # 1c. Apply override filtering. Colony entries take precedence over + # queen entries on name collision; the store's ``is_disabled`` keeps + # the resolution rule in one place. + self._all_skills = list(discovered) + discovered = self._apply_overrides(discovered, skills_config, queen_store, colony_store) + catalog = SkillCatalog(discovered) self._catalog = catalog self._allowlisted_dirs = catalog.allowlisted_dirs @@ -174,6 +233,95 @@ class SkillsManager: len(catalog_prompt), ) + # ------------------------------------------------------------------ + # Override application + # ------------------------------------------------------------------ + + @staticmethod + def _apply_overrides( + discovered: list, + skills_config: SkillsConfig, + queen_store: object, + colony_store: object, + ) -> list: + """Filter ``discovered`` per the queen + colony override stores. + + Resolution rule (mirrors the plan's schema): + 1. Tombstoned names (``deleted_ui_skills``) drop out. + 2. An explicit ``enabled=False`` override drops the skill. + 3. An explicit ``enabled=True`` override keeps it (wins over + ``all_defaults_disabled`` for framework defaults). + 4. Otherwise the skill inherits :meth:`SkillsConfig.is_default_enabled`. + """ + from framework.skills.overrides import SkillOverrideStore + + stores: list[SkillOverrideStore] = [s for s in (queen_store, colony_store) if s is not None] + if not stores: + return discovered + + tombstones: set[str] = set() + for store in stores: + tombstones |= set(store.deleted_ui_skills) + + out = [] + for skill in discovered: + if skill.name in tombstones: + continue + # Check colony first so colony overrides win over queen's. + explicit: bool | None = None + master_disabled = False + for store in reversed(stores): # colony, then queen + entry = store.get(skill.name) + if entry is not None and entry.enabled is not None: + explicit = entry.enabled + break + if store.all_defaults_disabled: + master_disabled = True + if explicit is False: + continue + if explicit is True: + out.append(skill) + continue + # No explicit entry — master switch takes effect against framework defaults. + default_enabled = skills_config.is_default_enabled(skill.name) + if master_disabled and default_enabled and skill.source_scope == "framework": + continue + if default_enabled: + out.append(skill) + return out + + # ------------------------------------------------------------------ + # Override accessors + # ------------------------------------------------------------------ + + @property + def queen_overrides(self) -> object: + """The queen-scope :class:`SkillOverrideStore` or ``None``.""" + return self._queen_overrides + + @property + def colony_overrides(self) -> object: + """The colony-scope :class:`SkillOverrideStore` or ``None``.""" + return self._colony_overrides + + @property + def mutation_lock(self) -> asyncio.Lock: + """Serializes in-process override mutations (routes + queen tools).""" + return self._mutation_lock + + def reload(self) -> None: + """Re-run discovery and rebuild cached prompts. Public wrapper for ``_reload``.""" + self._reload() + + def enumerate_skills_with_source(self) -> list: + """Return every discovered skill, including ones disabled by overrides. + + The UI relies on this: a disabled framework skill needs to render + in the list so the user can toggle it back on. The post-filter + catalog omits those entries. + """ + return list(self._all_skills) + # ------------------------------------------------------------------ # Hot-reload: watch skill directories for SKILL.md changes. # ------------------------------------------------------------------ @@ -181,14 +329,14 @@ class SkillsManager: async def start_watching(self) -> None: """Start a background task watching skill directories for changes. - When a ``SKILL.md`` file is added/modified/removed, the cached - ``skills_catalog_prompt`` is rebuilt. The next node iteration picks - up the new prompt automatically via the ``dynamic_prompt_provider``. + Triggers a reload when any ``SKILL.md`` changes or an override + JSON file is modified. The next node iteration picks up the new + prompt via the ``dynamic_prompt_provider`` / per-worker + ``dynamic_skills_catalog_provider``. - Silently no-ops when ``watchfiles`` is not installed or when no - directories are being watched (e.g. bare mode, no project_root). + Silently no-ops when ``watchfiles`` is not installed or there + are no paths to watch. """ - import asyncio try: import watchfiles # noqa: F401 -- optional dep check @@ -196,7 +344,7 @@ class SkillsManager: logger.debug("watchfiles not installed; skill hot-reload disabled") return - if not self._watched_dirs: + if not self._watched_dirs and not self._watched_files: logger.debug("No skill directories to watch; hot-reload skipped") return @@ -208,14 +356,13 @@ class SkillsManager: name="skills-hot-reload", ) logger.info( - "Skill hot-reload enabled (watching %d directories)", + "Skill hot-reload enabled (watching %d dirs, %d override files)", len(self._watched_dirs), + len(self._watched_files), ) async def stop_watching(self) -> None: """Cancel the background watcher task (if running).""" - import asyncio - task = self._watcher_task if task is None: return @@ -228,22 +375,35 @@ class SkillsManager: pass async def _watch_loop(self) -> None: - """Background coroutine that watches SKILL.md files and triggers reload.""" - import asyncio - + """Watch SKILL.md + override JSON files and trigger reload on change.""" import watchfiles def _filter(_change: object, path: str) -> bool: - return path.endswith("SKILL.md") + return path.endswith("SKILL.md") or path.endswith("skills_overrides.json") + + # watchfiles accepts a mix of dirs and files; file watches survive + # a tmp+rename (the containing dir sees the event). + watch_targets = list(self._watched_dirs) + for f in self._watched_files: + # watchfiles needs the parent dir for file-level events to fire + # reliably through atomic replace; adding the file path directly + # works on Linux/macOS inotify/FSEvents but a dir watch is + # belt-and-braces. + parent = str(Path(f).parent) + if parent not in watch_targets: + watch_targets.append(parent) + + if not watch_targets: + return try: async for changes in watchfiles.awatch( - *self._watched_dirs, + *watch_targets, watch_filter=_filter, debounce=1000, ): paths = [p for _, p in changes] - logger.info("SKILL.md changes detected: %s", paths) + logger.info("Skill state changes detected: %s", paths) try: self._reload() except Exception: diff --git a/core/framework/skills/overrides.py b/core/framework/skills/overrides.py new file mode 100644 index 00000000..e8a6e69b --- /dev/null +++ b/core/framework/skills/overrides.py @@ -0,0 +1,241 @@ +"""Per-scope skill override store. + +Sits between :mod:`framework.skills.discovery` and +:class:`framework.skills.catalog.SkillCatalog`: records the user's +per-queen and per-colony decisions about which skills are enabled, +who created them (provenance), and any parameter tweaks. + +Two well-known paths back this module: + +* Queen scope: ``~/.hive/agents/queens/{queen_id}/skills_overrides.json`` +* Colony scope: ``~/.hive/colonies/{colony_name}/skills_overrides.json`` + +The schema is intentionally small; see :class:`SkillOverrideStore` for +the JSON shape. Atomic writes mirror +:class:`framework.skills.trust.TrustedRepoStore` (tmp + rename). +""" + +from __future__ import annotations + +import json +import logging +from dataclasses import dataclass, field +from datetime import UTC, datetime +from enum import StrEnum +from pathlib import Path +from typing import Any + +logger = logging.getLogger(__name__) + +_SCHEMA_VERSION = 1 + + +class Provenance(StrEnum): + """Where a skill came from. + + The override store is the authoritative provenance ledger for anything + the UI or the queen tools touched. Framework / user-dropped / + project-dropped skills don't need an entry unless they've been + explicitly configured. + """ + + FRAMEWORK = "framework" + USER_DROPPED = "user_dropped" + USER_UI_CREATED = "user_ui_created" + QUEEN_CREATED = "queen_created" + LEARNED_RUNTIME = "learned_runtime" + PROJECT_DROPPED = "project_dropped" + # Catch-all for skills with no recorded authorship: legacy rows from + # before the override store existed, PATCHes that precede any CREATE, + # etc. Keeps the ledger honest rather than forcing a guess. + OTHER = "other" + + +@dataclass +class OverrideEntry: + """Per-skill override record inside a scope's store.""" + + enabled: bool | None = None + provenance: Provenance = Provenance.FRAMEWORK + trust: str | None = None + param_overrides: dict[str, Any] = field(default_factory=dict) + notes: str | None = None + created_at: datetime | None = None + created_by: str | None = None + + def to_dict(self) -> dict[str, Any]: + out: dict[str, Any] = {"provenance": str(self.provenance)} + if self.enabled is not None: + out["enabled"] = bool(self.enabled) + if self.trust is not None: + out["trust"] = self.trust + if self.param_overrides: + out["param_overrides"] = dict(self.param_overrides) + if self.notes is not None: + out["notes"] = self.notes + if self.created_at is not None: + out["created_at"] = self.created_at.isoformat() + if self.created_by is not None: + out["created_by"] = self.created_by + return out + + @classmethod + def from_dict(cls, raw: dict[str, Any]) -> OverrideEntry: + created_at_raw = raw.get("created_at") + created_at: datetime | None = None + if isinstance(created_at_raw, str): + try: + created_at = datetime.fromisoformat(created_at_raw) + except ValueError: + created_at = None + provenance_raw = raw.get("provenance") or Provenance.FRAMEWORK + try: + provenance = Provenance(provenance_raw) + except ValueError: + logger.warning("override: unknown provenance %r; defaulting to framework", provenance_raw) + provenance = Provenance.FRAMEWORK + enabled = raw.get("enabled") + return cls( + enabled=enabled if isinstance(enabled, bool) else None, + provenance=provenance, + trust=raw.get("trust") if isinstance(raw.get("trust"), str) else None, + param_overrides=dict(raw.get("param_overrides") or {}), + notes=raw.get("notes") if isinstance(raw.get("notes"), str) else None, + created_at=created_at, + created_by=raw.get("created_by") if isinstance(raw.get("created_by"), str) else None, + ) + + +@dataclass +class SkillOverrideStore: + """Persistent per-scope override file. + + The file is created lazily on first save; a missing file behaves like + an empty store (all skills inherit defaults, no metadata recorded). + """ + + path: Path + scope_label: str = "" + version: int = _SCHEMA_VERSION + all_defaults_disabled: bool = False + overrides: dict[str, OverrideEntry] = field(default_factory=dict) + deleted_ui_skills: set[str] = field(default_factory=set) + + # ------------------------------------------------------------------ + # Factory + # ------------------------------------------------------------------ + + @classmethod + def load(cls, path: Path, scope_label: str = "") -> SkillOverrideStore: + """Load the store from disk; return an empty store if the file is absent. + + Permissive on parse errors: logs and returns an empty store rather + than raising, so a corrupted file never takes down skill loading. + """ + store = cls(path=path, scope_label=scope_label) + try: + raw = json.loads(path.read_text(encoding="utf-8")) + except FileNotFoundError: + return store + except Exception as exc: + logger.warning("override: failed to read %s (%s); starting empty", path, exc) + return store + if not isinstance(raw, dict): + logger.warning("override: %s is not an object; starting empty", path) + return store + + store.version = int(raw.get("version", _SCHEMA_VERSION)) + store.all_defaults_disabled = bool(raw.get("all_defaults_disabled", False)) + raw_overrides = raw.get("overrides") or {} + if isinstance(raw_overrides, dict): + for name, entry_raw in raw_overrides.items(): + if not isinstance(name, str) or not isinstance(entry_raw, dict): + continue + store.overrides[name] = OverrideEntry.from_dict(entry_raw) + deleted = raw.get("deleted_ui_skills") or [] + if isinstance(deleted, list): + store.deleted_ui_skills = {s for s in deleted if isinstance(s, str)} + return store + + # ------------------------------------------------------------------ + # Mutations + # ------------------------------------------------------------------ + + def upsert(self, skill_name: str, entry: OverrideEntry) -> None: + """Insert or replace a skill's override entry.""" + self.overrides[skill_name] = entry + # If we're explicitly managing this skill again, lift any tombstone. + self.deleted_ui_skills.discard(skill_name) + + def set_enabled(self, skill_name: str, enabled: bool, *, provenance: Provenance | None = None) -> None: + """Convenience: toggle enabled without rewriting other fields.""" + existing = self.overrides.get(skill_name) + if existing is None: + existing = OverrideEntry( + enabled=enabled, + provenance=provenance or Provenance.FRAMEWORK, + ) + else: + existing.enabled = enabled + if provenance is not None: + existing.provenance = provenance + self.overrides[skill_name] = existing + + def remove(self, skill_name: str, *, tombstone: bool = True) -> None: + """Drop a skill's override entry; optionally leave a tombstone. + + Tombstones matter for UI-created skills: if the user deletes a + queen-scope skill via the UI, we rm-tree its directory, but the + file watcher might lag or a background process might have an + open handle. A tombstone ensures the loader treats the skill as + gone even if a stale SKILL.md lingers. + """ + self.overrides.pop(skill_name, None) + if tombstone: + self.deleted_ui_skills.add(skill_name) + + def is_disabled(self, skill_name: str, *, default_enabled: bool) -> bool: + """Return True when this scope's override force-disables the skill.""" + if self.all_defaults_disabled and default_enabled: + # Caller says "default enabled"; master switch flips it off unless + # an explicit enabled=True override re-enables. + entry = self.overrides.get(skill_name) + if entry is not None and entry.enabled is True: + return False + return True + entry = self.overrides.get(skill_name) + if entry is None: + return not default_enabled + if entry.enabled is None: + return not default_enabled + return not entry.enabled + + def effective_enabled(self, skill_name: str, *, default_enabled: bool) -> bool: + """The inverse of :meth:`is_disabled`, for readability at call sites.""" + return not self.is_disabled(skill_name, default_enabled=default_enabled) + + def get(self, skill_name: str) -> OverrideEntry | None: + return self.overrides.get(skill_name) + + # ------------------------------------------------------------------ + # Persistence + # ------------------------------------------------------------------ + + def save(self) -> None: + """Atomic write: tmp + rename. Creates the parent dir if needed.""" + self.path.parent.mkdir(parents=True, exist_ok=True) + payload: dict[str, Any] = { + "version": self.version, + "all_defaults_disabled": self.all_defaults_disabled, + "overrides": {name: entry.to_dict() for name, entry in sorted(self.overrides.items())}, + } + if self.deleted_ui_skills: + payload["deleted_ui_skills"] = sorted(self.deleted_ui_skills) + tmp = self.path.with_suffix(self.path.suffix + ".tmp") + tmp.write_text(json.dumps(payload, indent=2), encoding="utf-8") + tmp.replace(self.path) + + +def utc_now() -> datetime: + """Single source of truth for override timestamps.""" + return datetime.now(tz=UTC) diff --git a/core/framework/skills/trust.py b/core/framework/skills/trust.py index e18ae637..b282f487 100644 --- a/core/framework/skills/trust.py +++ b/core/framework/skills/trust.py @@ -318,13 +318,18 @@ class TrustGate: ) -> list[ParsedSkill]: """Return the subset of skills that are trusted for loading. - - Framework and user-scope skills: always included. + - Framework, user, queen_ui, and colony_ui scopes: always included. + (UI-created skills are authenticated by the user creating them + through the authenticated UI — they do not go through the + trusted_repos.json flow.) - Project-scope skills: classified; consent prompt shown if untrusted. """ import os - # Separate project skills from always-trusted scopes - always_trusted = [s for s in skills if s.source_scope != "project"] + # UI-authored scopes bypass the trust gate — they're implicitly + # trusted because the user authored them through the UI. + _bypass_scopes = {"framework", "user", "queen_ui", "colony_ui"} + always_trusted = [s for s in skills if s.source_scope in _bypass_scopes] project_skills = [s for s in skills if s.source_scope == "project"] if not project_skills: diff --git a/core/framework/tools/queen_lifecycle_tools.py b/core/framework/tools/queen_lifecycle_tools.py index 86e2cf9d..0063f290 100644 --- a/core/framework/tools/queen_lifecycle_tools.py +++ b/core/framework/tools/queen_lifecycle_tools.py @@ -1425,124 +1425,8 @@ def register_queen_lifecycle_tools( # re-runs idempotent. import re as _re - import shutil as _shutil _COLONY_NAME_RE = _re.compile(r"^[a-z0-9_]+$") - _SKILL_NAME_RE = _re.compile(r"^[a-z0-9-]+$") - - def _materialize_skill_folder( - *, - skill_name: str, - skill_description: str, - skill_body: str, - skill_files: list[dict] | None, - colony_dir: Path, - ) -> tuple[Path | None, str | None, bool]: - """Write a skill folder under ``{colony_dir}/.hive/skills/{name}/`` from inline content. - - The skill is scoped to a single colony: ``SkillDiscovery`` scans - ``{project_root}/.hive/skills/`` as project-scope, and the - colony's worker uses ``project_root = colony_dir`` — so only - that colony's workers see it, not every colony on the machine. - We deliberately avoid ``~/.hive/skills/`` here because that - directory is scanned as user scope and leaks into every agent. - - Returns ``(installed_path, error, replaced)``. On success - ``error`` is ``None`` and ``installed_path`` is the final - location; ``replaced`` is ``True`` when an existing skill with - the same name was overwritten. On failure ``installed_path`` is - ``None``, ``error`` is a human-readable reason, and - ``replaced`` is ``False``. - """ - name = (skill_name or "").strip() if isinstance(skill_name, str) else "" - if not name: - return None, "skill_name is required", False - if not _SKILL_NAME_RE.match(name): - return None, (f"skill_name '{name}' must match [a-z0-9-] pattern"), False - if name.startswith("-") or name.endswith("-") or "--" in name: - return None, (f"skill_name '{name}' has leading/trailing/consecutive hyphens"), False - if len(name) > 64: - return None, f"skill_name '{name}' exceeds 64 chars", False - - desc = (skill_description or "").strip() if isinstance(skill_description, str) else "" - if not desc: - return None, "skill_description is required", False - if len(desc) > 1024: - return None, "skill_description must be 1–1024 chars", False - # Frontmatter descriptions must stay on a single line because - # our frontmatter parser is line-oriented and the downstream - # skill loader expects ``description:`` to resolve to one value. - if "\n" in desc or "\r" in desc: - return None, "skill_description must be a single line (no newlines)", False - - body = skill_body if isinstance(skill_body, str) else "" - if not body.strip(): - return ( - None, - ( - "skill_body is required — the operational procedure the " - "colony worker needs to run this job unattended" - ), - False, - ) - - # Optional supporting files (scripts/, references/, assets/…). - # Each entry: {"path": "", "content": ""}. - normalized_files: list[tuple[Path, str]] = [] - if skill_files: - if not isinstance(skill_files, list): - return None, "skill_files must be an array", False - for entry in skill_files: - if not isinstance(entry, dict): - return None, "each skill_files entry must be an object with 'path' and 'content'", False - rel_raw = entry.get("path") - content = entry.get("content") - if not isinstance(rel_raw, str) or not rel_raw.strip(): - return None, "skill_files entry missing non-empty 'path'", False - if not isinstance(content, str): - return None, f"skill_files entry '{rel_raw}' missing string 'content'", False - rel_stripped = rel_raw.strip() - # Normalize a leading ``./`` but do NOT strip bare ``/`` — - # an absolute path should be rejected, not silently relativized. - if rel_stripped.startswith("./"): - rel_stripped = rel_stripped[2:] - rel_path = Path(rel_stripped) - if rel_stripped.startswith("/") or rel_path.is_absolute() or ".." in rel_path.parts: - return None, (f"skill_files path '{rel_raw}' must be relative and inside the skill folder"), False - if rel_path.as_posix() == "SKILL.md": - return None, ("skill_files must not contain SKILL.md — pass skill_body instead"), False - normalized_files.append((rel_path, content)) - - target_root = colony_dir / ".hive" / "skills" - target = target_root / name - try: - target_root.mkdir(parents=True, exist_ok=True) - except OSError as e: - return None, f"failed to create skills root: {e}", False - - replaced = False - try: - if target.exists(): - # Queen is re-creating a skill under the same name — - # her latest content wins. rmtree first so stale files - # from a prior version don't linger alongside the new - # ones (copytree with dirs_exist_ok would merge them). - replaced = True - _shutil.rmtree(target) - target.mkdir(parents=True, exist_ok=False) - - body_norm = body.rstrip() + "\n" - skill_md_text = f"---\nname: {name}\ndescription: {desc}\n---\n\n{body_norm}" - (target / "SKILL.md").write_text(skill_md_text, encoding="utf-8") - - for rel_path, file_content in normalized_files: - full_path = target / rel_path - full_path.parent.mkdir(parents=True, exist_ok=True) - full_path.write_text(file_content, encoding="utf-8") - except OSError as e: - return None, f"failed to write skill folder {target}: {e}", False - - return target, None, replaced def _validate_triggers(raw: Any) -> tuple[list[dict] | None, str | None]: """Validate and normalize the ``triggers`` argument for create_colony. @@ -1688,17 +1572,26 @@ def register_queen_lifecycle_tools( except OSError as e: return json.dumps({"error": f"failed to create colony dir {colony_dir}: {e}"}) - installed_skill, skill_err, skill_replaced = _materialize_skill_folder( + # Validate + write via the shared authoring module so the HTTP + # routes and this tool stay in lockstep. + from framework.skills.authoring import build_draft, write_skill + from framework.skills.overrides import ( + OverrideEntry, + Provenance, + SkillOverrideStore, + utc_now, + ) + + draft, draft_err = build_draft( skill_name=skill_name, skill_description=skill_description, skill_body=skill_body, skill_files=skill_files, - colony_dir=colony_dir, ) - if skill_err is not None: + if draft_err is not None or draft is None: return json.dumps( { - "error": skill_err, + "error": draft_err or "invalid skill draft", "hint": ( "Provide skill_name (lowercase [a-z0-9-], ≤64 chars), " "skill_description (single line, 1–1024 chars), and " @@ -1711,6 +1604,40 @@ def register_queen_lifecycle_tools( } ) + installed_skill, write_err, skill_replaced = write_skill( + draft, + target_root=colony_dir / ".hive" / "skills", + replace_existing=True, + ) + if write_err is not None or installed_skill is None: + return json.dumps( + { + "error": write_err or "failed to write skill folder", + } + ) + + # Register the write in the colony's override store so the UI can + # edit/toggle it and :func:`SkillsManager._apply_overrides` carries + # the right provenance. + try: + overrides_path = colony_dir / "skills_overrides.json" + store = SkillOverrideStore.load(overrides_path, scope_label=f"colony:{cn}") + queen_id = getattr(session, "queen_name", None) or "unknown" + store.upsert( + draft.name, + OverrideEntry( + enabled=True, + provenance=Provenance.QUEEN_CREATED, + created_at=utc_now(), + created_by=f"queen:{queen_id}", + ), + ) + store.save() + except Exception: + # Registration is best-effort; discovery still surfaces the + # skill as project-scope even if the ledger fails to update. + logger.warning("create_colony: override registration failed", exc_info=True) + logger.info( "create_colony: materialized skill at %s (replaced=%s)", installed_skill, diff --git a/core/frontend/src/App.tsx b/core/frontend/src/App.tsx index 1f04e716..66319e4e 100644 --- a/core/frontend/src/App.tsx +++ b/core/frontend/src/App.tsx @@ -5,6 +5,7 @@ import ColonyChat from "./pages/colony-chat"; import QueenDM from "./pages/queen-dm"; import OrgChart from "./pages/org-chart"; import PromptLibrary from "./pages/prompt-library"; +import SkillsLibrary from "./pages/skills-library"; import ToolLibrary from "./pages/tool-library"; import CredentialsPage from "./pages/credentials"; import NotFound from "./pages/not-found"; @@ -17,6 +18,7 @@ function App() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/core/frontend/src/api/skills.ts b/core/frontend/src/api/skills.ts new file mode 100644 index 00000000..5df6905d --- /dev/null +++ b/core/frontend/src/api/skills.ts @@ -0,0 +1,153 @@ +import { api } from "./client"; + +export type SkillScopeKind = "queen" | "colony" | "user"; + +export type SkillProvenance = + | "framework" + | "user_dropped" + | "user_ui_created" + | "queen_created" + | "learned_runtime" + | "project_dropped" + | "other"; + +export interface SkillOwner { + type: "queen" | "colony"; + id: string; + name: string; +} + +export interface SkillRow { + name: string; + description: string; + source_scope: string; + provenance: SkillProvenance; + enabled: boolean; + editable: boolean; + deletable: boolean; + location: string; + base_dir?: string; + visibility: string[] | null; + trust: string | null; + created_at: string | null; + created_by: string | null; + notes: string | null; + param_overrides?: Record; + owner?: SkillOwner | null; + visible_to?: { queens: string[]; colonies: string[] }; + enabled_by_default?: boolean; +} + +export interface ScopeSkillsResponse { + queen_id?: string; + colony_name?: string; + all_defaults_disabled: boolean; + skills: SkillRow[]; + inherited_from_queen?: string[]; +} + +export interface AggregatedSkillsResponse { + skills: SkillRow[]; + queens: Array<{ id: string; name: string }>; + colonies: Array<{ name: string; queen_id: string | null }>; +} + +export interface SkillScopesResponse { + queens: Array<{ id: string; name: string }>; + colonies: Array<{ name: string; queen_id: string | null }>; +} + +export interface SkillDetailResponse { + name: string; + description: string; + source_scope: string; + location: string; + base_dir: string; + body: string; + visibility: string[] | null; +} + +export interface SkillCreatePayload { + name: string; + description: string; + body: string; + files?: Array<{ path: string; content: string }>; + enabled?: boolean; + notes?: string | null; + replace_existing?: boolean; +} + +export interface SkillPatchPayload { + enabled?: boolean; + param_overrides?: Record; + notes?: string | null; + all_defaults_disabled?: boolean; +} + +const scopePath = (scope: "queen" | "colony", targetId: string) => + scope === "queen" + ? `/queen/${encodeURIComponent(targetId)}/skills` + : `/colonies/${encodeURIComponent(targetId)}/skills`; + +export const skillsApi = { + // Aggregated library + listAll: () => api.get("/skills"), + listScopes: () => api.get("/skills/scopes"), + getDetail: (name: string) => + api.get(`/skills/${encodeURIComponent(name)}`), + + // Per-scope + listForQueen: (queenId: string) => + api.get(`/queen/${encodeURIComponent(queenId)}/skills`), + listForColony: (colonyName: string) => + api.get( + `/colonies/${encodeURIComponent(colonyName)}/skills`, + ), + + create: ( + scope: "queen" | "colony", + targetId: string, + payload: SkillCreatePayload, + ) => api.post(scopePath(scope, targetId), payload), + + patch: ( + scope: "queen" | "colony", + targetId: string, + skillName: string, + payload: SkillPatchPayload, + ) => + api.patch<{ name: string; enabled: boolean | null; ok: boolean }>( + `${scopePath(scope, targetId)}/${encodeURIComponent(skillName)}`, + payload, + ), + + putBody: ( + scope: "queen" | "colony", + targetId: string, + skillName: string, + payload: { body: string; description?: string }, + ) => + api.put<{ name: string; installed_path: string }>( + `${scopePath(scope, targetId)}/${encodeURIComponent(skillName)}/body`, + payload, + ), + + remove: (scope: "queen" | "colony", targetId: string, skillName: string) => + api.delete<{ name: string; removed: boolean }>( + `${scopePath(scope, targetId)}/${encodeURIComponent(skillName)}`, + ), + + reload: (scope: "queen" | "colony", targetId: string) => + api.post<{ ok: boolean }>(`${scopePath(scope, targetId)}/reload`), + + // Multipart upload. File may be a SKILL.md or a .zip bundle. + upload: (formData: FormData) => + api.upload<{ + name: string; + installed_path: string; + replaced: boolean; + scope: SkillScopeKind; + target_id: string | null; + enabled: boolean; + }>("/skills/upload", formData), +}; diff --git a/core/frontend/src/components/Sidebar.tsx b/core/frontend/src/components/Sidebar.tsx index 55d47f70..65b9552e 100644 --- a/core/frontend/src/components/Sidebar.tsx +++ b/core/frontend/src/components/Sidebar.tsx @@ -13,6 +13,7 @@ import { Crown, Loader2, Wrench, + Library, } from "lucide-react"; import SidebarColonyItem from "./SidebarColonyItem"; import SidebarQueenItem from "./SidebarQueenItem"; @@ -166,6 +167,13 @@ export default function Sidebar() { Org Chart + + ); +} + +// --------------------------------------------------------------------------- +// Queens tab +// --------------------------------------------------------------------------- + +function QueensTab() { + const [queens, setQueens] = useState | null>( + null, + ); + const [selected, setSelected] = useState(null); + const [error, setError] = useState(null); + + useEffect(() => { + queensApi + .list() + .then((r) => { + setQueens(r.queens); + if (r.queens.length > 0) setSelected((prev) => prev ?? r.queens[0].id); + }) + .catch((e: Error) => setError(e.message || "Failed to load queens")); + }, []); + + if (error) return ; + if (queens === null) return ; + if (queens.length === 0) return ; + + return ( +
+ + {queens.map((q) => ( + setSelected(q.id)} + primary={q.name} + secondary={q.title} + /> + ))} + +
+ {selected ? ( + <> + {(() => { + const q = queens.find((x) => x.id === selected); + return q ? ( +
+

{q.name}

+

{q.title}

+
+ ) : null; + })()} + + + ) : ( + + )} +
+
+ ); +} + +// --------------------------------------------------------------------------- +// Colonies tab +// --------------------------------------------------------------------------- + +function ColoniesTab() { + const [colonies, setColonies] = useState(null); + const [selected, setSelected] = useState(null); + const [error, setError] = useState(null); + + useEffect(() => { + coloniesApi + .list() + .then((r) => { + setColonies(r.colonies); + if (r.colonies.length > 0) setSelected((prev) => prev ?? r.colonies[0].name); + }) + .catch((e: Error) => setError(e.message || "Failed to load colonies")); + }, []); + + const sorted = useMemo(() => { + if (!colonies) return null; + return [...colonies].sort((a, b) => a.name.localeCompare(b.name)); + }, [colonies]); + + if (error) return ; + if (sorted === null) return ; + if (sorted.length === 0) + return ( + + ); + + return ( +
+ + {sorted.map((c) => ( + setSelected(c.name)} + primary={slugToDisplayName(c.name)} + secondary={c.queen_name ? `@${c.queen_name}` : undefined} + tertiary={c.name} + /> + ))} + +
+ {selected ? ( + <> +
+

+ {slugToDisplayName(selected)} +

+

{selected}

+
+ + + ) : ( + + )} +
+
+ ); +} + +// --------------------------------------------------------------------------- +// Per-scope section (shared body for Queens + Colonies tabs) +// --------------------------------------------------------------------------- + +function SkillsPerScopeSection({ + scopeKind, + targetId, +}: { + scopeKind: "queen" | "colony"; + targetId: string; +}) { + const [resp, setResp] = useState(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [search, setSearch] = useState(""); + const [createOpen, setCreateOpen] = useState(false); + const [detailName, setDetailName] = useState(null); + + const reload = useCallback(async () => { + setLoading(true); + setError(null); + try { + const r = + scopeKind === "queen" + ? await skillsApi.listForQueen(targetId) + : await skillsApi.listForColony(targetId); + setResp(r); + } catch (e) { + setError(e instanceof ApiError ? e.body.error : String(e)); + } finally { + setLoading(false); + } + }, [scopeKind, targetId]); + + useEffect(() => { + reload(); + }, [reload]); + + const rows = resp?.skills ?? []; + const filtered = useMemo(() => { + const q = search.trim().toLowerCase(); + if (!q) return rows; + return rows.filter( + (r) => r.name.toLowerCase().includes(q) || r.description.toLowerCase().includes(q), + ); + }, [rows, search]); + + const toggle = async (row: SkillRow) => { + try { + await skillsApi.patch(scopeKind, targetId, row.name, { enabled: !row.enabled }); + await reload(); + } catch (e) { + setError(e instanceof ApiError ? e.body.error : String(e)); + } + }; + + const remove = async (row: SkillRow) => { + if (!window.confirm(`Delete skill '${row.name}'? This removes its files.`)) return; + try { + await skillsApi.remove(scopeKind, targetId, row.name); + await reload(); + } catch (e) { + setError(e instanceof ApiError ? e.body.error : String(e)); + } + }; + + return ( +
+
+
+ + setSearch(e.target.value)} + placeholder="Search by name or description" + className="w-full pl-9 pr-3 py-1.5 rounded-md border border-border/60 bg-muted/30 text-sm text-foreground placeholder:text-muted-foreground/50 focus:outline-none focus:ring-1 focus:ring-primary/40" + /> +
+
+ +
+ + {resp?.inherited_from_queen?.length ? ( +
+ Inherited from queen{resp.queen_id ? ` (${resp.queen_id})` : ""}:{" "} + {resp.inherited_from_queen.join(", ")} +
+ ) : null} + + {loading && } + {error && ( +
+ {error} +
+ )} + {!loading && filtered.length === 0 && ( +

No skills match your filter.

+ )} + +
+ {filtered.map((row) => ( + toggle(row)} + onOpen={() => setDetailName(row.name)} + onRemove={row.deletable ? () => remove(row) : undefined} + /> + ))} +
+ + setCreateOpen(false)} + onSaved={reload} + /> + setDetailName(null)} /> +
+ ); +} + +// --------------------------------------------------------------------------- +// Catalog tab +// --------------------------------------------------------------------------- + +function CatalogTab() { + const [resp, setResp] = useState(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [search, setSearch] = useState(""); + const [uploadOpen, setUploadOpen] = useState(false); + const [detailName, setDetailName] = useState(null); + + const reload = useCallback(async () => { + setLoading(true); + setError(null); + try { + setResp(await skillsApi.listAll()); + } catch (e) { + setError(e instanceof ApiError ? e.body.error : String(e)); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + reload(); + }, [reload]); + + const rows = resp?.skills ?? []; + const filtered = useMemo(() => { + const q = search.trim().toLowerCase(); + if (!q) return rows; + return rows.filter( + (r) => r.name.toLowerCase().includes(q) || r.description.toLowerCase().includes(q), + ); + }, [rows, search]); + + return ( +
+
+
+ + setSearch(e.target.value)} + placeholder="Search every skill on this machine" + className="w-full pl-9 pr-3 py-1.5 rounded-md border border-border/60 bg-muted/30 text-sm text-foreground placeholder:text-muted-foreground/50 focus:outline-none focus:ring-1 focus:ring-primary/40" + /> +
+
+ +
+ + {loading && } + {error && ( +
+ {error} +
+ )} + {!loading && filtered.length === 0 && ( +

No skills match your filter.

+ )} + +
+ {filtered.map((row) => ( + setDetailName(row.name)} + // Catalog view is read-only for toggle/delete — all mutations + // happen in the scoped tabs. + /> + ))} +
+ + setUploadOpen(false)} + onUploaded={reload} + /> + setDetailName(null)} /> +
+ ); +} + +// --------------------------------------------------------------------------- +// Skill card (shared across all three tabs) +// --------------------------------------------------------------------------- + +function SkillCard({ + row, + onOpen, + onToggle, + onRemove, +}: { + row: SkillRow; + onOpen: () => void; + onToggle?: () => void; + onRemove?: () => void; +}) { + return ( +
+
+ {onToggle ? ( + + ) : ( +
+ {row.enabled ? ( + + ) : ( + + )} +
+ )} +
+ +
+ + {row.owner && ( + @{row.owner.id} + )} + {!row.editable && ( + + Read-only + + )} +
+
+ {onRemove && ( + + )} +
+

{row.description}

+ {row.visible_to && ( +

+ Visible on {row.visible_to.queens.length} queens, {row.visible_to.colonies.length}{" "} + colonies +

+ )} +
+ ); +} + +// --------------------------------------------------------------------------- +// Modals + drawer (shared) +// --------------------------------------------------------------------------- + +function CreateSkillModal({ + open, + scopeKind, + targetId, + onClose, + onSaved, +}: { + open: boolean; + scopeKind: "queen" | "colony"; + targetId: string; + onClose: () => void; + onSaved: () => void; +}) { + const [name, setName] = useState(""); + const [description, setDescription] = useState(""); + const [body, setBody] = useState(""); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(null); + + if (!open) return null; + + const submit = async () => { + setError(null); + if (!name.trim() || !description.trim() || !body.trim()) { + setError("Name, description, and body are required."); + return; + } + setSaving(true); + try { + await skillsApi.create(scopeKind, targetId, { + name: name.trim(), + description: description.trim(), + body, + enabled: true, + }); + setName(""); + setDescription(""); + setBody(""); + onSaved(); + onClose(); + } catch (e) { + setError(e instanceof ApiError ? e.body.error : String(e)); + } finally { + setSaving(false); + } + }; + + const label = scopeKind === "queen" ? `Queen: ${targetId}` : `Colony: ${targetId}`; + + return ( +
+
+
+
+
+

New Skill

+

Scope: {label}

+
+ +
+
+
+ + setName(e.target.value.toLowerCase())} + placeholder="e.g. vendor-api-protocol" + className="w-full bg-muted/30 border border-border/50 rounded-lg px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground/50 focus:outline-none focus:ring-1 focus:ring-primary/40" + /> +

+ Lowercase letters, digits, hyphens, dots. Max 64 chars. +

+
+
+ + setDescription(e.target.value)} + placeholder="One-line summary shown in the catalog picker" + className="w-full bg-muted/30 border border-border/50 rounded-lg px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground/50 focus:outline-none focus:ring-1 focus:ring-primary/40" + /> +
+
+ +