feat: skill library

This commit is contained in:
Richard Tang
2026-04-21 18:48:22 -07:00
parent 8a0ec070b8
commit 14f927996c
23 changed files with 3734 additions and 150 deletions
+7 -1
View File
@@ -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)
+8 -1
View File
@@ -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 "",
+7
View File
@@ -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 = ""
+123 -2
View File
@@ -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",
)
+4
View File
@@ -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
+2
View File
@@ -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 /
+26 -4
View File
@@ -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
File diff suppressed because it is too large Load Diff
+42
View File
@@ -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)
# ------------------------------------------------------------------
+204
View File
@@ -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
+33 -2
View File
@@ -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
+177 -17
View File
@@ -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:
+241
View File
@@ -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)
+8 -3
View File
@@ -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:
+47 -120
View File
@@ -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 11024 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": "<relative>", "content": "<text>"}.
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, 11024 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,
+2
View File
@@ -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() {
<Route path="/colony/:colonyId" element={<ColonyChat />} />
<Route path="/queen/:queenId" element={<QueenDM />} />
<Route path="/org-chart" element={<OrgChart />} />
<Route path="/skills-library" element={<SkillsLibrary />} />
<Route path="/prompt-library" element={<PromptLibrary />} />
<Route path="/tool-library" element={<ToolLibrary />} />
<Route path="/credentials" element={<CredentialsPage />} />
+153
View File
@@ -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<string, unknown>;
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<string, unknown>;
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<AggregatedSkillsResponse>("/skills"),
listScopes: () => api.get<SkillScopesResponse>("/skills/scopes"),
getDetail: (name: string) =>
api.get<SkillDetailResponse>(`/skills/${encodeURIComponent(name)}`),
// Per-scope
listForQueen: (queenId: string) =>
api.get<ScopeSkillsResponse>(`/queen/${encodeURIComponent(queenId)}/skills`),
listForColony: (colonyName: string) =>
api.get<ScopeSkillsResponse>(
`/colonies/${encodeURIComponent(colonyName)}/skills`,
),
create: (
scope: "queen" | "colony",
targetId: string,
payload: SkillCreatePayload,
) => api.post<SkillRow>(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),
};
+8
View File
@@ -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() {
<Network className="w-4 h-4" />
<span>Org Chart</span>
</button>
<button
onClick={() => navigate("/skills-library")}
className="flex items-center gap-2.5 px-3 py-1.5 rounded-md text-sm text-foreground/70 hover:bg-sidebar-item-hover hover:text-foreground transition-colors"
>
<Library className="w-4 h-4" />
<span>Skills Library</span>
</button>
<button
onClick={() => navigate("/prompt-library")}
className="flex items-center gap-2.5 px-3 py-1.5 rounded-md text-sm text-foreground/70 hover:bg-sidebar-item-hover hover:text-foreground transition-colors"
+979
View File
@@ -0,0 +1,979 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import {
Library,
Crown,
Network,
BookOpen,
Search,
Plus,
Upload,
X,
Trash2,
Lock,
AlertCircle,
Loader2,
CheckCircle2,
Circle,
} from "lucide-react";
import { queensApi } from "@/api/queens";
import { coloniesApi, type ColonySummary } from "@/api/colonies";
import { slugToDisplayName } from "@/lib/colony-registry";
import { ApiError } from "@/api/client";
import {
skillsApi,
type AggregatedSkillsResponse,
type ScopeSkillsResponse,
type SkillDetailResponse,
type SkillProvenance,
type SkillRow,
} from "@/api/skills";
type Tab = "queens" | "colonies" | "catalog";
const PROVENANCE_LABEL: Record<SkillProvenance, string> = {
framework: "Framework",
user_dropped: "User",
user_ui_created: "User (UI)",
queen_created: "Queen",
learned_runtime: "Learned",
project_dropped: "Colony",
other: "Other",
};
function ProvenanceBadge({ provenance }: { provenance: SkillProvenance }) {
const tone =
provenance === "framework"
? "bg-slate-400/10 text-slate-400"
: provenance === "queen_created"
? "bg-amber-500/10 text-amber-500"
: provenance === "learned_runtime"
? "bg-purple-500/10 text-purple-500"
: "bg-primary/10 text-primary";
return (
<span className={`px-1.5 py-0.5 rounded text-[10px] font-medium ${tone}`}>
{PROVENANCE_LABEL[provenance]}
</span>
);
}
// ---------------------------------------------------------------------------
// Page shell
// ---------------------------------------------------------------------------
export default function SkillsLibrary() {
const [tab, setTab] = useState<Tab>("queens");
return (
<div className="flex-1 flex flex-col min-w-0 overflow-hidden">
<div className="px-6 py-4 border-b border-border/60">
<div className="flex items-baseline gap-3 mb-3">
<h2 className="text-lg font-semibold text-foreground flex items-center gap-2">
<Library className="w-5 h-5 text-primary" />
Skills Library
</h2>
<span className="text-xs text-muted-foreground">
Curate which skills each queen and colony exposes, upload your own, or browse the full catalog.
</span>
</div>
<div className="flex items-center gap-1">
<TabButton active={tab === "queens"} onClick={() => setTab("queens")} icon={<Crown className="w-3.5 h-3.5" />}>
Queens
</TabButton>
<TabButton active={tab === "colonies"} onClick={() => setTab("colonies")} icon={<Network className="w-3.5 h-3.5" />}>
Colonies
</TabButton>
<TabButton active={tab === "catalog"} onClick={() => setTab("catalog")} icon={<BookOpen className="w-3.5 h-3.5" />}>
Catalog
</TabButton>
</div>
</div>
<div className="flex-1 overflow-y-auto">
{tab === "queens" && <QueensTab />}
{tab === "colonies" && <ColoniesTab />}
{tab === "catalog" && <CatalogTab />}
</div>
</div>
);
}
function TabButton({
active,
onClick,
icon,
children,
}: {
active: boolean;
onClick: () => void;
icon: React.ReactNode;
children: React.ReactNode;
}) {
return (
<button
onClick={onClick}
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-md text-sm font-medium ${
active
? "bg-primary/15 text-primary"
: "text-muted-foreground hover:text-foreground hover:bg-muted/30"
}`}
>
{icon}
{children}
</button>
);
}
// ---------------------------------------------------------------------------
// Queens tab
// ---------------------------------------------------------------------------
function QueensTab() {
const [queens, setQueens] = useState<Array<{ id: string; name: string; title: string }> | null>(
null,
);
const [selected, setSelected] = useState<string | null>(null);
const [error, setError] = useState<string | null>(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 <ErrorBlock message={error} />;
if (queens === null) return <LoadingBlock label="Loading queens…" />;
if (queens.length === 0) return <EmptyBlock label="No queens yet." />;
return (
<div className="flex h-full">
<SidePicker>
{queens.map((q) => (
<PickerItem
key={q.id}
active={selected === q.id}
onClick={() => setSelected(q.id)}
primary={q.name}
secondary={q.title}
/>
))}
</SidePicker>
<div className="flex-1 overflow-y-auto px-6 py-5 min-w-0">
{selected ? (
<>
{(() => {
const q = queens.find((x) => x.id === selected);
return q ? (
<div className="mb-4 pb-3 border-b border-border/40">
<h3 className="text-base font-semibold text-foreground">{q.name}</h3>
<p className="text-xs text-muted-foreground mt-0.5">{q.title}</p>
</div>
) : null;
})()}
<SkillsPerScopeSection scopeKind="queen" targetId={selected} />
</>
) : (
<EmptyBlock label="Pick a queen to edit her skill catalog." />
)}
</div>
</div>
);
}
// ---------------------------------------------------------------------------
// Colonies tab
// ---------------------------------------------------------------------------
function ColoniesTab() {
const [colonies, setColonies] = useState<ColonySummary[] | null>(null);
const [selected, setSelected] = useState<string | null>(null);
const [error, setError] = useState<string | null>(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 <ErrorBlock message={error} />;
if (sorted === null) return <LoadingBlock label="Loading colonies…" />;
if (sorted.length === 0)
return (
<EmptyBlock label="No colonies yet. Ask a queen to incubate one and its skills will show up here." />
);
return (
<div className="flex h-full">
<SidePicker>
{sorted.map((c) => (
<PickerItem
key={c.name}
active={selected === c.name}
onClick={() => setSelected(c.name)}
primary={slugToDisplayName(c.name)}
secondary={c.queen_name ? `@${c.queen_name}` : undefined}
tertiary={c.name}
/>
))}
</SidePicker>
<div className="flex-1 overflow-y-auto px-6 py-5 min-w-0">
{selected ? (
<>
<div className="mb-4 pb-3 border-b border-border/40">
<h3 className="text-base font-semibold text-foreground">
{slugToDisplayName(selected)}
</h3>
<p className="text-[11px] text-muted-foreground font-mono mt-0.5">{selected}</p>
</div>
<SkillsPerScopeSection scopeKind="colony" targetId={selected} />
</>
) : (
<EmptyBlock label="Pick a colony to edit its skill catalog." />
)}
</div>
</div>
);
}
// ---------------------------------------------------------------------------
// Per-scope section (shared body for Queens + Colonies tabs)
// ---------------------------------------------------------------------------
function SkillsPerScopeSection({
scopeKind,
targetId,
}: {
scopeKind: "queen" | "colony";
targetId: string;
}) {
const [resp, setResp] = useState<ScopeSkillsResponse | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [search, setSearch] = useState("");
const [createOpen, setCreateOpen] = useState(false);
const [detailName, setDetailName] = useState<string | null>(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 (
<div>
<div className="flex items-center gap-3 mb-4">
<div className="relative flex-1 min-w-[200px] max-w-[320px]">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-muted-foreground" />
<input
value={search}
onChange={(e) => 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"
/>
</div>
<div className="flex-1" />
<button
onClick={() => setCreateOpen(true)}
className="flex items-center gap-1.5 px-3 py-1.5 rounded-md bg-primary/10 text-primary text-sm font-medium hover:bg-primary/20"
>
<Plus className="w-3.5 h-3.5" /> New Skill
</button>
</div>
{resp?.inherited_from_queen?.length ? (
<div className="mb-3 text-xs text-muted-foreground">
Inherited from queen{resp.queen_id ? ` (${resp.queen_id})` : ""}:{" "}
{resp.inherited_from_queen.join(", ")}
</div>
) : null}
{loading && <LoadingBlock label="Loading skills…" />}
{error && (
<div className="mb-4 px-3 py-2 rounded-lg bg-destructive/10 text-destructive text-sm">
{error}
</div>
)}
{!loading && filtered.length === 0 && (
<p className="text-sm text-muted-foreground">No skills match your filter.</p>
)}
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
{filtered.map((row) => (
<SkillCard
key={row.name}
row={row}
onToggle={() => toggle(row)}
onOpen={() => setDetailName(row.name)}
onRemove={row.deletable ? () => remove(row) : undefined}
/>
))}
</div>
<CreateSkillModal
open={createOpen}
scopeKind={scopeKind}
targetId={targetId}
onClose={() => setCreateOpen(false)}
onSaved={reload}
/>
<SkillDetailDrawer skillName={detailName} onClose={() => setDetailName(null)} />
</div>
);
}
// ---------------------------------------------------------------------------
// Catalog tab
// ---------------------------------------------------------------------------
function CatalogTab() {
const [resp, setResp] = useState<AggregatedSkillsResponse | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [search, setSearch] = useState("");
const [uploadOpen, setUploadOpen] = useState(false);
const [detailName, setDetailName] = useState<string | null>(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 (
<div className="px-6 py-5">
<div className="flex items-center gap-3 mb-4">
<div className="relative flex-1 min-w-[200px] max-w-[360px]">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-muted-foreground" />
<input
value={search}
onChange={(e) => 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"
/>
</div>
<div className="flex-1" />
<button
onClick={() => setUploadOpen(true)}
className="flex items-center gap-1.5 px-3 py-1.5 rounded-md border border-border/60 bg-card text-sm font-medium text-foreground hover:bg-muted/50"
>
<Upload className="w-3.5 h-3.5" /> Upload
</button>
</div>
{loading && <LoadingBlock label="Loading catalog…" />}
{error && (
<div className="mb-4 px-3 py-2 rounded-lg bg-destructive/10 text-destructive text-sm">
{error}
</div>
)}
{!loading && filtered.length === 0 && (
<p className="text-sm text-muted-foreground">No skills match your filter.</p>
)}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
{filtered.map((row) => (
<SkillCard
key={row.name}
row={row}
onOpen={() => setDetailName(row.name)}
// Catalog view is read-only for toggle/delete — all mutations
// happen in the scoped tabs.
/>
))}
</div>
<UploadSkillModal
open={uploadOpen}
scopes={{
queens: resp?.queens ?? [],
colonies: resp?.colonies ?? [],
}}
onClose={() => setUploadOpen(false)}
onUploaded={reload}
/>
<SkillDetailDrawer skillName={detailName} onClose={() => setDetailName(null)} />
</div>
);
}
// ---------------------------------------------------------------------------
// Skill card (shared across all three tabs)
// ---------------------------------------------------------------------------
function SkillCard({
row,
onOpen,
onToggle,
onRemove,
}: {
row: SkillRow;
onOpen: () => void;
onToggle?: () => void;
onRemove?: () => void;
}) {
return (
<div className="rounded-lg border border-border/60 bg-card p-4 hover:border-primary/30 transition-colors flex flex-col">
<div className="flex items-start gap-2 mb-1">
{onToggle ? (
<button
onClick={onToggle}
title={row.enabled ? "Disable" : "Enable"}
className="flex-shrink-0 mt-0.5"
>
{row.enabled ? (
<CheckCircle2 className="w-4 h-4 text-emerald-500" />
) : (
<Circle className="w-4 h-4 text-muted-foreground" />
)}
</button>
) : (
<div className="flex-shrink-0 mt-0.5" aria-hidden>
{row.enabled ? (
<CheckCircle2 className="w-4 h-4 text-muted-foreground/40" />
) : (
<Circle className="w-4 h-4 text-muted-foreground/40" />
)}
</div>
)}
<div className="min-w-0 flex-1">
<button
onClick={onOpen}
className="text-sm font-medium text-foreground text-left hover:text-primary line-clamp-1"
>
{row.name}
</button>
<div className="flex items-center gap-1.5 mt-0.5 flex-wrap">
<ProvenanceBadge provenance={row.provenance} />
{row.owner && (
<span className="text-[10px] text-muted-foreground">@{row.owner.id}</span>
)}
{!row.editable && (
<Lock
className="w-3 h-3 text-muted-foreground"
aria-label="Read-only"
>
<title>Read-only</title>
</Lock>
)}
</div>
</div>
{onRemove && (
<button
onClick={onRemove}
className="p-1 rounded-md text-muted-foreground hover:text-destructive hover:bg-destructive/10"
title="Delete skill"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
)}
</div>
<p className="text-xs text-muted-foreground line-clamp-2 mb-2">{row.description}</p>
{row.visible_to && (
<p className="text-[10px] text-muted-foreground mt-auto">
Visible on {row.visible_to.queens.length} queens, {row.visible_to.colonies.length}{" "}
colonies
</p>
)}
</div>
);
}
// ---------------------------------------------------------------------------
// 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<string | null>(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 (
<div className="fixed inset-0 z-50 flex items-center justify-center">
<div className="absolute inset-0 bg-black/40" onClick={onClose} />
<div className="relative bg-card border border-border/60 rounded-2xl shadow-2xl w-full max-w-[640px] p-6 max-h-[90vh] overflow-y-auto">
<div className="flex items-center justify-between mb-5">
<div>
<h3 className="text-lg font-semibold text-foreground">New Skill</h3>
<p className="text-xs text-muted-foreground mt-0.5">Scope: {label}</p>
</div>
<button
onClick={onClose}
className="p-1 rounded-md text-muted-foreground hover:text-foreground hover:bg-muted/50"
>
<X className="w-4 h-4" />
</button>
</div>
<div className="flex flex-col gap-4">
<div>
<label className="text-sm font-medium text-foreground mb-1.5 block">
Name <span className="text-primary">*</span>
</label>
<input
value={name}
onChange={(e) => 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"
/>
<p className="text-[11px] text-muted-foreground mt-1">
Lowercase letters, digits, hyphens, dots. Max 64 chars.
</p>
</div>
<div>
<label className="text-sm font-medium text-foreground mb-1.5 block">
Description <span className="text-primary">*</span>
</label>
<input
value={description}
onChange={(e) => 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"
/>
</div>
<div>
<label className="text-sm font-medium text-foreground mb-1.5 block">
Body (SKILL.md content) <span className="text-primary">*</span>
</label>
<textarea
value={body}
onChange={(e) => setBody(e.target.value)}
rows={14}
placeholder={"## When to use\n\n...\n\n## Steps\n\n1. ..."}
className="w-full bg-muted/30 border border-border/50 rounded-lg px-3 py-2 text-sm font-mono text-foreground placeholder:text-muted-foreground/50 focus:outline-none focus:ring-1 focus:ring-primary/40 resize-none"
/>
</div>
{error && (
<div className="px-3 py-2 rounded-lg bg-destructive/10 text-destructive text-xs">
{error}
</div>
)}
<div className="flex justify-end gap-2 pt-1">
<button
onClick={onClose}
className="px-4 py-2 rounded-lg text-sm font-medium text-muted-foreground hover:text-foreground hover:bg-muted/30"
>
Cancel
</button>
<button
onClick={submit}
disabled={saving}
className="px-4 py-2 rounded-lg bg-primary text-primary-foreground text-sm font-medium hover:bg-primary/90 disabled:opacity-50"
>
{saving ? "Saving…" : "Create"}
</button>
</div>
</div>
</div>
</div>
);
}
function UploadSkillModal({
open,
scopes,
onClose,
onUploaded,
}: {
open: boolean;
scopes: {
queens: Array<{ id: string; name: string }>;
colonies: Array<{ name: string; queen_id: string | null }>;
};
onClose: () => void;
onUploaded: () => void;
}) {
const [file, setFile] = useState<File | null>(null);
const [scopeKind, setScopeKind] = useState<"user" | "queen" | "colony">("user");
const [targetId, setTargetId] = useState<string>("");
const [enabled, setEnabled] = useState(true);
const [replaceExisting, setReplaceExisting] = useState(false);
const [uploading, setUploading] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (scopeKind === "queen" && scopes.queens.length > 0 && !targetId) {
setTargetId(scopes.queens[0].id);
} else if (scopeKind === "colony" && scopes.colonies.length > 0 && !targetId) {
setTargetId(scopes.colonies[0].name);
} else if (scopeKind === "user") {
setTargetId("");
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [scopeKind]);
if (!open) return null;
const submit = async () => {
if (!file) {
setError("Pick a .md or .zip file first.");
return;
}
setError(null);
setUploading(true);
try {
const fd = new FormData();
fd.append("file", file);
fd.append("scope", scopeKind);
if (scopeKind !== "user") fd.append("target_id", targetId);
fd.append("enabled", String(enabled));
fd.append("replace_existing", String(replaceExisting));
await skillsApi.upload(fd);
onUploaded();
onClose();
setFile(null);
} catch (e) {
setError(e instanceof ApiError ? e.body.error : String(e));
} finally {
setUploading(false);
}
};
return (
<div className="fixed inset-0 z-50 flex items-center justify-center">
<div className="absolute inset-0 bg-black/40" onClick={onClose} />
<div className="relative bg-card border border-border/60 rounded-2xl shadow-2xl w-full max-w-[520px] p-6">
<div className="flex items-center justify-between mb-5">
<h3 className="text-lg font-semibold text-foreground">Upload Skill</h3>
<button
onClick={onClose}
className="p-1 rounded-md text-muted-foreground hover:text-foreground hover:bg-muted/50"
>
<X className="w-4 h-4" />
</button>
</div>
<div className="flex flex-col gap-4">
<div>
<label className="text-sm font-medium text-foreground mb-1.5 block">
File (.md or .zip)
</label>
<input
type="file"
accept=".md,.zip"
onChange={(e) => setFile(e.target.files?.[0] ?? null)}
className="w-full text-sm text-foreground file:mr-3 file:rounded-md file:border-0 file:bg-primary/10 file:px-3 file:py-1.5 file:text-sm file:font-medium file:text-primary hover:file:bg-primary/20"
/>
</div>
<div>
<label className="text-sm font-medium text-foreground mb-1.5 block">Scope</label>
<select
value={scopeKind}
onChange={(e) => setScopeKind(e.target.value as typeof scopeKind)}
className="w-full bg-muted/30 border border-border/50 rounded-lg px-3 py-2 text-sm text-foreground focus:outline-none focus:ring-1 focus:ring-primary/40"
>
<option value="user">User library (available to all queens)</option>
<option value="queen">Queen</option>
<option value="colony">Colony</option>
</select>
</div>
{scopeKind === "queen" && (
<div>
<label className="text-sm font-medium text-foreground mb-1.5 block">Queen</label>
<select
value={targetId}
onChange={(e) => setTargetId(e.target.value)}
className="w-full bg-muted/30 border border-border/50 rounded-lg px-3 py-2 text-sm text-foreground focus:outline-none focus:ring-1 focus:ring-primary/40"
>
{scopes.queens.map((q) => (
<option key={q.id} value={q.id}>
{q.name}
</option>
))}
</select>
</div>
)}
{scopeKind === "colony" && (
<div>
<label className="text-sm font-medium text-foreground mb-1.5 block">Colony</label>
<select
value={targetId}
onChange={(e) => setTargetId(e.target.value)}
className="w-full bg-muted/30 border border-border/50 rounded-lg px-3 py-2 text-sm text-foreground focus:outline-none focus:ring-1 focus:ring-primary/40"
>
{scopes.colonies.map((c) => (
<option key={c.name} value={c.name}>
{c.name} {c.queen_id ? `(${c.queen_id})` : ""}
</option>
))}
</select>
</div>
)}
<div className="flex items-center gap-4">
<label className="flex items-center gap-2 text-sm text-foreground">
<input
type="checkbox"
checked={enabled}
onChange={(e) => setEnabled(e.target.checked)}
/>
Enable immediately
</label>
<label className="flex items-center gap-2 text-sm text-foreground">
<input
type="checkbox"
checked={replaceExisting}
onChange={(e) => setReplaceExisting(e.target.checked)}
/>
Replace if exists
</label>
</div>
{error && (
<div className="px-3 py-2 rounded-lg bg-destructive/10 text-destructive text-xs">
{error}
</div>
)}
<div className="flex justify-end gap-2 pt-1">
<button
onClick={onClose}
className="px-4 py-2 rounded-lg text-sm font-medium text-muted-foreground hover:text-foreground hover:bg-muted/30"
>
Cancel
</button>
<button
onClick={submit}
disabled={uploading || !file}
className="px-4 py-2 rounded-lg bg-primary text-primary-foreground text-sm font-medium hover:bg-primary/90 disabled:opacity-50"
>
{uploading ? "Uploading…" : "Upload"}
</button>
</div>
</div>
</div>
</div>
);
}
function SkillDetailDrawer({
skillName,
onClose,
}: {
skillName: string | null;
onClose: () => void;
}) {
const [detail, setDetail] = useState<SkillDetailResponse | null>(null);
const [loading, setLoading] = useState(false);
useEffect(() => {
if (!skillName) return;
setLoading(true);
skillsApi
.getDetail(skillName)
.then(setDetail)
.catch(() => setDetail(null))
.finally(() => setLoading(false));
}, [skillName]);
if (!skillName) return null;
return (
<div className="fixed inset-0 z-40 flex justify-end">
<div className="absolute inset-0 bg-black/40" onClick={onClose} />
<div className="relative w-full max-w-[640px] h-full bg-card border-l border-border/60 overflow-y-auto p-6">
<div className="flex items-start justify-between mb-4">
<div>
<h3 className="text-lg font-semibold text-foreground">{skillName}</h3>
{detail && (
<p className="text-xs text-muted-foreground mt-0.5">{detail.description}</p>
)}
</div>
<button
onClick={onClose}
className="p-1 rounded-md text-muted-foreground hover:text-foreground hover:bg-muted/50"
>
<X className="w-4 h-4" />
</button>
</div>
{loading && <p className="text-sm text-muted-foreground">Loading</p>}
{detail && (
<pre className="whitespace-pre-wrap text-xs font-mono bg-muted/30 border border-border/40 rounded-lg p-4 text-foreground/80">
{detail.body}
</pre>
)}
</div>
</div>
);
}
// ---------------------------------------------------------------------------
// Primitives (match tool-library style)
// ---------------------------------------------------------------------------
function SidePicker({ children }: { children: React.ReactNode }) {
return (
<div className="w-[260px] flex-shrink-0 border-r border-border/60 overflow-y-auto py-3 px-2 flex flex-col gap-1">
{children}
</div>
);
}
function PickerItem({
active,
onClick,
primary,
secondary,
tertiary,
}: {
active: boolean;
onClick: () => void;
primary: string;
secondary?: string;
tertiary?: string;
}) {
return (
<button
onClick={onClick}
className={`text-left px-3 py-2 rounded-md text-sm ${
active ? "bg-primary/15 text-primary" : "text-foreground hover:bg-muted/30"
}`}
>
<div className="font-medium truncate">{primary}</div>
{secondary && (
<div className="text-[11px] text-muted-foreground truncate">{secondary}</div>
)}
{tertiary && (
<div className="text-[10px] text-muted-foreground/60 font-mono truncate">{tertiary}</div>
)}
</button>
);
}
function LoadingBlock({ label }: { label: string }) {
return (
<div className="flex items-center gap-2 text-xs text-muted-foreground px-6 py-6">
<Loader2 className="w-3 h-3 animate-spin" />
{label}
</div>
);
}
function EmptyBlock({ label }: { label: string }) {
return (
<div className="flex items-start gap-2 text-xs text-muted-foreground px-6 py-6">
<AlertCircle className="w-3.5 h-3.5 mt-0.5" />
<span>{label}</span>
</div>
);
}
function ErrorBlock({ message }: { message: string }) {
return (
<div className="flex items-start gap-2 text-xs text-destructive px-6 py-6">
<AlertCircle className="w-3.5 h-3.5 mt-0.5 flex-shrink-0" />
<span>{message}</span>
</div>
);
}
+1
View File
@@ -42,6 +42,7 @@ _HIVE_PATH_CONSUMERS = (
"framework.server.session_manager",
"framework.server.queen_orchestrator",
"framework.server.routes_queens",
"framework.server.routes_skills",
"framework.server.app",
"framework.agents.discovery",
"framework.agents.queen.queen_profiles",
+16
View File
@@ -204,6 +204,22 @@ async def test_happy_path_materializes_skill_under_colony_dir(patched_home: Path
assert f"description: {description}" in text
assert "HoneyComb API Operational Protocol" in text
# create_colony should also register the skill in the colony's
# override store with ``queen_created`` provenance so the UI can
# display it as queen-authored + editable.
from framework.skills.overrides import Provenance, SkillOverrideStore
overrides_path = (
patched_home / ".hive" / "colonies" / "honeycomb_research" / "skills_overrides.json"
)
assert overrides_path.exists(), "create_colony should write a skills_overrides.json ledger"
store = SkillOverrideStore.load(overrides_path)
entry = store.get("honeycomb-api-protocol")
assert entry is not None
assert entry.provenance == Provenance.QUEEN_CREATED
assert entry.enabled is True
assert (entry.created_by or "").startswith("queen:")
# Critically: the skill must NOT land in the shared user-scope dir —
# that was the leak we are fixing.
assert not (patched_home / ".hive" / "skills" / "honeycomb-api-protocol").exists()
+289
View File
@@ -0,0 +1,289 @@
"""HTTP integration tests for the skills routes.
Covers the per-queen, per-colony, and aggregated-library surfaces plus
the multipart upload handler. Uses aiohttp's TestClient directly (no
pytest-aiohttp plugin), which is why each test sets up its own client.
"""
from __future__ import annotations
import io
import zipfile
from collections.abc import AsyncIterator
from pathlib import Path
import pytest
import pytest_asyncio
from aiohttp import web
from aiohttp.test_utils import TestClient, TestServer
from framework.server.routes_skills import register_routes
from framework.skills.overrides import (
OverrideEntry,
Provenance,
SkillOverrideStore,
)
pytestmark = pytest.mark.asyncio
class _StubSessionManager:
"""Tiny stand-in that satisfies the iter_* contracts used by routes.
The routes_skills handlers call ``manager.iter_queen_sessions`` and
``manager.iter_colony_runtimes`` to find live managers to reload.
In-process tests don't spin up runtimes, so these iterators yield
nothing the routes fall back to the admin manager built from disk.
"""
def iter_queen_sessions(self, queen_id: str):
return iter([])
def iter_colony_runtimes(self, *, queen_id=None, colony_name=None):
return iter([])
def _build_app() -> web.Application:
application = web.Application()
application["manager"] = _StubSessionManager()
register_routes(application)
return application
@pytest_asyncio.fixture
async def client() -> AsyncIterator[TestClient]:
app = _build_app()
server = TestServer(app)
async with TestClient(server) as tc:
yield tc
@pytest.fixture
def _seed_queen(tmp_path: Path):
"""Write a queen profile so _queen_scope recognises the id."""
queen_home = Path.home() / ".hive" / "agents" / "queens" / "ops"
queen_home.mkdir(parents=True, exist_ok=True)
(queen_home / "profile.yaml").write_text(
"name: Ops\ntitle: Ops queen\n", encoding="utf-8"
)
return queen_home
@pytest.fixture
def _seed_colony(tmp_path: Path):
colony_home = Path.home() / ".hive" / "colonies" / "research_one"
colony_home.mkdir(parents=True, exist_ok=True)
return colony_home
async def test_get_queen_skills_returns_empty_for_fresh_queen(
client: TestClient, _seed_queen
) -> None:
resp = await client.get("/api/queen/ops/skills")
assert resp.status == 200
data = await resp.json()
assert data["queen_id"] == "ops"
assert data["all_defaults_disabled"] is False
# Fresh install → framework default skills show up via discovery.
assert isinstance(data["skills"], list)
async def test_create_queen_skill_writes_file_and_override(
client: TestClient, _seed_queen
) -> None:
payload = {
"name": "ops-runbook",
"description": "Runbook for ops",
"body": "## Steps\n1. Check\n",
"enabled": True,
}
resp = await client.post("/api/queen/ops/skills", json=payload)
assert resp.status == 201
data = await resp.json()
assert data["name"] == "ops-runbook"
# Verify files were written to the queen skill dir.
skill_md = _seed_queen / "skills" / "ops-runbook" / "SKILL.md"
assert skill_md.exists()
# Verify override was registered with USER_UI_CREATED provenance.
store = SkillOverrideStore.load(_seed_queen / "skills_overrides.json")
entry = store.get("ops-runbook")
assert entry is not None
assert entry.provenance == Provenance.USER_UI_CREATED
assert entry.enabled is True
async def test_patch_queen_skill_toggles_enabled(
client: TestClient, _seed_queen
) -> None:
await client.post(
"/api/queen/ops/skills",
json={"name": "ops-a", "description": "a", "body": "body"},
)
resp = await client.patch(
"/api/queen/ops/skills/ops-a",
json={"enabled": False},
)
assert resp.status == 200
store = SkillOverrideStore.load(_seed_queen / "skills_overrides.json")
assert store.get("ops-a").enabled is False
async def test_delete_queen_skill_removes_files(
client: TestClient, _seed_queen
) -> None:
await client.post(
"/api/queen/ops/skills",
json={"name": "tmp-skill", "description": "d", "body": "body"},
)
skill_dir = _seed_queen / "skills" / "tmp-skill"
assert skill_dir.exists()
resp = await client.delete("/api/queen/ops/skills/tmp-skill")
assert resp.status == 200
assert not skill_dir.exists()
store = SkillOverrideStore.load(_seed_queen / "skills_overrides.json")
assert "tmp-skill" in store.deleted_ui_skills
async def test_delete_framework_skill_is_refused(
client: TestClient, _seed_queen
) -> None:
# Pre-seed an override entry with framework provenance — simulates the
# user toggling a framework default so the override exists on disk.
store = SkillOverrideStore.load(_seed_queen / "skills_overrides.json")
store.upsert(
"hive.note-taking",
OverrideEntry(enabled=False, provenance=Provenance.FRAMEWORK),
)
store.save()
resp = await client.delete("/api/queen/ops/skills/hive.note-taking")
assert resp.status == 403
async def test_upload_markdown_places_in_user_library(client: TestClient) -> None:
skill_md = "---\nname: from-upload\ndescription: Uploaded skill\n---\n\n## Body\nHi.\n"
form = {
"file": skill_md.encode("utf-8"),
"scope": "user",
"enabled": "true",
}
# Use multipart writer pattern: aiohttp test client auto-serializes dicts.
data = _as_form(form, filename="SKILL.md")
resp = await client.post("/api/skills/upload", data=data)
assert resp.status == 201
body = await resp.json()
assert body["name"] == "from-upload"
assert (Path.home() / ".hive" / "skills" / "from-upload" / "SKILL.md").exists()
async def test_upload_zip_bundle_places_in_queen_scope(
client: TestClient, _seed_queen
) -> None:
# Build a zip in memory with SKILL.md + a supporting file.
buf = io.BytesIO()
with zipfile.ZipFile(buf, "w") as z:
z.writestr(
"SKILL.md",
"---\nname: zipped-skill\ndescription: From zip\n---\n\nbody\n",
)
z.writestr("scripts/helper.py", "print('hi')\n")
payload = buf.getvalue()
form = {
"file": payload,
"scope": "queen",
"target_id": "ops",
"enabled": "true",
}
data = _as_form(form, filename="bundle.zip")
resp = await client.post("/api/skills/upload", data=data)
assert resp.status == 201
skill_dir = _seed_queen / "skills" / "zipped-skill"
assert (skill_dir / "SKILL.md").exists()
assert (skill_dir / "scripts" / "helper.py").exists()
async def test_patch_does_not_mislabel_legacy_colony_skill_as_framework(
client: TestClient, _seed_colony
) -> None:
"""Regression: toggling a legacy colony skill (no ledger entry yet)
must not stamp provenance=FRAMEWORK on the new entry. Before the fix,
the first PATCH wrote FRAMEWORK and the next GET displayed 'Framework'
instead of the queen-authored label.
"""
skill_dir = _seed_colony / ".hive" / "skills" / "legacy-queen-skill"
skill_dir.mkdir(parents=True)
(skill_dir / "SKILL.md").write_text(
"---\nname: legacy-queen-skill\ndescription: From create_colony\n---\n\nbody\n",
encoding="utf-8",
)
resp = await client.patch(
"/api/colonies/research_one/skills/legacy-queen-skill",
json={"enabled": False},
)
assert resp.status == 200
list_resp = await client.get("/api/colonies/research_one/skills")
rows = {r["name"]: r for r in (await list_resp.json())["skills"]}
assert rows["legacy-queen-skill"]["provenance"] == "queen_created"
assert rows["legacy-queen-skill"]["enabled"] is False
async def test_colony_skill_is_editable_even_without_override_entry(
client: TestClient, _seed_colony
) -> None:
"""Regression: a SKILL.md dropped into a colony's .hive/skills dir
(e.g. from a pre-override-store colony) must still be marked editable
when listed via /api/colonies/{name}/skills. The admin manager used
to set project_root=colony_home, which retagged the skill as
source_scope='project' and fell back to PROJECT_DROPPED provenance
flipping editable to False.
"""
# Write a bare SKILL.md directly; no override ledger entry.
skill_dir = _seed_colony / ".hive" / "skills" / "legacy-skill"
skill_dir.mkdir(parents=True)
(skill_dir / "SKILL.md").write_text(
"---\nname: legacy-skill\ndescription: A legacy\n---\n\nbody\n",
encoding="utf-8",
)
resp = await client.get("/api/colonies/research_one/skills")
assert resp.status == 200
data = await resp.json()
rows = {r["name"]: r for r in data["skills"]}
assert "legacy-skill" in rows
assert rows["legacy-skill"]["editable"] is True
assert rows["legacy-skill"]["source_scope"] == "colony_ui"
# Legacy colony skills (no override ledger entry) were authored by
# create_colony() before the ledger existed — the fallback provenance
# must reflect that, not be misreported as user-UI-created.
assert rows["legacy-skill"]["provenance"] == "queen_created"
async def test_list_scopes_enumerates_queens_and_colonies(
client: TestClient, _seed_queen, _seed_colony
) -> None:
resp = await client.get("/api/skills/scopes")
assert resp.status == 200
data = await resp.json()
assert any(q["id"] == "ops" for q in data["queens"])
assert any(c["name"] == "research_one" for c in data["colonies"])
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _as_form(fields: dict, *, filename: str):
"""Build aiohttp FormData; bytes entries are attached as file parts."""
from aiohttp import FormData
fd = FormData()
for key, value in fields.items():
if isinstance(value, bytes):
fd.add_field(key, value, filename=filename)
else:
fd.add_field(key, value)
return fd
+226
View File
@@ -0,0 +1,226 @@
"""Tests for the per-scope skill override store and its interaction with SkillsManager."""
from __future__ import annotations
import json
from datetime import UTC, datetime
from pathlib import Path
import pytest
from framework.skills.authoring import build_draft, write_skill
from framework.skills.config import SkillsConfig
from framework.skills.discovery import ExtraScope
from framework.skills.manager import SkillsManager, SkillsManagerConfig
from framework.skills.overrides import (
OverrideEntry,
Provenance,
SkillOverrideStore,
)
def _write_skill_file(base: Path, name: str, description: str = "desc") -> Path:
skill_dir = base / name
skill_dir.mkdir(parents=True, exist_ok=True)
(skill_dir / "SKILL.md").write_text(
f"---\nname: {name}\ndescription: {description}\n---\n\nbody\n",
encoding="utf-8",
)
return skill_dir
class TestSkillOverrideStore:
def test_load_missing_returns_empty(self, tmp_path: Path) -> None:
store = SkillOverrideStore.load(tmp_path / "skills_overrides.json", scope_label="queen:x")
assert store.overrides == {}
assert store.all_defaults_disabled is False
def test_upsert_and_save_roundtrip(self, tmp_path: Path) -> None:
path = tmp_path / "skills_overrides.json"
store = SkillOverrideStore.load(path, scope_label="queen:x")
store.upsert(
"foo",
OverrideEntry(
enabled=False,
provenance=Provenance.FRAMEWORK,
created_at=datetime(2026, 4, 21, tzinfo=UTC),
created_by="user",
),
)
store.save()
raw = json.loads(path.read_text(encoding="utf-8"))
assert raw["version"] == 1
assert raw["overrides"]["foo"]["enabled"] is False
assert raw["overrides"]["foo"]["provenance"] == "framework"
# Re-load preserves values
again = SkillOverrideStore.load(path, scope_label="queen:x")
assert again.get("foo") is not None
assert again.get("foo").enabled is False
def test_tombstone_survives_reload(self, tmp_path: Path) -> None:
path = tmp_path / "skills_overrides.json"
store = SkillOverrideStore.load(path, scope_label="queen:x")
store.upsert("foo", OverrideEntry(enabled=True, provenance=Provenance.USER_UI_CREATED))
store.remove("foo", tombstone=True)
store.save()
again = SkillOverrideStore.load(path, scope_label="queen:x")
assert "foo" in again.deleted_ui_skills
assert again.get("foo") is None
def test_corrupt_file_loads_empty(self, tmp_path: Path) -> None:
path = tmp_path / "skills_overrides.json"
path.write_text("{not valid json", encoding="utf-8")
store = SkillOverrideStore.load(path, scope_label="queen:x")
assert store.overrides == {}
class TestAuthoring:
def test_write_and_remove(self, tmp_path: Path) -> None:
draft, err = build_draft(
skill_name="demo",
skill_description="A demo skill",
skill_body="## Steps\n1. Do it.\n",
skill_files=[{"path": "notes.md", "content": "notes"}],
)
assert err is None
assert draft is not None
installed, werr, replaced = write_skill(draft, target_root=tmp_path, replace_existing=True)
assert werr is None
assert installed is not None
assert (installed / "SKILL.md").exists()
assert (installed / "notes.md").read_text() == "notes"
assert replaced is False
def test_reject_absolute_path(self, tmp_path: Path) -> None:
_, err = build_draft(
skill_name="demo",
skill_description="desc",
skill_body="body",
skill_files=[{"path": "/etc/passwd", "content": "oops"}],
)
assert err is not None
assert "relative" in err
def test_reject_traversal(self, tmp_path: Path) -> None:
_, err = build_draft(
skill_name="demo",
skill_description="desc",
skill_body="body",
skill_files=[{"path": "../escape.sh", "content": "oops"}],
)
assert err is not None
def test_reject_invalid_name(self, tmp_path: Path) -> None:
_, err = build_draft(
skill_name="Demo_Skill",
skill_description="desc",
skill_body="body",
)
assert err is not None
class TestSkillsManagerOverrides:
def test_override_disables_framework_default(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
# Quarantine user-scope and skip framework-scope discovery by pointing HOME
# at an empty tmp dir; supply only one "framework" skill manually via an
# extra scope tagged as framework so the manager sees it.
monkeypatch.setattr(Path, "home", lambda: tmp_path / "home")
fake_fw = tmp_path / "fake_framework"
_write_skill_file(fake_fw, "hive.note-taking", "Fake default")
overrides_path = tmp_path / "queen_overrides.json"
store = SkillOverrideStore.load(overrides_path, scope_label="queen:q")
store.upsert(
"hive.note-taking",
OverrideEntry(enabled=False, provenance=Provenance.FRAMEWORK),
)
store.save()
mgr = SkillsManager(
SkillsManagerConfig(
queen_id="q",
queen_overrides_path=overrides_path,
extra_scope_dirs=[
ExtraScope(directory=fake_fw, label="framework", priority=0)
],
project_root=None,
skip_community_discovery=True,
interactive=False,
)
)
mgr.load()
names_enabled = {s.name for s in mgr._catalog._skills.values()} # type: ignore[attr-defined]
assert "hive.note-taking" not in names_enabled
# Enumeration (for UI rendering) still returns the hidden entry.
assert any(s.name == "hive.note-taking" for s in mgr.enumerate_skills_with_source())
def test_colony_disable_overrides_queen_enable(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(Path, "home", lambda: tmp_path / "home")
# One skill in a "queen_ui" extra scope.
queen_skills = tmp_path / "queen_home" / "skills"
_write_skill_file(queen_skills, "shared-skill")
queen_overrides = tmp_path / "queen_overrides.json"
qstore = SkillOverrideStore.load(queen_overrides, scope_label="queen:q")
qstore.upsert(
"shared-skill",
OverrideEntry(enabled=True, provenance=Provenance.USER_UI_CREATED),
)
qstore.save()
colony_overrides = tmp_path / "colony_overrides.json"
cstore = SkillOverrideStore.load(colony_overrides, scope_label="colony:c")
cstore.upsert(
"shared-skill",
OverrideEntry(enabled=False, provenance=Provenance.USER_UI_CREATED),
)
cstore.save()
mgr = SkillsManager(
SkillsManagerConfig(
queen_id="q",
queen_overrides_path=queen_overrides,
colony_name="c",
colony_overrides_path=colony_overrides,
extra_scope_dirs=[
ExtraScope(directory=queen_skills, label="queen_ui", priority=2)
],
project_root=None,
skip_community_discovery=True,
skills_config=SkillsConfig(),
interactive=False,
)
)
mgr.load()
enabled = {s.name for s in mgr._catalog._skills.values()} # type: ignore[attr-defined]
assert "shared-skill" not in enabled
def test_reload_picks_up_store_change(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(Path, "home", lambda: tmp_path / "home")
fw = tmp_path / "fw"
_write_skill_file(fw, "alpha")
path = tmp_path / "queen.json"
mgr = SkillsManager(
SkillsManagerConfig(
queen_id="q",
queen_overrides_path=path,
extra_scope_dirs=[ExtraScope(directory=fw, label="framework", priority=0)],
project_root=None,
skip_community_discovery=True,
interactive=False,
)
)
mgr.load()
assert "alpha" in {s.name for s in mgr._catalog._skills.values()} # type: ignore[attr-defined]
# Disable via override file + reload
store = SkillOverrideStore.load(path, scope_label="queen:q")
store.upsert("alpha", OverrideEntry(enabled=False, provenance=Provenance.FRAMEWORK))
store.save()
mgr.reload()
assert "alpha" not in {s.name for s in mgr._catalog._skills.values()} # type: ignore[attr-defined]