From 54966e1daed73651de788bf7aa17c9c11b6d674a Mon Sep 17 00:00:00 2001 From: notque Date: Sat, 11 Apr 2026 03:17:52 -0700 Subject: [PATCH 1/2] fix(routing): add WrestleJoy/voice skills to routing-tables.md + fix 14 uncategorized skills (#368) * Fix Codex skill mirror install paths * Fix install doctor path checks for Codex support * Document /do for Claude and $do for Codex * Fix install-doctor test lint * fix(routing): add WrestleJoy/voice skills to routing-tables.md and fix 14 uncategorized skills 9 WrestleJoy and voice skills were in INDEX.json but completely absent from routing-tables.md, making them invisible to any process that consults the routing reference. Additionally, 14 skills had no category field in their SKILL.md routing sections. Changes: - Add WrestleJoy Skills section to routing-tables.md (6 skills) - Add voice-andy-nemmity, voice-amy-nemmity, voice-andy-disagreement to Voice Skills section in routing-tables.md - Add category: field to routing: section in 7 SKILL.md files (e2e-testing, fish-shell-config, go-patterns, nano-banana-builder, testing-anti-patterns, video-editing, workflow) - Private skill categories (7 WrestleJoy/voice SKILL.md files) updated locally via symlink targets; regenerate-skill-index picks these up - Regenerate INDEX.json: 0 uncategorized skills (was 14) Detected by Phase 1 routing-table drift check in toolkit-evolution cycle 2026-04-11. Validated 0 missing skills in routing-tables.md post-fix. --- skills/INDEX.json | 278 +++++++++++++++++++------ skills/do/references/routing-tables.md | 16 ++ skills/e2e-testing/SKILL.md | 1 + skills/fish-shell-config/SKILL.md | 1 + skills/go-patterns/SKILL.md | 1 + skills/nano-banana-builder/SKILL.md | 1 + skills/testing-anti-patterns/SKILL.md | 1 + skills/video-editing/SKILL.md | 1 + skills/workflow/SKILL.md | 1 + 9 files changed, 241 insertions(+), 60 deletions(-) diff --git a/skills/INDEX.json b/skills/INDEX.json index 1f122de1..4bc8d184 100644 --- a/skills/INDEX.json +++ b/skills/INDEX.json @@ -1,6 +1,6 @@ { "version": "2.0", - "generated": "2026-04-07T03:31:40Z", + "generated": "2026-04-11T10:14:10Z", "generated_by": "scripts/generate-skill-index.py", "skills": { "adr-consultation": { @@ -310,6 +310,56 @@ "user_invocable": false, "version": "2.0.0" }, + "csuite": { + "file": "skills/csuite/SKILL.md", + "description": "C-suite executive decision support: strategy, technology, growth, competitive intelligence, project evaluation.", + "triggers": [ + "should I", + "should we", + "evaluate opportunity", + "decision", + "trade-off", + "worth it", + "invest in", + "strategy", + "strategic", + "build or buy", + "build vs buy", + "vendor evaluation", + "adopt", + "SaaS vs", + "technology choice", + "tech stack", + "architecture decision", + "should we use", + "grow audience", + "content strategy", + "marketing", + "SEO strategy", + "growth", + "brand", + "positioning", + "community building", + "competitor", + "competition", + "market landscape", + "competitive analysis", + "differentiation", + "feasibility", + "is it worth", + "effort estimate", + "ROI", + "priority", + "should we start", + "project evaluation", + "go no go", + "viability" + ], + "category": "decision-support", + "user_invocable": false, + "version": "1.0.0", + "pairs_with": [] + }, "data-analysis": { "file": "skills/data-analysis/SKILL.md", "description": "Decision-first data analysis with statistical rigor gates.", @@ -413,6 +463,7 @@ "POM", "test flakiness" ], + "category": "testing", "user_invocable": false, "version": "1.0.0", "pairs_with": [ @@ -541,6 +592,7 @@ ".fish file", "#!/usr/bin/env fish" ], + "category": "process", "force_route": true, "user_invocable": false, "version": "2.0.0", @@ -700,6 +752,19 @@ "workflow" ] }, + "gemini-wrestlejoy-comparison": { + "file": "skills/gemini-wrestlejoy-comparison/SKILL.md", + "description": "A/B test Gemini Pro vs Claude for WrestleJoy voice replication: Prepare inputs, generate with both models, validate outputs, compare results.", + "triggers": [ + "gemini-wrestlejoy-comparison", + "gemini", + "wrestlejoy", + "comparison" + ], + "category": "analysis", + "user_invocable": false, + "version": "2.0.0" + }, "generate-claudemd": { "file": "skills/generate-claudemd/SKILL.md", "description": "Generate project-specific CLAUDE.md from repo analysis.", @@ -812,6 +877,7 @@ "make check", "Go lint" ], + "category": "language", "force_route": true, "user_invocable": false, "version": "1.0.0", @@ -912,7 +978,7 @@ }, "joy-check": { "file": "skills/joy-check/SKILL.md", - "description": "Validate content framing on joy-grievance spectrum.", + "description": "Validate content for joy-centered tonal framing.", "triggers": [ "joy check", "check framing", @@ -920,18 +986,15 @@ "negative framing", "joy validation", "too negative", - "reframe positively", - "positive framing check", - "instruction framing" + "reframe positively" ], "category": "content", - "user_invocable": false, - "version": "2.0.0", + "user_invocable": true, + "version": "1.0.0", "pairs_with": [ "voice-writer", "anti-ai-editor", - "voice-validator", - "skill-creator" + "voice-validator" ] }, "kairos-lite": { @@ -1127,6 +1190,7 @@ "sprite generation", "generate card art" ], + "category": "image-generation", "user_invocable": false, "version": "3.0.0", "pairs_with": [ @@ -1957,6 +2021,7 @@ "fragile test", "testing implementation details" ], + "category": "testing", "user_invocable": false, "version": "2.0.0", "pairs_with": [ @@ -2007,7 +2072,7 @@ }, "toolkit-evolution": { "file": "skills/toolkit-evolution/SKILL.md", - "description": "Closed-loop toolkit self-improvement: diagnose, propose, critique, build, test, evolve.", + "description": "Closed-loop toolkit self-improvement: discover gaps, diagnose, propose, critique, build, test, evolve.", "triggers": [ "evolve toolkit", "improve the system", @@ -2015,6 +2080,8 @@ "toolkit evolution", "what should we improve", "find improvement opportunities", + "discover skill gaps", + "what skills are missing", "systematic improvement" ], "category": "meta-tooling", @@ -2097,6 +2164,7 @@ "assemble clips", "video editing" ], + "category": "video-creation", "user_invocable": false, "version": "1.0.0", "pairs_with": [ @@ -2119,6 +2187,56 @@ "user_invocable": false, "version": "2.0.0" }, + "voice-amy-nemmity": { + "file": "skills/voice-amy-nemmity/SKILL.md", + "description": "Amy Nemmity's writing voice: warmth, celebration, poetic fragments, community focus, character arc narratives.", + "triggers": [ + "voice-amy-nemmity", + "voice", + "amy", + "nemmity" + ], + "category": "voice", + "user_invocable": false, + "version": "2.0.0" + }, + "voice-andy-disagreement": { + "file": "skills/voice-andy-disagreement/SKILL.md", + "description": "Andy Nemmity's disagreement and forum voice with reasoning ladder, claims-over-people discipline, and anti-escalation harshness control.", + "triggers": [ + "disagree", + "counter-argument", + "critique", + "rebuttal", + "pushback", + "that's wrong", + "I disagree", + "Hacker News", + "HN comment", + "Reddit", + "forum", + "community" + ], + "category": "voice", + "user_invocable": false, + "version": "2.0.0", + "pairs_with": [ + "voice-andy-nemmity" + ] + }, + "voice-andy-nemmity": { + "file": "skills/voice-andy-nemmity/SKILL.md", + "description": "Apply Andy Nemmity's voice profile for content generation: precision-driven improvisation, constraint accumulation, systems framing, calibration qu...", + "triggers": [ + "voice-andy-nemmity", + "voice", + "andy", + "nemmity" + ], + "category": "voice", + "user_invocable": false, + "version": "2.0.0" + }, "voice-validator": { "file": "skills/voice-validator/SKILL.md", "description": "Critique-and-rewrite loop for voice fidelity validation.", @@ -2240,6 +2358,7 @@ "phased execution", "orchestrated workflow" ], + "category": "meta-tooling", "user_invocable": false, "version": "2.0.0", "model": "sonnet" @@ -2272,6 +2391,95 @@ "user_invocable": false, "version": "1.0.0" }, + "wrestlejoy-aew-images": { + "file": "skills/wrestlejoy-aew-images/SKILL.md", + "description": "Fetch official promotional images from allelitewrestling.com for WrestleJoy articles.", + "triggers": [ + "aew image", + "aew photo", + "wrestler photo", + "official aew image", + "roster image", + "roster photo", + "aew event poster", + "get aew image" + ], + "category": "content-pipeline", + "user_invocable": true, + "version": "1.0.0", + "pairs_with": [ + "wrestlejoy-news-pipeline", + "voice-andy-nemmity", + "wrestlejoy-external-research" + ], + "agent": "wrestlejoy-news-producer", + "model": "sonnet" + }, + "wrestlejoy-external-research": { + "file": "skills/wrestlejoy-external-research/SKILL.md", + "description": "Evidence-sourced 4-phase external research: Identify, Search, Extract, Validate.", + "triggers": [ + "wrestlejoy-external-research", + "wrestlejoy", + "external", + "research" + ], + "category": "content-pipeline", + "user_invocable": false, + "version": "2.0.0" + }, + "wrestlejoy-mmr-research": { + "file": "skills/wrestlejoy-mmr-research/SKILL.md", + "description": "Evidence-based wrestler research from MMR database: Assess, Query, Synthesize, Output.", + "triggers": [ + "wrestlejoy-mmr-research", + "wrestlejoy", + "mmr", + "research" + ], + "category": "content-pipeline", + "user_invocable": false, + "version": "2.0.0" + }, + "wrestlejoy-news-pipeline": { + "file": "skills/wrestlejoy-news-pipeline/SKILL.md", + "description": "Automated AEW wrestling news production pipeline for WrestleJoy.com.", + "triggers": [ + "run news pipeline", + "wrestlejoy news", + "scan wrestling news", + "generate news articles", + "wrestling news automation", + "aew news pipeline", + "news production pipeline" + ], + "category": "content-pipeline", + "user_invocable": false, + "version": "1.0.0", + "pairs_with": [ + "voice-andy-nemmity", + "voice-writer", + "anti-ai-editor", + "wordpress-uploader", + "seo-optimizer", + "wrestlejoy-external-research" + ], + "agent": "wrestlejoy-news-producer", + "model": "opus" + }, + "wrestlejoy-research-transform": { + "file": "skills/wrestlejoy-research-transform/SKILL.md", + "description": "Transform MMR research data into narrative language: convert ratings to superlatives, trajectories to journey language, peaks to memorable moments.", + "triggers": [ + "wrestlejoy-research-transform", + "wrestlejoy", + "research", + "transform" + ], + "category": "content-pipeline", + "user_invocable": false, + "version": "2.0.0" + }, "x-api": { "file": "skills/x-api/SKILL.md", "description": "Post tweets, build threads, upload media via the X API.", @@ -2298,56 +2506,6 @@ ], "agent": "python-general-engineer", "model": "sonnet" - }, - "csuite": { - "file": "skills/csuite/SKILL.md", - "description": "C-suite executive decision support: strategy, technology, growth, competitive intelligence, project evaluation.", - "triggers": [ - "should I", - "should we", - "evaluate opportunity", - "decision", - "trade-off", - "worth it", - "invest in", - "strategy", - "strategic", - "build or buy", - "build vs buy", - "vendor evaluation", - "adopt", - "SaaS vs", - "technology choice", - "tech stack", - "architecture decision", - "should we use", - "grow audience", - "content strategy", - "marketing", - "SEO strategy", - "growth", - "brand", - "positioning", - "community building", - "competitor", - "competition", - "market landscape", - "competitive analysis", - "differentiation", - "feasibility", - "is it worth", - "effort estimate", - "ROI", - "priority", - "should we start", - "project evaluation", - "go no go", - "viability" - ], - "category": "decision-support", - "user_invocable": false, - "version": "1.0.0", - "pairs_with": [] } } } diff --git a/skills/do/references/routing-tables.md b/skills/do/references/routing-tables.md index ac92488b..e0a24fa0 100644 --- a/skills/do/references/routing-tables.md +++ b/skills/do/references/routing-tables.md @@ -225,6 +225,9 @@ Route to these agents based on the user's task domain. Each entry describes what | **voice-writer** | User wants to generate content in an established voice — multi-step generation with validation. | | **voice-calibrator** | User wants to refine an existing voice profile or improve how well it captures their writing style. | | **voice-validator** | User wants to run a validation loop to confirm generated content matches the voice profile. | +| **voice-andy-nemmity** | User wants to generate content in Andy Nemmity's specific voice: precision-driven, systems framing, improvement-focused, modal writing across 5 registers. The primary voice for VexJoy and WrestleJoy long-form content. NOT: general voice generation (use voice-writer), Amy Nemmity voice (use voice-amy-nemmity). | +| **voice-amy-nemmity** | User wants to generate content in Amy Nemmity's voice: warmth, celebration, poetic fragments, community focus, wabi-sabi authenticity. The WrestleJoy editorial voice for wrestling coverage and awards content. NOT: Andy Nemmity voice (use voice-andy-nemmity). | +| **voice-andy-disagreement** | User wants to write a counter-argument, disagreement post, or forum response in Andy Nemmity's reasoning-first voice: claims over politeness, reasoning ladder, direct rebuttal. NOT: general debate framing (use roast or multi-persona-critique). | **Voice selection:** Use `create-voice` to build voice profiles from writing samples, then `voice-writer` for multi-step generation in that voice. Custom voice profiles are matched via their skill triggers. @@ -298,6 +301,19 @@ Workflows that work together in common sequences: --- +## WrestleJoy Skills + +| Skill | When to Route Here | +|-------|-------------------| +| **wrestlejoy-news-pipeline** | User wants to run the automated WrestleJoy news pipeline: discover positive AEW wrestling news, filter through positivity gate, cluster stories, generate articles in Andy's voice, validate, and upload as WordPress drafts. Triggers: "run news pipeline", "wrestlejoy news", "scan wrestling news", "generate news articles". NOT: long-form wrestler profiles (use voice-andy-nemmity directly). | +| **wrestlejoy-aew-images** | User wants to fetch official promotional images from allelitewrestling.com for WrestleJoy content: event banners, wrestler headshots, promotional art. | +| **wrestlejoy-external-research** | User wants evidence-sourced external research for a WrestleJoy article: identify claims, search sources, extract evidence, validate against multiple sources. | +| **wrestlejoy-mmr-research** | User wants to research wrestler match quality ratings from the MMR database for WrestleJoy content: fetch ratings, assess match history, synthesize data. | +| **wrestlejoy-research-transform** | User wants to convert raw MMR wrestling data into narrative language for WrestleJoy articles: transform ratings into superlatives, statistics into story beats, data into prose. | +| **gemini-wrestlejoy-comparison** | User wants to A/B test Gemini Pro vs Claude for WrestleJoy voice replication quality — comparing outputs against Andy Nemmity's voice profile. | + +--- + ## Validation Skills | Skill | When to Route Here | diff --git a/skills/e2e-testing/SKILL.md b/skills/e2e-testing/SKILL.md index 6f7437b5..9650acb8 100644 --- a/skills/e2e-testing/SKILL.md +++ b/skills/e2e-testing/SKILL.md @@ -16,6 +16,7 @@ allowed-tools: - Skill adr: adr/ADR-107-e2e-testing.md routing: + category: testing triggers: - playwright - E2E test diff --git a/skills/fish-shell-config/SKILL.md b/skills/fish-shell-config/SKILL.md index a4be3b75..fb214bc1 100644 --- a/skills/fish-shell-config/SKILL.md +++ b/skills/fish-shell-config/SKILL.md @@ -11,6 +11,7 @@ allowed-tools: - Glob - Edit routing: + category: process triggers: - fish - fish shell diff --git a/skills/go-patterns/SKILL.md b/skills/go-patterns/SKILL.md index 5ad3908f..746a283b 100644 --- a/skills/go-patterns/SKILL.md +++ b/skills/go-patterns/SKILL.md @@ -14,6 +14,7 @@ allowed-tools: - Skill agent: golang-general-engineer routing: + category: language force_route: true triggers: # testing triggers diff --git a/skills/nano-banana-builder/SKILL.md b/skills/nano-banana-builder/SKILL.md index cb8fb313..b24899ac 100644 --- a/skills/nano-banana-builder/SKILL.md +++ b/skills/nano-banana-builder/SKILL.md @@ -10,6 +10,7 @@ allowed-tools: - Glob command: /nano-banana routing: + category: image-generation triggers: - nano banana - gemini image generation diff --git a/skills/testing-anti-patterns/SKILL.md b/skills/testing-anti-patterns/SKILL.md index 1354f371..1da41c47 100644 --- a/skills/testing-anti-patterns/SKILL.md +++ b/skills/testing-anti-patterns/SKILL.md @@ -12,6 +12,7 @@ allowed-tools: - Edit - Task routing: + category: testing triggers: - flaky test - brittle test diff --git a/skills/video-editing/SKILL.md b/skills/video-editing/SKILL.md index ca5a1769..d076b77a 100644 --- a/skills/video-editing/SKILL.md +++ b/skills/video-editing/SKILL.md @@ -15,6 +15,7 @@ allowed-tools: - Task - Skill routing: + category: video-creation triggers: - edit video - cut footage diff --git a/skills/workflow/SKILL.md b/skills/workflow/SKILL.md index 340d8cf0..2e270e7e 100644 --- a/skills/workflow/SKILL.md +++ b/skills/workflow/SKILL.md @@ -6,6 +6,7 @@ user-invocable: false context: fork model: sonnet routing: + category: meta-tooling triggers: - "workflow" - "multi-phase task" From 26becebc750a8910dd1ac004723d1b5cb0220f40 Mon Sep 17 00:00:00 2001 From: notque Date: Sat, 11 Apr 2026 08:39:25 -0700 Subject: [PATCH 2/2] fix(security): revert private skill leak from PR #368 + fix root cause PR #368 (merge commit 54966e1) leaked 9 private skills into two tracked files via the generate-skill-index.py script following symlinks from skills/ into gitignored private directories. Part 1: surgical revert of leaked content - Remove 9 private skill entries from skills/INDEX.json (voice-andy-nemmity, voice-amy-nemmity, voice-andy-disagreement, wrestlejoy-aew-images, wrestlejoy-external-research, wrestlejoy-mmr-research, wrestlejoy-news-pipeline, wrestlejoy-research-transform, gemini-wrestlejoy-comparison) - Remove 3 voice rows (lines 228-230) and WrestleJoy Skills section (7 rows + section header) from skills/do/references/routing-tables.md - Regenerate skills/INDEX.json via fixed generator (public-only mode) to restore correct metadata for joy-check and toolkit-evolution entries that were also corrupted by the leaked-index regeneration Part 2: root-cause fix in scripts/generate-skill-index.py - Add is_private_path() guard: resolves realpath and checks for private directory components (private-skills, private-agents, private-hooks, private-voices) before including any SKILL.md in the index - Default mode is now public-only (private symlinks silently skipped) - Add --include-private flag for local private-skill index generation - Add --output flag defaulting to skills/INDEX.json - Add --skills-dir flag for testability and override support - Add 14 tests in scripts/tests/test_generate_skill_index.py covering all four required scenarios (all pass) Part 3: local override mechanism - Add skills/INDEX.local.json to .gitignore with generation instructions - Update scripts/routing-manifest.py to prefer INDEX.local.json when present, falling back to the committed public INDEX.json - This gives local workflows access to private skills without committing Part 4: fix the nightly cron runner - Update scripts/toolkit-evolution-cron.sh prompt with explicit guard: run generate-skill-index.py without --include-private, never add private skill rows to routing-tables.md - Root cause: toolkit-evolution DIAGNOSE phase detected skills in INDEX.json absent from routing-tables.md (correct), then BUILD phase regenerated INDEX.json via the unguarded script (incorrect), picking up private symlinks and committing the result via PR #368 The 7 legitimate category: field additions to SKILL.md files in #368 (e2e-testing, fish-shell-config, go-patterns, nano-banana-builder, testing-anti-patterns, video-editing, workflow) are preserved. --- .gitignore | 5 + scripts/generate-skill-index.py | 95 +++++- scripts/routing-manifest.py | 9 +- scripts/tests/test_generate_skill_index.py | 361 +++++++++++++++++++++ scripts/toolkit-evolution-cron.sh | 12 +- skills/INDEX.json | 167 +--------- skills/do/references/routing-tables.md | 16 - 7 files changed, 484 insertions(+), 181 deletions(-) create mode 100644 scripts/tests/test_generate_skill_index.py diff --git a/.gitignore b/.gitignore index daad5370..81b608d2 100644 --- a/.gitignore +++ b/.gitignore @@ -88,6 +88,11 @@ private-agents/ private-hooks/ private-voices/ +# Local skill index: generated with --include-private for local workflows. +# Contains private skill entries that must not be committed to the public repo. +# Generate with: python3 scripts/generate-skill-index.py --include-private --output skills/INDEX.local.json +skills/INDEX.local.json + # Draft articles (work in progress, not committed) drafts/ draft-*.md diff --git a/scripts/generate-skill-index.py b/scripts/generate-skill-index.py index 1b66e509..b86b7b27 100755 --- a/scripts/generate-skill-index.py +++ b/scripts/generate-skill-index.py @@ -7,10 +7,12 @@ - skills/INDEX.json (skills only, v2.0) Usage: - python scripts/generate-skill-index.py + python scripts/generate-skill-index.py # public-only (default) + python scripts/generate-skill-index.py --include-private # include symlinked private skills + python scripts/generate-skill-index.py --output skills/INDEX.local.json # alternate output path Output: - skills/INDEX.json - Skill routing index for /do router + skills/INDEX.json - Skill routing index for /do router (public skills only by default) Exit codes: @@ -19,6 +21,7 @@ 2 - Trigger collisions detected among force-routed entries """ +import argparse import json import re import sys @@ -27,6 +30,11 @@ import yaml +# Directory name segments that mark a path as private/local-only. +# Any SKILL.md whose resolved realpath contains one of these as a path component +# is excluded from the public index unless --include-private is passed. +PRIVATE_DIR_NAMES: frozenset[str] = frozenset({"private-skills", "private-agents", "private-hooks", "private-voices"}) + # Phase header regex: matches "## Phase 1:" or "### Phase 1:", "### Phase 0.5:", "### Phase 4b:", etc. # Captures the NAME part after the colon, stopping before parenthetical or em-dash suffixes. PHASE_HEADER_RE = re.compile(r"^##+ Phase [\d]+[a-z.]?[\d]*:\s*(.+?)(?:\s*\(|\s*--|\s*\u2014|$)") @@ -259,11 +267,35 @@ def build_entry( return entry +def is_private_path(path: Path) -> bool: + """Return True if the resolved realpath of ``path`` lives inside a private directory. + + A path is considered private when any component of its resolved absolute path + matches one of the names in PRIVATE_DIR_NAMES (e.g., ``private-skills``, + ``private-voices``). The check uses ``Path.resolve()`` so symlinks are + followed before the component scan runs. + + Args: + path: File or directory path to test (symlinks are resolved). + + Returns: + True when the realpath contains a private directory component, False otherwise. + """ + try: + resolved = path.resolve() + except OSError: + # If resolution fails (broken symlink etc.) treat as non-private so + # the caller's normal error-handling path fires on the subsequent read. + return False + return bool(PRIVATE_DIR_NAMES & {p.name for p in resolved.parents} | ({resolved.name} & PRIVATE_DIR_NAMES)) + + def generate_index( source_dir: Path, dir_prefix: str, collection_key: str, is_pipeline: bool = False, + include_private: bool = False, ) -> tuple[dict, list[str]]: """Generate a dict-keyed routing index from all SKILL.md files in a directory. @@ -272,6 +304,10 @@ def generate_index( dir_prefix: Path prefix for file field (e.g., "skills" or "pipelines"). collection_key: Top-level key name in the index (e.g., "skills" or "pipelines"). is_pipeline: Whether entries are pipelines (enables phase extraction). + include_private: When False (default), skip any SKILL.md whose resolved + realpath lives inside a private directory (private-skills, + private-agents, private-hooks, private-voices). Pass True to + include private skills for local index generation. Returns: tuple: (index dict with version/generated/generated_by/collection, @@ -293,6 +329,12 @@ def generate_index( if not skill_file.exists(): continue + # Private-path guard: skip symlinks that resolve into gitignored private + # directories unless the caller explicitly requested private inclusion. + if not include_private and is_private_path(skill_file): + print(f" [skip-private] {skill_dir.name} (symlink target is in a private directory)", file=sys.stderr) + continue + try: content = skill_file.read_text(encoding="utf-8") except (OSError, UnicodeDecodeError) as e: @@ -385,20 +427,65 @@ def write_index(index: dict, output_path: Path) -> bool: def main() -> int: """Main entry point.""" + parser = argparse.ArgumentParser(description="Generate skill routing index from SKILL.md frontmatter.") + parser.add_argument( + "--include-private", + action="store_true", + default=False, + help=( + "Include private skills (symlinks into private-skills/, private-voices/, etc.). " + "Default: public-only. Use this flag for local index generation only — " + "never pass it in CI or when generating the committed skills/INDEX.json." + ), + ) + parser.add_argument( + "--output", + type=Path, + default=None, + help=( + "Output file path. Defaults to skills/INDEX.json. " + "Use skills/INDEX.local.json for local private-skill indexing " + "(that path is gitignored)." + ), + ) + parser.add_argument( + "--skills-dir", + type=Path, + default=None, + help="Override the skills directory to scan. Defaults to /skills/.", + ) + args = parser.parse_args() + script_dir = Path(__file__).parent repo_root = script_dir.parent - skills_dir = repo_root / "skills" + + # Allow caller to override the skills directory (useful for testing with isolated dirs) + if args.skills_dir is not None: + skills_dir = args.skills_dir.resolve() + else: + skills_dir = repo_root / "skills" if not skills_dir.exists(): print(f"Error: skills directory not found at {skills_dir}", file=sys.stderr) return 1 + # Resolve output path: explicit --output wins, otherwise default to skills/INDEX.json + skills_index_path: Path = args.output if args.output is not None else skills_dir / "INDEX.json" + + include_private: bool = args.include_private + + if include_private: + print("Mode: --include-private (private skills will be included)", file=sys.stderr) + else: + print("Mode: public-only (symlinks into private directories are skipped)", file=sys.stderr) + # Generate skills index skills_index, skills_warnings = generate_index( source_dir=skills_dir, dir_prefix="skills", collection_key="skills", is_pipeline=False, + include_private=include_private, ) # Report warnings if any @@ -412,8 +499,6 @@ def main() -> int: print("Error: No skills found. Index file not written.", file=sys.stderr) return 1 - # Write skills/INDEX.json - skills_index_path = skills_dir / "INDEX.json" if not write_index(skills_index, skills_index_path): return 1 diff --git a/scripts/routing-manifest.py b/scripts/routing-manifest.py index 25e3c2e8..b36f00e0 100644 --- a/scripts/routing-manifest.py +++ b/scripts/routing-manifest.py @@ -21,8 +21,15 @@ REPO_ROOT = Path(__file__).resolve().parent.parent +# Prefer skills/INDEX.local.json when present (local workflows with private skills). +# Fall back to skills/INDEX.json (the committed public index). +# INDEX.local.json is gitignored; generate it with: +# python3 scripts/generate-skill-index.py --include-private --output skills/INDEX.local.json +_SKILLS_LOCAL = REPO_ROOT / "skills" / "INDEX.local.json" +_SKILLS_PUBLIC = REPO_ROOT / "skills" / "INDEX.json" + INDEX_PATHS = { - "skills": REPO_ROOT / "skills" / "INDEX.json", + "skills": _SKILLS_LOCAL if _SKILLS_LOCAL.exists() else _SKILLS_PUBLIC, "agents": REPO_ROOT / "agents" / "INDEX.json", "pipelines": REPO_ROOT / "skills" / "workflow" / "references" / "pipeline-index.json", } diff --git a/scripts/tests/test_generate_skill_index.py b/scripts/tests/test_generate_skill_index.py new file mode 100644 index 00000000..d4247724 --- /dev/null +++ b/scripts/tests/test_generate_skill_index.py @@ -0,0 +1,361 @@ +"""Tests for scripts/generate-skill-index.py. + +Covers: +- Private-path symlink exclusion (default public-only mode) +- Private-path symlink inclusion with --include-private +- Non-symlink public skills are always included +- Output JSON structure matches the expected format +""" + +from __future__ import annotations + +import importlib +import importlib.util +import json +import subprocess +import sys +from pathlib import Path + +import pytest + +# --------------------------------------------------------------------------- +# Module import helpers +# --------------------------------------------------------------------------- + +REPO_ROOT = Path(__file__).resolve().parent.parent.parent +SCRIPT = REPO_ROOT / "scripts" / "generate-skill-index.py" + + +def _load_module(): + """Load generate-skill-index.py as a module despite the hyphenated filename.""" + spec = importlib.util.spec_from_file_location("generate_skill_index", SCRIPT) + assert spec is not None and spec.loader is not None, f"Cannot load {SCRIPT}" + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) # type: ignore[union-attr] + return module + + +_mod = _load_module() +generate_index = _mod.generate_index +is_private_path = _mod.is_private_path + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +_MINIMAL_SKILL_MD = """\ +--- +name: {name} +description: "A test skill: {name}." +version: 1.0.0 +user-invocable: false +routing: + category: testing + triggers: + - {name} +--- + +# {name} + +Test skill body. +""" + + +def _write_skill(skill_dir: Path, name: str) -> Path: + """Create a minimal SKILL.md inside *skill_dir//* and return its path.""" + entry = skill_dir / name + entry.mkdir(parents=True, exist_ok=True) + skill_file = entry / "SKILL.md" + skill_file.write_text(_MINIMAL_SKILL_MD.format(name=name), encoding="utf-8") + return skill_file + + +# --------------------------------------------------------------------------- +# is_private_path unit tests +# --------------------------------------------------------------------------- + + +class TestIsPrivatePath: + """Unit tests for the is_private_path() guard function.""" + + def test_regular_path_is_not_private(self, tmp_path: Path) -> None: + """A file inside a normal directory is not private.""" + regular_file = tmp_path / "skills" / "my-skill" / "SKILL.md" + regular_file.parent.mkdir(parents=True) + regular_file.touch() + assert is_private_path(regular_file) is False + + def test_path_inside_private_skills_is_private(self, tmp_path: Path) -> None: + """A file directly inside private-skills/ is private.""" + private_file = tmp_path / "private-skills" / "secret-skill" / "SKILL.md" + private_file.parent.mkdir(parents=True) + private_file.touch() + assert is_private_path(private_file) is True + + def test_path_inside_private_voices_is_private(self, tmp_path: Path) -> None: + """A file resolved into private-voices/ is private.""" + private_file = tmp_path / "private-voices" / "voice-x" / "SKILL.md" + private_file.parent.mkdir(parents=True) + private_file.touch() + assert is_private_path(private_file) is True + + def test_symlink_into_private_voices_is_private(self, tmp_path: Path) -> None: + """A symlink whose resolved target lives in private-voices/ is private.""" + private_target = tmp_path / "private-voices" / "voice-secret" / "SKILL.md" + private_target.parent.mkdir(parents=True) + private_target.write_text("---\nname: voice-secret\n---\n", encoding="utf-8") + + skills_dir = tmp_path / "skills" + skills_dir.mkdir() + link_dir = skills_dir / "voice-secret" + link_dir.symlink_to(private_target.parent) + + symlinked_skill = link_dir / "SKILL.md" + assert is_private_path(symlinked_skill) is True + + def test_broken_symlink_pointing_into_private_dir_is_private(self, tmp_path: Path) -> None: + """A broken symlink whose target path contains a private directory name is private. + + Even though the target does not exist, the path components are checked so that + someone cannot work around the guard by pointing at a not-yet-created private path. + """ + broken = tmp_path / "skills" / "broken-skill" + broken.parent.mkdir(parents=True) + broken.symlink_to(tmp_path / "private-skills" / "nonexistent" / "SKILL.md") + # The unresolvable symlink still has "private-skills" in the target path, + # so is_private_path returns True (conservative -- better safe than leaky). + assert is_private_path(broken) is True + + +# --------------------------------------------------------------------------- +# generate_index integration tests +# --------------------------------------------------------------------------- + + +class TestGenerateIndexPrivateFilter: + """Integration tests for the private-skill exclusion in generate_index().""" + + def _make_private_symlink_skill( + self, + tmp_path: Path, + skills_dir: Path, + skill_name: str, + private_category: str = "private-voices", + ) -> None: + """Create a private skill directory and symlink it into skills_dir.""" + private_dir = tmp_path / private_category / skill_name + private_dir.mkdir(parents=True) + skill_content = _MINIMAL_SKILL_MD.format(name=skill_name) + (private_dir / "SKILL.md").write_text(skill_content, encoding="utf-8") + link = skills_dir / skill_name + link.symlink_to(private_dir) + + def test_symlink_into_private_voices_excluded_by_default(self, tmp_path: Path) -> None: + """A symlink into private-voices/ is excluded from the public index by default.""" + skills_dir = tmp_path / "skills" + skills_dir.mkdir() + + _write_skill(skills_dir, "public-skill-a") + self._make_private_symlink_skill(tmp_path, skills_dir, "voice-private", "private-voices") + + index, warnings = generate_index( + source_dir=skills_dir, + dir_prefix="skills", + collection_key="skills", + is_pipeline=False, + include_private=False, + ) + + assert "public-skill-a" in index["skills"], "Public skill should be included" + assert "voice-private" not in index["skills"], "Private voice skill should be excluded" + + def test_symlink_into_private_skills_excluded_by_default(self, tmp_path: Path) -> None: + """A symlink into private-skills/ is excluded from the public index by default.""" + skills_dir = tmp_path / "skills" + skills_dir.mkdir() + + _write_skill(skills_dir, "public-skill-b") + self._make_private_symlink_skill(tmp_path, skills_dir, "private-secret", "private-skills") + + index, _ = generate_index( + source_dir=skills_dir, + dir_prefix="skills", + collection_key="skills", + is_pipeline=False, + include_private=False, + ) + + assert "public-skill-b" in index["skills"] + assert "private-secret" not in index["skills"] + + def test_symlink_into_private_voices_included_with_flag(self, tmp_path: Path) -> None: + """With include_private=True, private symlinks are included in the index.""" + skills_dir = tmp_path / "skills" + skills_dir.mkdir() + + _write_skill(skills_dir, "public-skill-c") + self._make_private_symlink_skill(tmp_path, skills_dir, "voice-private", "private-voices") + + index, _ = generate_index( + source_dir=skills_dir, + dir_prefix="skills", + collection_key="skills", + is_pipeline=False, + include_private=True, + ) + + assert "public-skill-c" in index["skills"] + assert "voice-private" in index["skills"], "Private skill should be included with include_private=True" + + def test_non_symlink_skills_always_included(self, tmp_path: Path) -> None: + """Regular (non-symlink) directories in skills/ are always included regardless of flag.""" + skills_dir = tmp_path / "skills" + skills_dir.mkdir() + + _write_skill(skills_dir, "always-public-1") + _write_skill(skills_dir, "always-public-2") + + for include_private in (False, True): + index, _ = generate_index( + source_dir=skills_dir, + dir_prefix="skills", + collection_key="skills", + is_pipeline=False, + include_private=include_private, + ) + assert "always-public-1" in index["skills"] + assert "always-public-2" in index["skills"] + + +class TestGenerateIndexOutputStructure: + """Tests that the output JSON structure matches the required format.""" + + def test_index_has_required_top_level_fields(self, tmp_path: Path) -> None: + """Index output must include version, generated, generated_by, and skills keys.""" + skills_dir = tmp_path / "skills" + skills_dir.mkdir() + _write_skill(skills_dir, "struct-test-skill") + + index, _ = generate_index( + source_dir=skills_dir, + dir_prefix="skills", + collection_key="skills", + is_pipeline=False, + ) + + assert index["version"] == "2.0" + assert "generated" in index + assert index["generated_by"] == "scripts/generate-skill-index.py" + assert "skills" in index + assert isinstance(index["skills"], dict) + + def test_skill_entry_has_required_fields(self, tmp_path: Path) -> None: + """Each skill entry must have file, description, triggers, user_invocable, and version.""" + skills_dir = tmp_path / "skills" + skills_dir.mkdir() + _write_skill(skills_dir, "entry-format-skill") + + index, _ = generate_index( + source_dir=skills_dir, + dir_prefix="skills", + collection_key="skills", + is_pipeline=False, + ) + + entry = index["skills"]["entry-format-skill"] + assert "file" in entry + assert "description" in entry + assert "triggers" in entry + assert isinstance(entry["triggers"], list) + assert "user_invocable" in entry + assert "version" in entry + + def test_output_is_valid_json(self, tmp_path: Path) -> None: + """The index dict must round-trip through JSON without error.""" + skills_dir = tmp_path / "skills" + skills_dir.mkdir() + _write_skill(skills_dir, "json-round-trip-skill") + + index, _ = generate_index( + source_dir=skills_dir, + dir_prefix="skills", + collection_key="skills", + is_pipeline=False, + ) + + serialized = json.dumps(index, indent=2) + parsed = json.loads(serialized) + assert parsed["skills"]["json-round-trip-skill"]["version"] == "1.0.0" + + +# --------------------------------------------------------------------------- +# CLI integration tests (subprocess) +# --------------------------------------------------------------------------- + + +class TestCLIFlags: + """Test the --include-private and --output CLI flags via subprocess.""" + + def _make_private_symlink_skill( + self, + tmp_path: Path, + skills_dir: Path, + skill_name: str, + private_category: str = "private-voices", + ) -> None: + private_dir = tmp_path / private_category / skill_name + private_dir.mkdir(parents=True) + (private_dir / "SKILL.md").write_text(_MINIMAL_SKILL_MD.format(name=skill_name), encoding="utf-8") + (skills_dir / skill_name).symlink_to(private_dir) + + def test_default_excludes_private_symlink(self, tmp_path: Path) -> None: + """Running the script with no flags excludes private symlink skills.""" + skills_dir = tmp_path / "skills" + skills_dir.mkdir() + _write_skill(skills_dir, "public-only") + self._make_private_symlink_skill(tmp_path, skills_dir, "voice-secret") + output_path = tmp_path / "INDEX.json" + + result = subprocess.run( + [ + sys.executable, + str(SCRIPT), + "--skills-dir", + str(skills_dir), + "--output", + str(output_path), + ], + capture_output=True, + text=True, + ) + assert result.returncode == 0, f"Script failed:\nstdout: {result.stdout}\nstderr: {result.stderr}" + index = json.loads(output_path.read_text()) + assert "public-only" in index["skills"], "Public skill must be present" + assert "voice-secret" not in index["skills"], "Private symlink must be excluded by default" + + def test_include_private_includes_symlink(self, tmp_path: Path) -> None: + """Running the script with --include-private includes private symlink skills.""" + skills_dir = tmp_path / "skills" + skills_dir.mkdir() + _write_skill(skills_dir, "public-only") + self._make_private_symlink_skill(tmp_path, skills_dir, "voice-secret") + output_path = tmp_path / "INDEX.local.json" + + result = subprocess.run( + [ + sys.executable, + str(SCRIPT), + "--include-private", + "--skills-dir", + str(skills_dir), + "--output", + str(output_path), + ], + capture_output=True, + text=True, + ) + assert result.returncode == 0, f"Script failed:\nstdout: {result.stdout}\nstderr: {result.stderr}" + index = json.loads(output_path.read_text()) + assert "public-only" in index["skills"], "Public skill must be present" + assert "voice-secret" in index["skills"], "Private skill must be included with --include-private" diff --git a/scripts/toolkit-evolution-cron.sh b/scripts/toolkit-evolution-cron.sh index 27890d37..070567d8 100755 --- a/scripts/toolkit-evolution-cron.sh +++ b/scripts/toolkit-evolution-cron.sh @@ -92,7 +92,17 @@ Key constraints: - Maximum 3 implementations per cycle (focus over breadth) - Winners that pass critique (STRONG consensus) AND A/B testing (WIN) should be merged via PR - Record all outcomes (wins AND losses) to the learning DB -- Write evolution report to evolution-reports/evolution-report-$(date +%Y-%m-%d).md" +- Write evolution report to evolution-reports/evolution-report-$(date +%Y-%m-%d).md + +PRIVATE SKILL GUARD — MANDATORY: +- When regenerating skills/INDEX.json, ALWAYS run: python3 scripts/generate-skill-index.py + (no --include-private flag). This public-only mode skips any SKILL.md that is a symlink + resolving into a private directory (private-skills/, private-voices/, etc.). +- Never add rows for private skills (wrestlejoy-*, voice-andy-nemmity, voice-amy-nemmity, + voice-andy-disagreement, gemini-wrestlejoy-comparison, or any skill whose SKILL.md is a + symlink to a private directory) to skills/do/references/routing-tables.md. +- Do not pass --include-private to generate-skill-index.py in this cycle. That flag is for + local development workflows only and must never run in the committed-index path." if [ -z "$GH_AUTH_VALID" ]; then PROMPT="$PROMPT diff --git a/skills/INDEX.json b/skills/INDEX.json index 4bc8d184..083a3722 100644 --- a/skills/INDEX.json +++ b/skills/INDEX.json @@ -1,6 +1,6 @@ { "version": "2.0", - "generated": "2026-04-11T10:14:10Z", + "generated": "2026-04-11T15:37:07Z", "generated_by": "scripts/generate-skill-index.py", "skills": { "adr-consultation": { @@ -752,19 +752,6 @@ "workflow" ] }, - "gemini-wrestlejoy-comparison": { - "file": "skills/gemini-wrestlejoy-comparison/SKILL.md", - "description": "A/B test Gemini Pro vs Claude for WrestleJoy voice replication: Prepare inputs, generate with both models, validate outputs, compare results.", - "triggers": [ - "gemini-wrestlejoy-comparison", - "gemini", - "wrestlejoy", - "comparison" - ], - "category": "analysis", - "user_invocable": false, - "version": "2.0.0" - }, "generate-claudemd": { "file": "skills/generate-claudemd/SKILL.md", "description": "Generate project-specific CLAUDE.md from repo analysis.", @@ -978,7 +965,7 @@ }, "joy-check": { "file": "skills/joy-check/SKILL.md", - "description": "Validate content for joy-centered tonal framing.", + "description": "Validate content framing on joy-grievance spectrum.", "triggers": [ "joy check", "check framing", @@ -986,15 +973,18 @@ "negative framing", "joy validation", "too negative", - "reframe positively" + "reframe positively", + "positive framing check", + "instruction framing" ], "category": "content", - "user_invocable": true, - "version": "1.0.0", + "user_invocable": false, + "version": "2.0.0", "pairs_with": [ "voice-writer", "anti-ai-editor", - "voice-validator" + "voice-validator", + "skill-creator" ] }, "kairos-lite": { @@ -2187,56 +2177,6 @@ "user_invocable": false, "version": "2.0.0" }, - "voice-amy-nemmity": { - "file": "skills/voice-amy-nemmity/SKILL.md", - "description": "Amy Nemmity's writing voice: warmth, celebration, poetic fragments, community focus, character arc narratives.", - "triggers": [ - "voice-amy-nemmity", - "voice", - "amy", - "nemmity" - ], - "category": "voice", - "user_invocable": false, - "version": "2.0.0" - }, - "voice-andy-disagreement": { - "file": "skills/voice-andy-disagreement/SKILL.md", - "description": "Andy Nemmity's disagreement and forum voice with reasoning ladder, claims-over-people discipline, and anti-escalation harshness control.", - "triggers": [ - "disagree", - "counter-argument", - "critique", - "rebuttal", - "pushback", - "that's wrong", - "I disagree", - "Hacker News", - "HN comment", - "Reddit", - "forum", - "community" - ], - "category": "voice", - "user_invocable": false, - "version": "2.0.0", - "pairs_with": [ - "voice-andy-nemmity" - ] - }, - "voice-andy-nemmity": { - "file": "skills/voice-andy-nemmity/SKILL.md", - "description": "Apply Andy Nemmity's voice profile for content generation: precision-driven improvisation, constraint accumulation, systems framing, calibration qu...", - "triggers": [ - "voice-andy-nemmity", - "voice", - "andy", - "nemmity" - ], - "category": "voice", - "user_invocable": false, - "version": "2.0.0" - }, "voice-validator": { "file": "skills/voice-validator/SKILL.md", "description": "Critique-and-rewrite loop for voice fidelity validation.", @@ -2391,95 +2331,6 @@ "user_invocable": false, "version": "1.0.0" }, - "wrestlejoy-aew-images": { - "file": "skills/wrestlejoy-aew-images/SKILL.md", - "description": "Fetch official promotional images from allelitewrestling.com for WrestleJoy articles.", - "triggers": [ - "aew image", - "aew photo", - "wrestler photo", - "official aew image", - "roster image", - "roster photo", - "aew event poster", - "get aew image" - ], - "category": "content-pipeline", - "user_invocable": true, - "version": "1.0.0", - "pairs_with": [ - "wrestlejoy-news-pipeline", - "voice-andy-nemmity", - "wrestlejoy-external-research" - ], - "agent": "wrestlejoy-news-producer", - "model": "sonnet" - }, - "wrestlejoy-external-research": { - "file": "skills/wrestlejoy-external-research/SKILL.md", - "description": "Evidence-sourced 4-phase external research: Identify, Search, Extract, Validate.", - "triggers": [ - "wrestlejoy-external-research", - "wrestlejoy", - "external", - "research" - ], - "category": "content-pipeline", - "user_invocable": false, - "version": "2.0.0" - }, - "wrestlejoy-mmr-research": { - "file": "skills/wrestlejoy-mmr-research/SKILL.md", - "description": "Evidence-based wrestler research from MMR database: Assess, Query, Synthesize, Output.", - "triggers": [ - "wrestlejoy-mmr-research", - "wrestlejoy", - "mmr", - "research" - ], - "category": "content-pipeline", - "user_invocable": false, - "version": "2.0.0" - }, - "wrestlejoy-news-pipeline": { - "file": "skills/wrestlejoy-news-pipeline/SKILL.md", - "description": "Automated AEW wrestling news production pipeline for WrestleJoy.com.", - "triggers": [ - "run news pipeline", - "wrestlejoy news", - "scan wrestling news", - "generate news articles", - "wrestling news automation", - "aew news pipeline", - "news production pipeline" - ], - "category": "content-pipeline", - "user_invocable": false, - "version": "1.0.0", - "pairs_with": [ - "voice-andy-nemmity", - "voice-writer", - "anti-ai-editor", - "wordpress-uploader", - "seo-optimizer", - "wrestlejoy-external-research" - ], - "agent": "wrestlejoy-news-producer", - "model": "opus" - }, - "wrestlejoy-research-transform": { - "file": "skills/wrestlejoy-research-transform/SKILL.md", - "description": "Transform MMR research data into narrative language: convert ratings to superlatives, trajectories to journey language, peaks to memorable moments.", - "triggers": [ - "wrestlejoy-research-transform", - "wrestlejoy", - "research", - "transform" - ], - "category": "content-pipeline", - "user_invocable": false, - "version": "2.0.0" - }, "x-api": { "file": "skills/x-api/SKILL.md", "description": "Post tweets, build threads, upload media via the X API.", diff --git a/skills/do/references/routing-tables.md b/skills/do/references/routing-tables.md index e0a24fa0..ac92488b 100644 --- a/skills/do/references/routing-tables.md +++ b/skills/do/references/routing-tables.md @@ -225,9 +225,6 @@ Route to these agents based on the user's task domain. Each entry describes what | **voice-writer** | User wants to generate content in an established voice — multi-step generation with validation. | | **voice-calibrator** | User wants to refine an existing voice profile or improve how well it captures their writing style. | | **voice-validator** | User wants to run a validation loop to confirm generated content matches the voice profile. | -| **voice-andy-nemmity** | User wants to generate content in Andy Nemmity's specific voice: precision-driven, systems framing, improvement-focused, modal writing across 5 registers. The primary voice for VexJoy and WrestleJoy long-form content. NOT: general voice generation (use voice-writer), Amy Nemmity voice (use voice-amy-nemmity). | -| **voice-amy-nemmity** | User wants to generate content in Amy Nemmity's voice: warmth, celebration, poetic fragments, community focus, wabi-sabi authenticity. The WrestleJoy editorial voice for wrestling coverage and awards content. NOT: Andy Nemmity voice (use voice-andy-nemmity). | -| **voice-andy-disagreement** | User wants to write a counter-argument, disagreement post, or forum response in Andy Nemmity's reasoning-first voice: claims over politeness, reasoning ladder, direct rebuttal. NOT: general debate framing (use roast or multi-persona-critique). | **Voice selection:** Use `create-voice` to build voice profiles from writing samples, then `voice-writer` for multi-step generation in that voice. Custom voice profiles are matched via their skill triggers. @@ -301,19 +298,6 @@ Workflows that work together in common sequences: --- -## WrestleJoy Skills - -| Skill | When to Route Here | -|-------|-------------------| -| **wrestlejoy-news-pipeline** | User wants to run the automated WrestleJoy news pipeline: discover positive AEW wrestling news, filter through positivity gate, cluster stories, generate articles in Andy's voice, validate, and upload as WordPress drafts. Triggers: "run news pipeline", "wrestlejoy news", "scan wrestling news", "generate news articles". NOT: long-form wrestler profiles (use voice-andy-nemmity directly). | -| **wrestlejoy-aew-images** | User wants to fetch official promotional images from allelitewrestling.com for WrestleJoy content: event banners, wrestler headshots, promotional art. | -| **wrestlejoy-external-research** | User wants evidence-sourced external research for a WrestleJoy article: identify claims, search sources, extract evidence, validate against multiple sources. | -| **wrestlejoy-mmr-research** | User wants to research wrestler match quality ratings from the MMR database for WrestleJoy content: fetch ratings, assess match history, synthesize data. | -| **wrestlejoy-research-transform** | User wants to convert raw MMR wrestling data into narrative language for WrestleJoy articles: transform ratings into superlatives, statistics into story beats, data into prose. | -| **gemini-wrestlejoy-comparison** | User wants to A/B test Gemini Pro vs Claude for WrestleJoy voice replication quality — comparing outputs against Andy Nemmity's voice profile. | - ---- - ## Validation Skills | Skill | When to Route Here |