skillpool 4.3.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (90) hide show
  1. skillpool/__init__.py +74 -0
  2. skillpool/__main__.py +6 -0
  3. skillpool/adapters/__init__.py +8 -0
  4. skillpool/adapters/base.py +41 -0
  5. skillpool/adapters/claude_adapter.py +36 -0
  6. skillpool/adapters/codex_adapter.py +92 -0
  7. skillpool/adapters/hermes_adapter.py +38 -0
  8. skillpool/audit/__init__.py +651 -0
  9. skillpool/bridge/__init__.py +16 -0
  10. skillpool/bridge/freeze_detector.py +134 -0
  11. skillpool/bridge/maintenance.py +119 -0
  12. skillpool/bridge/wal_manager.py +136 -0
  13. skillpool/clawmem_client.py +176 -0
  14. skillpool/cli.py +700 -0
  15. skillpool/combiner/__init__.py +31 -0
  16. skillpool/combiner/lifecycle.py +453 -0
  17. skillpool/combiner/models.py +99 -0
  18. skillpool/config.py +34 -0
  19. skillpool/cost/__init__.py +111 -0
  20. skillpool/cost/audit_hash.py +51 -0
  21. skillpool/cost/budget_tracker.py +66 -0
  22. skillpool/cost/dashboard.py +189 -0
  23. skillpool/cost/models.py +129 -0
  24. skillpool/cost/token_governor.py +264 -0
  25. skillpool/cost/trace_ceiling.py +38 -0
  26. skillpool/csdf.py +126 -0
  27. skillpool/evolver/__init__.py +978 -0
  28. skillpool/gain/__init__.py +285 -0
  29. skillpool/gate.py +282 -0
  30. skillpool/gate_policy/__init__.py +31 -0
  31. skillpool/gate_policy/incremental.py +157 -0
  32. skillpool/gate_policy/parser.py +258 -0
  33. skillpool/gate_policy/state_machine.py +432 -0
  34. skillpool/graph/__init__.py +14 -0
  35. skillpool/graph/ppr.py +279 -0
  36. skillpool/health/__init__.py +73 -0
  37. skillpool/health/check.py +85 -0
  38. skillpool/health/degradation.py +90 -0
  39. skillpool/health/models.py +43 -0
  40. skillpool/hooks/__init__.py +4 -0
  41. skillpool/hooks/security_scanner.py +288 -0
  42. skillpool/lifecycle.py +150 -0
  43. skillpool/materializer/__init__.py +124 -0
  44. skillpool/materializer/budget_cropper.py +178 -0
  45. skillpool/materializer/csdf_loader.py +114 -0
  46. skillpool/materializer/lazy_loader.py +265 -0
  47. skillpool/materializer/lifecycle_filter.py +93 -0
  48. skillpool/materializer/mapper.py +178 -0
  49. skillpool/materializer/models.py +66 -0
  50. skillpool/mcp_server.py +2005 -0
  51. skillpool/monitor/__init__.py +576 -0
  52. skillpool/monitor/bug_collector.py +392 -0
  53. skillpool/monitor/defect_classifier.py +218 -0
  54. skillpool/monitor/self_healing.py +530 -0
  55. skillpool/monitor/telemetry_bridge.py +197 -0
  56. skillpool/paradigm/__init__.py +312 -0
  57. skillpool/paradigm/override.py +285 -0
  58. skillpool/profile.py +94 -0
  59. skillpool/quality.py +254 -0
  60. skillpool/registry/__init__.py +509 -0
  61. skillpool/registry/models.py +98 -0
  62. skillpool/resolver/__init__.py +320 -0
  63. skillpool/resolver/cache.py +103 -0
  64. skillpool/resolver/circuit_breaker.py +103 -0
  65. skillpool/resolver/conflict_detector.py +111 -0
  66. skillpool/resolver/health_filter.py +38 -0
  67. skillpool/resolver/models.py +154 -0
  68. skillpool/resolver/rate_limiter.py +48 -0
  69. skillpool/resolver/skill_graph.py +183 -0
  70. skillpool/review/__init__.py +242 -0
  71. skillpool/review/async_queue.py +96 -0
  72. skillpool/review/checkpoint_runner.py +345 -0
  73. skillpool/review/models.py +164 -0
  74. skillpool/review/suspect_marker.py +39 -0
  75. skillpool/review/veto_evaluator.py +94 -0
  76. skillpool/router/__init__.py +481 -0
  77. skillpool/schemas.py +119 -0
  78. skillpool/synergy/__init__.py +240 -0
  79. skillpool/synergy/detector.py +5 -0
  80. skillpool/telemetry.py +126 -0
  81. skillpool/utils/__init__.py +21 -0
  82. skillpool/utils/changelog.py +218 -0
  83. skillpool/utils/logger.py +273 -0
  84. skillpool/utils/runtime_audit.py +163 -0
  85. skillpool/utils/time_utils.py +13 -0
  86. skillpool-4.3.0.dist-info/METADATA +21 -0
  87. skillpool-4.3.0.dist-info/RECORD +90 -0
  88. skillpool-4.3.0.dist-info/WHEEL +5 -0
  89. skillpool-4.3.0.dist-info/entry_points.txt +3 -0
  90. skillpool-4.3.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,2005 @@
1
+ """MCP Server — expose skillpool via FastMCP with Resources/Tools/Prompts separation.
2
+
3
+ Architecture (V4.3 dual-channel):
4
+ - CLI (Start Hook): Materialization channel — one-time file writes at session start
5
+ - MCP Resources: Read-only context delivery — skill definitions, audit records
6
+ - MCP Tools: Governance actions — register, transition, gate_check, review, telemetry
7
+ - MCP Prompts: User-controlled templates — skill invocation, review trigger
8
+
9
+ Why Resources vs Tools (per MCP 2025-03-26 spec):
10
+ - Resources = application-controlled, read-only contextual data
11
+ - Tools = model-controlled, executable functions that change state
12
+ - Skill content delivery is read-only context → Resources
13
+ - Governance mutations are state-changing actions → Tools
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import logging
19
+ import os
20
+ import time
21
+ from typing import Optional
22
+
23
+ import yaml
24
+ from fastmcp import FastMCP
25
+ from fastmcp.server.middleware import Middleware
26
+
27
+ from skillpool.materializer.lazy_loader import LazySkillLoader # Part of SkillPool — independent infrastructure
28
+ from skillpool.materializer.csdf_loader import load_csdf as _load_csdf_shared # Part of SkillPool
29
+ from skillpool.hooks.security_scanner import SecurityScanner # Part of SkillPool — independent infrastructure
30
+ from skillpool.telemetry import TelemetryBridge
31
+ from skillpool.gate import GateManager
32
+ from skillpool.profile import (
33
+ CLAUDE_CODE_PROFILE,
34
+ CODEX_PROFILE,
35
+ HERMES_PROFILE,
36
+ OPENCLAW_PROFILE,
37
+ AgentCapabilityProfile,
38
+ )
39
+ from skillpool.audit import AuditLayer
40
+ from skillpool.evolver import EvolverLayer, DefectSeverity
41
+ from skillpool.registry import Registry
42
+ from skillpool.monitor import MonitorLayer, MetricType
43
+ from skillpool.monitor.bug_collector import BugCollector # Part of SkillPool — independent infrastructure
44
+ from skillpool.monitor.self_healing import SelfHealingLoop # Part of SkillPool — independent infrastructure
45
+ from skillpool.health import HealthManager
46
+ from skillpool.config import get_data_dir
47
+
48
+ logger = logging.getLogger("skillpool.mcp")
49
+
50
+ mcp = FastMCP("skillpool", version="4.3.0")
51
+
52
+ # Search-first enforcement: tracks which callers have performed a search.
53
+ # Keyed by agent_type (all agents sharing the same MCP process).
54
+ # This is Agent-neutral — no dependency on Claude Code session IDs.
55
+ _search_done_callers: set[str] = set()
56
+
57
+
58
+ # ═══════════════════════════════════════════════════════════════════
59
+ # MIDDLEWARE — Auth + Logging + Timing
60
+ # ═══════════════════════════════════════════════════════════════════
61
+
62
+
63
+ class AuthMiddleware(Middleware):
64
+ """API key authentication for MCP endpoints.
65
+
66
+ Enabled by setting SKILLPOOL_API_KEY environment variable.
67
+ If not set, authentication is disabled (backward compatible).
68
+ Clients must send the key via Authorization: Bearer <key> header
69
+ or as api_key query parameter in HTTP transport.
70
+ """
71
+
72
+ def __init__(self) -> None:
73
+ self._required_key: str | None = os.environ.get("SKILLPOOL_API_KEY")
74
+ if self._required_key:
75
+ logger.info("mcp_auth_enabled")
76
+ else:
77
+ logger.info("mcp_auth_disabled_no_key_set")
78
+
79
+ @property
80
+ def auth_enabled(self) -> bool:
81
+ return self._required_key is not None
82
+
83
+ def _validate_key(self, context) -> bool:
84
+ """Extract and validate API key from request context."""
85
+ if not self._required_key:
86
+ return True
87
+ # Check Authorization: Bearer <key>
88
+ headers = getattr(context, "headers", None) or {}
89
+ auth_header = headers.get("authorization", "") or headers.get("Authorization", "")
90
+ if auth_header.startswith("Bearer "):
91
+ token = auth_header[7:].strip()
92
+ if token == self._required_key:
93
+ return True
94
+ # Check api_key in message arguments (for tool calls)
95
+ args = getattr(context.message, "arguments", None) or {}
96
+ if isinstance(args, dict) and args.get("api_key") == self._required_key:
97
+ return True
98
+ return False
99
+
100
+ async def on_call_tool(self, context, call_next):
101
+ if not self._validate_key(context):
102
+ logger.warning("mcp_auth_rejected", extra={"tool": context.message.name})
103
+ from fastmcp.utilities.types import TextContent
104
+ from fastmcp.server.types import ToolResult
105
+
106
+ return ToolResult(
107
+ content=[
108
+ TextContent(type="text", text='{"error": "unauthorized", "detail": "Invalid or missing API key"}')
109
+ ]
110
+ )
111
+ return await call_next(context)
112
+
113
+ async def on_read_resource(self, context, call_next):
114
+ if not self._validate_key(context):
115
+ logger.warning("mcp_auth_rejected_resource", extra={"uri": str(getattr(context.message, "uri", "unknown"))})
116
+ from fastmcp.utilities.types import TextContent
117
+
118
+ return [TextContent(type="text", text='{"error": "unauthorized", "detail": "Invalid or missing API key"}')]
119
+ return await call_next(context)
120
+
121
+
122
+ class SkillPoolLoggingMiddleware(Middleware):
123
+ """Log every MCP tool call and resource read."""
124
+
125
+ async def on_call_tool(self, context, call_next):
126
+ tool_name = context.message.name
127
+ args = context.message.arguments or {}
128
+ args_summary = {k: type(v).__name__ for k, v in args.items()} if args else {}
129
+ logger.info("mcp_tool_call_start", extra={"tool": tool_name, "args_summary": args_summary})
130
+ result = await call_next(context)
131
+ status = self._detect_error_status(result)
132
+ logger.info("mcp_tool_call_end", extra={"tool": tool_name, "status": status})
133
+ return result
134
+
135
+ async def on_read_resource(self, context, call_next):
136
+ uri = str(context.message.uri) if hasattr(context.message, "uri") else "unknown"
137
+ logger.info("mcp_resource_read_start", extra={"uri": uri})
138
+
139
+ # Search-first enforcement: soft guidance for Resource reads
140
+ # Hard enforcement is in skill_get Tool (for model-controlled access)
141
+ # Resource reads can't be blocked (application-controlled), so we only log
142
+ uri_str = uri.lower()
143
+ if uri_str.startswith("skill://") and uri_str != "skill://list" and uri_str != "skill://graph":
144
+ if not _search_done_callers:
145
+ logger.info("search_enforcement_soft", extra={"uri": uri, "note": "skill_get Tool enforces hard block"})
146
+
147
+ result = await call_next(context)
148
+ logger.info("mcp_resource_read_end", extra={"uri": uri, "status": "success"})
149
+ return result
150
+
151
+ @staticmethod
152
+ def _detect_error_status(result) -> str:
153
+ """Detect error from ToolResult — no is_error field on FastMCP ToolResult."""
154
+ # Check structured_content for error key
155
+ sc = getattr(result, "structured_content", None)
156
+ if isinstance(sc, dict) and "error" in sc:
157
+ return "error"
158
+ # Check content text for error indicators
159
+ content = getattr(result, "content", [])
160
+ for block in content:
161
+ text = getattr(block, "text", "")
162
+ if isinstance(text, str) and '"error"' in text:
163
+ return "error"
164
+ return "success"
165
+
166
+
167
+ class TimingMiddleware(Middleware):
168
+ """Track execution time for tool calls and resource reads; store in MonitorLayer."""
169
+
170
+ def __init__(self, monitor: MonitorLayer) -> None:
171
+ self._monitor = monitor
172
+
173
+ async def on_call_tool(self, context, call_next):
174
+ tool_name = context.message.name
175
+ start = time.monotonic()
176
+ result = await call_next(context)
177
+ elapsed_ms = (time.monotonic() - start) * 1000
178
+ success = SkillPoolLoggingMiddleware._detect_error_status(result) == "success"
179
+ self._monitor.record_latency(
180
+ skill_id=f"mcp_tool:{tool_name}",
181
+ latency_ms=elapsed_ms,
182
+ success=success,
183
+ )
184
+ logger.debug("mcp_tool_timing", extra={"tool": tool_name, "elapsed_ms": round(elapsed_ms, 2)})
185
+ return result
186
+
187
+ async def on_read_resource(self, context, call_next):
188
+ uri = str(context.message.uri) if hasattr(context.message, "uri") else "unknown"
189
+ start = time.monotonic()
190
+ result = await call_next(context)
191
+ elapsed_ms = (time.monotonic() - start) * 1000
192
+ self._monitor.record_metric(
193
+ name="mcp_resource_read_latency_ms",
194
+ value=elapsed_ms,
195
+ metric_type=MetricType.HISTOGRAM,
196
+ labels={"uri": uri},
197
+ )
198
+ logger.debug("mcp_resource_timing", extra={"uri": uri, "elapsed_ms": round(elapsed_ms, 2)})
199
+ return result
200
+
201
+
202
+ _PROFILES: dict[str, AgentCapabilityProfile] = {
203
+ "claude-code": CLAUDE_CODE_PROFILE,
204
+ "codex": CODEX_PROFILE,
205
+ "hermes": HERMES_PROFILE,
206
+ "openclaw": OPENCLAW_PROFILE,
207
+ }
208
+
209
+ _SKILLPOOL_DIR = get_data_dir()
210
+ _SKILLS_DIR = _SKILLPOOL_DIR / "skills"
211
+
212
+ # Resource cache — TTL-based LRU for skill definitions/rules/manifests
213
+ _RESOURCE_CACHE: dict[str, tuple[float, object]] = {} # uri → (timestamp, result)
214
+ _RESOURCE_CACHE_TTL = 60.0 # seconds — stale entries expire after 1 minute
215
+ _RESOURCE_CACHE_MAX = 50 # max cached entries
216
+
217
+ # Shared instances (lazy-initialized)
218
+ # Part of SkillPool — independent infrastructure, shared by all agents
219
+ _audit = AuditLayer()
220
+ _evolver = EvolverLayer(audit_layer=_audit)
221
+ _registry = Registry(audit_layer=_audit)
222
+ _monitor = MonitorLayer(audit_layer=_audit)
223
+ _health = HealthManager(monitor=_monitor)
224
+ _lazy_loader = LazySkillLoader()
225
+ _bug_collector = BugCollector() # Part of SkillPool — independent infrastructure
226
+ _self_healing = SelfHealingLoop(bug_collector=_bug_collector, evolver=_evolver) # Part of SkillPool
227
+ _security_scanner = SecurityScanner() # Part of SkillPool
228
+
229
+ # Register middleware on the MCP instance
230
+ mcp.add_middleware(AuthMiddleware())
231
+ mcp.add_middleware(SkillPoolLoggingMiddleware())
232
+ mcp.add_middleware(TimingMiddleware(monitor=_monitor))
233
+
234
+
235
+ def _get_profile(name: str) -> AgentCapabilityProfile:
236
+ """Resolve profile by name. Raises ValueError for unknown agent types."""
237
+ if name not in _PROFILES:
238
+ raise ValueError(f"Unknown agent_type '{name}'. Must be one of: {', '.join(_PROFILES.keys())}")
239
+ return _PROFILES[name]
240
+
241
+
242
+ def _cached_resource(uri: str, compute_fn):
243
+ """TTL-based cache for MCP Resources. Returns cached value if fresh."""
244
+ now = time.monotonic()
245
+ if uri in _RESOURCE_CACHE:
246
+ ts, val = _RESOURCE_CACHE[uri]
247
+ if now - ts < _RESOURCE_CACHE_TTL:
248
+ return val
249
+ # Evict oldest if at capacity
250
+ if len(_RESOURCE_CACHE) >= _RESOURCE_CACHE_MAX:
251
+ oldest = min(_RESOURCE_CACHE, key=lambda k: _RESOURCE_CACHE[k][0])
252
+ del _RESOURCE_CACHE[oldest]
253
+ val = compute_fn()
254
+ _RESOURCE_CACHE[uri] = (now, val)
255
+ return val
256
+
257
+
258
+ def _load_csdf(skill_id: str) -> dict | None:
259
+ """Load CSDF YAML for a skill_id from ~/.skillpool/skills/.
260
+
261
+ Delegates to the shared csdf_loader module.
262
+ """
263
+ # Part of SkillPool — independent infrastructure, shared by all agents
264
+ return _load_csdf_shared(skill_id, _SKILLS_DIR)
265
+
266
+
267
+ # ═══════════════════════════════════════════════════════════════════
268
+ # RESOURCES — Application-controlled, read-only context delivery
269
+ # ═══════════════════════════════════════════════════════════════════
270
+
271
+
272
+ @mcp.resource("skill://list")
273
+ def skill_list() -> list[dict]:
274
+ """Return lightweight metadata for all available skills (cached 60s).
275
+
276
+ Returns only id, name, version, dimension, tags — no full content.
277
+ Uses LazySkillLoader L0 tier for token-efficient delivery (~50 tokens/skill).
278
+
279
+ Use skill://{skill_id}/summary for medium detail (~200 tokens).
280
+ Use skill://{skill_id}/definition for full content.
281
+ """
282
+ # Part of SkillPool — independent infrastructure, shared by all agents
283
+ return _cached_resource("skill://list", lambda: _compute_skill_list())
284
+
285
+
286
+ def _compute_skill_list() -> list[dict]:
287
+ """Compute skill list from filesystem (uncached)."""
288
+ skills = []
289
+ if not _SKILLS_DIR.exists():
290
+ return skills
291
+
292
+ # Collect all skill IDs from filesystem
293
+ skill_ids = []
294
+ # 1. CSDF YAML skills
295
+ for yaml_file in sorted(_SKILLS_DIR.glob("*.yaml")):
296
+ if yaml_file.name == "skill_graph.yaml":
297
+ continue
298
+ # Extract skill_id from filename: "S09-resilience-degradation" → "S09"
299
+ stem = yaml_file.stem
300
+ skill_id = stem.split("-")[0] if stem[0:2].isupper() else stem
301
+ skill_ids.append(skill_id)
302
+ # 2. Directory-based skills
303
+ for skill_dir in sorted(_SKILLS_DIR.iterdir()):
304
+ if skill_dir.is_dir() and (skill_dir / "SKILL.md").exists():
305
+ skill_ids.append(skill_dir.name)
306
+
307
+ # Use LazySkillLoader for L0 metadata
308
+ loaded = _lazy_loader.preload(skill_ids, tier="L0")
309
+ for skill_id, data in sorted(loaded.items()):
310
+ skills.append(data)
311
+
312
+ return skills
313
+
314
+
315
+ @mcp.resource("skill://{skill_id}/definition")
316
+ def skill_definition(skill_id: str) -> str:
317
+ """Return the full SKILL.md content for a skill.
318
+
319
+ Uses LazySkillLoader L2 tier (full materialization) with in-memory cache.
320
+ First call materializes, subsequent calls return cached result.
321
+ """
322
+ # Part of SkillPool — independent infrastructure, shared by all agents
323
+ try:
324
+ data = _lazy_loader.load(skill_id, tier="L2")
325
+ # For directory-based skills, prefer the raw SKILL.md body over
326
+ # the Materializer-generated summary (which only uses frontmatter fields)
327
+ if "_markdown_body" in data and data["_markdown_body"]:
328
+ return data["_markdown_body"]
329
+ if "markdown" in data and data["markdown"]:
330
+ return data["markdown"]
331
+ # Materialization failed — return partial info
332
+ name = data.get("name", skill_id)
333
+ return f"# {name}\n\nContent unavailable (materialization errors: {data.get('_materialization_errors', [])})"
334
+ except ValueError:
335
+ return f"Skill not found: {skill_id}"
336
+
337
+
338
+ @mcp.resource("skill://{skill_id}/summary")
339
+ def skill_summary(skill_id: str) -> dict:
340
+ """Return medium-detail metadata for a skill (~200 tokens).
341
+
342
+ Uses LazySkillLoader L1 tier: includes description, triggers,
343
+ veto rules, and dependencies — enough to decide whether to load
344
+ the full definition.
345
+ """
346
+ # Part of SkillPool — independent infrastructure, shared by all agents
347
+ try:
348
+ return _lazy_loader.load(skill_id, tier="L1")
349
+ except ValueError:
350
+ return {"error": f"Skill not found: {skill_id}"}
351
+
352
+
353
+ @mcp.resource("skill://{skill_id}/manifest.yaml")
354
+ def skill_manifest(skill_id: str) -> dict:
355
+ """Return the dependency manifest for a skill.
356
+
357
+ Includes: dependencies, conflicts, requires, veto_rule.
358
+ """
359
+ # Part of SkillPool — independent infrastructure, shared by all agents
360
+ csdf = _load_csdf(skill_id)
361
+ if csdf is None:
362
+ return {"error": f"Skill not found: {skill_id}"}
363
+
364
+ return {
365
+ "id": csdf.get("id", skill_id),
366
+ "name": csdf.get("name", ""),
367
+ "version": csdf.get("version", ""),
368
+ "description": csdf.get("description", ""),
369
+ "dimension": csdf.get("dimension", ""),
370
+ "dependencies": csdf.get("dependencies", []),
371
+ "conflicts": csdf.get("conflicts", []),
372
+ "requires": csdf.get("requires", []),
373
+ "synergies": csdf.get("synergies", []),
374
+ "values": csdf.get("values", []),
375
+ "veto_rule": csdf.get("veto_rule", ""),
376
+ "weight": csdf.get("weight", 0),
377
+ }
378
+
379
+
380
+ @mcp.resource("skill://{skill_id}/x-execution")
381
+ def skill_execution(skill_id: str) -> dict:
382
+ """Return the execution method for a skill.
383
+
384
+ Describes how the skill is invoked: prompt, script, or mcp_tool.
385
+ """
386
+ # Part of SkillPool — independent infrastructure, shared by all agents
387
+ csdf = _load_csdf(skill_id)
388
+ if csdf is None:
389
+ return {"error": f"Skill not found: {skill_id}"}
390
+
391
+ input_schema = csdf.get("input_schema", {})
392
+ output_schema = csdf.get("output_schema", {})
393
+
394
+ return {
395
+ "id": csdf.get("id", skill_id),
396
+ "execution_type": "prompt", # All current skills are prompt-based
397
+ "input_schema": input_schema,
398
+ "output_schema": output_schema,
399
+ "checklist": csdf.get("checklist", []),
400
+ }
401
+
402
+
403
+ @mcp.resource("skill://{skill_id}/rules")
404
+ def skill_rules(skill_id: str) -> str:
405
+ """Return the full RULES.md content for a directory-based skill.
406
+
407
+ For skills like multi-dim-review that have a separate RULES.md with
408
+ detailed scoring rules, veto conditions, and blind spot management.
409
+ Returns empty string if no RULES.md exists.
410
+ """
411
+ # Part of SkillPool — independent infrastructure, shared by all agents
412
+ skill_dir = _SKILLS_DIR / skill_id
413
+ rules_path = skill_dir / "RULES.md"
414
+ if rules_path.exists():
415
+ return rules_path.read_text(encoding="utf-8")
416
+ # Not a directory-based skill or no RULES.md
417
+ return ""
418
+
419
+
420
+ @mcp.resource("skill://graph")
421
+ def skill_graph() -> dict:
422
+ """Return the skill dependency graph (DAG structure, cached 60s)."""
423
+ # Part of SkillPool — independent infrastructure, shared by all agents
424
+ return _cached_resource("skill://graph", lambda: _compute_skill_graph())
425
+
426
+
427
+ def _compute_skill_graph() -> dict:
428
+ """Compute skill graph from filesystem (uncached)."""
429
+ graph_path = _SKILLS_DIR / "skill_graph.yaml"
430
+ if not graph_path.exists():
431
+ return {"error": "skill_graph.yaml not found"}
432
+
433
+ try:
434
+ return yaml.safe_load(graph_path.read_text()) or {}
435
+ except Exception as e:
436
+ return {"error": f"Failed to load graph: {e}"}
437
+
438
+
439
+ @mcp.resource("audit://records/{cursor}")
440
+ def audit_records(cursor: int = 0, limit: int = 100) -> dict:
441
+ """Return audit records with pagination (read-only, immutable).
442
+
443
+ Audit records cannot be modified via MCP — only read for traceability.
444
+
445
+ Args:
446
+ cursor: Offset index to start from (0-based).
447
+ limit: Maximum number of records to return (1-500, default 100).
448
+ """
449
+ limit = max(1, min(limit, 500))
450
+ records = _audit.get_records()
451
+ total = len(records)
452
+ page = records[cursor : cursor + limit]
453
+ return {
454
+ "total": total,
455
+ "cursor": cursor,
456
+ "limit": limit,
457
+ "next_cursor": cursor + limit if cursor + limit < total else None,
458
+ "records": [
459
+ {
460
+ "audit_id": r.audit_id,
461
+ "action": r.action,
462
+ "actor": r.actor,
463
+ "resource_id": r.resource_id,
464
+ "result": r.result,
465
+ "severity": r.severity,
466
+ "timestamp": r.created_at,
467
+ "chain_index": r.chain_index,
468
+ }
469
+ for r in page
470
+ ],
471
+ }
472
+
473
+
474
+ @mcp.resource("bug://list/{cursor}")
475
+ def bug_list(cursor: int = 0, limit: int = 100) -> dict:
476
+ """Return collected bug records with pagination from BugCollector.
477
+
478
+ Query bug records captured by the 4-stage pipeline
479
+ (Capture→Enrich→Filter→Persist). Read-only via MCP.
480
+
481
+ Args:
482
+ cursor: Offset index to start from (0-based).
483
+ limit: Maximum number of records to return (1-500, default 100).
484
+ """
485
+ # Part of SkillPool — independent infrastructure, shared by all agents
486
+ limit = max(1, min(limit, 500))
487
+ bugs = _bug_collector.get_bugs()
488
+ total = len(bugs)
489
+ page = bugs[cursor : cursor + limit]
490
+ return {
491
+ "total": total,
492
+ "cursor": cursor,
493
+ "limit": limit,
494
+ "next_cursor": cursor + limit if cursor + limit < total else None,
495
+ "bugs": [b.to_dict() for b in page],
496
+ }
497
+
498
+
499
+ # ═══════════════════════════════════════════════════════════════════
500
+ # TOOLS — Model-controlled, state-changing governance actions
501
+ # ═══════════════════════════════════════════════════════════════════
502
+
503
+
504
+ @mcp.tool()
505
+ def gate_check(csdf: dict, profile_name: str) -> dict:
506
+ """Check gate decision for a CSDF skill.
507
+
508
+ Returns ALLOW/GUARD/ESCALATE/DENY based on complexity and profile.
509
+ On timeout or error, returns DENY (safe-deny default).
510
+
511
+ Args:
512
+ csdf: CSDF skill definition dict
513
+ profile_name: Agent profile name — MUST be one of: claude-code, codex, hermes, openclaw
514
+ """
515
+ # Part of SkillPool — independent infrastructure, shared by all agents
516
+ try:
517
+ profile = _get_profile(profile_name)
518
+ gate = GateManager(profile=profile)
519
+ result = gate.check(csdf)
520
+ return {
521
+ "decision": str(result.decision),
522
+ "reason": result.reason,
523
+ "complexity_level": result.complexity.level if result.complexity else None,
524
+ "complexity_total": result.complexity.total if result.complexity else None,
525
+ "conditions": result.conditions,
526
+ }
527
+ except Exception as e:
528
+ # Safe-deny: gate_check failure defaults to DENY
529
+ return {
530
+ "decision": "DENY",
531
+ "reason": f"gate_check error (safe-deny): {type(e).__name__}: {e}",
532
+ "complexity_level": None,
533
+ "complexity_total": None,
534
+ "conditions": [],
535
+ }
536
+
537
+
538
+ @mcp.tool()
539
+ def gate_check_with_policy(
540
+ csdf: dict,
541
+ profile_name: str,
542
+ policy_path: str = "",
543
+ changed_files: Optional[list[str]] = None,
544
+ ) -> dict:
545
+ """Gate check with policy-based 4D phase enforcement.
546
+
547
+ Extends gate_check with gate.policy integration for:
548
+ - Path-based complexity level resolution
549
+ - Incremental mode (git diff changed files)
550
+ - Phase gate artifact validation
551
+ - Emergency bypass awareness
552
+
553
+ On timeout or error, returns DENY (safe-deny default).
554
+
555
+ Args:
556
+ csdf: CSDF skill definition dict
557
+ profile_name: Agent profile name — MUST be one of: claude-code, codex, hermes, openclaw
558
+ policy_path: Path to gate.policy YAML file
559
+ changed_files: Optional list of changed files for incremental mode
560
+ """
561
+ # Part of SkillPool — independent infrastructure, shared by all agents
562
+ try:
563
+ profile = _get_profile(profile_name)
564
+ gate = GateManager(profile=profile)
565
+
566
+ from pathlib import Path as _Path
567
+
568
+ pp = _Path(policy_path) if policy_path else None
569
+
570
+ result = gate.check_with_policy(
571
+ csdf,
572
+ policy_path=pp,
573
+ changed_files=changed_files,
574
+ )
575
+
576
+ state_dict = None
577
+ if result.state:
578
+ state_dict = result.state.model_dump(mode="json")
579
+
580
+ return {
581
+ "decision": str(result.decision),
582
+ "reason": result.reason,
583
+ "complexity_level": result.complexity.level if result.complexity else None,
584
+ "complexity_total": result.complexity.total if result.complexity else None,
585
+ "conditions": result.conditions,
586
+ "policy_level": result.policy_level,
587
+ "skip_phases": result.skip_phases,
588
+ "state": state_dict,
589
+ }
590
+ except Exception as e:
591
+ return {
592
+ "decision": "DENY",
593
+ "reason": f"gate_check_with_policy error (safe-deny): {type(e).__name__}: {e}",
594
+ "complexity_level": None,
595
+ "complexity_total": None,
596
+ "conditions": [],
597
+ "policy_level": None,
598
+ "skip_phases": [],
599
+ "state": None,
600
+ }
601
+
602
+
603
+ @mcp.tool()
604
+ def telemetry_report(
605
+ event_type: str,
606
+ skill_id: str,
607
+ channel: str = "hook",
608
+ payload: Optional[dict] = None,
609
+ trace_id: str = "",
610
+ ) -> dict:
611
+ """Report a telemetry event.
612
+
613
+ On failure, silently drops the event and returns error info
614
+ (does not block the caller).
615
+
616
+ Args:
617
+ event_type: Event type string (e.g., skill_used, skill_error)
618
+ skill_id: Skill ID this event relates to
619
+ channel: Telemetry channel (hook, mcp, log_file)
620
+ payload: Optional event payload dict
621
+ trace_id: Optional W3C trace ID
622
+ """
623
+ # Part of SkillPool — independent infrastructure, shared by all agents
624
+ try:
625
+ bridge = TelemetryBridge()
626
+ event = bridge.emit(
627
+ event_type=event_type,
628
+ skill_id=skill_id,
629
+ channel=channel,
630
+ payload=payload or {},
631
+ trace_id=trace_id,
632
+ )
633
+ return {
634
+ "event_type": event.event_type,
635
+ "skill_id": event.skill_id,
636
+ "channel": str(event.channel),
637
+ "timestamp": event.timestamp,
638
+ }
639
+ except Exception as e:
640
+ # Silent failure — telemetry should not block operations
641
+ return {
642
+ "event_type": event_type,
643
+ "skill_id": skill_id,
644
+ "error": f"telemetry dropped: {type(e).__name__}: {e}",
645
+ "fallback": "event logged locally for retry",
646
+ }
647
+
648
+
649
+ @mcp.tool()
650
+ def audit_verify() -> dict:
651
+ """Verify the integrity of the audit hash chain.
652
+
653
+ Returns True if all records form a valid chain, False if tampered.
654
+ On error, returns integrity=False with error details.
655
+ """
656
+ # Part of SkillPool — independent infrastructure, shared by all agents
657
+ try:
658
+ return {
659
+ "integrity": _audit.verify_integrity(),
660
+ "record_count": _audit.get_record_count(),
661
+ }
662
+ except Exception as e:
663
+ return {
664
+ "integrity": False,
665
+ "record_count": 0,
666
+ "error": f"audit_verify error: {type(e).__name__}",
667
+ }
668
+
669
+
670
+ # ── Registry tools ──
671
+
672
+
673
+ @mcp.tool()
674
+ def skill_register(
675
+ skill_id: str,
676
+ name: str,
677
+ version: str,
678
+ sbom_ref: str = "",
679
+ provenance_ref: str = "",
680
+ source_pin: str = "",
681
+ signature_ref: str = "",
682
+ trace_id: str = "",
683
+ ) -> dict:
684
+ """Register a skill candidate into the Registry.
685
+
686
+ Requires SPDX SBOM, SLSA provenance, source pin, and signature evidence.
687
+ Skill enters 'testing' state (not production-routable).
688
+
689
+ Args:
690
+ skill_id: Unique skill identifier
691
+ name: Human-readable skill name
692
+ version: Semantic version string
693
+ sbom_ref: SPDX SBOM reference
694
+ provenance_ref: SLSA provenance reference
695
+ source_pin: Source pin (sha256 or version)
696
+ signature_ref: Signature reference
697
+ """
698
+ # Part of SkillPool — independent infrastructure, shared by all agents
699
+ from skillpool.registry.models import RegisterSkillRequest, SkillMetadata
700
+
701
+ meta = SkillMetadata(
702
+ skill_id=skill_id,
703
+ name=name,
704
+ version=version,
705
+ security={
706
+ "sbom_ref": sbom_ref,
707
+ "provenance_ref": provenance_ref,
708
+ "source_pin": source_pin,
709
+ "signature_ref": signature_ref,
710
+ },
711
+ )
712
+ req = RegisterSkillRequest(skill_metadata=meta)
713
+ try:
714
+ resp = _registry.register_candidate(req)
715
+ return {"skill_id": resp.skill_id, "status": resp.status, "audit_ref": resp.audit_ref}
716
+ except Exception as e:
717
+ return {"error": type(e).__name__, "detail": str(e)}
718
+
719
+
720
+ @mcp.tool()
721
+ def skill_transition(
722
+ skill_id: str,
723
+ from_status: str,
724
+ to_status: str,
725
+ sandbox_result: str = "",
726
+ policy_approval: bool = False,
727
+ trace_id: str = "",
728
+ ) -> dict:
729
+ """Transition a skill's lifecycle state in the Registry.
730
+
731
+ Args:
732
+ skill_id: Skill to transition
733
+ from_status: Current state (draft/imported/testing/enabled/disabled/deprecated)
734
+ to_status: Target state
735
+ sandbox_result: "pass" required for enabled state
736
+ policy_approval: True required for enabled state
737
+ trace_id: Optional W3C TraceContext trace_id for cross-Agent correlation
738
+ """
739
+ # Part of SkillPool — independent infrastructure, shared by all agents
740
+ from skillpool.registry.models import StateTransitionRequest, SkillStatus
741
+
742
+ try:
743
+ req = StateTransitionRequest(
744
+ from_status=SkillStatus(from_status),
745
+ to_status=SkillStatus(to_status),
746
+ )
747
+ resp = _registry.transition_state(
748
+ skill_id,
749
+ req,
750
+ sandbox_result=sandbox_result or None,
751
+ policy_approval=policy_approval,
752
+ )
753
+ if trace_id and _audit:
754
+ _audit.append(action="skill_transition", object_id=skill_id, result=to_status, trace_id=trace_id)
755
+ return {
756
+ "skill_id": resp.skill_id,
757
+ "from_status": resp.from_status,
758
+ "to_status": resp.to_status,
759
+ "audit_ref": resp.audit_ref,
760
+ "trace_id": trace_id,
761
+ }
762
+ except Exception as e:
763
+ return {"error": type(e).__name__, "detail": str(e)}
764
+
765
+
766
+ @mcp.tool()
767
+ def skill_status(skill_id: str) -> dict:
768
+ """Query a skill's current lifecycle state and metadata.
769
+
770
+ All Agents share the same Registry via SkillPool MCP — any state
771
+ change made by one Agent (via skill_transition) is immediately
772
+ visible to other Agents via this tool.
773
+
774
+ Args:
775
+ skill_id: Skill ID to query.
776
+ """
777
+ try:
778
+ record = _registry.get_skill(skill_id)
779
+ if record is None:
780
+ return {"skill_id": skill_id, "status": "not_found"}
781
+ return {
782
+ "skill_id": record.metadata.skill_id,
783
+ "name": record.metadata.name,
784
+ "version": record.metadata.version,
785
+ "status": str(record.metadata.status),
786
+ "enabled": _registry.is_enabled(skill_id),
787
+ }
788
+ except Exception as e:
789
+ return {"skill_id": skill_id, "error": type(e).__name__, "detail": str(e)}
790
+
791
+
792
+ # ── Evolver tools ──
793
+
794
+
795
+ @mcp.tool()
796
+ def evolution_trigger(
797
+ skill_id: str,
798
+ version: str,
799
+ severity: str,
800
+ description: str,
801
+ trace_id: str = "",
802
+ ) -> dict:
803
+ """Record a defect that may trigger skill evolution.
804
+
805
+ Args:
806
+ skill_id: Skill with the defect
807
+ version: Version string
808
+ severity: "critical", "major", or "minor"
809
+ description: Defect description
810
+ """
811
+ # Part of SkillPool — independent infrastructure, shared by all agents
812
+ try:
813
+ defect = _evolver.record_defect(
814
+ skill_id=skill_id,
815
+ version=version,
816
+ severity=DefectSeverity(severity),
817
+ description=description,
818
+ )
819
+ pending = _evolver.get_pending_evolutions()
820
+ return {
821
+ "defect_id": defect.defect_id,
822
+ "severity": defect.severity.value,
823
+ "evolution_queued": len(pending) > 0,
824
+ "pending_evolutions": len(pending),
825
+ }
826
+ except (ValueError, KeyError) as e:
827
+ return {"error": type(e).__name__, "detail": str(e)}
828
+
829
+
830
+ @mcp.tool()
831
+ def evolution_proposal(
832
+ reason: str,
833
+ risk: str = "medium",
834
+ trace_id: str = "",
835
+ ) -> dict:
836
+ """Create a recommendation-only evolution proposal.
837
+
838
+ IMPORTANT: This does NOT mutate any Registry state.
839
+ It only creates a proposal for human review.
840
+
841
+ Args:
842
+ reason: Context/reason for the evolution
843
+ risk: Risk level ("low", "medium", "high")
844
+ """
845
+ # Part of SkillPool — independent infrastructure, shared by all agents
846
+ proposal = _evolver.create_proposal(
847
+ context={"reason": reason},
848
+ risk=risk,
849
+ )
850
+ return {
851
+ "proposal_id": proposal.proposal_id,
852
+ "recommendation_only": proposal.recommendation_only,
853
+ "risk": proposal.risk,
854
+ "audit_ref": proposal.audit_ref,
855
+ }
856
+
857
+
858
+ # ── Monitor tools ──
859
+
860
+
861
+ @mcp.tool()
862
+ def monitor_evaluate(
863
+ skill_id: str,
864
+ error_rate: float = 0.0,
865
+ security_issues: int = 0,
866
+ coverage: float = 0.5,
867
+ doc_completeness: float = 0.5,
868
+ p99_latency_ms: float = 1000.0,
869
+ update_frequency_days: float = 30.0,
870
+ resource_efficiency: float = 0.5,
871
+ ) -> dict:
872
+ """Perform five-dimension evaluation on a skill.
873
+
874
+ Args:
875
+ skill_id: Skill to evaluate
876
+ error_rate: Error rate (0.0-1.0)
877
+ security_issues: Number of security issues
878
+ coverage: Test coverage (0.0-1.0)
879
+ doc_completeness: Documentation completeness (0.0-1.0)
880
+ p99_latency_ms: P99 latency in milliseconds
881
+ update_frequency_days: Days since last update
882
+ resource_efficiency: Resource efficiency (0.0-1.0)
883
+ """
884
+ # Part of SkillPool — independent infrastructure, shared by all agents
885
+ try:
886
+ eval_ = _monitor.evaluate_skill(
887
+ skill_id,
888
+ {
889
+ "error_rate": error_rate,
890
+ "security_issues": security_issues,
891
+ "coverage": coverage,
892
+ "doc_completeness": doc_completeness,
893
+ "p99_latency_ms": p99_latency_ms,
894
+ "update_frequency_days": update_frequency_days,
895
+ "resource_efficiency": resource_efficiency,
896
+ },
897
+ )
898
+ return {
899
+ "skill_id": eval_.skill_id,
900
+ "overall_score": round(eval_.overall_score, 4),
901
+ "safety": {"score": eval_.safety_score, "level": eval_.safety.value},
902
+ "completeness": {"score": eval_.completeness_score, "level": eval_.completeness.value},
903
+ "executability": {"score": eval_.executability_score, "level": eval_.executability.value},
904
+ "maintainability": {"score": eval_.maintainability_score, "level": eval_.maintainability.value},
905
+ "cost_awareness": {"score": eval_.cost_awareness_score, "level": eval_.cost_awareness.value},
906
+ }
907
+ except Exception as e:
908
+ return {
909
+ "skill_id": skill_id,
910
+ "overall_score": 0.0,
911
+ "error": f"monitor_evaluate error: {type(e).__name__}",
912
+ }
913
+
914
+
915
+ # ── Health tools ──
916
+
917
+
918
+ @mcp.tool()
919
+ def health_check(include_gateway: bool = False) -> dict:
920
+ """Run health checks on all registered components and return status.
921
+
922
+ On error, returns DEGRADED status.
923
+
924
+ Args:
925
+ include_gateway: If True, also check vMCP Gateway /health endpoint.
926
+ """
927
+ # Part of SkillPool — independent infrastructure, shared by all agents
928
+ try:
929
+ resp = _health.check_health()
930
+ result = {
931
+ "status": str(resp.status),
932
+ "components": [
933
+ {
934
+ "component": c.component,
935
+ "status": str(c.status),
936
+ "critical": c.critical,
937
+ "message": c.message,
938
+ }
939
+ for c in resp.components
940
+ ],
941
+ "degradation_level": str(_health.get_degradation_level()),
942
+ }
943
+
944
+ if include_gateway:
945
+ try:
946
+ import httpx
947
+
948
+ with httpx.Client(timeout=3.0) as client:
949
+ gw_resp = client.get("http://127.0.0.1:9000/health")
950
+ result["gateway"] = (
951
+ gw_resp.json()
952
+ if gw_resp.status_code == 200
953
+ else {
954
+ "status": "unreachable",
955
+ "http_status": gw_resp.status_code,
956
+ }
957
+ )
958
+ except Exception as e:
959
+ result["gateway"] = {"status": "unreachable", "error": str(e)}
960
+
961
+ return result
962
+ except Exception as e:
963
+ return {
964
+ "status": "DEGRADED",
965
+ "error": f"health_check error: {type(e).__name__}: {e}",
966
+ "components": [],
967
+ "degradation_level": "unknown",
968
+ }
969
+
970
+
971
+ # ── Review tool ──
972
+
973
+
974
+ @mcp.tool()
975
+ def review_trigger(
976
+ checkpoint: str = "L2",
977
+ skill_ids: Optional[list[str]] = None,
978
+ trace_id: str = "",
979
+ ) -> dict:
980
+ """Trigger a review checkpoint (L1-L4).
981
+
982
+ L1: DocsDD — 7-dim shadow review (non-blocking)
983
+ L2: SDD — 12-dim full review + VETO V1-V6
984
+ L3: BDD — baseline 5-dim + all VETO
985
+ L4: TDD — baseline regression, new blind spots only
986
+
987
+ Args:
988
+ checkpoint: Review level (L1, L2, L3, L4)
989
+ skill_ids: Optional list of skill IDs to review (empty = all)
990
+ """
991
+ # Part of SkillPool — independent infrastructure, shared by all agents
992
+ from skillpool.review import ReviewManager
993
+ from skillpool.review.models import CheckpointLevel, ReviewTrigger, ReviewTriggerRequest
994
+
995
+ try:
996
+ rm = ReviewManager()
997
+ request = ReviewTriggerRequest(
998
+ trigger=ReviewTrigger.MANUAL,
999
+ checkpoint=CheckpointLevel(checkpoint),
1000
+ affected_skills=skill_ids or ["all"],
1001
+ )
1002
+ result = rm.trigger(request)
1003
+ return {
1004
+ "status": result.status.value,
1005
+ "review_id": result.review_id,
1006
+ "checkpoint": checkpoint,
1007
+ "scores": result.scores,
1008
+ "veto_triggered": result.veto_triggered,
1009
+ "veto_details": [
1010
+ {
1011
+ "rule": v.rule.value,
1012
+ "dimension": v.dimension,
1013
+ "score": v.score,
1014
+ "threshold": v.threshold,
1015
+ "blocks": v.blocks,
1016
+ "decision": "block" if v.blocks else "risk_notice",
1017
+ "reason": v.recommendation,
1018
+ }
1019
+ for v in result.veto_details
1020
+ ],
1021
+ "suspect_skills": [
1022
+ {"skill_id": s.skill_id, "reason": s.reason, "dimension": s.suspected_dimension}
1023
+ for s in result.suspect_skills
1024
+ ],
1025
+ "recommendation": result.recommendation.value,
1026
+ "duration_ms": result.duration_ms,
1027
+ }
1028
+ except Exception as e:
1029
+ return {"error": type(e).__name__, "detail": str(e)}
1030
+
1031
+
1032
+ @mcp.tool()
1033
+ def security_scan(content: str, skill_id: str = "") -> dict:
1034
+ """Scan skill content for security threats before materialization.
1035
+
1036
+ Runs YAML safety checks, dangerous pattern scanning, and signature
1037
+ verification (placeholder). Returns threat level and details.
1038
+
1039
+ Args:
1040
+ content: The skill content (YAML or SKILL.md) to scan.
1041
+ skill_id: Optional skill ID for context (used in warnings).
1042
+ """
1043
+ # Part of SkillPool — independent infrastructure, shared by all agents
1044
+ try:
1045
+ result = _security_scanner.full_check(content)
1046
+ return {
1047
+ "skill_id": skill_id,
1048
+ "threat_level": result.threat_level.value,
1049
+ "is_safe": result.is_safe,
1050
+ "checks_passed": result.checks_passed,
1051
+ "warnings": result.warnings,
1052
+ "blockers": result.blockers,
1053
+ }
1054
+ except Exception as e:
1055
+ return {
1056
+ "skill_id": skill_id,
1057
+ "threat_level": "critical",
1058
+ "is_safe": False,
1059
+ "error": f"security_scan error: {type(e).__name__}",
1060
+ "checks_passed": [],
1061
+ "warnings": [],
1062
+ "blockers": ["Scan failed: internal error"],
1063
+ }
1064
+
1065
+
1066
+ @mcp.tool()
1067
+ def healing_scan() -> dict:
1068
+ """Scan BugCollector for recurring defects and propose self-healing evolutions.
1069
+
1070
+ Groups bugs by (skill_id, defect_type), applies trigger thresholds,
1071
+ and returns proposed healing actions. Does NOT execute any changes.
1072
+
1073
+ Trigger thresholds:
1074
+ - >=3 P2 bugs -> PATCH (auto)
1075
+ - >=1 P1 or >=5 P2 -> MINOR (auto + notify)
1076
+ - >=1 P0 -> MAJOR (needs_human, must NOT auto-execute)
1077
+ """
1078
+ # Part of SkillPool — independent infrastructure, shared by all agents
1079
+ try:
1080
+ proposals = _self_healing.scan_and_propose()
1081
+ return {
1082
+ "proposals": proposals,
1083
+ "total_proposals": len(proposals),
1084
+ "status": "scanned",
1085
+ }
1086
+ except Exception as e:
1087
+ return {
1088
+ "proposals": [],
1089
+ "total_proposals": 0,
1090
+ "status": "error",
1091
+ "error": f"healing_scan error: {type(e).__name__}: {e}",
1092
+ }
1093
+
1094
+
1095
+ @mcp.tool()
1096
+ def healing_execute(proposal_id: str) -> dict:
1097
+ """Execute a proposed self-healing evolution with BDD verification.
1098
+
1099
+ Steps:
1100
+ 1. Validate proposal exists and is in PROPOSED state
1101
+ 2. MAJOR proposals require human approval (return needs_human)
1102
+ 3. Run BDD verification (check bug count decreased)
1103
+ 4. If verification fails -> auto-rollback
1104
+
1105
+ Args:
1106
+ proposal_id: The healing proposal ID to execute (from healing_scan).
1107
+ """
1108
+ # Part of SkillPool — independent infrastructure, shared by all agents
1109
+ try:
1110
+ result = _self_healing.execute_healing(proposal_id)
1111
+ return result
1112
+ except Exception as e:
1113
+ return {
1114
+ "proposal_id": proposal_id,
1115
+ "status": "error",
1116
+ "error": f"healing_execute error: {type(e).__name__}: {e}",
1117
+ }
1118
+
1119
+
1120
+ # ═══════════════════════════════════════════════════════════════════
1121
+ # PROMPTS — User-controlled, templated skill invocation
1122
+ # ═══════════════════════════════════════════════════════════════════
1123
+
1124
+
1125
+ @mcp.prompt()
1126
+ def skill_context(skill_id: str) -> str:
1127
+ """Inject skill context into the conversation.
1128
+
1129
+ Use this to load a specific skill's definition and checklist
1130
+ as context for the current task.
1131
+
1132
+ Args:
1133
+ skill_id: Skill ID to load (e.g., S09, S13a)
1134
+ """
1135
+ # Part of SkillPool — independent infrastructure, shared by all agents
1136
+ definition = skill_definition(skill_id)
1137
+ manifest = skill_manifest(skill_id)
1138
+
1139
+ deps = manifest.get("dependencies", [])
1140
+ deps_str = ", ".join(deps) if deps else "none"
1141
+
1142
+ return f"""# Skill: {skill_id}
1143
+
1144
+ ## Dependencies
1145
+ {deps_str}
1146
+
1147
+ ## Definition
1148
+ {definition}
1149
+ """
1150
+
1151
+
1152
+ @mcp.prompt()
1153
+ def trigger_review() -> str:
1154
+ """Trigger a multi-dimension review of the current work.
1155
+
1156
+ Evaluates across 12 dimensions (D1-D12) with VETO rules V1-V6.
1157
+ Uses SkillPool MCP Resources for dynamic content — no hardcoded paths.
1158
+ """
1159
+ # Part of SkillPool — independent infrastructure, shared by all agents
1160
+ return """Execute a Multi-Dimension Review V9.0 on the current work.
1161
+
1162
+ Steps:
1163
+ 1. Read skill://multi-dim-review/definition for full rules and rubric
1164
+ 2. Read skill://multi-dim-review/manifest.yaml for dependencies and veto rules
1165
+ 3. Score all 12 dimensions per V9.0 rubric
1166
+ 4. Check VETO rules V1-V6
1167
+ 5. Collect blind spots with severity (P0/P1/P2)
1168
+ 6. Determine if any skill upgrades are triggered
1169
+ 7. Write blind spots to blindspots/ directory and ClawMem
1170
+
1171
+ Fallback: If MCP Resources are unavailable, read local files at
1172
+ ~/.skillpool/skills/multi-dim-review/RULES.md and state.yaml
1173
+ """
1174
+
1175
+
1176
+ @mcp.prompt()
1177
+ def gate_status(skill_id: str, agent_type: str = "claude-code") -> str:
1178
+ """Check gate status for a skill.
1179
+
1180
+ Returns whether the skill is ALLOWED, GUARDED, ESCALATED, or DENIED.
1181
+
1182
+ Args:
1183
+ skill_id: Skill ID to check
1184
+ """
1185
+ # Part of SkillPool — independent infrastructure, shared by all agents
1186
+ csdf = _load_csdf(skill_id)
1187
+ if csdf is None:
1188
+ return f"Skill not found: {skill_id}"
1189
+
1190
+ profile = _get_profile(agent_type)
1191
+ gate = GateManager(profile=profile)
1192
+ result = gate.check(csdf)
1193
+
1194
+ return f"""Gate Check Result for {skill_id}:
1195
+ - Decision: {result.decision}
1196
+ - Reason: {result.reason}
1197
+ - Complexity Level: {result.complexity.level if result.complexity else "N/A"}
1198
+ - Complexity Total: {result.complexity.total if result.complexity else "N/A"}
1199
+ """
1200
+
1201
+
1202
+ # ═══════════════════════════════════════════════════════════════════
1203
+ # MCP Tools — V4.1 补齐(match / report_usage / assess_paradigm / get_emergency_overrides)
1204
+ # ═══════════════════════════════════════════════════════════════════
1205
+
1206
+
1207
+ @mcp.tool()
1208
+ def skill_search(
1209
+ intent: str,
1210
+ agent_type: str,
1211
+ top_k: int = 5,
1212
+ include_lifecycle: bool = True,
1213
+ ) -> dict:
1214
+ """Search for optimal skill combinations for a given intent.
1215
+
1216
+ This is the MANDATORY first step before accessing any skill definition.
1217
+ Uses four-layer routing (semantic + logical + causal + predictive) to
1218
+ find the best skill combination for the described intent.
1219
+
1220
+ Layer 1 (Semantic): BGE-M3 embedding similarity via Ollama
1221
+ Layer 2 (Logical): DAG dependencies + synergy edges from CSDF
1222
+ Layer 3 (Causal): Historical combination gain data (Thompson Sampling)
1223
+ Layer 4 (Predictive): Collaborative filtering + gain decay
1224
+
1225
+ Returns ranked skill candidates with combination recommendations
1226
+ and lifecycle state information.
1227
+
1228
+ Args:
1229
+ intent: Natural language description of what you want to do.
1230
+ agent_type: Agent profile name for capability filtering.
1231
+ top_k: Maximum number of candidates to return (1-10).
1232
+ include_lifecycle: If True, include combination lifecycle state.
1233
+ """
1234
+ # Part of SkillPool — independent infrastructure, shared by all agents
1235
+ from skillpool.router import IntentRouter
1236
+ from skillpool.synergy import SynergyDetector
1237
+
1238
+ top_k = max(1, min(top_k, 10))
1239
+
1240
+ # L1+L2: Intent routing (semantic + logical)
1241
+ intent_router = IntentRouter(skills_dir=_SKILLS_DIR)
1242
+ routing_result = intent_router.route(intent, top_k=top_k)
1243
+
1244
+ primary = routing_result.primary
1245
+ result = {
1246
+ "intent": intent,
1247
+ "agent": agent_type,
1248
+ "layers_used": routing_result.layers_used,
1249
+ "primary_skill": {
1250
+ "id": primary.skill_id if primary else None,
1251
+ "score": primary.score if primary else 0,
1252
+ "layer": primary.layer if primary else "none",
1253
+ "reason": primary.reason if primary else "",
1254
+ }
1255
+ if primary
1256
+ else None,
1257
+ "candidates": [
1258
+ {"id": c.skill_id, "score": c.score, "layer": c.layer, "reason": c.reason[:100]}
1259
+ for c in routing_result.candidates[:top_k]
1260
+ ],
1261
+ }
1262
+
1263
+ # Include combination recommendations
1264
+ if routing_result.enhancers:
1265
+ result["recommended_combinations"] = [
1266
+ {
1267
+ "primary": primary.skill_id if primary else "",
1268
+ "enhancer": e.skill_id,
1269
+ "gain": e.gain,
1270
+ "reason": e.reason[:100],
1271
+ "score": e.score,
1272
+ }
1273
+ for e in routing_result.enhancers[:5]
1274
+ ]
1275
+
1276
+ # Add synergy data from CSDF
1277
+ if primary:
1278
+ detector = SynergyDetector(skills_dir=_SKILLS_DIR)
1279
+ detector.load_expert_synergies()
1280
+ synergies = detector.get_synergies_for(primary.skill_id)
1281
+ if synergies:
1282
+ result["expert_synergies"] = [
1283
+ {"skill_id": s.target, "gain": s.gain, "reason": s.reason, "weight": s.weight} for s in synergies
1284
+ ]
1285
+
1286
+ # Include combination lifecycle data
1287
+ if include_lifecycle:
1288
+ from skillpool.combiner import CombinationLifecycleManager
1289
+
1290
+ lifecycle_mgr = CombinationLifecycleManager()
1291
+
1292
+ # Get promoted combinations for the primary skill
1293
+ if primary:
1294
+ promoted = lifecycle_mgr.get_promoted_combinations(primary.skill_id)
1295
+ if promoted:
1296
+ result["active_combinations"] = [
1297
+ {
1298
+ "combination_id": c.combination_id,
1299
+ "enhancers": c.enhancers,
1300
+ "state": c.state.name,
1301
+ "gain_avg": round(c.gain_avg, 2),
1302
+ "weight": round(c.current_weight(), 3),
1303
+ "source": c.source,
1304
+ }
1305
+ for c in promoted
1306
+ ]
1307
+
1308
+ # Get validating combinations (candidates being evaluated)
1309
+ validating = lifecycle_mgr.get_validating_combinations()
1310
+ if validating:
1311
+ result["validating_combinations"] = [
1312
+ {
1313
+ "combination_id": c.combination_id,
1314
+ "primary": c.primary,
1315
+ "enhancers": c.enhancers,
1316
+ "execution_count": c.execution_count,
1317
+ "gain_avg": round(c.gain_avg, 2),
1318
+ "confidence": round(c.gain_confidence, 2),
1319
+ }
1320
+ for c in validating[:5]
1321
+ ]
1322
+
1323
+ # Mark search as done for this caller (enables direct skill access)
1324
+ # Agent-neutral: uses agent_type as caller identifier, not Claude Code session ID
1325
+ _search_done_callers.add(agent_type)
1326
+
1327
+ return result
1328
+
1329
+
1330
+ @mcp.tool()
1331
+ def skill_get(
1332
+ skill_id: str,
1333
+ agent_type: str,
1334
+ detail: str = "definition",
1335
+ ) -> dict:
1336
+ """Get skill content with search-first enforcement.
1337
+
1338
+ This is a model-controlled Tool wrapper around skill Resources.
1339
+ It enforces that skill_search must be called before accessing
1340
+ skill content — applies to ALL Agents equally via MCP server logic.
1341
+
1342
+ If skill_search has not been called for this agent_type yet,
1343
+ returns a guidance message instead of skill content.
1344
+
1345
+ Args:
1346
+ skill_id: Skill ID to retrieve.
1347
+ agent_type: Agent identifier for search-first tracking.
1348
+ detail: Level of detail: "summary" (~200 tokens), "definition" (full), "manifest" (deps only).
1349
+ """
1350
+ # Part of SkillPool — independent infrastructure, shared by all agents
1351
+
1352
+ # Search-first enforcement: Agent-neutral, no Claude Code dependencies
1353
+ if agent_type not in _search_done_callers:
1354
+ return {
1355
+ "error": "search_required",
1356
+ "message": "Please call skill_search(intent=...) first to find the optimal skill combination. "
1357
+ "Direct skill access is blocked until you search for the best match.",
1358
+ "skill_id": skill_id,
1359
+ "agent_type": agent_type,
1360
+ }
1361
+
1362
+ # Delegate to the appropriate Resource function
1363
+ if detail == "summary":
1364
+ data = _lazy_loader.load(skill_id, tier="L1")
1365
+ return {"skill_id": skill_id, "detail": "summary", "data": data}
1366
+ elif detail == "manifest":
1367
+ csdf = _load_csdf(skill_id)
1368
+ if csdf is None:
1369
+ return {"error": f"Skill not found: {skill_id}"}
1370
+ return {
1371
+ "skill_id": skill_id,
1372
+ "detail": "manifest",
1373
+ "data": {
1374
+ "id": csdf.get("id", skill_id),
1375
+ "version": csdf.get("version", ""),
1376
+ "dependencies": csdf.get("dependencies", []),
1377
+ "synergies": csdf.get("synergies", []),
1378
+ "values": csdf.get("values", {}),
1379
+ },
1380
+ }
1381
+ else: # definition
1382
+ try:
1383
+ data = _lazy_loader.load(skill_id, tier="L2")
1384
+ if "markdown" in data and data["markdown"]:
1385
+ content = data["markdown"]
1386
+ elif "_markdown_body" in data and data["_markdown_body"]:
1387
+ content = data["_markdown_body"]
1388
+ else:
1389
+ content = f"Content unavailable (errors: {data.get('_materialization_errors', [])})"
1390
+ return {"skill_id": skill_id, "detail": "definition", "content": content}
1391
+ except ValueError:
1392
+ return {"error": f"Skill not found: {skill_id}"}
1393
+
1394
+
1395
+ @mcp.tool()
1396
+ def skill_match(task_description: str, agent_type: str, include_combinations: bool = True) -> dict:
1397
+ """Match skills to a task description using IntentRouter + Resolver DAG traversal.
1398
+
1399
+ V4.3 Phase 9 upgrade: Uses IntentRouter (L1 semantic + L2 logical routing)
1400
+ to find optimal skill combinations, including enhancers from synergy data.
1401
+
1402
+ Args:
1403
+ task_description: Description of the task to match skills for.
1404
+ agent_type: Agent profile name for capability filtering.
1405
+ include_combinations: If True, also recommend skill combinations with gain data.
1406
+ """
1407
+ # Part of SkillPool — independent infrastructure, shared by all agents
1408
+ from skillpool.resolver import SkillResolver, SkillResolveRequest
1409
+ from skillpool.router import IntentRouter
1410
+
1411
+ _profile = _get_profile(agent_type)
1412
+
1413
+ # L1+L2: Intent routing (semantic + logical)
1414
+ intent_router = IntentRouter(skills_dir=_SKILLS_DIR)
1415
+ routing_result = intent_router.route(task_description, top_k=5)
1416
+
1417
+ # Resolver DAG traversal for dependency resolution
1418
+ resolver = SkillResolver()
1419
+ skill_ids = []
1420
+ if _SKILLS_DIR.exists():
1421
+ for yaml_file in sorted(_SKILLS_DIR.glob("*.yaml")):
1422
+ if yaml_file.name == "skill_graph.yaml":
1423
+ continue
1424
+ skill_id = yaml_file.stem.split("-")[0]
1425
+ csdf = _load_csdf(skill_id)
1426
+ if csdf:
1427
+ skill_ids.append(skill_id)
1428
+
1429
+ request = SkillResolveRequest(
1430
+ skill_ids=skill_ids or ["S00"],
1431
+ task_description=task_description,
1432
+ )
1433
+ response = resolver.resolve(request)
1434
+ matches = response.resolved if hasattr(response, "resolved") else []
1435
+
1436
+ # Combine results
1437
+ primary = routing_result.primary
1438
+ result = {
1439
+ "task": task_description,
1440
+ "agent": agent_type,
1441
+ "layers_used": routing_result.layers_used,
1442
+ "primary_skill": {
1443
+ "id": primary.skill_id if primary else None,
1444
+ "score": primary.score if primary else 0,
1445
+ "layer": primary.layer if primary else "none",
1446
+ "reason": primary.reason if primary else "",
1447
+ }
1448
+ if primary
1449
+ else None,
1450
+ "skill_candidates": [
1451
+ {"id": c.skill_id, "score": c.score, "layer": c.layer, "reason": c.reason[:100]}
1452
+ for c in routing_result.candidates[:10]
1453
+ ],
1454
+ "dag_matches": [{"id": m.skill_id, "name": m.name, "score": m.score} for m in matches[:10]],
1455
+ "total_candidates": len(skill_ids),
1456
+ }
1457
+
1458
+ # Include combination recommendations if requested
1459
+ if include_combinations and routing_result.enhancers:
1460
+ # Load lifecycle data for combination state info
1461
+ combo_lifecycle_states: dict[str, str] = {}
1462
+ try:
1463
+ from skillpool.combiner import CombinationLifecycleManager
1464
+ from skillpool.combiner.models import CombinationLifecycleState
1465
+
1466
+ lifecycle_mgr = CombinationLifecycleManager()
1467
+ for e in routing_result.enhancers[:5]:
1468
+ combos = lifecycle_mgr.get_combinations_for_skill(e.skill_id)
1469
+ if combos:
1470
+ best = max(combos, key=lambda c: c.current_weight())
1471
+ combo_lifecycle_states[e.skill_id] = CombinationLifecycleState(best.state).name
1472
+ except Exception as e:
1473
+ logger.warning("Failed to load combination lifecycle states: %s", e)
1474
+
1475
+ result["combinations"] = [
1476
+ {
1477
+ "primary": primary.skill_id if primary else "",
1478
+ "enhancer": e.skill_id,
1479
+ "gain": e.gain,
1480
+ "reason": e.reason[:100],
1481
+ "score": e.score,
1482
+ "lifecycle_state": combo_lifecycle_states.get(e.skill_id, ""),
1483
+ }
1484
+ for e in routing_result.enhancers[:5]
1485
+ ]
1486
+
1487
+ # Add synergy data from CSDF
1488
+ if primary and include_combinations:
1489
+ from skillpool.synergy import SynergyDetector
1490
+
1491
+ detector = SynergyDetector(skills_dir=_SKILLS_DIR)
1492
+ detector.load_expert_synergies()
1493
+ synergies = detector.get_synergies_for(primary.skill_id)
1494
+ if synergies:
1495
+ result["expert_synergies"] = [
1496
+ {"skill_id": s.target, "gain": s.gain, "reason": s.reason, "weight": s.weight} for s in synergies
1497
+ ]
1498
+
1499
+ return result
1500
+
1501
+
1502
+ @mcp.tool()
1503
+ def report_usage(
1504
+ skill_name: str,
1505
+ session_id: str,
1506
+ agent_type: str,
1507
+ duration_ms: int = 0,
1508
+ result: str = "success",
1509
+ combination_skills: Optional[list[str]] = None,
1510
+ effectiveness: float = 0.0,
1511
+ efficiency: float = 0.0,
1512
+ quality: float = 0.0,
1513
+ gain: float = 0.0,
1514
+ intent: str = "",
1515
+ ) -> dict:
1516
+ """Report skill usage with optional combination data and four-dimension scores.
1517
+
1518
+ V4.3 Phase 9 upgrade: Supports reporting skill combinations and gain data.
1519
+ Implicit tracking is zero-burden — only skill_name + session_id required.
1520
+ Explicit scoring is optional — provide four-dimension scores for richer data.
1521
+
1522
+ Args:
1523
+ skill_name: Name of the primary skill that was used.
1524
+ session_id: Session identifier for correlation.
1525
+ duration_ms: Duration of skill execution in milliseconds.
1526
+ result: Execution result status (success/partial/failed).
1527
+ agent_type: Agent profile name.
1528
+ combination_skills: Other skills used alongside the primary skill.
1529
+ effectiveness: Task goal achievement (0-10, optional).
1530
+ efficiency: Resource consumption reasonableness (0-10, optional).
1531
+ quality: Output sustainability (0-10, optional).
1532
+ gain: Combination marginal contribution (-10 to +10, optional).
1533
+ intent: Original agent intent description (optional).
1534
+ """
1535
+ # Part of SkillPool — independent infrastructure, shared by all agents
1536
+
1537
+ # 1. Record telemetry event
1538
+ payload = {
1539
+ "session_id": session_id,
1540
+ "duration_ms": duration_ms,
1541
+ "result": result,
1542
+ "agent_type": agent_type,
1543
+ }
1544
+ if combination_skills:
1545
+ payload["combination_skills"] = combination_skills
1546
+ payload["combination"] = f"{skill_name}+{','.join(combination_skills)}"
1547
+ if intent:
1548
+ payload["intent"] = intent
1549
+
1550
+ tel_result = telemetry_report(
1551
+ event_type="usage",
1552
+ skill_id=skill_name,
1553
+ channel="mcp",
1554
+ payload=payload,
1555
+ )
1556
+
1557
+ # 2. Record gain data if scores provided
1558
+ gain_recorded = False
1559
+ if effectiveness > 0 or efficiency > 0 or quality > 0 or gain != 0:
1560
+ from skillpool.gain import GainTracker, SkillExecution, GainScores
1561
+
1562
+ tracker = GainTracker()
1563
+
1564
+ skill_ids = [skill_name]
1565
+ if combination_skills:
1566
+ skill_ids.extend(combination_skills)
1567
+
1568
+ scores = GainScores(
1569
+ effectiveness=effectiveness,
1570
+ efficiency=efficiency,
1571
+ quality=quality,
1572
+ gain=gain,
1573
+ )
1574
+
1575
+ source = "explicit" if effectiveness > 0 else "implicit"
1576
+ tracker.record(
1577
+ SkillExecution(
1578
+ skill_ids=skill_ids,
1579
+ intent=intent,
1580
+ scores=scores,
1581
+ duration_ms=duration_ms,
1582
+ source=source,
1583
+ )
1584
+ )
1585
+ gain_recorded = True
1586
+
1587
+ # 3. Update combination lifecycle if combination_skills provided
1588
+ lifecycle_updated = False
1589
+ _lifecycle_state = ""
1590
+ if combination_skills:
1591
+ from skillpool.combiner import CombinationLifecycleManager
1592
+ from skillpool.combiner.models import CombinationLifecycleState
1593
+
1594
+ lifecycle_mgr = CombinationLifecycleManager()
1595
+
1596
+ # Record execution for the combination
1597
+ combo_id = f"{skill_name}+{'+'.join(sorted(combination_skills))}"
1598
+ combo = lifecycle_mgr.record_execution(
1599
+ combo_id,
1600
+ gain=gain,
1601
+ success=(result == "success"),
1602
+ )
1603
+
1604
+ if combo:
1605
+ _lifecycle_state = CombinationLifecycleState(combo.state).name
1606
+
1607
+ # DISCOVERED → VALIDATING: first execution triggers validation
1608
+ if combo.state == CombinationLifecycleState.DISCOVERED:
1609
+ transition_result = lifecycle_mgr.transition(
1610
+ combo_id,
1611
+ CombinationLifecycleState.VALIDATING,
1612
+ )
1613
+ if transition_result.success:
1614
+ lifecycle_updated = True
1615
+ _lifecycle_state = "VALIDATING"
1616
+
1617
+ # VALIDATING → PROMOTED: try promotion after enough executions
1618
+ if combo.state == CombinationLifecycleState.VALIDATING:
1619
+ promote_result = lifecycle_mgr.try_promote(combo_id)
1620
+ if promote_result.success:
1621
+ lifecycle_updated = True
1622
+ _lifecycle_state = "PROMOTED"
1623
+
1624
+ # If combination doesn't exist yet, create it
1625
+ if combo is None:
1626
+ lifecycle_mgr.create_combination(
1627
+ primary=skill_name,
1628
+ enhancers=combination_skills,
1629
+ source="auto_discovered",
1630
+ )
1631
+ _lifecycle_state = "DISCOVERED"
1632
+
1633
+ return {
1634
+ "skill_name": skill_name,
1635
+ "session_id": session_id,
1636
+ "telemetry": tel_result,
1637
+ "gain_recorded": gain_recorded,
1638
+ "combination_count": len(combination_skills) if combination_skills else 0,
1639
+ "lifecycle_updated": lifecycle_updated,
1640
+ }
1641
+
1642
+
1643
+ @mcp.tool()
1644
+ def assess_paradigm(paradigm: str, agent_type: str, skill_id: str = "") -> dict:
1645
+ """Assess whether an agent can execute a given paradigm.
1646
+
1647
+ Checks agent profile capabilities and trust level against paradigm requirements.
1648
+
1649
+ Args:
1650
+ paradigm: Paradigm to assess (docsdd/sdd/bdd/tdd or review/code/test/planning).
1651
+ skill_id: Optional skill ID for context-specific assessment.
1652
+ agent_type: Agent profile name.
1653
+ """
1654
+ # Part of SkillPool — independent infrastructure, shared by all agents
1655
+ from skillpool.paradigm import ParadigmRegistry
1656
+
1657
+ profile = _get_profile(agent_type)
1658
+ registry = ParadigmRegistry()
1659
+
1660
+ # Check if paradigm exists
1661
+ paradigms = registry.list_paradigms() if hasattr(registry, "list_paradigms") else []
1662
+ paradigm_exists = paradigm in {p.get("paradigm", "") for p in paradigms}
1663
+
1664
+ # Check agent compatibility
1665
+ can_execute, reason = profile.can_execute({"paradigm": paradigm})
1666
+
1667
+ return {
1668
+ "paradigm": paradigm,
1669
+ "agent_type": agent_type,
1670
+ "paradigm_registered": paradigm_exists,
1671
+ "can_execute": can_execute,
1672
+ "reason": reason,
1673
+ "trust_level": profile.trust_level,
1674
+ "capabilities": sorted(profile.required_capabilities),
1675
+ }
1676
+
1677
+
1678
+ @mcp.tool()
1679
+ def combination_create(
1680
+ primary: str,
1681
+ enhancers: list[str],
1682
+ agent_type: str,
1683
+ source: str = "human_specified",
1684
+ ) -> dict:
1685
+ """Create a new skill combination via MCP.
1686
+
1687
+ Human-specified combinations skip DISCOVERED, enter VALIDATING directly.
1688
+ Auto-discovered combinations start at DISCOVERED.
1689
+ ALL Agents can create combinations — this is MCP-level, agent-neutral.
1690
+
1691
+ Args:
1692
+ primary: Primary skill ID.
1693
+ enhancers: List of enhancing skill IDs.
1694
+ agent_type: Agent creating this combination (for audit trail).
1695
+ source: Discovery source: human_specified or auto_discovered.
1696
+ """
1697
+ # Part of SkillPool — independent infrastructure
1698
+ from skillpool.combiner import CombinationLifecycleManager
1699
+
1700
+ mgr = CombinationLifecycleManager()
1701
+ combo = mgr.create_combination(
1702
+ primary=primary,
1703
+ enhancers=enhancers,
1704
+ source=source,
1705
+ )
1706
+
1707
+ return {
1708
+ "combination_id": combo.combination_id,
1709
+ "primary": combo.primary,
1710
+ "enhancers": combo.enhancers,
1711
+ "state": combo.state.name,
1712
+ "source": combo.source,
1713
+ "message": f"Combination created in {combo.state.name} state",
1714
+ }
1715
+
1716
+
1717
+ @mcp.tool()
1718
+ def combination_get(combination_id: str) -> dict:
1719
+ """Get details of a specific skill combination.
1720
+
1721
+ Returns combination state, gain data, and lifecycle information.
1722
+
1723
+ Args:
1724
+ combination_id: The combination ID (e.g. "review+karnpathy-guidelines").
1725
+ """
1726
+ from skillpool.combiner import CombinationLifecycleManager
1727
+
1728
+ mgr = CombinationLifecycleManager()
1729
+ combo = mgr.get_combination(combination_id)
1730
+
1731
+ if combo is None:
1732
+ return {"error": "not_found", "message": f"Combination '{combination_id}' not found"}
1733
+
1734
+ return {
1735
+ "combination_id": combo.combination_id,
1736
+ "primary": combo.primary,
1737
+ "enhancers": combo.enhancers,
1738
+ "state": combo.state.name,
1739
+ "source": combo.source,
1740
+ "gain_avg": combo.gain_avg,
1741
+ "gain_confidence": combo.gain_confidence,
1742
+ "all_time_gain_avg": combo.all_time_gain_avg,
1743
+ "recent_gain_avg": combo.recent_gain_avg,
1744
+ "execution_count": combo.execution_count,
1745
+ "current_weight": combo.current_weight(),
1746
+ "last_execution": combo.last_execution,
1747
+ "promoted_at": combo.promoted_at,
1748
+ }
1749
+
1750
+
1751
+ @mcp.tool()
1752
+ def combination_list(
1753
+ state: str = "",
1754
+ primary: str = "",
1755
+ ) -> dict:
1756
+ """List skill combinations, optionally filtered by state or primary skill.
1757
+
1758
+ Args:
1759
+ state: Filter by lifecycle state (DISCOVERED/VALIDATING/PROMOTED/
1760
+ REJECTED/DEPRECATED/RETIRED). Empty = all states.
1761
+ primary: Filter by primary skill ID. Empty = all skills.
1762
+ """
1763
+ from skillpool.combiner import CombinationLifecycleManager, CombinationLifecycleState
1764
+
1765
+ mgr = CombinationLifecycleManager()
1766
+ mgr._ensure_loaded()
1767
+
1768
+ combos = list(mgr._combinations.values())
1769
+
1770
+ if state:
1771
+ try:
1772
+ state_enum = CombinationLifecycleState[state]
1773
+ except KeyError:
1774
+ return {"error": "invalid_state", "message": f"Unknown state '{state}'"}
1775
+ combos = [c for c in combos if c.state == state_enum]
1776
+
1777
+ if primary:
1778
+ combos = [c for c in combos if c.primary == primary]
1779
+
1780
+ return {
1781
+ "count": len(combos),
1782
+ "combinations": [
1783
+ {
1784
+ "combination_id": c.combination_id,
1785
+ "primary": c.primary,
1786
+ "enhancers": c.enhancers,
1787
+ "state": c.state.name,
1788
+ "gain_avg": c.gain_avg,
1789
+ "execution_count": c.execution_count,
1790
+ "current_weight": c.current_weight(),
1791
+ }
1792
+ for c in combos
1793
+ ],
1794
+ }
1795
+
1796
+
1797
+ @mcp.tool()
1798
+ def combination_transition(
1799
+ combination_id: str,
1800
+ to_state: str,
1801
+ agent_type: str,
1802
+ reason: str = "",
1803
+ ) -> dict:
1804
+ """Manually transition a combination's lifecycle state.
1805
+
1806
+ Use with caution — automated transitions happen via report_usage
1807
+ and skill_lifecycle_check. This tool exists for administrative overrides.
1808
+
1809
+ Args:
1810
+ combination_id: The combination ID to transition.
1811
+ to_state: Target state name (DISCOVERED/VALIDATING/PROMOTED/
1812
+ REJECTED/DEPRECATED/RETIRED).
1813
+ agent_type: Agent requesting the transition (for audit trail).
1814
+ reason: Reason for the manual transition.
1815
+ """
1816
+ from skillpool.combiner import CombinationLifecycleManager, CombinationLifecycleState
1817
+
1818
+ try:
1819
+ target = CombinationLifecycleState[to_state]
1820
+ except KeyError:
1821
+ return {"error": "invalid_state", "message": f"Unknown state '{to_state}'"}
1822
+
1823
+ mgr = CombinationLifecycleManager()
1824
+ result = mgr.transition(combination_id, target, reason=reason)
1825
+
1826
+ return {
1827
+ "combination_id": result.combination_id,
1828
+ "from_state": result.from_state.name,
1829
+ "to_state": result.to_state.name,
1830
+ "success": result.success,
1831
+ "reason": result.reason,
1832
+ }
1833
+
1834
+
1835
+ @mcp.tool()
1836
+ def skill_lifecycle_check(
1837
+ skill_id: str = "",
1838
+ check_deprecation: bool = True,
1839
+ check_combinations: bool = True,
1840
+ ) -> dict:
1841
+ """Check skill lifecycle state and trigger auto-deprecation if needed.
1842
+
1843
+ Available to ALL Agents — this is the MCP-level entry point for
1844
+ skill auto-deprecation, not an internal-only function.
1845
+
1846
+ Args:
1847
+ skill_id: Specific skill to check. Empty = check all ACTIVE skills.
1848
+ check_deprecation: If True, trigger auto-deprecation checks.
1849
+ check_combinations: If True, also check combination lifecycle.
1850
+ """
1851
+ # Part of SkillPool — independent infrastructure, shared by all agents
1852
+ from skillpool.lifecycle import check_auto_deprecation
1853
+ from skillpool.combiner import CombinationLifecycleManager
1854
+
1855
+ results = {
1856
+ "deprecation_checks": [],
1857
+ "combination_checks": [],
1858
+ }
1859
+
1860
+ if check_deprecation:
1861
+ if skill_id:
1862
+ deprecated = check_auto_deprecation(skill_id)
1863
+ results["deprecation_checks"].append(
1864
+ {
1865
+ "skill_id": skill_id,
1866
+ "deprecated": deprecated,
1867
+ }
1868
+ )
1869
+
1870
+ if check_combinations:
1871
+ mgr = CombinationLifecycleManager()
1872
+
1873
+ if skill_id:
1874
+ combos = mgr.get_combinations_for_skill(skill_id)
1875
+ else:
1876
+ mgr._ensure_loaded()
1877
+ combos = list(mgr._combinations.values())
1878
+
1879
+ for combo in combos:
1880
+ if combo.state.value == 2: # PROMOTED
1881
+ result = mgr.check_deprecation(combo.combination_id)
1882
+ if result:
1883
+ results["combination_checks"].append(
1884
+ {
1885
+ "combination_id": combo.combination_id,
1886
+ "action": result.to_state.name,
1887
+ "reason": result.reason,
1888
+ }
1889
+ )
1890
+
1891
+ elif combo.state.value == 4: # DEPRECATED
1892
+ result = mgr.check_retirement(combo.combination_id)
1893
+ if result:
1894
+ results["combination_checks"].append(
1895
+ {
1896
+ "combination_id": combo.combination_id,
1897
+ "action": result.to_state.name,
1898
+ "reason": result.reason,
1899
+ }
1900
+ )
1901
+
1902
+ return results
1903
+
1904
+
1905
+ @mcp.tool()
1906
+ def get_emergency_overrides(skill_id: str = "") -> dict:
1907
+ """Query current emergency overrides for skills.
1908
+
1909
+ Returns active WARN/DEGRADE/QUARANTINE/KILL overrides.
1910
+ If skill_id is provided, returns overrides for that skill only.
1911
+ If omitted, returns all active overrides.
1912
+
1913
+ Args:
1914
+ skill_id: Optional skill ID to filter overrides.
1915
+ """
1916
+ # Part of SkillPool — independent infrastructure, shared by all agents
1917
+ import json
1918
+
1919
+ overrides_path = _SKILLPOOL_DIR / "emergency_overrides.json"
1920
+ if overrides_path.exists():
1921
+ overrides = json.loads(overrides_path.read_text())
1922
+ if skill_id:
1923
+ overrides = {k: v for k, v in overrides.items() if k == skill_id}
1924
+ return {"overrides": overrides, "count": len(overrides)}
1925
+ return {"overrides": {}, "count": 0}
1926
+
1927
+
1928
+ @mcp.tool()
1929
+ def cost_estimate(
1930
+ skill_id: str,
1931
+ skill_length: int = 0,
1932
+ review_level: str = "L1",
1933
+ include_review_checkpoint: bool = False,
1934
+ emergency_bypass_path: str = "",
1935
+ ) -> dict:
1936
+ """Estimate session cost for a skill execution using P50 conservative pricing.
1937
+
1938
+ Uses $0.003/1K tokens pricing model. Combines skill execution cost
1939
+ + L2/L3 review overhead + review checkpoint overhead.
1940
+
1941
+ Args:
1942
+ skill_id: Skill identifier (e.g. "dev-4d-sdd").
1943
+ skill_length: Character count of skill definition (fallback if skill_get unavailable).
1944
+ review_level: Complexity level (L0/L1/L2/L3+L2+).
1945
+ include_review_checkpoint: Whether to include review checkpoint overhead.
1946
+ emergency_bypass_path: Path to emergency_overrides.json file.
1947
+ """
1948
+ # Part of SkillPool — independent infrastructure, shared by all agents
1949
+ try:
1950
+ from skillpool.cost.token_governor import TokenGovernor, PRESET_AGENT_CONFIGS
1951
+
1952
+ governor = TokenGovernor(PRESET_AGENT_CONFIGS)
1953
+ result = governor.estimate_session_cost(
1954
+ skill_id=skill_id,
1955
+ skill_length=skill_length,
1956
+ review_level=review_level,
1957
+ include_review_checkpoint=include_review_checkpoint,
1958
+ emergency_bypass_path=emergency_bypass_path or None,
1959
+ )
1960
+ return result.model_dump()
1961
+ except Exception as e:
1962
+ return {
1963
+ "error": f"cost_estimate error: {type(e).__name__}: {e}",
1964
+ "skill_id": skill_id,
1965
+ "total_cost_usd": 0.0,
1966
+ }
1967
+
1968
+
1969
+ # ═══════════════════════════════════════════════════════════════════
1970
+ # Entry point
1971
+ # ═══════════════════════════════════════════════════════════════════
1972
+
1973
+
1974
+ def main():
1975
+ """Entry point for skillpool-mcp CLI command."""
1976
+ import argparse
1977
+
1978
+ parser = argparse.ArgumentParser(description="SkillPool MCP Server")
1979
+ parser.add_argument("--agent-type", help="Agent type for logging/metadata (tools receive agent_type per-call)")
1980
+ parser.add_argument(
1981
+ "--transport",
1982
+ choices=["stdio", "sse", "streamable-http"],
1983
+ default="stdio",
1984
+ help="MCP transport protocol (default: stdio)",
1985
+ )
1986
+ parser.add_argument(
1987
+ "--port", type=int, default=8101, help="HTTP port for streamable-http transport (default: 8101)"
1988
+ )
1989
+ parser.add_argument(
1990
+ "--host", default="127.0.0.1", help="HTTP host for streamable-http transport (default: 127.0.0.1)"
1991
+ )
1992
+ args = parser.parse_args()
1993
+
1994
+ if args.transport in ("streamable-http", "sse"):
1995
+ import uvicorn
1996
+
1997
+ logger.info("Starting SkillPool MCP on %s:%d (%s)", args.host, args.port, args.transport)
1998
+ app = mcp.http_app()
1999
+ uvicorn.run(app, host=args.host, port=args.port, log_level="info")
2000
+ else:
2001
+ mcp.run(transport=args.transport)
2002
+
2003
+
2004
+ if __name__ == "__main__":
2005
+ main()