feat: skill library
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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 "",
|
||||
|
||||
@@ -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 = ""
|
||||
|
||||
@@ -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",
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 /
|
||||
|
||||
@@ -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
@@ -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)
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
@@ -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:
|
||||
|
||||
@@ -1425,124 +1425,8 @@ def register_queen_lifecycle_tools(
|
||||
# re-runs idempotent.
|
||||
|
||||
import re as _re
|
||||
import shutil as _shutil
|
||||
|
||||
_COLONY_NAME_RE = _re.compile(r"^[a-z0-9_]+$")
|
||||
_SKILL_NAME_RE = _re.compile(r"^[a-z0-9-]+$")
|
||||
|
||||
def _materialize_skill_folder(
|
||||
*,
|
||||
skill_name: str,
|
||||
skill_description: str,
|
||||
skill_body: str,
|
||||
skill_files: list[dict] | None,
|
||||
colony_dir: Path,
|
||||
) -> tuple[Path | None, str | None, bool]:
|
||||
"""Write a skill folder under ``{colony_dir}/.hive/skills/{name}/`` from inline content.
|
||||
|
||||
The skill is scoped to a single colony: ``SkillDiscovery`` scans
|
||||
``{project_root}/.hive/skills/`` as project-scope, and the
|
||||
colony's worker uses ``project_root = colony_dir`` — so only
|
||||
that colony's workers see it, not every colony on the machine.
|
||||
We deliberately avoid ``~/.hive/skills/`` here because that
|
||||
directory is scanned as user scope and leaks into every agent.
|
||||
|
||||
Returns ``(installed_path, error, replaced)``. On success
|
||||
``error`` is ``None`` and ``installed_path`` is the final
|
||||
location; ``replaced`` is ``True`` when an existing skill with
|
||||
the same name was overwritten. On failure ``installed_path`` is
|
||||
``None``, ``error`` is a human-readable reason, and
|
||||
``replaced`` is ``False``.
|
||||
"""
|
||||
name = (skill_name or "").strip() if isinstance(skill_name, str) else ""
|
||||
if not name:
|
||||
return None, "skill_name is required", False
|
||||
if not _SKILL_NAME_RE.match(name):
|
||||
return None, (f"skill_name '{name}' must match [a-z0-9-] pattern"), False
|
||||
if name.startswith("-") or name.endswith("-") or "--" in name:
|
||||
return None, (f"skill_name '{name}' has leading/trailing/consecutive hyphens"), False
|
||||
if len(name) > 64:
|
||||
return None, f"skill_name '{name}' exceeds 64 chars", False
|
||||
|
||||
desc = (skill_description or "").strip() if isinstance(skill_description, str) else ""
|
||||
if not desc:
|
||||
return None, "skill_description is required", False
|
||||
if len(desc) > 1024:
|
||||
return None, "skill_description must be 1–1024 chars", False
|
||||
# Frontmatter descriptions must stay on a single line because
|
||||
# our frontmatter parser is line-oriented and the downstream
|
||||
# skill loader expects ``description:`` to resolve to one value.
|
||||
if "\n" in desc or "\r" in desc:
|
||||
return None, "skill_description must be a single line (no newlines)", False
|
||||
|
||||
body = skill_body if isinstance(skill_body, str) else ""
|
||||
if not body.strip():
|
||||
return (
|
||||
None,
|
||||
(
|
||||
"skill_body is required — the operational procedure the "
|
||||
"colony worker needs to run this job unattended"
|
||||
),
|
||||
False,
|
||||
)
|
||||
|
||||
# Optional supporting files (scripts/, references/, assets/…).
|
||||
# Each entry: {"path": "<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, 1–1024 chars), and "
|
||||
@@ -1711,6 +1604,40 @@ def register_queen_lifecycle_tools(
|
||||
}
|
||||
)
|
||||
|
||||
installed_skill, write_err, skill_replaced = write_skill(
|
||||
draft,
|
||||
target_root=colony_dir / ".hive" / "skills",
|
||||
replace_existing=True,
|
||||
)
|
||||
if write_err is not None or installed_skill is None:
|
||||
return json.dumps(
|
||||
{
|
||||
"error": write_err or "failed to write skill folder",
|
||||
}
|
||||
)
|
||||
|
||||
# Register the write in the colony's override store so the UI can
|
||||
# edit/toggle it and :func:`SkillsManager._apply_overrides` carries
|
||||
# the right provenance.
|
||||
try:
|
||||
overrides_path = colony_dir / "skills_overrides.json"
|
||||
store = SkillOverrideStore.load(overrides_path, scope_label=f"colony:{cn}")
|
||||
queen_id = getattr(session, "queen_name", None) or "unknown"
|
||||
store.upsert(
|
||||
draft.name,
|
||||
OverrideEntry(
|
||||
enabled=True,
|
||||
provenance=Provenance.QUEEN_CREATED,
|
||||
created_at=utc_now(),
|
||||
created_by=f"queen:{queen_id}",
|
||||
),
|
||||
)
|
||||
store.save()
|
||||
except Exception:
|
||||
# Registration is best-effort; discovery still surfaces the
|
||||
# skill as project-scope even if the ledger fails to update.
|
||||
logger.warning("create_colony: override registration failed", exc_info=True)
|
||||
|
||||
logger.info(
|
||||
"create_colony: materialized skill at %s (replaced=%s)",
|
||||
installed_skill,
|
||||
|
||||
@@ -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 />} />
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
@@ -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]
|
||||
Reference in New Issue
Block a user