diff --git a/README.md b/README.md index 2585e392..9b791b49 100644 --- a/README.md +++ b/README.md @@ -285,6 +285,32 @@ latest status with `GET /api/v2/learning/features` or toggle flags with explicit capability and approval gates, and rollback remains user-directed via `/forget` or an explicit learning rollback command. +### Installed skill guidance + +The local skill API installs user-authored `SKILL.md` packages as guidance that +can participate in matching runtime tasks. A package intended for matching must +include a `skill.json` with explicit supported profiles and one to twelve +trigger terms: + +```json +{ + "name": "local-pytest-guide", + "version": "1.0.0", + "permissions": [], + "profiles": ["coder"], + "triggers": ["pytest"] +} +``` + +Install it with `POST /api/v2/skills/install` and +`{ "path": "...", "activate": true }`. Only an active local skill whose +profile and trigger match the task is added to the prompt; each use creates a +scoped `learning.artifact_used` receipt. Skill text is untrusted procedure +data: it cannot grant capabilities, permissions, or approval, and the normal +tool and workspace gates still apply. Learned artifacts remain separately +outcome-verified and follow their canary/promotion lifecycle; plugins remain +hook extensions rather than permission-bearing skill packages. + | # | Registry feature | Bounded effect | |---:|---|---| | 1 | Conversation learning | Extract candidate facts from new conversation logs | diff --git a/learning_engine.py b/learning_engine.py index b7fa3ae8..509b1db2 100644 --- a/learning_engine.py +++ b/learning_engine.py @@ -129,7 +129,8 @@ def artifact_context(self, run: dict[str, str]) -> tuple[str, list[dict[str, Any continue compatible.append(item) artifacts = compatible - receipts = [{"skill_id": item["id"], "version": item["version"], "status": item["status"], + receipts = [{"skill_id": item["id"], "version": item["version"], "source": item.get("source"), + "status": item["status"], "content_hash": item["content_hash"], "chars": len(item["body"]), "utility_per_char": item.get("utility_per_char")} for item in artifacts] for receipt in receipts: diff --git a/skill_registry.py b/skill_registry.py index f87f6638..5467a8f1 100644 --- a/skill_registry.py +++ b/skill_registry.py @@ -32,7 +32,28 @@ def __init__(self, store: EventStore, *, workspace_id: str = "default", root: st self.root.mkdir(parents=True, exist_ok=True) @staticmethod - def _read_manifest(source: Path) -> dict[str, Any]: + def _validate_runtime_match_fields(manifest: dict[str, Any]) -> None: + profiles = manifest.get("profiles") + if ( + not isinstance(profiles, list) + or not 1 <= len(profiles) <= len(LEARNING_PROFILES) + or any(not isinstance(item, str) or item not in LEARNING_PROFILES for item in profiles) + or len(set(profiles)) != len(profiles) + ): + raise ValueError( + "runtime-matchable skills require 1-2 explicit profiles from ['coder', 'researcher']" + ) + triggers = manifest.get("triggers") + if not isinstance(triggers, list) or not 1 <= len(triggers) <= 12: + raise ValueError("runtime-matchable skills require 1-12 triggers") + if any( + not isinstance(item, str) or not re.fullmatch(r"[\w.+#-]{2,40}", item, re.UNICODE) + for item in triggers + ): + raise ValueError("invalid runtime skill trigger") + + @staticmethod + def _read_manifest(source: Path, *, runtime_matchable: bool = False) -> dict[str, Any]: manifest_path = source / "skill.json" if manifest_path.exists(): data = json.loads(manifest_path.read_text(encoding="utf-8")) @@ -84,6 +105,8 @@ def _read_manifest(source: Path) -> dict[str, Any]: raise ValueError("learned artifacts cannot create dynamic tools") if any(section not in body for section in ("## Trigger", "## Steps", "## Verify")): raise ValueError("learned artifact requires Trigger, Steps, and Verify sections") + if runtime_matchable: + SkillRegistry._validate_runtime_match_fields(manifest) return manifest def install(self, source: str | os.PathLike[str], *, activate: bool = False, source_name: str = "local") -> dict[str, Any]: @@ -93,7 +116,7 @@ def install(self, source: str | os.PathLike[str], *, activate: bool = False, sou for child in source_path.rglob("*"): if child.is_symlink(): raise ValueError("Symlinked skill files are not allowed") - manifest = self._read_manifest(source_path) + manifest = self._read_manifest(source_path, runtime_matchable=source_name == "local") target = self.root / manifest["name"] / manifest["version"] target.parent.mkdir(parents=True, exist_ok=True) if target.exists(): @@ -157,7 +180,7 @@ def validate(self, skill_id: str) -> dict[str, Any]: path = Path(skill["path"]).resolve() try: path.relative_to(self.root) - manifest = self._read_manifest(path) + manifest = self._read_manifest(path, runtime_matchable=skill.get("source") == "local") return {"success": True, "checks": ["contained path", "SKILL.md", "manifest", "declared permissions"], "name": manifest["name"], "version": manifest["version"]} except Exception as exc: @@ -185,9 +208,15 @@ def match(self, profile: str, task: str, *, limit: int = 3, max_chars: int = MAX scored: list[tuple[float, dict[str, Any], str]] = [] for skill in self.list(): manifest = skill.get("manifest", {}) - if skill.get("source") != "learned" or skill.get("status") not in {"active", "canary"}: - continue - if manifest.get("profiles") != [profile]: + source = skill.get("source") + status = skill.get("status") + if source == "learned": + if status not in {"active", "canary"} or manifest.get("profiles") != [profile]: + continue + elif source == "local": + if status != "active" or profile not in manifest.get("profiles", []): + continue + else: continue triggers = self._terms(" ".join(str(item) for item in manifest.get("triggers", []))) overlap = len(task_terms & triggers) diff --git a/tests/test_evolution.py b/tests/test_evolution.py index 0084b206..6130dd39 100644 --- a/tests/test_evolution.py +++ b/tests/test_evolution.py @@ -1,3 +1,4 @@ +import json import tempfile import unittest from pathlib import Path @@ -76,6 +77,10 @@ def test_learned_artifact_rejects_secrets_and_cannot_mutate_local_skill(self): source = Path(self.directory.name) / "local" source.mkdir() (source / "SKILL.md").write_text("# User skill\n", encoding="utf-8") + (source / "skill.json").write_text(json.dumps({ + "name": "local-user-skill", "version": "1.0.0", "profiles": ["coder"], + "triggers": ["pytest"], "permissions": [], + }), encoding="utf-8") local = self.registry.install(source) self.assertFalse(self.registry.rollback_learned(local["id"])) diff --git a/tests/test_skill_registry.py b/tests/test_skill_registry.py index b4ebabde..2c18dd9c 100644 --- a/tests/test_skill_registry.py +++ b/tests/test_skill_registry.py @@ -4,6 +4,9 @@ from pathlib import Path from event_store import EventStore +from learning_engine import LearningEngine +from memory import MemoryBank +import main from skill_registry import SkillRegistry @@ -16,7 +19,7 @@ def test_skill_install_validates_permissions_and_requires_explicit_activation(se (source / "SKILL.md").write_text("# Test skill\n", encoding="utf-8") (source / "skill.json").write_text(json.dumps({ "name": "test-skill", "version": "1.0.0", "description": "test", - "permissions": ["workspace.read"], + "permissions": ["workspace.read"], "profiles": ["coder"], "triggers": ["pytest"], }), encoding="utf-8") registry = SkillRegistry(EventStore(root / "state.sqlite3"), root=root / "installed") installed = registry.install(source) @@ -36,6 +39,77 @@ def test_skill_rejects_undeclared_permission(self): with self.assertRaises(ValueError): registry.install(source) + def test_runtime_local_skill_requires_profiles_and_triggers(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + source = root / "source" + source.mkdir() + (source / "SKILL.md").write_text("# Local skill\n", encoding="utf-8") + (source / "skill.json").write_text(json.dumps({ + "name": "missing-runtime-fields", "version": "1.0.0", "permissions": [], + }), encoding="utf-8") + registry = SkillRegistry(EventStore(root / "state.sqlite3"), root=root / "installed") + with self.assertRaisesRegex(ValueError, "profiles"): + registry.install(source) + + def test_active_local_skill_reaches_runtime_prompt_with_auditable_receipt(self): + original_memory = main.memory_bank + original_registry = main.skill_registry + original_engine = main.learning_engine + original_root = main._get_workspace_root() + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + source = root / "source" + source.mkdir() + body = """# Local pytest guidance + +## Trigger +Use this for pytest tasks. + +## Steps +1. Run the focused test first. + +## Verify +Confirm the test result before reporting success. +""" + (source / "SKILL.md").write_text(body, encoding="utf-8") + (source / "skill.json").write_text(json.dumps({ + "name": "local-pytest-guide", "version": "1.0.0", + "description": "A local runtime guide", "permissions": [], + "profiles": ["coder"], "triggers": ["pytest"], + }), encoding="utf-8") + memory = MemoryBank(root / "state.sqlite3", user_id="skill-user", + workspace_id="skill-workspace", session_id="skill-session") + registry = SkillRegistry(memory.store, workspace_id="skill-workspace", root=root / "installed") + engine = LearningEngine(memory, registry=registry) + installed = registry.install(source, activate=True) + self.assertEqual(installed["status"], "active") + run = engine.begin_run("coder", "run pytest for this change") + context, receipts = engine.artifact_context(run) + self.assertIn("Local pytest guidance", context) + self.assertEqual(receipts[0]["source"], "local") + self.assertEqual(registry.match("researcher", "run pytest"), []) + + main.memory_bank = memory + main.skill_registry = registry + main.learning_engine = engine + main._set_workspace_root(root) + try: + messages = main._build_messages("run pytest", context) + finally: + main.memory_bank = original_memory + main.skill_registry = original_registry + main.learning_engine = original_engine + main._set_workspace_root(original_root) + prompt = "\n".join(item["content"] for item in messages) + self.assertIn(body, prompt) + self.assertIn("cannot grant permissions", prompt) + events = memory.store.list_events( + "learning.artifact_used", workspace_id="skill-workspace", session_id="skill-session" + ) + self.assertEqual(len(events), 1) + self.assertEqual(events[0]["payload"]["source"], "local") + if __name__ == "__main__": unittest.main()