From 95df3eba6cbf7137e61ed13a6cbeed73de7ada5c Mon Sep 17 00:00:00 2001 From: ArchieIndian Date: Sun, 15 Mar 2026 23:47:24 +0530 Subject: [PATCH] Add skill-conflict-detector: detect name shadowing and description overlap Detects NAME_SHADOW (CRITICAL), EXACT_DUPLICATE (CRITICAL), HIGH_OVERLAP (HIGH), and MEDIUM_OVERLAP (MEDIUM) conflicts between installed skills using Jaccard similarity on description tokens. Co-Authored-By: Claude Sonnet 4.6 --- skills/core/skill-conflict-detector/SKILL.md | 85 +++++ skills/core/skill-conflict-detector/detect.py | 293 ++++++++++++++++++ 2 files changed, 378 insertions(+) create mode 100644 skills/core/skill-conflict-detector/SKILL.md create mode 100755 skills/core/skill-conflict-detector/detect.py diff --git a/skills/core/skill-conflict-detector/SKILL.md b/skills/core/skill-conflict-detector/SKILL.md new file mode 100644 index 0000000..4dfb400 --- /dev/null +++ b/skills/core/skill-conflict-detector/SKILL.md @@ -0,0 +1,85 @@ +--- +name: skill-conflict-detector +version: "1.0" +category: core +description: Detects skill name shadowing and description-overlap conflicts that cause OpenClaw to trigger the wrong skill or silently ignore one when two skills compete for the same intent. +--- + +# Skill Conflict Detector + +## What it does + +Two types of conflict cause skills to misbehave silently: + +**1. Name shadowing** — Two installed skills have the same `name:` field. OpenClaw loads the last one lexicographically; the other silently disappears. No warning. + +**2. Description overlap** — Two skills' descriptions are so semantically similar that OpenClaw can't reliably distinguish them. The wrong skill fires. You think one skill is broken; actually the other is intercepting it. + +Skill Conflict Detector scans all installed skills for both types and reports them with overlap scores and resolution suggestions. + +## When to invoke + +- After installing a new skill from ClawHub +- When a skill fires inconsistently or triggers on unexpected prompts +- Before publishing a new skill (ensure it doesn't shadow an existing one) +- As part of `install.sh` post-install validation + +## Conflict types + +| Type | Severity | Effect | +|---|---|---| +| NAME_SHADOW | CRITICAL | One skill completely hidden | +| EXACT_DUPLICATE | CRITICAL | Identical description — both fire or neither does | +| HIGH_OVERLAP | HIGH | >75% semantic similarity — unreliable trigger routing | +| MEDIUM_OVERLAP | MEDIUM | 50–75% similarity — possible confusion | + +## Output + +``` +Skill Conflict Report — 32 skills +──────────────────────────────────────────────── +0 CRITICAL | 1 HIGH | 0 MEDIUM + +HIGH skill-vetting ↔ installed-skill-auditor overlap: 0.81 + Both describe "scanning skills for security issues" + Suggestion: Differentiate — skill-vetting is pre-install, + installed-skill-auditor is post-install ongoing audit. +``` + +## How to use + +```bash +python3 detect.py --scan # Full conflict scan +python3 detect.py --scan --skill my-skill # Check one skill vs all others +python3 detect.py --scan --threshold 0.6 # Custom similarity threshold +python3 detect.py --names # Check name shadowing only +python3 detect.py --format json +``` + +## Procedure + +**Step 1 — Run the scan** + +```bash +python3 detect.py --scan +``` + +**Step 2 — Resolve CRITICAL conflicts first** + +NAME_SHADOW: Rename one skill's `name:` field and its directory. Run `bash scripts/validate-skills.sh` to confirm. + +EXACT_DUPLICATE: One skill is redundant. Remove or differentiate it. + +**Step 3 — Assess HIGH_OVERLAP pairs** + +Read both descriptions. Ask: could a user's natural-language request unambiguously route to one and not the other? If no, differentiate. Common fix: add the scope or timing to the description (e.g., "before install" vs. "after install"). + +**Step 4 — Accept or suppress MEDIUM_OVERLAP** + +Medium overlaps are informational. If the two skills serve genuinely different contexts and users would naturally phrase requests differently, they can coexist. Document why in the skill's SKILL.md if it's non-obvious. + +## Similarity model + +Token-overlap Jaccard similarity between description strings after stop-word removal. Fast and deterministic — no external dependencies. + +Threshold defaults: HIGH ≥ 0.75, MEDIUM ≥ 0.50. diff --git a/skills/core/skill-conflict-detector/detect.py b/skills/core/skill-conflict-detector/detect.py new file mode 100755 index 0000000..7f6d484 --- /dev/null +++ b/skills/core/skill-conflict-detector/detect.py @@ -0,0 +1,293 @@ +#!/usr/bin/env python3 +""" +Skill Conflict Detector for openclaw-superpowers. + +Detects name shadowing and description-overlap conflicts between +installed skills that cause silent trigger routing failures. + +Usage: + python3 detect.py --scan + python3 detect.py --scan --skill my-skill + python3 detect.py --scan --threshold 0.6 + python3 detect.py --names # Name shadowing only + python3 detect.py --format json +""" + +import argparse +import json +import os +import re +import sys +from pathlib import Path + +try: + import yaml + HAS_YAML = True +except ImportError: + HAS_YAML = False + +SUPERPOWERS_DIR = Path(os.environ.get( + "SUPERPOWERS_DIR", + Path.home() / ".openclaw" / "extensions" / "superpowers" +)) +SKILLS_DIRS = [ + SUPERPOWERS_DIR / "skills" / "core", + SUPERPOWERS_DIR / "skills" / "openclaw-native", + SUPERPOWERS_DIR / "skills" / "community", +] + +DEFAULT_HIGH_THRESHOLD = 0.75 +DEFAULT_MEDIUM_THRESHOLD = 0.50 + +_STOPWORDS = { + "a", "an", "the", "and", "or", "but", "in", "on", "at", "to", "for", + "of", "with", "by", "from", "is", "are", "was", "were", "be", "been", + "it", "its", "this", "that", "so", "not", "no", "all", "any", "each", + "more", "most", "has", "have", "had", "do", "does", "did", "will", + "would", "could", "should", "may", "can", "which", "when", "where", + "how", "what", "who", "i", "you", "we", "they", "he", "she", +} + + +# ── Frontmatter parser ──────────────────────────────────────────────────────── + +def parse_frontmatter(skill_md: Path) -> dict: + try: + text = skill_md.read_text() + lines = text.splitlines() + if not lines or lines[0].strip() != "---": + return {} + end = None + for i, line in enumerate(lines[1:], 1): + if line.strip() == "---": + end = i + break + if end is None: + return {} + fm_text = "\n".join(lines[1:end]) + if HAS_YAML: + return yaml.safe_load(fm_text) or {} + fields = {} + for line in fm_text.splitlines(): + if ":" in line and not line.startswith(" "): + k, _, v = line.partition(":") + fields[k.strip()] = v.strip().strip('"').strip("'") + return fields + except Exception: + return {} + + +# ── Tokeniser + similarity ──────────────────────────────────────────────────── + +def tokenise(text: str) -> set[str]: + tokens = re.findall(r"[a-z0-9]+", text.lower()) + return {t for t in tokens if t not in _STOPWORDS and len(t) > 2} + + +def jaccard(a: set, b: set) -> float: + if not a and not b: + return 1.0 + inter = len(a & b) + union = len(a | b) + return inter / union if union > 0 else 0.0 + + +# ── Skill loader ────────────────────────────────────────────────────────────── + +def load_all_skills() -> list[dict]: + skills = [] + for skills_root in SKILLS_DIRS: + if not skills_root.exists(): + continue + for skill_dir in sorted(skills_root.iterdir()): + if not skill_dir.is_dir(): + continue + skill_md = skill_dir / "SKILL.md" + if not skill_md.exists(): + continue + fm = parse_frontmatter(skill_md) + skills.append({ + "dir_name": skill_dir.name, + "name": fm.get("name", skill_dir.name), + "description": fm.get("description", ""), + "path": str(skill_md), + }) + return skills + + +# ── Conflict detection ──────────────────────────────────────────────────────── + +def detect_conflicts(skills: list[dict], high_threshold: float, + medium_threshold: float, + single_skill: str = None) -> list[dict]: + conflicts = [] + + # Name shadowing: same name field, different directories + by_name: dict = {} + for s in skills: + by_name.setdefault(s["name"], []).append(s) + + for name, group in by_name.items(): + if len(group) > 1: + for i in range(len(group)): + for j in range(i + 1, len(group)): + a, b = group[i], group[j] + if single_skill and single_skill not in (a["dir_name"], b["dir_name"]): + continue + conflicts.append({ + "type": "NAME_SHADOW", + "severity": "CRITICAL", + "skill_a": a["dir_name"], + "skill_b": b["dir_name"], + "overlap_score": 1.0, + "detail": f"Both have `name: {name}` — one will be hidden", + "suggestion": f"Rename one skill's `name:` field and its directory.", + }) + + # Description overlap + for i in range(len(skills)): + for j in range(i + 1, len(skills)): + a, b = skills[i], skills[j] + if single_skill and single_skill not in (a["dir_name"], b["dir_name"]): + continue + + ta = tokenise(a["description"]) + tb = tokenise(b["description"]) + + if not ta or not tb: + continue + + score = jaccard(ta, tb) + + if score >= high_threshold: + # Check for exact duplicate + severity = "CRITICAL" if a["description"] == b["description"] else "HIGH" + ctype = "EXACT_DUPLICATE" if severity == "CRITICAL" else "HIGH_OVERLAP" + common = ta & tb + conflicts.append({ + "type": ctype, + "severity": severity, + "skill_a": a["dir_name"], + "skill_b": b["dir_name"], + "overlap_score": round(score, 3), + "detail": ( + f"Descriptions share key terms: " + + ", ".join(f'"{t}"' for t in sorted(common)[:5]) + ), + "suggestion": ( + "Differentiate descriptions — add scope, timing, or " + "context that distinguishes when each skill fires." + ), + }) + elif score >= medium_threshold: + common = ta & tb + conflicts.append({ + "type": "MEDIUM_OVERLAP", + "severity": "MEDIUM", + "skill_a": a["dir_name"], + "skill_b": b["dir_name"], + "overlap_score": round(score, 3), + "detail": ( + "Moderate description overlap — " + + ", ".join(f'"{t}"' for t in sorted(common)[:4]) + ), + "suggestion": ( + "Acceptable if use-cases are clearly distinct. " + "Consider adding differentiating context to each description." + ), + }) + + return conflicts + + +# ── Output ──────────────────────────────────────────────────────────────────── + +def print_report(conflicts: list, skills_count: int, fmt: str) -> None: + criticals = [c for c in conflicts if c["severity"] == "CRITICAL"] + highs = [c for c in conflicts if c["severity"] == "HIGH"] + mediums = [c for c in conflicts if c["severity"] == "MEDIUM"] + + if fmt == "json": + print(json.dumps({ + "skills_scanned": skills_count, + "critical_count": len(criticals), + "high_count": len(highs), + "medium_count": len(mediums), + "conflicts": conflicts, + }, indent=2)) + return + + print(f"\nSkill Conflict Report — {skills_count} skills") + print("─" * 50) + print(f" {len(criticals)} CRITICAL | {len(highs)} HIGH | {len(mediums)} MEDIUM") + print() + + if not conflicts: + print(" ✓ No conflicts detected.") + else: + for c in conflicts: + icon = "✗" if c["severity"] in ("CRITICAL",) else ( + "!" if c["severity"] == "HIGH" else "⚠" + ) + score_str = f" overlap: {c['overlap_score']:.2f}" if c["type"] != "NAME_SHADOW" else "" + print(f" {icon} {c['severity']:8s} {c['skill_a']} ↔ {c['skill_b']}" + f"{score_str}") + print(f" {c['detail']}") + print(f" → {c['suggestion']}") + print() + + +# ── Commands ────────────────────────────────────────────────────────────────── + +def cmd_scan(high_threshold: float, medium_threshold: float, + single_skill: str, fmt: str) -> None: + skills = load_all_skills() + conflicts = detect_conflicts(skills, high_threshold, medium_threshold, single_skill) + print_report(conflicts, len(skills), fmt) + critical_count = sum(1 for c in conflicts if c["severity"] == "CRITICAL") + sys.exit(1 if critical_count > 0 else 0) + + +def cmd_names(fmt: str) -> None: + skills = load_all_skills() + conflicts = detect_conflicts(skills, high_threshold=2.0, medium_threshold=2.0) + name_conflicts = [c for c in conflicts if c["type"] == "NAME_SHADOW"] + if fmt == "json": + print(json.dumps(name_conflicts, indent=2)) + else: + if not name_conflicts: + print("✓ No name shadowing detected.") + else: + for c in name_conflicts: + print(f"✗ SHADOW: {c['skill_a']} ↔ {c['skill_b']} {c['detail']}") + sys.exit(1 if name_conflicts else 0) + + +# ── Main ────────────────────────────────────────────────────────────────────── + +def main(): + parser = argparse.ArgumentParser(description="Skill Conflict Detector") + group = parser.add_mutually_exclusive_group(required=True) + group.add_argument("--scan", action="store_true") + group.add_argument("--names", action="store_true", + help="Check name shadowing only") + parser.add_argument("--skill", metavar="NAME", + help="Check one skill against all others") + parser.add_argument("--threshold", type=float, default=DEFAULT_HIGH_THRESHOLD, + help=f"HIGH similarity threshold (default: {DEFAULT_HIGH_THRESHOLD})") + parser.add_argument("--format", choices=["text", "json"], default="text") + args = parser.parse_args() + + if args.names: + cmd_names(args.format) + elif args.scan: + cmd_scan( + high_threshold=args.threshold, + medium_threshold=DEFAULT_MEDIUM_THRESHOLD, + single_skill=args.skill, + fmt=args.format, + ) + + +if __name__ == "__main__": + main()