maestro-skills 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. package/.github/workflows/ci.yml +26 -0
  2. package/.github/workflows/publish-npm.yml +30 -0
  3. package/CONTRIBUTING.md +31 -0
  4. package/LICENSE +21 -0
  5. package/README.md +300 -0
  6. package/SECURITY.md +33 -0
  7. package/docs/github-workflow.md +96 -0
  8. package/docs/maestro-skills-cli.md +113 -0
  9. package/package.json +35 -0
  10. package/packages/maestro-skills/README.md +37 -0
  11. package/packages/maestro-skills/agents.json +36 -0
  12. package/packages/maestro-skills/bin/cli.js +37 -0
  13. package/packages/maestro-skills/lib/detect-agents.js +28 -0
  14. package/packages/maestro-skills/lib/install.js +58 -0
  15. package/packages/maestro-skills/lib/paths.js +42 -0
  16. package/packages/maestro-skills/lib/remove.js +71 -0
  17. package/packages/maestro-skills/lib/run-manifest.js +92 -0
  18. package/packages/maestro-skills/lib/setup.js +115 -0
  19. package/packages/maestro-skills/package.json +47 -0
  20. package/packages/maestro-skills/test/agents.test.js +17 -0
  21. package/packages/rodovalhofs-maestro/agents.json +36 -0
  22. package/packages/rodovalhofs-maestro/bin/cli.js +10 -0
  23. package/packages/rodovalhofs-maestro/lib/detect-agents.js +28 -0
  24. package/packages/rodovalhofs-maestro/lib/install.js +58 -0
  25. package/packages/rodovalhofs-maestro/lib/paths.js +42 -0
  26. package/packages/rodovalhofs-maestro/lib/remove.js +71 -0
  27. package/packages/rodovalhofs-maestro/lib/run-manifest.js +92 -0
  28. package/packages/rodovalhofs-maestro/lib/setup.js +115 -0
  29. package/packages/rodovalhofs-maestro/package.json +33 -0
  30. package/scripts/sync-skill-to-cli.mjs +75 -0
  31. package/scripts/sync-templates.ps1 +22 -0
  32. package/skills/maestro/SKILL.md +272 -0
  33. package/skills/maestro/maestro-exclude.example.txt +6 -0
  34. package/skills/maestro/scripts/bm25.py +70 -0
  35. package/skills/maestro/scripts/build_manifest.py +183 -0
  36. package/skills/maestro/scripts/concept_gaps.py +196 -0
  37. package/skills/maestro/scripts/domains.py +148 -0
  38. package/skills/maestro/scripts/intents.py +167 -0
  39. package/skills/maestro/scripts/maestro_paths.py +41 -0
  40. package/skills/maestro/scripts/route_tasks.py +101 -0
  41. package/skills/maestro/scripts/routing.py +106 -0
  42. package/skills/maestro/scripts/search_skills.py +287 -0
  43. package/skills/maestro/scripts/synonyms.py +47 -0
  44. package/templates/.github/ISSUE_TEMPLATE/bug_report.yml +34 -0
  45. package/templates/.github/ISSUE_TEMPLATE/chore.yml +17 -0
  46. package/templates/.github/ISSUE_TEMPLATE/config.yml +1 -0
  47. package/templates/.github/ISSUE_TEMPLATE/feature_request.yml +27 -0
  48. package/templates/.github/workflows/ci-failure-to-issue.yml +47 -0
  49. package/templates/.github/workflows/ci.yml +27 -0
  50. package/templates/CONTRIBUTING.md +22 -0
  51. package/templates/labels.json +12 -0
  52. package/templates/pull_request_template.md +18 -0
  53. package/tests/fixtures/sample-manifest.json +76 -0
  54. package/tests/test_concept_gaps.py +63 -0
  55. package/tests/test_maestro_paths.py +29 -0
  56. package/tests/test_search_routing.py +161 -0
@@ -0,0 +1,196 @@
1
+ #!/usr/bin/env python3
2
+ """Detect prompt concepts that lack local skill coverage (discover via find-skills)."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import re
7
+ from typing import Any, Callable
8
+
9
+ MAX_DISCOVER_GAPS = 2
10
+
11
+ STOPWORDS: frozenset[str] = frozenset(
12
+ {
13
+ "ui",
14
+ "ux",
15
+ "app",
16
+ "web",
17
+ "api",
18
+ "codigo",
19
+ "código",
20
+ "alteracao",
21
+ "alteração",
22
+ "fazer",
23
+ "vamos",
24
+ "projeto",
25
+ "sistema",
26
+ "pagina",
27
+ "página",
28
+ "tela",
29
+ "componente",
30
+ "frontend",
31
+ "backend",
32
+ "nova",
33
+ "novo",
34
+ "uma",
35
+ "uns",
36
+ "the",
37
+ "and",
38
+ "for",
39
+ "with",
40
+ }
41
+ )
42
+
43
+ IMPLEMENTATION_VERB_PATTERN = re.compile(
44
+ r"\b(?:colocar|usar|implementar|adicionar|integrar|instalar|"
45
+ r"add|use|implement|integrate|install)\s+"
46
+ r"([a-z][a-z0-9._/-]*)",
47
+ re.IGNORECASE,
48
+ )
49
+
50
+ HYPHENATED_PATTERN = re.compile(r"\b([a-z][a-z0-9]*(?:-[a-z0-9]+)+)\b", re.IGNORECASE)
51
+
52
+ CAMEL_CASE_PATTERN = re.compile(r"\b([a-z]+(?:[A-Z][a-z0-9]+)+)\b")
53
+
54
+ CONCEPT_GAP_SCORE_THRESHOLD = 1.5
55
+
56
+
57
+ def _normalize_term(term: str) -> str:
58
+ return term.strip().lower().replace("_", "-")
59
+
60
+
61
+ def is_stopword(term: str) -> bool:
62
+ normalized = _normalize_term(term)
63
+ if len(normalized) < 3:
64
+ return True
65
+ if normalized in STOPWORDS:
66
+ return True
67
+ parts = normalized.split("-")
68
+ return len(parts) == 1 and normalized in STOPWORDS
69
+
70
+
71
+ def _concept_specificity(term: str) -> int:
72
+ normalized = _normalize_term(term)
73
+ score = 0
74
+ if "-" in normalized:
75
+ score += 3
76
+ if any(ch.isupper() for ch in term):
77
+ score += 2
78
+ if "." in normalized:
79
+ score += 2
80
+ if len(normalized) >= 8:
81
+ score += 1
82
+ return score
83
+
84
+
85
+ def extract_concept_candidates(query: str) -> list[str]:
86
+ """Extract salient implementable concepts from the user prompt."""
87
+ seen: set[str] = set()
88
+ ordered: list[str] = []
89
+
90
+ def add(raw: str) -> None:
91
+ term = _normalize_term(raw)
92
+ if not term or is_stopword(term):
93
+ return
94
+ if term in seen:
95
+ return
96
+ seen.add(term)
97
+ ordered.append(term)
98
+
99
+ for match in IMPLEMENTATION_VERB_PATTERN.finditer(query):
100
+ add(match.group(1))
101
+
102
+ for pattern in (HYPHENATED_PATTERN, CAMEL_CASE_PATTERN):
103
+ for match in pattern.finditer(query):
104
+ add(match.group(1))
105
+
106
+ ordered.sort(key=_concept_specificity, reverse=True)
107
+ return ordered
108
+
109
+
110
+ def _result_covers_term(term: str, results: list[dict[str, Any]]) -> bool:
111
+ normalized = _normalize_term(term)
112
+ compact = normalized.replace("-", "")
113
+ for skill in results:
114
+ blob = " ".join(
115
+ [
116
+ str(skill.get("name", "")),
117
+ str(skill.get("folder", "")),
118
+ str(skill.get("description", "")),
119
+ " ".join(str(t) for t in (skill.get("tags") or [])),
120
+ ]
121
+ ).lower()
122
+ blob_compact = blob.replace("-", "").replace("_", "")
123
+ if normalized in blob or compact in blob_compact:
124
+ return True
125
+ return False
126
+
127
+
128
+ def _top_score_for_term(
129
+ term: str,
130
+ pool: list[dict[str, Any]],
131
+ skill_document: Callable[[dict[str, Any]], str],
132
+ expand_query: Callable[[str], str],
133
+ ) -> float:
134
+ from bm25 import BM25
135
+
136
+ if not pool:
137
+ return 0.0
138
+
139
+ documents = [skill_document(skill) for skill in pool]
140
+ bm25 = BM25()
141
+ bm25.fit(documents)
142
+ ranked = bm25.score(expand_query(term))
143
+ positive = [score for _, score in ranked if score > 0]
144
+ return max(positive) if positive else 0.0
145
+
146
+
147
+ def find_concept_gaps(
148
+ query: str,
149
+ results: list[dict[str, Any]],
150
+ pool: list[dict[str, Any]],
151
+ skill_document: Callable[[dict[str, Any]], str],
152
+ expand_query: Callable[[str], str],
153
+ ) -> tuple[list[str], list[str]]:
154
+ """
155
+ Return (gaps_for_discover, gap_notes).
156
+
157
+ gaps_for_discover: up to MAX_DISCOVER_GAPS concepts needing find-skills.
158
+ gap_notes: additional concepts beyond the limit (for graph annotations).
159
+ """
160
+ candidates = extract_concept_candidates(query)
161
+ if not candidates:
162
+ return [], []
163
+
164
+ confirmed: list[str] = []
165
+ for term in candidates:
166
+ if _result_covers_term(term, results):
167
+ continue
168
+ term_score = _top_score_for_term(term, pool, skill_document, expand_query)
169
+ if term_score >= CONCEPT_GAP_SCORE_THRESHOLD:
170
+ continue
171
+ confirmed.append(term)
172
+
173
+ gaps = confirmed[:MAX_DISCOVER_GAPS]
174
+ notes = confirmed[MAX_DISCOVER_GAPS:]
175
+ return gaps, notes
176
+
177
+
178
+ def build_discover_queries(
179
+ gaps: list[str],
180
+ full_query: str,
181
+ domain: str,
182
+ ) -> list[str]:
183
+ """Build npx skills find queries for each concept gap."""
184
+ context_tokens: list[str] = []
185
+ if domain and domain not in {"general", "meta"}:
186
+ context_tokens.append(domain.replace("-", " "))
187
+ for token in re.findall(r"[a-z]{4,}", full_query.lower()):
188
+ if token not in STOPWORDS and token not in context_tokens:
189
+ context_tokens.append(token)
190
+
191
+ context = " ".join(context_tokens[:4])
192
+ queries: list[str] = []
193
+ for gap in gaps:
194
+ query = f"{gap} {context}".strip()
195
+ queries.append(query)
196
+ return queries
@@ -0,0 +1,148 @@
1
+ #!/usr/bin/env python3
2
+ """Domain taxonomy and classification for maestro."""
3
+
4
+ from __future__ import annotations
5
+
6
+ DOMAINS: list[str] = [
7
+ "web",
8
+ "data-viz",
9
+ "analytics",
10
+ "design",
11
+ "creative",
12
+ "devops-git",
13
+ "video-media",
14
+ "integrations",
15
+ "security",
16
+ "meta",
17
+ "general",
18
+ ]
19
+
20
+ DOMAIN_KEYWORDS: dict[str, list[str]] = {
21
+ "web": [
22
+ "react", "nextjs", "next.js", "frontend", "backend", "api", "typescript",
23
+ "javascript", "shadcn", "stripe", "supabase", "postgres", "tailwind",
24
+ "component", "browser", "testing", "debug", "remix", "vue", "svelte",
25
+ "html", "css", "web app", "full-stack", "auth", "payment",
26
+ ],
27
+ "data-viz": [
28
+ "chart", "graph", "visualization", "dashboard", "d3", "canvas", "threejs",
29
+ "geospatial", "map", "gantt", "diagram", "scrollytelling", "plot",
30
+ "accessibility", "svg", "webgl", "painel",
31
+ ],
32
+ "analytics": [
33
+ "data quality", "kpi", "jupyter", "notebook", "metric", "report",
34
+ "analytics", "business context", "market sizing", "validate data",
35
+ "pandas", "sql", "spreadsheet", "excel",
36
+ ],
37
+ "design": [
38
+ "prototype", "ideate", "audit", "design qa", "ux research", "figma",
39
+ "mockup", "wireframe", "ui", "ux", "product design", "url-to-code",
40
+ "image-to-code", "user flow", "onboarding", "superdesign", "design system",
41
+ "interface", "layout",
42
+ ],
43
+ "security": [
44
+ "security", "cybersecurity", "forensics", "malware", "pentest",
45
+ "penetration", "threat", "incident response", "mitre", "attack",
46
+ "vulnerability", "siem", "dfir", "red team", "blue team", "seguranca",
47
+ "forense", "volatility",
48
+ ],
49
+ "creative": [
50
+ "moodboard", "logo", "ads", "brand", "creative", "shot", "scene",
51
+ "positioning", "offer", "generative polish", "explorer",
52
+ ],
53
+ "devops-git": [
54
+ "github", "git", "pull request", "ci", "cd", "commit", "merge",
55
+ "workflow", "actions", "yeet", "fix ci", "address comments",
56
+ "failing check", "actions check", "quebrado", "falhando", "pipeline",
57
+ ],
58
+ "video-media": [
59
+ "remotion", "video", "animation", "render", "composition", "ffmpeg",
60
+ "media", "audio",
61
+ ],
62
+ "integrations": [
63
+ "twilio", "zoom", "slack", "notion", "airtable", "jira", "linear",
64
+ "stripe api", "webhook", "oauth", "sdk", "mcp", "salesforce",
65
+ "hubspot", "intercom",
66
+ ],
67
+ "meta": [
68
+ "skill creator", "skill installer", "plugin creator", "create skill",
69
+ "openai docs", "imagegen", "context7", "documentation library",
70
+ ],
71
+ }
72
+
73
+ NAME_PREFIX_DOMAIN: list[tuple[str, str]] = [
74
+ ("build-web-data-visualization-", "data-viz"),
75
+ ("data-analytics-", "analytics"),
76
+ ("product-design-", "design"),
77
+ ("creative-production-", "creative"),
78
+ ("gh-", "devops-git"),
79
+ ("netlify-", "web"),
80
+ ("twilio-", "integrations"),
81
+ ("zoom-", "integrations"),
82
+ ("figma-", "design"),
83
+ ("codex-", "meta"),
84
+ ]
85
+
86
+ HUB_SKILLS: set[str] = {
87
+ "index",
88
+ "product-design-index",
89
+ "data-visualization",
90
+ "build-web-data-visualization-data-visualization",
91
+ "explore",
92
+ }
93
+
94
+
95
+ def _text_blob(name: str, description: str) -> str:
96
+ return f"{name} {description}".lower()
97
+
98
+
99
+ def classify_skill(name: str, description: str) -> str:
100
+ if name.lower() == "superdesign":
101
+ return "design"
102
+
103
+ blob = _text_blob(name, description)
104
+ for prefix, domain in NAME_PREFIX_DOMAIN:
105
+ if name.lower().startswith(prefix):
106
+ return domain
107
+
108
+ scores = {domain: 0 for domain in DOMAINS}
109
+ for domain, keywords in DOMAIN_KEYWORDS.items():
110
+ for kw in keywords:
111
+ if kw in blob:
112
+ scores[domain] += 1
113
+
114
+ best = max(scores, key=scores.get)
115
+ if scores[best] > 0:
116
+ return best
117
+ return "general"
118
+
119
+
120
+ def classify_query(query: str) -> tuple[str, dict[str, int]]:
121
+ query_lower = query.lower()
122
+ scores = {domain: 0 for domain in DOMAINS}
123
+ for domain, keywords in DOMAIN_KEYWORDS.items():
124
+ for kw in keywords:
125
+ if kw in query_lower:
126
+ scores[domain] += 1
127
+
128
+ best = max(scores, key=scores.get)
129
+ if scores[best] == 0:
130
+ return "general", scores
131
+ return best, scores
132
+
133
+
134
+ def domain_label(domain: str) -> str:
135
+ labels = {
136
+ "web": "Web / apps",
137
+ "data-viz": "Data visualization",
138
+ "analytics": "Data analytics",
139
+ "design": "Product design",
140
+ "creative": "Creative production",
141
+ "devops-git": "Git / CI / DevOps",
142
+ "video-media": "Video / media",
143
+ "integrations": "Integrations / SDKs",
144
+ "security": "Cybersecurity",
145
+ "meta": "Meta / tooling",
146
+ "general": "General",
147
+ }
148
+ return labels.get(domain, domain)
@@ -0,0 +1,167 @@
1
+ #!/usr/bin/env python3
2
+ """Workflow intent profiles that boost BM25 matches beyond raw text similarity."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import re
7
+ from typing import Any
8
+
9
+ INTENT_PROFILES: list[dict[str, Any]] = [
10
+ {
11
+ "name": "root-cause-debugging",
12
+ "task_patterns": [
13
+ r"\b(root[- ]?cause|debug|bug|bugs?|crash|exception|traceback)\b",
14
+ r"\b(failing tests?|test failures?)\b",
15
+ r"\b(depurar|depuracao|raiz|falhando|erro|bug)\b",
16
+ ],
17
+ "skill_patterns": [
18
+ r"systematic-debugging",
19
+ r"root[- ]?cause",
20
+ r"troubleshooting",
21
+ r"problem-solving",
22
+ ],
23
+ "score_multiplier": 1.45,
24
+ "min_boost": 1.5,
25
+ "suggested_mode": "auto-load",
26
+ },
27
+ {
28
+ "name": "frontend-design",
29
+ "task_patterns": [
30
+ r"\b(landing page|redesign|ui|frontend|visual design|superdesign)\b",
31
+ r"\b(dashboard|mockup|wireframe|figma)\b",
32
+ r"\b(interface|layout|design system)\b",
33
+ ],
34
+ "skill_patterns": [
35
+ r"superdesign",
36
+ r"shadcn",
37
+ r"frontend",
38
+ r"ui-ux",
39
+ r"design",
40
+ r"mockup",
41
+ ],
42
+ "score_multiplier": 1.4,
43
+ "min_boost": 1.2,
44
+ "suggested_mode": "auto-load",
45
+ },
46
+ {
47
+ "name": "devops-ci",
48
+ "task_patterns": [
49
+ r"\b(ci|github actions|pull request|pr|pipeline)\b",
50
+ r"\b(falhando|quebrado|fix ci)\b",
51
+ ],
52
+ "skill_patterns": [
53
+ r"gh-fix-ci",
54
+ r"github",
55
+ r"yeet",
56
+ r"ci",
57
+ ],
58
+ "score_multiplier": 1.35,
59
+ "min_boost": 1.0,
60
+ "suggested_mode": "auto-load",
61
+ },
62
+ {
63
+ "name": "security-forensics",
64
+ "task_patterns": [
65
+ r"\b(forensics|volatility|memory dump|credential|mitre|dfir)\b",
66
+ r"\b(seguranca|forense|malware|incident)\b",
67
+ ],
68
+ "skill_patterns": [
69
+ r"forensics",
70
+ r"volatility",
71
+ r"credential",
72
+ r"malware",
73
+ r"incident-response",
74
+ ],
75
+ "score_multiplier": 1.5,
76
+ "min_boost": 2.0,
77
+ "suggested_mode": "auto-load",
78
+ },
79
+ {
80
+ "name": "skill-discovery",
81
+ "task_patterns": [
82
+ r"\bfind (a )?skill\b",
83
+ r"\bnpx skills\b",
84
+ r"\bskills\.sh\b",
85
+ r"\btem skill (para|de)\b",
86
+ r"\binstalar skill\b",
87
+ r"\bis there a skill\b",
88
+ r"\bdiscover (agent )?skills\b",
89
+ ],
90
+ "skill_patterns": [
91
+ r"find-skills",
92
+ r"create-skill",
93
+ r"skill-creator",
94
+ r"skill-installer",
95
+ ],
96
+ "score_multiplier": 2.0,
97
+ "min_boost": 3.0,
98
+ "suggested_mode": "auto-load",
99
+ },
100
+ ]
101
+
102
+ FORCE_DISCOVER_PATTERNS: list[str] = [
103
+ r"\bfind (a )?skill\b",
104
+ r"\bnpx skills\b",
105
+ r"\bskills\.sh\b",
106
+ r"\btem skill (para|de)\b",
107
+ r"\binstalar skill\b",
108
+ r"\bis there a skill\b",
109
+ r"\bdiscover (agent )?skills\b",
110
+ ]
111
+
112
+ BYPASS_PATTERNS = [
113
+ r"^\s*(hi|hello|hey|oi|ola|olá)\b",
114
+ r"^\s*(what time|que horas)\b",
115
+ ]
116
+
117
+
118
+ def matches_any_pattern(text: str, patterns: list[str]) -> bool:
119
+ return any(re.search(pattern, text, re.IGNORECASE) for pattern in patterns)
120
+
121
+
122
+ def is_bypass_task(task: str) -> bool:
123
+ task_lower = task.strip().lower()
124
+ if not task_lower:
125
+ return False
126
+ return matches_any_pattern(task_lower, BYPASS_PATTERNS)
127
+
128
+
129
+ def is_force_discover(task: str) -> bool:
130
+ if is_bypass_task(task):
131
+ return False
132
+ return matches_any_pattern(task, FORCE_DISCOVER_PATTERNS)
133
+
134
+
135
+ def task_intents(task: str) -> list[dict[str, Any]]:
136
+ if is_bypass_task(task):
137
+ return []
138
+ return [
139
+ profile
140
+ for profile in INTENT_PROFILES
141
+ if matches_any_pattern(task, list(profile.get("task_patterns", [])))
142
+ ]
143
+
144
+
145
+ def apply_intent_boost(
146
+ score: float,
147
+ skill_name: str,
148
+ skill_text: str,
149
+ intents: list[dict[str, Any]],
150
+ ) -> tuple[float, list[str], str]:
151
+ adjusted = score
152
+ matched: list[str] = []
153
+ suggested_mode = ""
154
+ searchable = f"{skill_name} {skill_text}".lower()
155
+
156
+ for profile in intents:
157
+ if not matches_any_pattern(searchable, list(profile.get("skill_patterns", []))):
158
+ continue
159
+ matched.append(str(profile.get("name", "")))
160
+ multiplier = float(profile.get("score_multiplier", 1.0))
161
+ min_boost = float(profile.get("min_boost", 0.0))
162
+ adjusted = max(adjusted * multiplier, min_boost)
163
+ mode = str(profile.get("suggested_mode", "") or "")
164
+ if mode:
165
+ suggested_mode = mode
166
+
167
+ return adjusted, [name for name in matched if name], suggested_mode
@@ -0,0 +1,41 @@
1
+ #!/usr/bin/env python3
2
+ """Shared paths for Maestro (multi-agent, agent-agnostic home)."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import os
7
+ from pathlib import Path
8
+
9
+ MAESTRO_HOME = Path(os.environ.get("MAESTRO_HOME", Path.home() / ".maestro"))
10
+ MANIFEST_PATH = MAESTRO_HOME / "skills-manifest.json"
11
+ EXCLUDE_PATH = MAESTRO_HOME / "maestro-exclude.txt"
12
+ CONFIG_PATH = MAESTRO_HOME / "config.json"
13
+
14
+ LEGACY_CURSOR_HOME = Path(os.environ.get("CURSOR_HOME", Path.home() / ".cursor"))
15
+ LEGACY_MANIFEST_PATH = LEGACY_CURSOR_HOME / "skills-manifest.json"
16
+ LEGACY_EXCLUDE_PATH = LEGACY_CURSOR_HOME / "maestro-exclude.txt"
17
+
18
+ # (skills directory, scope label)
19
+ GLOBAL_SKILL_ROOTS: list[tuple[Path, str]] = [
20
+ (Path.home() / ".cursor" / "skills", "cursor"),
21
+ (Path.home() / ".claude" / "skills", "claude"),
22
+ (Path.home() / ".codex" / "skills", "codex"),
23
+ (Path.home() / ".agents" / "skills", "agents"),
24
+ ]
25
+
26
+
27
+ def project_skill_roots(project_root: Path) -> list[tuple[Path, str]]:
28
+ root = project_root.resolve()
29
+ return [
30
+ (root / ".cursor" / "skills", "project-cursor"),
31
+ (root / ".claude" / "skills", "project-claude"),
32
+ (root / ".codex" / "skills", "project-codex"),
33
+ (root / ".agents" / "skills", "project-agents"),
34
+ ]
35
+
36
+
37
+ def all_skill_roots(project_root: Path | None = None) -> list[tuple[Path, str]]:
38
+ roots = list(GLOBAL_SKILL_ROOTS)
39
+ if project_root:
40
+ roots.extend(project_skill_roots(project_root))
41
+ return roots
@@ -0,0 +1,101 @@
1
+ #!/usr/bin/env python3
2
+ """Batch route decomposed tasks using the fast manifest-based search engine."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import argparse
7
+ import json
8
+ import sys
9
+ from pathlib import Path
10
+
11
+ SCRIPT_DIR = Path(__file__).resolve().parent
12
+ sys.path.insert(0, str(SCRIPT_DIR))
13
+
14
+ from search_skills import DEFAULT_MANIFEST, load_manifest, search_skills # noqa: E402
15
+
16
+
17
+ def route_batch(
18
+ tasks: list[str],
19
+ manifest_path: Path,
20
+ ) -> dict:
21
+ manifest = load_manifest(manifest_path)
22
+ results = [
23
+ search_skills(task.strip(), manifest)
24
+ for task in tasks
25
+ if task.strip()
26
+ ]
27
+
28
+ all_gaps: list[str] = []
29
+ all_gap_notes: list[str] = []
30
+ discover_triggered = False
31
+ for result in results:
32
+ discover = result.get("discover", {})
33
+ if discover.get("triggered"):
34
+ discover_triggered = True
35
+ for gap in discover.get("gaps", []):
36
+ if gap not in all_gaps:
37
+ all_gaps.append(gap)
38
+ for note in discover.get("gap_notes", []):
39
+ if note not in all_gap_notes:
40
+ all_gap_notes.append(note)
41
+
42
+ priorities = [r.get("routing", {}).get("priority", "P3") for r in results]
43
+ if "P0" in priorities:
44
+ batch_priority, batch_decision = "P0", "recommend"
45
+ elif "P1" in priorities:
46
+ batch_priority, batch_decision = "P1", "auto-load"
47
+ elif "P2" in priorities:
48
+ batch_priority, batch_decision = "P2", "optional-load"
49
+ else:
50
+ batch_priority, batch_decision = "P3", "bypass"
51
+
52
+ return {
53
+ "batch": True,
54
+ "task_count": len(results),
55
+ "routing": {
56
+ "priority": batch_priority,
57
+ "decision": batch_decision,
58
+ "report_policy": "report" if batch_priority == "P0" else "silent",
59
+ },
60
+ "results": results,
61
+ "discover": {
62
+ "triggered": discover_triggered,
63
+ "gaps": all_gaps,
64
+ "gap_notes": all_gap_notes,
65
+ },
66
+ }
67
+
68
+
69
+ def main() -> int:
70
+ parser = argparse.ArgumentParser(description="Route decomposed tasks to skills")
71
+ parser.add_argument(
72
+ "tasks",
73
+ nargs="*",
74
+ help="Task strings; omit and use stdin for batch mode",
75
+ )
76
+ parser.add_argument("--manifest", default=str(DEFAULT_MANIFEST))
77
+ parser.add_argument("--json", action="store_true")
78
+ args = parser.parse_args()
79
+
80
+ if args.tasks:
81
+ tasks = args.tasks
82
+ else:
83
+ tasks = [line.strip() for line in sys.stdin if line.strip()]
84
+
85
+ payload = route_batch(tasks, Path(args.manifest))
86
+
87
+ if args.json or not sys.stdout.isatty():
88
+ print(json.dumps(payload, indent=2, ensure_ascii=False))
89
+ else:
90
+ for result in payload["results"]:
91
+ top = result["results"][0]["name"] if result.get("results") else "(none)"
92
+ routing = result.get("routing", {})
93
+ print(
94
+ f"- {result['query']}: {top} "
95
+ f"[{routing.get('priority')} / {routing.get('decision')}]"
96
+ )
97
+ return 0
98
+
99
+
100
+ if __name__ == "__main__":
101
+ raise SystemExit(main())