This commit is contained in:
2026-07-06 18:06:28 +02:00
parent 9d189020de
commit fdbe811cdc
279 changed files with 60644 additions and 0 deletions
@@ -0,0 +1,329 @@
#!/usr/bin/env python3
"""
Skill Matching Algorithm for Agent Orchestrator.
Scores and ranks skills against a user query to determine
which agents are relevant for the current request.
Scoring:
- Skill name appears in query: +15
- Exact trigger keyword match: +10 per keyword
- Capability category match: +5 per category
- Description word overlap: +1 per word
- Project assignment boost: +20 if skill is assigned to active project
Usage:
python match_skills.py "raspar dados de um site"
python match_skills.py "coletar precos e enviar por whatsapp"
python match_skills.py --project myproject "query here"
"""
import json
import sys
import os
import re
import subprocess
from pathlib import Path
# ── Configuration ──────────────────────────────────────────────────────────
# Resolve paths relative to this script's location
_SCRIPT_DIR = Path(__file__).resolve().parent
ORCHESTRATOR_DIR = _SCRIPT_DIR.parent
SKILLS_ROOT = ORCHESTRATOR_DIR.parent
DATA_DIR = ORCHESTRATOR_DIR / "data"
REGISTRY_PATH = DATA_DIR / "registry.json"
PROJECTS_PATH = DATA_DIR / "projects.json"
SCAN_SCRIPT = _SCRIPT_DIR / "scan_registry.py"
# Capability keywords for query -> category matching (PT + EN)
CAPABILITY_KEYWORDS = {
"data-extraction": [
"scrape", "extract", "crawl", "parse", "harvest", "collect", "data",
"raspar", "extrair", "coletar", "dados", "tabela", "table", "csv",
"web data", "pull info", "get data",
],
"messaging": [
"whatsapp", "message", "send", "chat", "notify", "notification", "sms",
"mensagem", "enviar", "notificar", "notificacao", "atendimento",
"comunicar", "avisar",
],
"social-media": [
"instagram", "facebook", "twitter", "post", "stories", "reels",
"social", "feed", "follower", "publicar", "rede social", "engajamento",
],
"government-data": [
"junta", "leiloeiro", "cadastro", "governo", "comercial", "tribunal",
"diario oficial", "certidao", "registro", "uf", "estado",
],
"web-automation": [
"browser", "selenium", "playwright", "automate", "click", "fill form",
"navegador", "automatizar", "automacao", "preencher",
],
"api-integration": [
"api", "endpoint", "webhook", "rest", "graph", "oauth", "token",
"integracao", "integrar", "conectar",
],
"analytics": [
"insight", "analytics", "metrics", "dashboard", "report", "stats",
"relatorio", "metricas", "analise", "estatistica",
],
"content-management": [
"publish", "schedule", "template", "content", "media", "upload",
"publicar", "agendar", "conteudo", "midia",
],
"legal": [
"advogado", "direito", "juridico", "lei", "processo",
"acao", "peticao", "recurso", "sentenca", "juiz",
"divorcio", "guarda", "alimentos", "pensao", "alimenticia", "inventario", "heranca", "partilha",
"acidente de trabalho", "acidente",
"familia", "criminal", "penal", "crime", "feminicidio", "maria da penha",
"violencia domestica", "medida protetiva", "stalking",
"danos morais", "responsabilidade civil", "indenizacao", "dano",
"consumidor", "cdc", "plano de saude",
"trabalhista", "clt", "rescisao", "fgts", "horas extras",
"previdenciario", "aposentadoria", "aposentar", "inss",
"imobiliario", "usucapiao", "despejo", "inquilinato",
"alienacao fiduciaria", "bem de familia",
"tributario", "imposto", "icms", "execucao fiscal",
"administrativo", "licitacao", "improbidade", "mandado de seguranca",
"empresarial", "societario", "falencia", "recuperacao judicial",
"empresa", "ltda", "cnpj", "mei", "eireli", "contrato social",
"contrato", "clausula", "contestacao", "apelacao", "agravo",
"habeas corpus", "mandado", "liminar", "tutela",
"cpc", "stj", "stf", "sumula", "jurisprudencia",
"oab", "honorarios", "custas",
],
"auction": [
"leilao", "leilao judicial", "leilao extrajudicial", "hasta publica",
"arrematacao", "arrematar", "arrematante", "lance", "desagio",
"edital leilao", "penhora", "adjudicacao", "praca",
"imissao na posse", "carta arrematacao", "vil preco",
"avaliacao imovel", "laudo", "perito", "matricula",
"leiloeiro", "comissao leiloeiro",
],
"security": [
"seguranca", "security", "owasp", "vulnerability", "incident",
"pentest", "firewall", "malware", "phishing", "cve",
"autenticacao", "criptografia", "encryption",
],
"image-generation": [
"imagem", "image", "gerar imagem", "generate image",
"stable diffusion", "comfyui", "midjourney", "dall-e",
"foto", "ilustracao", "arte", "design",
],
"monitoring": [
"monitor", "monitorar", "health", "status",
"audit", "auditoria", "sentinel", "check",
],
"context-management": [
"contexto", "context", "sessao", "session", "compactacao", "compaction",
"comprimir", "compress", "snapshot", "checkpoint", "briefing",
"continuidade", "continuity", "preservar", "preserve",
"memoria", "memory", "resumo", "summary",
"salvar estado", "save state", "context window", "janela de contexto",
"perda de dados", "data loss", "backup",
],
}
# ── Functions ──────────────────────────────────────────────────────────────
def ensure_registry():
"""Run scan if registry doesn't exist."""
if not REGISTRY_PATH.exists():
subprocess.run(
[sys.executable, str(SCAN_SCRIPT)],
capture_output=True, text=True
)
def load_registry() -> list[dict]:
"""Load skills from registry.json."""
ensure_registry()
if not REGISTRY_PATH.exists():
return []
try:
data = json.loads(REGISTRY_PATH.read_text(encoding="utf-8"))
return data.get("skills", [])
except Exception:
return []
def load_projects() -> dict:
"""Load project assignments."""
if not PROJECTS_PATH.exists():
return {"projects": []}
try:
return json.loads(PROJECTS_PATH.read_text(encoding="utf-8"))
except Exception:
return {"projects": []}
def get_project_skills(project_name: str) -> set:
"""Get set of skill names assigned to a project."""
projects = load_projects()
for p in projects.get("projects", []):
if p.get("name", "").lower() == project_name.lower():
return set(p.get("skills", []))
return set()
def query_to_capabilities(query: str) -> list[str]:
"""Map a query to capability categories using word boundary matching."""
q_lower = query.lower()
q_words = set(re.findall(r'[a-zA-ZÀ-ÿ]+', q_lower))
caps = []
for cap, keywords in CAPABILITY_KEYWORDS.items():
for kw in keywords:
# Multi-word keywords: substring match. Single-word: exact word match.
if " " in kw:
if kw in q_lower:
caps.append(cap)
break
elif kw in q_words:
caps.append(cap)
break
return caps
def normalize(text: str) -> set[str]:
"""Normalize text to a set of lowercase words."""
return set(re.findall(r'[a-zA-ZÀ-ÿ]{3,}', text.lower()))
def score_skill(skill: dict, query: str, project_skills: set = None) -> dict:
"""
Score a skill's relevance to a query.
Returns dict with score, reasons, and skill info.
"""
q_lower = query.lower()
score = 0
reasons = []
name = skill.get("name", "")
description = skill.get("description", "")
triggers = skill.get("triggers", [])
capabilities = skill.get("capabilities", [])
# 1. Skill name in query (+15)
if name.lower() in q_lower or name.lower().replace("-", " ") in q_lower:
score += 15
reasons.append(f"name:{name}")
# 2. Trigger keyword matches (+10 each) - word boundary matching
q_words = set(re.findall(r'[a-zA-ZÀ-ÿ]+', q_lower))
for trigger in triggers:
trigger_lower = trigger.lower()
# Multi-word triggers: substring match. Single-word: exact word match.
if " " in trigger_lower:
if trigger_lower in q_lower:
score += 10
reasons.append(f"trigger:{trigger}")
elif trigger_lower in q_words:
score += 10
reasons.append(f"trigger:{trigger}")
# 3. Capability category match (+5 each)
query_caps = query_to_capabilities(query)
for cap in capabilities:
if cap in query_caps:
score += 5
reasons.append(f"capability:{cap}")
# 4. Description word overlap (+1 each, max 10)
query_words = normalize(query)
desc_words = normalize(description)
overlap = query_words & desc_words
overlap_score = min(len(overlap), 10)
if overlap_score > 0:
score += overlap_score
reasons.append(f"word_overlap:{overlap_score}")
# 5. Project assignment boost (+20)
if project_skills and name in project_skills:
score += 20
reasons.append("project_boost")
return {
"name": name,
"score": score,
"reasons": reasons,
"location": skill.get("location", ""),
"skill_md": skill.get("skill_md", ""),
"capabilities": capabilities,
"status": skill.get("status", "unknown"),
}
def match(query: str, project: str = None, top_n: int = 5, threshold: int = 5) -> list[dict]:
"""
Match a query against all registered skills.
Returns top N skills with score >= threshold, sorted by score descending.
"""
skills = load_registry()
if not skills:
return []
project_skills = get_project_skills(project) if project else set()
results = []
for skill in skills:
result = score_skill(skill, query, project_skills)
if result["score"] >= threshold:
results.append(result)
results.sort(key=lambda x: x["score"], reverse=True)
return results[:top_n]
# ── CLI Entry Point ────────────────────────────────────────────────────────
def main():
args = sys.argv[1:]
project = None
query_parts = []
i = 0
while i < len(args):
if args[i] == "--project" and i + 1 < len(args):
project = args[i + 1]
i += 2
else:
query_parts.append(args[i])
i += 1
query = " ".join(query_parts)
if not query:
print(json.dumps({
"error": "No query provided",
"usage": 'python match_skills.py "your query here"'
}, indent=2))
sys.exit(1)
results = match(query, project=project)
output = {
"query": query,
"project": project,
"matched": len(results),
"skills": results,
}
if len(results) == 0:
output["recommendation"] = "No skills matched. Operate without skills or suggest creating a new one."
elif len(results) == 1:
output["recommendation"] = f"Single skill match: use '{results[0]['name']}' directly."
output["action"] = "load_skill"
else:
output["recommendation"] = f"Multiple skills matched ({len(results)}). Use orchestration."
output["action"] = "orchestrate"
print(json.dumps(output, indent=2, ensure_ascii=False))
if __name__ == "__main__":
main()
@@ -0,0 +1,304 @@
#!/usr/bin/env python3
"""
Multi-Skill Orchestration Engine for Agent Orchestrator.
Given matched skills and a query, determines the orchestration pattern
and generates an execution plan for Claude to follow.
Patterns:
- single: One skill handles the entire request
- sequential: Skills form a pipeline (A output -> B input)
- parallel: Skills work independently on different aspects
- primary_support: One skill leads, others provide supporting data
Usage:
python orchestrate.py --skills web-scraper,whatsapp-cloud-api --query "monitorar precos e enviar alerta"
python orchestrate.py --match-result '{"skills": [...]}' --query "query"
"""
import json
import sys
from pathlib import Path
# ── Configuration ──────────────────────────────────────────────────────────
# Resolve paths relative to this script's location
_SCRIPT_DIR = Path(__file__).resolve().parent
ORCHESTRATOR_DIR = _SCRIPT_DIR.parent
SKILLS_ROOT = ORCHESTRATOR_DIR.parent
DATA_DIR = ORCHESTRATOR_DIR / "data"
REGISTRY_PATH = DATA_DIR / "registry.json"
# Define which capabilities are typically "producers" vs "consumers"
# Producers generate data; consumers act on data
PRODUCER_CAPABILITIES = {"data-extraction", "government-data", "analytics"}
CONSUMER_CAPABILITIES = {"messaging", "social-media", "content-management"}
HYBRID_CAPABILITIES = {"api-integration", "web-automation"}
# ── Functions ──────────────────────────────────────────────────────────────
def load_registry() -> dict[str, dict]:
"""Load registry as name->skill dict."""
if not REGISTRY_PATH.exists():
return {}
try:
data = json.loads(REGISTRY_PATH.read_text(encoding="utf-8"))
return {s["name"]: s for s in data.get("skills", [])}
except Exception:
return {}
def get_skill_role(skill: dict) -> str:
"""Determine if a skill is primarily a producer, consumer, or hybrid.
Uses weighted scoring: more specific capabilities (data-extraction,
messaging) outweigh generic ones (api-integration, content-management).
"""
caps = set(skill.get("capabilities", []))
producer_count = len(caps & PRODUCER_CAPABILITIES)
consumer_count = len(caps & CONSUMER_CAPABILITIES)
# If skill has both producer and consumer caps, use the dominant one
if producer_count > consumer_count:
return "producer"
elif consumer_count > producer_count:
return "consumer"
elif producer_count > 0 and consumer_count > 0:
# Equal weight - check if core name suggests a role
name = skill.get("name", "").lower()
if any(kw in name for kw in ["scraper", "extract", "collect", "data", "junta"]):
return "producer"
if any(kw in name for kw in ["whatsapp", "instagram", "messenger", "notify"]):
return "consumer"
return "hybrid"
else:
return "hybrid"
def classify_pattern(skills: list[dict], query: str) -> str:
"""
Determine the orchestration pattern based on skill roles and query.
Rules:
1. Single skill -> "single"
2. Producer(s) + Consumer(s) -> "sequential" (data flows producer->consumer)
3. All same role -> "parallel" (independent work)
4. One high-score + others lower -> "primary_support"
"""
if len(skills) <= 1:
return "single"
roles = [get_skill_role(s) for s in skills]
has_producer = "producer" in roles
has_consumer = "consumer" in roles
# Producer -> Consumer pipeline
if has_producer and has_consumer:
return "sequential"
# Check if one skill dominates by score
scores = [s.get("score", 0) for s in skills]
if len(scores) >= 2:
scores_sorted = sorted(scores, reverse=True)
if scores_sorted[0] >= scores_sorted[1] * 2:
return "primary_support"
# All same role or no clear pipeline
return "parallel"
def generate_plan(skills: list[dict], query: str, pattern: str) -> dict:
"""Generate an execution plan based on the pattern."""
if pattern == "single":
skill = skills[0]
return {
"pattern": "single",
"description": f"Use '{skill['name']}' to handle the entire request.",
"steps": [
{
"order": 1,
"skill": skill["name"],
"skill_md": skill.get("skill_md", skill.get("location", "")),
"action": f"Load SKILL.md and follow its workflow for: {query}",
"input": "user_query",
"output": "result",
}
],
"data_flow": "user_query -> result",
}
elif pattern == "sequential":
# Order: producers first, then consumers
producers = [s for s in skills if get_skill_role(s) in ("producer", "hybrid")]
consumers = [s for s in skills if get_skill_role(s) == "consumer"]
# If no clear producers, use score order
if not producers:
producers = [skills[0]]
consumers = skills[1:]
ordered = producers + consumers
steps = []
for i, skill in enumerate(ordered):
role = get_skill_role(skill)
if i == 0:
input_src = "user_query"
action = f"Extract/collect data: {query}"
else:
prev = ordered[i - 1]["name"]
input_src = f"{prev}.output"
if role == "consumer":
action = f"Process/deliver data from {prev}"
else:
action = f"Continue processing with data from {prev}"
steps.append({
"order": i + 1,
"skill": skill["name"],
"skill_md": skill.get("skill_md", skill.get("location", "")),
"action": action,
"input": input_src,
"output": f"{skill['name']}.output",
"role": role,
})
flow_parts = [s["skill"] for s in steps]
data_flow = " -> ".join(["user_query"] + flow_parts + ["result"])
return {
"pattern": "sequential",
"description": f"Pipeline: {' -> '.join(flow_parts)}",
"steps": steps,
"data_flow": data_flow,
}
elif pattern == "parallel":
steps = []
for i, skill in enumerate(skills):
steps.append({
"order": 1, # All run at the same "order" level
"skill": skill["name"],
"skill_md": skill.get("skill_md", skill.get("location", "")),
"action": f"Handle independently: aspect of '{query}' related to {', '.join(skill.get('capabilities', []))}",
"input": "user_query",
"output": f"{skill['name']}.output",
})
return {
"pattern": "parallel",
"description": f"Execute {len(skills)} skills in parallel, each handling their domain.",
"steps": steps,
"data_flow": "user_query -> [parallel] -> aggregated_result",
"aggregation": "Combine results from all skills into a unified response.",
}
elif pattern == "primary_support":
primary = skills[0] # Highest score
support = skills[1:]
steps = [
{
"order": 1,
"skill": primary["name"],
"skill_md": primary.get("skill_md", primary.get("location", "")),
"action": f"Primary: handle main request: {query}",
"input": "user_query",
"output": f"{primary['name']}.output",
"role": "primary",
}
]
for i, skill in enumerate(support):
steps.append({
"order": 2,
"skill": skill["name"],
"skill_md": skill.get("skill_md", skill.get("location", "")),
"action": f"Support: provide {', '.join(skill.get('capabilities', []))} data if needed",
"input": "user_query",
"output": f"{skill['name']}.output",
"role": "support",
})
return {
"pattern": "primary_support",
"description": f"Primary: '{primary['name']}'. Support: {', '.join(s['name'] for s in support)}.",
"steps": steps,
"data_flow": f"user_query -> {primary['name']} (primary) + support skills as needed -> result",
}
return {"pattern": "unknown", "steps": [], "data_flow": ""}
# ── CLI Entry Point ────────────────────────────────────────────────────────
def main():
args = sys.argv[1:]
skill_names = []
query = ""
match_result = None
i = 0
while i < len(args):
if args[i] == "--skills" and i + 1 < len(args):
skill_names = [s.strip() for s in args[i + 1].split(",")]
i += 2
elif args[i] == "--query" and i + 1 < len(args):
query = args[i + 1]
i += 2
elif args[i] == "--match-result" and i + 1 < len(args):
match_result = json.loads(args[i + 1])
i += 2
else:
# Treat as query if no flag
query = args[i]
i += 1
# Get skill data from match result or registry
skills = []
if match_result:
skills = match_result.get("skills", [])
elif skill_names:
registry = load_registry()
for name in skill_names:
if name in registry:
skill_data = registry[name]
skill_data["score"] = 10 # default score
skills.append(skill_data)
if not skills:
print(json.dumps({
"error": "No skills provided",
"usage": 'python orchestrate.py --skills skill1,skill2 --query "your query"'
}, indent=2))
sys.exit(1)
if not query:
print(json.dumps({
"error": "No query provided",
"usage": 'python orchestrate.py --skills skill1,skill2 --query "your query"'
}, indent=2))
sys.exit(1)
# Classify and generate plan
pattern = classify_pattern(skills, query)
plan = generate_plan(skills, query, pattern)
plan["query"] = query
plan["skill_count"] = len(skills)
# Add instructions for Claude
plan["instructions"] = []
for step in plan.get("steps", []):
skill_md = step.get("skill_md", "")
if skill_md:
plan["instructions"].append(
f"Step {step['order']}: Read {skill_md} and follow its workflow for: {step['action']}"
)
print(json.dumps(plan, indent=2, ensure_ascii=False))
if __name__ == "__main__":
main()
@@ -0,0 +1,508 @@
#!/usr/bin/env python3
"""
Auto-Discovery Engine for Agent Orchestrator.
Scans the skills ecosystem for SKILL.md files, parses metadata,
and maintains a centralized registry (registry.json).
Features:
- Runs automatically on every request (called by CLAUDE.md)
- Ultra-fast via MD5 hash caching (~<100ms when nothing changed)
- Auto-includes new skills, auto-removes deleted skills
- Zero manual intervention required
Usage:
python scan_registry.py # Quick scan (hash-based)
python scan_registry.py --status # Verbose status table
python scan_registry.py --force # Full re-scan ignoring hashes
"""
import os
import sys
import json
import hashlib
import re
from pathlib import Path
from datetime import datetime
# ── Configuration ──────────────────────────────────────────────────────────
# Resolve paths relative to this script's location
_SCRIPT_DIR = Path(__file__).resolve().parent
ORCHESTRATOR_DIR = _SCRIPT_DIR.parent
SKILLS_ROOT = ORCHESTRATOR_DIR.parent
DATA_DIR = ORCHESTRATOR_DIR / "data"
REGISTRY_PATH = DATA_DIR / "registry.json"
HASHES_PATH = DATA_DIR / "registry_hashes.json"
# Where to search for SKILL.md files
SEARCH_PATHS = [
SKILLS_ROOT / ".claude" / "skills", # registered skills
SKILLS_ROOT, # top-level standalone
]
MAX_DEPTH = 3 # max directory depth for SKILL.md search
# Capability keyword mapping (PT + EN)
CAPABILITY_MAP = {
"data-extraction": [
"scrape", "extract", "crawl", "parse", "harvest", "collect",
"raspar", "extrair", "coletar", "dados",
],
"messaging": [
"whatsapp", "message", "send", "chat", "notification", "sms",
"mensagem", "enviar", "notificacao", "atendimento",
],
"social-media": [
"instagram", "facebook", "twitter", "post", "stories", "reels",
"social", "engagement", "feed", "follower",
],
"government-data": [
"junta", "leiloeiro", "cadastro", "governo", "comercial",
"tribunal", "diario oficial", "certidao", "registro",
],
"web-automation": [
"browser", "selenium", "playwright", "automate", "click",
"navegador", "automatizar", "automacao",
],
"api-integration": [
"api", "endpoint", "webhook", "rest", "graph", "oauth",
"integracao", "integrar",
],
"analytics": [
"insight", "analytics", "metrics", "dashboard", "report",
"relatorio", "metricas", "analise",
],
"content-management": [
"publish", "schedule", "template", "content", "media",
"publicar", "agendar", "conteudo", "midia",
],
"legal": [
"advogado", "direito", "juridico", "lei", "processo",
"acao", "peticao", "recurso", "sentenca", "juiz",
"divorcio", "guarda", "alimentos", "pensao", "alimenticia", "inventario", "heranca", "partilha",
"acidente de trabalho", "acidente",
"familia", "criminal", "penal", "crime", "feminicidio", "maria da penha",
"violencia domestica", "medida protetiva", "stalking",
"danos morais", "responsabilidade civil", "indenizacao", "dano",
"consumidor", "cdc", "plano de saude",
"trabalhista", "clt", "rescisao", "fgts", "horas extras",
"previdenciario", "aposentadoria", "aposentar", "inss",
"imobiliario", "usucapiao", "despejo", "inquilinato",
"alienacao fiduciaria", "bem de familia",
"tributario", "imposto", "icms", "execucao fiscal",
"administrativo", "licitacao", "improbidade", "mandado de seguranca",
"empresarial", "societario", "falencia", "recuperacao judicial",
"empresa", "ltda", "cnpj", "mei", "eireli", "contrato social",
"contrato", "clausula", "contestacao", "apelacao", "agravo",
"habeas corpus", "mandado", "liminar", "tutela",
"cpc", "stj", "stf", "sumula", "jurisprudencia",
"oab", "honorarios", "custas",
],
"auction": [
"leilao", "leilao judicial", "leilao extrajudicial", "hasta publica",
"arrematacao", "arrematar", "arrematante", "lance", "desagio",
"edital leilao", "penhora", "adjudicacao", "praca",
"imissao na posse", "carta arrematacao", "vil preco",
"avaliacao imovel", "laudo", "perito", "matricula",
"leiloeiro", "comissao leiloeiro",
],
"security": [
"seguranca", "security", "owasp", "vulnerability", "incident",
"pentest", "firewall", "malware", "phishing", "cve",
"autenticacao", "criptografia", "encryption",
],
"image-generation": [
"imagem", "image", "gerar imagem", "generate image",
"stable diffusion", "comfyui", "midjourney", "dall-e",
"foto", "ilustracao", "arte", "design",
],
"monitoring": [
"monitor", "monitorar", "health", "status",
"audit", "auditoria", "sentinel", "check",
],
"context-management": [
"contexto", "context", "sessao", "session", "compactacao", "compaction",
"comprimir", "compress", "snapshot", "checkpoint", "briefing",
"continuidade", "continuity", "preservar", "preserve",
"memoria", "memory", "resumo", "summary",
"salvar estado", "save state", "context window", "janela de contexto",
"perda de dados", "data loss", "backup",
],
}
# ── Utility Functions ──────────────────────────────────────────────────────
def md5_file(path: Path) -> str:
"""Compute MD5 hash of a file."""
h = hashlib.md5()
with open(path, "rb") as f:
for chunk in iter(lambda: f.read(8192), b""):
h.update(chunk)
return h.hexdigest()
def parse_yaml_frontmatter(path: Path) -> dict:
"""Extract YAML frontmatter from a SKILL.md file."""
try:
text = path.read_text(encoding="utf-8")
except Exception:
return {}
match = re.match(r"^---\s*\n(.*?)\n---", text, re.DOTALL)
if not match:
return {}
try:
import yaml
return yaml.safe_load(match.group(1)) or {}
except Exception:
# Fallback: manual parsing for name/description
result = {}
block = match.group(1)
for key in ("name", "description", "version"):
m = re.search(rf'^{key}:\s*["\']?(.+?)["\']?\s*$', block, re.MULTILINE)
if m:
result[key] = m.group(1).strip()
else:
# Handle multi-line description with >- or >
m2 = re.search(rf'^{key}:\s*>-?\s*\n((?:\s+.+\n?)+)', block, re.MULTILINE)
if m2:
lines = m2.group(1).strip().split("\n")
result[key] = " ".join(line.strip() for line in lines)
return result
def find_skill_files() -> list[Path]:
"""Find all SKILL.md files in the ecosystem."""
found = set()
for base in SEARCH_PATHS:
if not base.exists():
continue
for root, dirs, files in os.walk(base):
depth = len(Path(root).relative_to(base).parts)
if depth > MAX_DEPTH:
dirs.clear()
continue
# Skip the orchestrator itself
if "agent-orchestrator" in Path(root).parts:
continue
if "SKILL.md" in files:
found.add(Path(root) / "SKILL.md")
return sorted(found)
def detect_language(skill_dir: Path) -> str:
"""Detect primary language from scripts/ directory."""
scripts_dir = skill_dir / "scripts"
if not scripts_dir.exists():
return "none"
extensions = set()
for f in scripts_dir.rglob("*"):
if f.is_file():
extensions.add(f.suffix.lower())
if ".py" in extensions:
return "python"
if ".ts" in extensions or ".js" in extensions:
return "nodejs"
if ".sh" in extensions:
return "bash"
return "none"
def extract_capabilities(description: str) -> list[str]:
"""Map description keywords to capability tags using word boundary matching."""
if not description:
return []
desc_lower = description.lower()
desc_words = set(re.findall(r'[a-zA-ZÀ-ÿ]+', desc_lower))
caps = []
for cap, keywords in CAPABILITY_MAP.items():
for kw in keywords:
# Multi-word keywords: substring match. Single-word: exact word match.
if " " in kw:
if kw in desc_lower:
caps.append(cap)
break
elif kw in desc_words:
caps.append(cap)
break
return sorted(caps)
def extract_triggers(description: str) -> list[str]:
"""Extract trigger keywords from description text using word boundary matching."""
if not description:
return []
# Collect all keywords from all capability categories
all_keywords = set()
for keywords in CAPABILITY_MAP.values():
all_keywords.update(keywords)
desc_lower = description.lower()
desc_words = set(re.findall(r'[a-zA-ZÀ-ÿ]+', desc_lower))
found = []
for kw in sorted(all_keywords):
if " " in kw:
if kw in desc_lower:
found.append(kw)
elif kw in desc_words:
found.append(kw)
return found
def assess_status(skill_dir: Path) -> str:
"""Check if skill is complete (active) or incomplete."""
skill_md = skill_dir / "SKILL.md"
if not skill_md.exists():
return "missing"
has_scripts = (skill_dir / "scripts").exists()
has_refs = (skill_dir / "references").exists()
# Parse frontmatter to check for required fields
meta = parse_yaml_frontmatter(skill_md)
has_name = bool(meta.get("name"))
has_desc = bool(meta.get("description"))
if has_name and has_desc:
return "active"
return "incomplete"
def is_registered(skill_dir: Path) -> bool:
"""Check if skill is in .claude/skills/."""
claude_skills = SKILLS_ROOT / ".claude" / "skills"
try:
skill_dir.relative_to(claude_skills)
return True
except ValueError:
return False
# ── Main Logic ─────────────────────────────────────────────────────────────
def load_hashes() -> dict:
"""Load stored hashes from registry_hashes.json."""
if HASHES_PATH.exists():
try:
return json.loads(HASHES_PATH.read_text(encoding="utf-8"))
except Exception:
pass
return {}
def save_hashes(hashes: dict):
"""Save hashes to registry_hashes.json."""
DATA_DIR.mkdir(parents=True, exist_ok=True)
HASHES_PATH.write_text(json.dumps(hashes, indent=2), encoding="utf-8")
def load_registry() -> dict:
"""Load existing registry.json."""
if REGISTRY_PATH.exists():
try:
return json.loads(REGISTRY_PATH.read_text(encoding="utf-8"))
except Exception:
pass
return {"generated_at": None, "skills_root": str(SKILLS_ROOT), "skills": []}
def save_registry(registry: dict):
"""Save registry.json."""
DATA_DIR.mkdir(parents=True, exist_ok=True)
registry["generated_at"] = datetime.now().isoformat()
REGISTRY_PATH.write_text(json.dumps(registry, indent=2, ensure_ascii=False), encoding="utf-8")
def build_skill_entry(skill_md_path: Path) -> dict:
"""Build a registry entry from a SKILL.md file."""
skill_dir = skill_md_path.parent
meta = parse_yaml_frontmatter(skill_md_path)
description = meta.get("description", "")
# Support explicit capabilities in frontmatter
explicit_caps = meta.get("capabilities", [])
if isinstance(explicit_caps, str):
explicit_caps = [c.strip() for c in explicit_caps.split(",")]
auto_caps = extract_capabilities(description)
all_caps = sorted(set(auto_caps + explicit_caps))
return {
"name": meta.get("name", skill_dir.name),
"description": description,
"version": meta.get("version", ""),
"location": str(skill_dir),
"skill_md": str(skill_md_path),
"registered": is_registered(skill_dir),
"has_scripts": (skill_dir / "scripts").exists(),
"has_references": (skill_dir / "references").exists(),
"has_data": (skill_dir / "data").exists(),
"capabilities": all_caps,
"triggers": extract_triggers(description),
"language": detect_language(skill_dir),
"status": assess_status(skill_dir),
"last_modified": datetime.fromtimestamp(
skill_md_path.stat().st_mtime
).isoformat(),
}
def scan(force: bool = False) -> dict:
"""
Main scan function.
With hash caching:
1. Find all SKILL.md files
2. Compare MD5 hashes with stored values
3. Only re-parse files that changed, were added, or removed
4. Update registry incrementally
"""
current_files = find_skill_files()
current_paths = {str(f): f for f in current_files}
stored_hashes = load_hashes()
registry = load_registry()
# Build lookup of existing registry entries by skill_md path
existing_by_path = {}
for entry in registry.get("skills", []):
existing_by_path[entry.get("skill_md", "")] = entry
# Compute current hashes
new_hashes = {}
changed = False
for path_str, path_obj in current_paths.items():
current_hash = md5_file(path_obj)
new_hashes[path_str] = current_hash
if force or path_str not in stored_hashes or stored_hashes[path_str] != current_hash:
# New or modified - rebuild entry
entry = build_skill_entry(path_obj)
existing_by_path[path_str] = entry
changed = True
# Detect removed skills
for old_path in list(existing_by_path.keys()):
if old_path not in current_paths and old_path != "":
del existing_by_path[old_path]
changed = True
# Check if file set changed (additions/removals)
if set(new_hashes.keys()) != set(stored_hashes.keys()):
changed = True
# Deduplicate by skill name (case-insensitive).
# When the same skill exists in both skills/ and .claude/skills/,
# prefer the primary location (skills/) over the registered copy.
if changed or not REGISTRY_PATH.exists():
by_name = {}
for entry in existing_by_path.values():
name = entry.get("name", "").lower()
if not name:
continue
if name not in by_name:
by_name[name] = entry
else:
# Prefer the version NOT in .claude/skills/ (the primary source)
existing = by_name[name]
existing_is_registered = existing.get("registered", False)
new_is_registered = entry.get("registered", False)
if existing_is_registered and not new_is_registered:
by_name[name] = entry
# If both are primary or both registered, keep first found
registry["skills"] = sorted(by_name.values(), key=lambda s: s.get("name", ""))
save_registry(registry)
save_hashes(new_hashes)
return registry
else:
# Nothing changed, return existing
return registry
def print_status(registry: dict):
"""Print a formatted status table."""
skills = registry.get("skills", [])
if not skills:
print("No skills found in the ecosystem.")
return
print(f"\n{'='*80}")
print(f" Agent Orchestrator - Skill Registry Status")
print(f" Scanned at: {registry.get('generated_at', 'N/A')}")
print(f" Root: {registry.get('skills_root', 'N/A')}")
print(f"{'='*80}\n")
# Header
print(f" {'Name':<22} {'Status':<12} {'Lang':<10} {'Registered':<12} {'Capabilities'}")
print(f" {'-'*22} {'-'*12} {'-'*10} {'-'*12} {'-'*30}")
for s in sorted(skills, key=lambda x: x.get("name", "")):
name = s.get("name", "?")[:20]
status = s.get("status", "?")
lang = s.get("language", "none")
reg = "Yes" if s.get("registered") else "No"
caps = ", ".join(s.get("capabilities", []))[:30]
print(f" {name:<22} {status:<12} {lang:<10} {reg:<12} {caps}")
print(f"\n Total: {len(skills)} skills")
# Recommendations
unregistered = [s for s in skills if not s.get("registered")]
incomplete = [s for s in skills if s.get("status") == "incomplete"]
if unregistered:
print(f"\n [!] {len(unregistered)} skill(s) not registered in .claude/skills/:")
for s in unregistered:
print(f" - {s['name']} ({s['location']})")
if incomplete:
print(f"\n [!] {len(incomplete)} skill(s) with incomplete status:")
for s in incomplete:
print(f" - {s['name']} ({s['location']})")
print()
# ── CLI Entry Point ────────────────────────────────────────────────────────
def main():
force = "--force" in sys.argv
show_status = "--status" in sys.argv
registry = scan(force=force)
if show_status:
print_status(registry)
else:
# Default: output JSON summary for Claude to parse
skills = registry.get("skills", [])
summary = {
"total": len(skills),
"active": len([s for s in skills if s.get("status") == "active"]),
"incomplete": len([s for s in skills if s.get("status") == "incomplete"]),
"skills": [
{
"name": s.get("name"),
"status": s.get("status"),
"capabilities": s.get("capabilities", []),
}
for s in skills
],
}
print(json.dumps(summary, indent=2, ensure_ascii=False))
if __name__ == "__main__":
main()