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 1f122de1..083a3722 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-11T15:37:07Z", "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", @@ -812,6 +864,7 @@ "make check", "Go lint" ], + "category": "language", "force_route": true, "user_invocable": false, "version": "1.0.0", @@ -1127,6 +1180,7 @@ "sprite generation", "generate card art" ], + "category": "image-generation", "user_invocable": false, "version": "3.0.0", "pairs_with": [ @@ -1957,6 +2011,7 @@ "fragile test", "testing implementation details" ], + "category": "testing", "user_invocable": false, "version": "2.0.0", "pairs_with": [ @@ -2007,7 +2062,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 +2070,8 @@ "toolkit evolution", "what should we improve", "find improvement opportunities", + "discover skill gaps", + "what skills are missing", "systematic improvement" ], "category": "meta-tooling", @@ -2097,6 +2154,7 @@ "assemble clips", "video editing" ], + "category": "video-creation", "user_invocable": false, "version": "1.0.0", "pairs_with": [ @@ -2240,6 +2298,7 @@ "phased execution", "orchestrated workflow" ], + "category": "meta-tooling", "user_invocable": false, "version": "2.0.0", "model": "sonnet" @@ -2298,56 +2357,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/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"