731 lines
30 KiB
Python
731 lines
30 KiB
Python
#!/usr/bin/env python3
|
|
import argparse
|
|
import csv
|
|
import html
|
|
import json
|
|
import re
|
|
from collections import Counter, defaultdict
|
|
from datetime import date, datetime
|
|
from io import StringIO
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
try:
|
|
import yaml
|
|
except ImportError: # pragma: no cover
|
|
yaml = None
|
|
|
|
|
|
ROOT = Path(__file__).resolve().parent.parent
|
|
IGNORE_PARTS = {
|
|
".git",
|
|
"__pycache__",
|
|
"dist",
|
|
".previews",
|
|
"node_modules",
|
|
".venv",
|
|
"venv",
|
|
}
|
|
STOPWORDS = {
|
|
"a",
|
|
"an",
|
|
"and",
|
|
"the",
|
|
"to",
|
|
"for",
|
|
"from",
|
|
"with",
|
|
"into",
|
|
"skill",
|
|
"skills",
|
|
"agent",
|
|
"reusable",
|
|
"use",
|
|
"when",
|
|
"create",
|
|
"turn",
|
|
}
|
|
CADENCE_DAYS = {
|
|
"monthly": 31,
|
|
"quarterly": 100,
|
|
"semiannual": 200,
|
|
"annual": 370,
|
|
"per-release": 120,
|
|
}
|
|
DEFAULT_SCOPE = {
|
|
"scope": "release",
|
|
"actionable": True,
|
|
"scope_reason": "default release-actionable skill",
|
|
}
|
|
TELEMETRY_REQUIRED_MATURITIES = {"production", "library", "governed"}
|
|
|
|
|
|
def parse_frontmatter(path: Path) -> tuple[dict[str, Any], str]:
|
|
text = path.read_text(encoding="utf-8", errors="replace")
|
|
lines = text.splitlines()
|
|
if not lines or lines[0].strip() != "---":
|
|
return {}, text
|
|
try:
|
|
end_index = lines[1:].index("---") + 1
|
|
except ValueError:
|
|
return {}, text
|
|
frontmatter_text = "\n".join(lines[1:end_index])
|
|
body = "\n".join(lines[end_index + 1 :]).lstrip()
|
|
if yaml is not None:
|
|
payload = yaml.safe_load(frontmatter_text) or {}
|
|
return payload if isinstance(payload, dict) else {}, body
|
|
payload = {}
|
|
for line in frontmatter_text.splitlines():
|
|
if ":" in line:
|
|
key, value = line.split(":", 1)
|
|
payload[key.strip()] = value.strip().strip('"')
|
|
return payload, body
|
|
|
|
|
|
def load_json(path: Path) -> dict[str, Any]:
|
|
if not path.exists():
|
|
return {}
|
|
try:
|
|
payload = json.loads(path.read_text(encoding="utf-8"))
|
|
except json.JSONDecodeError:
|
|
return {}
|
|
return payload if isinstance(payload, dict) else {}
|
|
|
|
|
|
def load_scope_policy(workspace_root: Path) -> dict[str, Any]:
|
|
path = workspace_root / "skill_atlas" / "policy.json"
|
|
if not path.exists():
|
|
return {"present": False, "path": safe_rel(workspace_root, path), "rules": []}
|
|
payload = load_json(path)
|
|
rules = payload.get("scope_rules", [])
|
|
return {
|
|
"present": True,
|
|
"path": safe_rel(workspace_root, path),
|
|
"schema_version": str(payload.get("schema_version", "")),
|
|
"rules": rules if isinstance(rules, list) else [],
|
|
}
|
|
|
|
|
|
def should_skip(path: Path, root: Path) -> bool:
|
|
try:
|
|
rel = path.relative_to(root)
|
|
except ValueError:
|
|
return True
|
|
if any(part in IGNORE_PARTS for part in rel.parts):
|
|
return True
|
|
return len(rel.parts) >= 2 and rel.parts[0] == "tests" and rel.parts[1].startswith("tmp")
|
|
|
|
|
|
def find_skill_dirs(workspace_root: Path) -> list[Path]:
|
|
workspace_root = workspace_root.resolve()
|
|
skill_dirs = []
|
|
for skill_md in sorted(workspace_root.rglob("SKILL.md")):
|
|
if should_skip(skill_md, workspace_root):
|
|
continue
|
|
skill_dirs.append(skill_md.parent)
|
|
return skill_dirs
|
|
|
|
|
|
def tokens(text: str) -> set[str]:
|
|
raw = re.findall(r"[a-zA-Z0-9_\-\u4e00-\u9fff]{2,}", text.casefold())
|
|
return {item for item in raw if item not in STOPWORDS}
|
|
|
|
|
|
def jaccard(left: set[str], right: set[str]) -> float:
|
|
if not left or not right:
|
|
return 0.0
|
|
return len(left & right) / len(left | right)
|
|
|
|
|
|
def safe_rel(root: Path, path: Path) -> str:
|
|
try:
|
|
return str(path.resolve().relative_to(root.resolve()))
|
|
except ValueError:
|
|
return str(path.resolve())
|
|
|
|
|
|
def path_matches_prefix(rel_path: str, prefix: str) -> bool:
|
|
normalized_path = rel_path.strip("/")
|
|
normalized_prefix = prefix.strip("/")
|
|
if not normalized_prefix:
|
|
return False
|
|
return normalized_path == normalized_prefix or normalized_path.startswith(normalized_prefix + "/")
|
|
|
|
|
|
def scope_for_path(rel_path: str, policy: dict[str, Any]) -> dict[str, Any]:
|
|
for rule in policy.get("rules", []):
|
|
if not isinstance(rule, dict):
|
|
continue
|
|
prefix = str(rule.get("path_prefix", "")).strip()
|
|
if not prefix or not path_matches_prefix(rel_path, prefix):
|
|
continue
|
|
return {
|
|
"scope": str(rule.get("scope") or "supporting"),
|
|
"actionable": bool(rule.get("actionable", False)),
|
|
"scope_reason": str(rule.get("reason") or f"matched policy prefix {prefix}"),
|
|
}
|
|
return dict(DEFAULT_SCOPE)
|
|
|
|
|
|
def display_path(path: Path) -> str:
|
|
try:
|
|
return str(path.resolve().relative_to(ROOT.resolve()))
|
|
except ValueError:
|
|
return str(path.resolve())
|
|
|
|
|
|
def resource_names(skill_dir: Path) -> list[str]:
|
|
names = []
|
|
for folder in ("scripts", "references", "assets", "templates"):
|
|
target = skill_dir / folder
|
|
if not target.exists():
|
|
continue
|
|
for path in sorted(target.rglob("*")):
|
|
rel = path.relative_to(skill_dir)
|
|
if any(part in IGNORE_PARTS for part in rel.parts):
|
|
continue
|
|
if path.suffix in {".pyc", ".pyo"}:
|
|
continue
|
|
if path.is_file() and not path.is_symlink():
|
|
names.append(f"{folder}/{path.name}")
|
|
return names
|
|
|
|
|
|
def collect_skill(workspace_root: Path, skill_dir: Path, policy: dict[str, Any]) -> dict[str, Any]:
|
|
frontmatter, _ = parse_frontmatter(skill_dir / "SKILL.md")
|
|
manifest = load_json(skill_dir / "manifest.json")
|
|
name = str(frontmatter.get("name") or manifest.get("name") or skill_dir.name)
|
|
description = str(frontmatter.get("description") or "")
|
|
targets = manifest.get("target_platforms", [])
|
|
rel_path = safe_rel(workspace_root, skill_dir)
|
|
scope = scope_for_path(rel_path, policy)
|
|
return {
|
|
"name": name,
|
|
"path": rel_path,
|
|
"description": description,
|
|
"owner": str(manifest.get("owner", "")),
|
|
"version": str(manifest.get("version", "")),
|
|
"status": str(manifest.get("status", "")),
|
|
"maturity": str(manifest.get("maturity_tier", manifest.get("skill_archetype", ""))),
|
|
"updated_at": str(manifest.get("updated_at", "")),
|
|
"review_cadence": str(manifest.get("review_cadence", "")),
|
|
"targets": [str(item) for item in targets] if isinstance(targets, list) else [],
|
|
"resources": resource_names(skill_dir),
|
|
"token_set": sorted(tokens(description)),
|
|
"atlas_scope": scope["scope"],
|
|
"actionable": scope["actionable"],
|
|
"scope_reason": scope["scope_reason"],
|
|
}
|
|
|
|
|
|
def load_telemetry_profile(workspace_root: Path, skill_dir: Path, skill: dict[str, Any]) -> tuple[dict[str, Any], list[dict[str, Any]]]:
|
|
report_path = skill_dir / "reports" / "adoption_drift_report.json"
|
|
rel_report = safe_rel(workspace_root, report_path)
|
|
maturity = str(skill.get("maturity", "")).casefold()
|
|
report_present = report_path.exists()
|
|
telemetry = {
|
|
"report_present": report_present,
|
|
"report": rel_report,
|
|
"risk_band": "missing",
|
|
"event_count": 0,
|
|
"adoption_sample_count": 0,
|
|
"adoption_rate": 0,
|
|
"candidate_count": 0,
|
|
}
|
|
signals: list[dict[str, Any]] = []
|
|
if not report_present:
|
|
if skill.get("actionable") and maturity in TELEMETRY_REQUIRED_MATURITIES:
|
|
signals.append(
|
|
{
|
|
"name": skill["name"],
|
|
"path": skill["path"],
|
|
"source": rel_report,
|
|
"risk_band": "no-data",
|
|
"signal_types": ["no telemetry"],
|
|
"recommendation": "Render adoption drift evidence or record a small metadata-only sample before release review.",
|
|
"actionable": bool(skill.get("actionable")),
|
|
"scope": str(skill.get("atlas_scope", "")),
|
|
"summary": {"event_count": 0, "adoption_sample_count": 0},
|
|
}
|
|
)
|
|
return telemetry, signals
|
|
|
|
payload = load_json(report_path)
|
|
summary = payload.get("summary", {}) if isinstance(payload.get("summary"), dict) else {}
|
|
candidates = payload.get("next_iteration_candidates", [])
|
|
candidates = candidates if isinstance(candidates, list) else []
|
|
risk_band = str(summary.get("risk_band") or "unknown")
|
|
telemetry.update(
|
|
{
|
|
"risk_band": risk_band,
|
|
"event_count": int(summary.get("event_count") or 0),
|
|
"adoption_sample_count": int(summary.get("adoption_sample_count") or 0),
|
|
"adoption_rate": summary.get("adoption_rate", 0),
|
|
"candidate_count": len(candidates),
|
|
}
|
|
)
|
|
|
|
signal_types: list[str] = []
|
|
if telemetry["event_count"] == 0 and maturity in TELEMETRY_REQUIRED_MATURITIES:
|
|
signal_types.append("no telemetry")
|
|
if int(summary.get("missed_trigger_count") or 0):
|
|
signal_types.append("missed trigger")
|
|
if int(summary.get("wrong_trigger_count") or 0):
|
|
signal_types.append("wrong trigger")
|
|
if int(summary.get("bad_output_count") or 0):
|
|
signal_types.append("bad output")
|
|
if int(summary.get("missing_resource_count") or 0):
|
|
signal_types.append("missing resource")
|
|
if int(summary.get("script_error_count") or 0):
|
|
signal_types.append("script error")
|
|
if int(summary.get("review_overdue_count") or 0):
|
|
signal_types.append("review overdue")
|
|
if risk_band in {"medium", "high"} and not signal_types:
|
|
signal_types.append("telemetry drift")
|
|
|
|
if signal_types:
|
|
recommendation = "Convert telemetry drift into eval, trust, or owner-review actions."
|
|
for candidate in candidates:
|
|
if not isinstance(candidate, dict):
|
|
continue
|
|
if str(candidate.get("signal", "")) in signal_types:
|
|
recommendation = str(candidate.get("recommendation") or recommendation)
|
|
break
|
|
signals.append(
|
|
{
|
|
"name": skill["name"],
|
|
"path": skill["path"],
|
|
"source": rel_report,
|
|
"risk_band": risk_band,
|
|
"signal_types": signal_types,
|
|
"recommendation": recommendation,
|
|
"actionable": bool(skill.get("actionable")),
|
|
"scope": str(skill.get("atlas_scope", "")),
|
|
"summary": {
|
|
"event_count": telemetry["event_count"],
|
|
"adoption_sample_count": telemetry["adoption_sample_count"],
|
|
"adoption_rate": telemetry["adoption_rate"],
|
|
"missed_trigger_count": int(summary.get("missed_trigger_count") or 0),
|
|
"wrong_trigger_count": int(summary.get("wrong_trigger_count") or 0),
|
|
"bad_output_count": int(summary.get("bad_output_count") or 0),
|
|
"missing_resource_count": int(summary.get("missing_resource_count") or 0),
|
|
"script_error_count": int(summary.get("script_error_count") or 0),
|
|
"review_overdue_count": int(summary.get("review_overdue_count") or 0),
|
|
},
|
|
}
|
|
)
|
|
return telemetry, signals
|
|
|
|
|
|
def route_overlap(skills: list[dict[str, Any]], threshold: float) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
|
|
rows = []
|
|
collisions = []
|
|
for i, left in enumerate(skills):
|
|
for right in skills[i + 1 :]:
|
|
score = round(jaccard(set(left["token_set"]), set(right["token_set"])), 3)
|
|
status = "collision" if score >= threshold else "clear"
|
|
row = {
|
|
"skill_a": left["name"],
|
|
"skill_b": right["name"],
|
|
"path_a": left["path"],
|
|
"path_b": right["path"],
|
|
"score": score,
|
|
"status": status,
|
|
"actionable": bool(left.get("actionable") and right.get("actionable")),
|
|
"scope_a": str(left.get("atlas_scope", "")),
|
|
"scope_b": str(right.get("atlas_scope", "")),
|
|
}
|
|
rows.append(row)
|
|
if status == "collision":
|
|
collisions.append(row)
|
|
duplicate_names = [
|
|
{"name": name, "paths": [item["path"] for item in skills if item["name"] == name]}
|
|
for name, count in Counter(item["name"] for item in skills).items()
|
|
if count > 1
|
|
]
|
|
for item in duplicate_names:
|
|
collisions.append(
|
|
{
|
|
"skill_a": item["name"],
|
|
"skill_b": item["name"],
|
|
"path_a": item["paths"][0],
|
|
"path_b": item["paths"][1],
|
|
"score": 1.0,
|
|
"status": "duplicate-name",
|
|
"actionable": all(
|
|
skill.get("actionable")
|
|
for skill in skills
|
|
if skill["name"] == item["name"] and skill["path"] in set(item["paths"][:2])
|
|
),
|
|
"scope_a": next(
|
|
(str(skill.get("atlas_scope", "")) for skill in skills if skill["path"] == item["paths"][0]),
|
|
"",
|
|
),
|
|
"scope_b": next(
|
|
(str(skill.get("atlas_scope", "")) for skill in skills if skill["path"] == item["paths"][1]),
|
|
"",
|
|
),
|
|
}
|
|
)
|
|
return rows, collisions
|
|
|
|
|
|
def dependency_graph(skills: list[dict[str, Any]]) -> dict[str, Any]:
|
|
by_resource: dict[str, list[str]] = defaultdict(list)
|
|
for skill in skills:
|
|
for resource in skill.get("resources", []):
|
|
by_resource[resource].append(skill["name"])
|
|
shared = [
|
|
{"resource": resource, "skills": sorted(names)}
|
|
for resource, names in sorted(by_resource.items())
|
|
if len(set(names)) > 1
|
|
]
|
|
return {
|
|
"nodes": [{"name": item["name"], "path": item["path"]} for item in skills],
|
|
"shared_resources": shared,
|
|
}
|
|
|
|
|
|
def parse_date(value: str) -> date | None:
|
|
if not value:
|
|
return None
|
|
try:
|
|
return datetime.strptime(value[:10], "%Y-%m-%d").date()
|
|
except ValueError:
|
|
return None
|
|
|
|
|
|
def stale_skills(skills: list[dict[str, Any]], today: date) -> list[dict[str, Any]]:
|
|
stale = []
|
|
for skill in skills:
|
|
updated = parse_date(skill.get("updated_at", ""))
|
|
cadence = skill.get("review_cadence") or ""
|
|
allowed_days = CADENCE_DAYS.get(cadence, 120)
|
|
if not updated:
|
|
stale.append(
|
|
{
|
|
"name": skill["name"],
|
|
"path": skill["path"],
|
|
"reason": "missing updated_at",
|
|
"actionable": bool(skill.get("actionable")),
|
|
"scope": str(skill.get("atlas_scope", "")),
|
|
}
|
|
)
|
|
continue
|
|
age = (today - updated).days
|
|
if age > allowed_days:
|
|
stale.append(
|
|
{
|
|
"name": skill["name"],
|
|
"path": skill["path"],
|
|
"reason": f"review overdue by cadence {cadence or 'unspecified'}",
|
|
"age_days": age,
|
|
"allowed_days": allowed_days,
|
|
"actionable": bool(skill.get("actionable")),
|
|
"scope": str(skill.get("atlas_scope", "")),
|
|
}
|
|
)
|
|
return stale
|
|
|
|
|
|
def owner_review_gaps(skills: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
|
gaps = []
|
|
for skill in skills:
|
|
missing = []
|
|
if not skill.get("owner"):
|
|
missing.append("owner")
|
|
if not skill.get("review_cadence"):
|
|
missing.append("review_cadence")
|
|
if not skill.get("maturity"):
|
|
missing.append("maturity")
|
|
if missing:
|
|
gaps.append(
|
|
{
|
|
"name": skill["name"],
|
|
"path": skill["path"],
|
|
"missing": missing,
|
|
"actionable": bool(skill.get("actionable")),
|
|
"scope": str(skill.get("atlas_scope", "")),
|
|
}
|
|
)
|
|
return gaps
|
|
|
|
|
|
def failure_case_no_route_opportunities(workspace_root: Path) -> list[dict[str, Any]]:
|
|
opportunities = []
|
|
for path in sorted(workspace_root.rglob("failure-cases.md")):
|
|
if should_skip(path, workspace_root):
|
|
continue
|
|
text = path.read_text(encoding="utf-8", errors="replace")
|
|
for line in text.splitlines():
|
|
stripped = line.strip()
|
|
if not stripped.startswith("-"):
|
|
continue
|
|
lowered = stripped.casefold()
|
|
if "no_route" in lowered or "no route" in lowered or "missed" in lowered or "under-trigger" in lowered:
|
|
opportunities.append(
|
|
{
|
|
"source_type": "failure-case",
|
|
"source": safe_rel(workspace_root, path),
|
|
"note": stripped.lstrip("- ").strip(),
|
|
"actionable": True,
|
|
"privacy_contract": "source note only; raw prompts are not required",
|
|
}
|
|
)
|
|
return opportunities[:50]
|
|
|
|
|
|
def telemetry_no_route_opportunities(drift_signals: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
|
opportunities = []
|
|
for signal in drift_signals:
|
|
signal_types = {str(item) for item in signal.get("signal_types", [])}
|
|
if not {"missed trigger", "under trigger"} & signal_types:
|
|
continue
|
|
summary = signal.get("summary", {}) if isinstance(signal.get("summary"), dict) else {}
|
|
opportunities.append(
|
|
{
|
|
"source_type": "telemetry",
|
|
"source": str(signal.get("source", "")),
|
|
"skill": str(signal.get("name", "")),
|
|
"path": str(signal.get("path", "")),
|
|
"signal": "missed trigger",
|
|
"missed_trigger_count": int(summary.get("missed_trigger_count") or 0),
|
|
"recommendation": str(
|
|
signal.get("recommendation")
|
|
or "Add missed prompts to trigger eval and evaluate whether a new skill route is needed."
|
|
),
|
|
"actionable": bool(signal.get("actionable")),
|
|
"scope": str(signal.get("scope", "")),
|
|
"privacy_contract": "metadata-only telemetry; no raw prompt, output, transcript, or note is stored",
|
|
}
|
|
)
|
|
return opportunities
|
|
|
|
|
|
def no_route_opportunities(workspace_root: Path, drift_signals: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
|
opportunities = failure_case_no_route_opportunities(workspace_root)
|
|
opportunities.extend(telemetry_no_route_opportunities(drift_signals))
|
|
return opportunities[:80]
|
|
|
|
|
|
def write_csv(path: Path, rows: list[dict[str, Any]]) -> None:
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
fields = ["skill_a", "skill_b", "path_a", "path_b", "score", "status", "actionable", "scope_a", "scope_b"]
|
|
buffer = StringIO()
|
|
writer = csv.DictWriter(buffer, fieldnames=fields, lineterminator="\n")
|
|
writer.writeheader()
|
|
for row in rows:
|
|
writer.writerow({field: row.get(field, "") for field in fields})
|
|
path.write_text(buffer.getvalue(), encoding="utf-8")
|
|
|
|
|
|
def render_html(payload: dict[str, Any]) -> str:
|
|
summary = payload["summary"]
|
|
rows = []
|
|
for skill in payload["catalog"]["skills"][:80]:
|
|
rows.append(
|
|
"<tr>"
|
|
f"<td>{html.escape(skill['name'])}</td>"
|
|
f"<td>{html.escape(skill['path'])}</td>"
|
|
f"<td>{html.escape(skill.get('owner') or 'missing')}</td>"
|
|
f"<td>{html.escape(skill.get('maturity') or 'unknown')}</td>"
|
|
f"<td>{html.escape(skill.get('review_cadence') or 'missing')}</td>"
|
|
f"<td>{html.escape(skill.get('atlas_scope') or 'release')}</td>"
|
|
"</tr>"
|
|
)
|
|
blockers = (
|
|
payload["actionable_route_collisions"][:20]
|
|
+ payload["actionable_owner_review_gaps"][:20]
|
|
+ payload["actionable_stale_skills"][:20]
|
|
+ payload["actionable_drift_signals"][:20]
|
|
)
|
|
blocker_items = "".join(
|
|
f"<li><strong>{html.escape(item.get('name', item.get('skill_a', 'issue')))}</strong> {html.escape(item.get('reason', item.get('status', ', '.join(item.get('missing', item.get('signal_types', []))))))}</li>"
|
|
for item in blockers
|
|
)
|
|
opportunity_items = "".join(
|
|
(
|
|
"<li>"
|
|
f"<strong>{html.escape(item.get('skill') or item.get('source_type', 'opportunity'))}</strong> "
|
|
f"{html.escape(item.get('note') or item.get('recommendation') or item.get('signal', 'no-route opportunity'))}"
|
|
f"<br><small>{html.escape(item.get('source', ''))}</small>"
|
|
"</li>"
|
|
)
|
|
for item in payload["no_route_opportunities"][:20]
|
|
)
|
|
return f"""<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="utf-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
<title>Skill Atlas</title>
|
|
<style>
|
|
body {{ margin: 0; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; color: #172033; background: #fff; }}
|
|
main {{ max-width: 1120px; margin: 0 auto; padding: 40px 24px; }}
|
|
h1 {{ font-size: 34px; margin-bottom: 8px; }}
|
|
.grid {{ display: grid; grid-template-columns: repeat(5, minmax(0, 1fr)); gap: 12px; margin: 28px 0; }}
|
|
.card {{ border: 1px solid #d9e0ea; border-radius: 8px; padding: 16px; background: #f8fafc; }}
|
|
.card span {{ display: block; color: #697386; font-size: 13px; }}
|
|
.card strong {{ display: block; font-size: 28px; color: #1B365D; margin-top: 6px; }}
|
|
table {{ width: 100%; border-collapse: collapse; margin-top: 20px; }}
|
|
th, td {{ text-align: left; border-bottom: 1px solid #e5e9f0; padding: 10px; vertical-align: top; }}
|
|
th {{ color: #1B365D; font-size: 13px; }}
|
|
li {{ margin: 8px 0; }}
|
|
@media (max-width: 760px) {{ .grid {{ grid-template-columns: 1fr 1fr; }} }}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<main>
|
|
<h1>Skill Atlas</h1>
|
|
<p>Portfolio-level review for route overlap, stale ownership, shared resources, telemetry drift, and no-route opportunities.</p>
|
|
<section class="grid">
|
|
<div class="card"><span>Skills</span><strong>{summary['skill_count']}</strong></div>
|
|
<div class="card"><span>Actionable</span><strong>{summary['actionable_skill_count']}</strong></div>
|
|
<div class="card"><span>Route Collisions</span><strong>{summary['actionable_route_collision_count']}</strong></div>
|
|
<div class="card"><span>Owner Gaps</span><strong>{summary['actionable_owner_gap_count']}</strong></div>
|
|
<div class="card"><span>Stale Skills</span><strong>{summary['actionable_stale_count']}</strong></div>
|
|
<div class="card"><span>Drift Signals</span><strong>{summary['actionable_drift_signal_count']}</strong></div>
|
|
<div class="card"><span>No-Route Opportunities</span><strong>{summary['no_route_opportunity_count']}</strong></div>
|
|
</section>
|
|
<section>
|
|
<h2>Actionable Issues</h2>
|
|
<ul>{blocker_items or '<li>No blocking portfolio issues detected.</li>'}</ul>
|
|
</section>
|
|
<section>
|
|
<h2>No-Route Opportunities</h2>
|
|
<p>Missed-trigger telemetry and explicit failure cases become candidate routing work without storing raw prompts or outputs.</p>
|
|
<ul>{opportunity_items or '<li>No no-route opportunities detected.</li>'}</ul>
|
|
</section>
|
|
<section>
|
|
<h2>Full Portfolio Counts</h2>
|
|
<p>All scanned skills remain visible: {summary['route_collision_count']} total route collisions, {summary['owner_gap_count']} total owner gaps, {summary['stale_count']} total stale signals, and {summary['drift_signal_count']} telemetry drift signals.</p>
|
|
</section>
|
|
<section>
|
|
<h2>Catalog</h2>
|
|
<table>
|
|
<thead><tr><th>Name</th><th>Path</th><th>Owner</th><th>Maturity</th><th>Review</th><th>Scope</th></tr></thead>
|
|
<tbody>{''.join(rows)}</tbody>
|
|
</table>
|
|
</section>
|
|
</main>
|
|
</body>
|
|
</html>
|
|
"""
|
|
|
|
|
|
def build_atlas(workspace_root: Path, output_dir: Path, report_html: Path, report_json: Path, threshold: float, today: date) -> dict[str, Any]:
|
|
workspace_root = workspace_root.resolve()
|
|
scope_policy = load_scope_policy(workspace_root)
|
|
skill_dirs = find_skill_dirs(workspace_root)
|
|
skills = []
|
|
drift_signals: list[dict[str, Any]] = []
|
|
telemetry_report_count = 0
|
|
for skill_dir in skill_dirs:
|
|
skill = collect_skill(workspace_root, skill_dir, scope_policy)
|
|
telemetry, signals = load_telemetry_profile(workspace_root, skill_dir, skill)
|
|
skill["telemetry"] = telemetry
|
|
telemetry_report_count += 1 if telemetry["report_present"] else 0
|
|
drift_signals.extend(signals)
|
|
skills.append(skill)
|
|
overlap_rows, collisions = route_overlap(skills, threshold)
|
|
graph = dependency_graph(skills)
|
|
stale = stale_skills(skills, today)
|
|
owner_gaps = owner_review_gaps(skills)
|
|
opportunities = no_route_opportunities(workspace_root, drift_signals)
|
|
actionable_skills = [skill for skill in skills if skill.get("actionable")]
|
|
actionable_collisions = [item for item in collisions if item.get("actionable")]
|
|
actionable_stale = [item for item in stale if item.get("actionable")]
|
|
actionable_owner_gaps = [item for item in owner_gaps if item.get("actionable")]
|
|
actionable_drift_signals = [item for item in drift_signals if item.get("actionable")]
|
|
summary = {
|
|
"skill_count": len(skills),
|
|
"actionable_skill_count": len(actionable_skills),
|
|
"route_collision_count": len(collisions),
|
|
"actionable_route_collision_count": len(actionable_collisions),
|
|
"owner_gap_count": len(owner_gaps),
|
|
"actionable_owner_gap_count": len(actionable_owner_gaps),
|
|
"stale_count": len(stale),
|
|
"actionable_stale_count": len(actionable_stale),
|
|
"shared_resource_count": len(graph["shared_resources"]),
|
|
"no_route_opportunity_count": len(opportunities),
|
|
"telemetry_report_count": telemetry_report_count,
|
|
"drift_signal_count": len(drift_signals),
|
|
"actionable_drift_signal_count": len(actionable_drift_signals),
|
|
"non_actionable_issue_count": (len(collisions) - len(actionable_collisions))
|
|
+ (len(owner_gaps) - len(actionable_owner_gaps))
|
|
+ (len(stale) - len(actionable_stale))
|
|
+ (len(drift_signals) - len(actionable_drift_signals)),
|
|
}
|
|
catalog = {
|
|
"workspace_root": display_path(workspace_root),
|
|
"generated_at": today.isoformat(),
|
|
"skills": skills,
|
|
"summary": summary,
|
|
}
|
|
payload = {
|
|
"ok": True,
|
|
"workspace_root": display_path(workspace_root),
|
|
"summary": summary,
|
|
"scope_policy": scope_policy,
|
|
"catalog": catalog,
|
|
"route_collisions": collisions,
|
|
"actionable_route_collisions": actionable_collisions,
|
|
"dependency_graph": graph,
|
|
"stale_skills": stale,
|
|
"actionable_stale_skills": actionable_stale,
|
|
"owner_review_gaps": owner_gaps,
|
|
"actionable_owner_review_gaps": actionable_owner_gaps,
|
|
"drift_signals": drift_signals,
|
|
"actionable_drift_signals": actionable_drift_signals,
|
|
"no_route_opportunities": opportunities,
|
|
"artifacts": {
|
|
"catalog": display_path(output_dir / "catalog.json"),
|
|
"route_overlap_matrix": display_path(output_dir / "route_overlap_matrix.csv"),
|
|
"dependency_graph": display_path(output_dir / "dependency_graph.json"),
|
|
"stale_skills": display_path(output_dir / "stale_skills.json"),
|
|
"owner_review_gaps": display_path(output_dir / "owner_review_gaps.json"),
|
|
"drift_signals": display_path(output_dir / "drift_signals.json"),
|
|
"no_route_opportunities": display_path(output_dir / "no_route_opportunities.json"),
|
|
"report_json": display_path(report_json),
|
|
"report_html": display_path(report_html),
|
|
},
|
|
}
|
|
output_dir.mkdir(parents=True, exist_ok=True)
|
|
(output_dir / "catalog.json").write_text(json.dumps(catalog, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
|
|
write_csv(output_dir / "route_overlap_matrix.csv", overlap_rows)
|
|
(output_dir / "dependency_graph.json").write_text(json.dumps(graph, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
|
|
(output_dir / "stale_skills.json").write_text(json.dumps(stale, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
|
|
(output_dir / "owner_review_gaps.json").write_text(json.dumps(owner_gaps, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
|
|
(output_dir / "drift_signals.json").write_text(json.dumps(drift_signals, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
|
|
(output_dir / "no_route_opportunities.json").write_text(json.dumps(opportunities, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
|
|
report_json.parent.mkdir(parents=True, exist_ok=True)
|
|
report_html.parent.mkdir(parents=True, exist_ok=True)
|
|
report_json.write_text(json.dumps(payload, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
|
|
report_html.write_text(render_html(payload), encoding="utf-8")
|
|
return payload
|
|
|
|
|
|
def main() -> None:
|
|
parser = argparse.ArgumentParser(description="Build a Skill Atlas for a workspace of agent skills.")
|
|
parser.add_argument("--workspace-root", default=".")
|
|
parser.add_argument("--output-dir", default=str(ROOT / "skill_atlas"))
|
|
parser.add_argument("--report-html", default=str(ROOT / "reports" / "skill_atlas.html"))
|
|
parser.add_argument("--report-json", default=str(ROOT / "reports" / "skill_atlas.json"))
|
|
parser.add_argument("--overlap-threshold", type=float, default=0.42)
|
|
parser.add_argument("--today", default=date.today().isoformat())
|
|
args = parser.parse_args()
|
|
today = datetime.strptime(args.today, "%Y-%m-%d").date()
|
|
payload = build_atlas(
|
|
Path(args.workspace_root).resolve(),
|
|
Path(args.output_dir).resolve(),
|
|
Path(args.report_html).resolve(),
|
|
Path(args.report_json).resolve(),
|
|
args.overlap_threshold,
|
|
today,
|
|
)
|
|
print(json.dumps(payload, ensure_ascii=False, indent=2))
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|