@jaguilar87/gaia-ops 1.0.0
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.
- package/CHANGELOG.md +315 -0
- package/CLAUDE.md +154 -0
- package/LICENSE +21 -0
- package/README.md +221 -0
- package/agents/aws-troubleshooter.md +50 -0
- package/agents/claude-architect.md +821 -0
- package/agents/devops-developer.md +92 -0
- package/agents/gcp-troubleshooter.md +50 -0
- package/agents/gitops-operator.md +360 -0
- package/agents/terraform-architect.md +289 -0
- package/bin/gaia-init.js +620 -0
- package/commands/architect.md +97 -0
- package/commands/restore-session.md +87 -0
- package/commands/save-session.md +88 -0
- package/commands/session-status.md +61 -0
- package/commands/speckit.add-task.md +144 -0
- package/commands/speckit.analyze-task.md +65 -0
- package/commands/speckit.implement.md +96 -0
- package/commands/speckit.init.md +237 -0
- package/commands/speckit.plan.md +88 -0
- package/commands/speckit.specify.md +161 -0
- package/commands/speckit.tasks.md +188 -0
- package/config/AGENTS.md +162 -0
- package/config/agent-catalog.md +604 -0
- package/config/context-contracts.md +682 -0
- package/config/git-standards.md +674 -0
- package/config/git_standards.json +69 -0
- package/config/orchestration-workflow.md +735 -0
- package/hooks/__pycache__/post_tool_use.cpython-312.pyc +0 -0
- package/hooks/__pycache__/pre_kubectl_security.cpython-312.pyc +0 -0
- package/hooks/__pycache__/pre_tool_use.cpython-312.pyc +0 -0
- package/hooks/__pycache__/session_start.cpython-312.pyc +0 -0
- package/hooks/__pycache__/subagent_stop.cpython-312.pyc +0 -0
- package/hooks/post_tool_use.py +463 -0
- package/hooks/pre_kubectl_security.py +205 -0
- package/hooks/pre_tool_use.py +530 -0
- package/hooks/session_start.py +315 -0
- package/hooks/subagent_stop.py +549 -0
- package/index.js +92 -0
- package/package.json +59 -0
- package/speckit/README.en.md +648 -0
- package/speckit/README.md +353 -0
- package/speckit/governance.md +169 -0
- package/speckit/scripts/check-prerequisites.sh +194 -0
- package/speckit/scripts/common.sh +126 -0
- package/speckit/scripts/create-new-feature.sh +131 -0
- package/speckit/scripts/init.sh +42 -0
- package/speckit/scripts/setup-plan.sh +95 -0
- package/speckit/scripts/update-agent-context.sh +718 -0
- package/speckit/templates/adr-template.md +118 -0
- package/speckit/templates/agent-file-template.md +23 -0
- package/speckit/templates/plan-template.md +233 -0
- package/speckit/templates/spec-template.md +116 -0
- package/speckit/templates/tasks-template-bkp.md +136 -0
- package/speckit/templates/tasks-template.md +345 -0
- package/templates/CLAUDE.template.md +170 -0
- package/templates/code-examples/approval_gate_workflow.py +141 -0
- package/templates/code-examples/clarification_workflow.py +94 -0
- package/templates/code-examples/commit_validation.py +86 -0
- package/templates/project-context.template.json +126 -0
- package/templates/settings.template.json +307 -0
- package/tools/__pycache__/agent_router.cpython-312.pyc +0 -0
- package/tools/__pycache__/approval_gate.cpython-312.pyc +0 -0
- package/tools/__pycache__/clarify_engine.cpython-312.pyc +0 -0
- package/tools/__pycache__/clarify_patterns.cpython-312.pyc +0 -0
- package/tools/__pycache__/commit_validator.cpython-312.pyc +0 -0
- package/tools/__pycache__/context_section_reader.cpython-312.pyc +0 -0
- package/tools/__pycache__/routing_dashboard.cpython-312.pyc +0 -0
- package/tools/__pycache__/routing_feedback.cpython-312.pyc +0 -0
- package/tools/__pycache__/semantic_matcher.cpython-312.pyc +0 -0
- package/tools/__pycache__/task_manager.cpython-312.pyc +0 -0
- package/tools/agent_capabilities.json +231 -0
- package/tools/agent_invoker_helper.py +239 -0
- package/tools/agent_router.py +730 -0
- package/tools/approval_gate.py +318 -0
- package/tools/clarify_engine.py +511 -0
- package/tools/clarify_patterns.py +356 -0
- package/tools/commit_validator.py +338 -0
- package/tools/context_provider.py +181 -0
- package/tools/context_section_reader.py +301 -0
- package/tools/demo_clarify.py +104 -0
- package/tools/generate_embeddings.py +168 -0
- package/tools/quicktriage_aws_troubleshooter.sh +45 -0
- package/tools/quicktriage_devops_developer.sh +38 -0
- package/tools/quicktriage_gcp_troubleshooter.sh +51 -0
- package/tools/quicktriage_gitops_operator.sh +47 -0
- package/tools/quicktriage_terraform_architect.sh +40 -0
- package/tools/semantic_matcher.py +222 -0
- package/tools/task_manager.py +547 -0
- package/tools/task_manager_README.md +395 -0
- package/tools/task_manager_example.py +215 -0
|
@@ -0,0 +1,730 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
Unified Agent Router for Claude Agent System
|
|
4
|
+
|
|
5
|
+
- If the request references a Spec-Kit task (Txxx or TASK-xxx), the router reads
|
|
6
|
+
the task metadata and routes to the agent declared there.
|
|
7
|
+
- Otherwise it falls back to keyword/pattern scoring.
|
|
8
|
+
|
|
9
|
+
This script replaces the previous split between TaskAnalyzer routing and the
|
|
10
|
+
keyword-only router.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
import argparse
|
|
14
|
+
import json
|
|
15
|
+
import logging
|
|
16
|
+
import re
|
|
17
|
+
from dataclasses import dataclass
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
from typing import Any, Dict, List, Optional, Tuple
|
|
20
|
+
|
|
21
|
+
logging.basicConfig(level=logging.INFO)
|
|
22
|
+
logger = logging.getLogger(__name__)
|
|
23
|
+
|
|
24
|
+
# Week 2: Try to import SemanticMatcher (optional - graceful degradation if not available)
|
|
25
|
+
try:
|
|
26
|
+
from semantic_matcher import SemanticMatcher
|
|
27
|
+
SEMANTIC_MATCHER_AVAILABLE = True
|
|
28
|
+
except ImportError:
|
|
29
|
+
SEMANTIC_MATCHER_AVAILABLE = False
|
|
30
|
+
logger.debug("SemanticMatcher not available (embeddings not generated yet)")
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
# ============================================================================
|
|
34
|
+
# WEEK 1: Enhanced Semantic Keywords - IntentClassifier
|
|
35
|
+
# ============================================================================
|
|
36
|
+
|
|
37
|
+
class IntentClassifier:
|
|
38
|
+
"""Classify request intent using semantic keywords (Week 1 addition)"""
|
|
39
|
+
|
|
40
|
+
def __init__(self):
|
|
41
|
+
self.intent_keywords = {
|
|
42
|
+
"infrastructure_creation": {
|
|
43
|
+
"include": [
|
|
44
|
+
"create", "provision", "deploy", "setup", "build",
|
|
45
|
+
"infrastructure", "infra", "resources", "services",
|
|
46
|
+
"cluster", "network", "vpc", "database", "instance"
|
|
47
|
+
],
|
|
48
|
+
"exclude": ["diagnose", "troubleshoot", "debug", "check", "analyze"],
|
|
49
|
+
"confidence_boost": 0.85
|
|
50
|
+
},
|
|
51
|
+
"infrastructure_diagnosis": {
|
|
52
|
+
"include": [
|
|
53
|
+
"diagnose", "troubleshoot", "debug", "check", "analyze",
|
|
54
|
+
"problem", "issue", "error", "failing", "broken",
|
|
55
|
+
"crash", "latency", "timeout", "connectivity"
|
|
56
|
+
],
|
|
57
|
+
"exclude": ["create", "provision", "deploy", "setup"],
|
|
58
|
+
"confidence_boost": 0.88
|
|
59
|
+
},
|
|
60
|
+
"kubernetes_operations": {
|
|
61
|
+
"include": [
|
|
62
|
+
"pod", "deployment", "service", "namespace", "helm",
|
|
63
|
+
"flux", "kubectl", "verify", "status", "logs",
|
|
64
|
+
"scale", "restart", "update"
|
|
65
|
+
],
|
|
66
|
+
"exclude": ["create infrastructure", "provision"],
|
|
67
|
+
"confidence_boost": 0.82
|
|
68
|
+
},
|
|
69
|
+
"application_development": {
|
|
70
|
+
"include": [
|
|
71
|
+
"build", "docker", "compile", "test", "run",
|
|
72
|
+
"npm", "python", "node", "typescript", "lint",
|
|
73
|
+
"unit test", "integration test"
|
|
74
|
+
],
|
|
75
|
+
"exclude": ["infrastructure"],
|
|
76
|
+
"confidence_boost": 0.80
|
|
77
|
+
},
|
|
78
|
+
"infrastructure_validation": {
|
|
79
|
+
"include": [
|
|
80
|
+
"validate", "check", "verify", "scan", "lint",
|
|
81
|
+
"terraform", "hcl", "syntax", "security", "plan"
|
|
82
|
+
],
|
|
83
|
+
"exclude": ["deploy", "apply", "execute"],
|
|
84
|
+
"confidence_boost": 0.86
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
def classify(self, request: str) -> Tuple[Optional[str], float]:
|
|
89
|
+
"""
|
|
90
|
+
Classify intent using semantic keyword matching
|
|
91
|
+
|
|
92
|
+
Returns:
|
|
93
|
+
(intent_name, confidence_score)
|
|
94
|
+
"""
|
|
95
|
+
request_lower = request.lower()
|
|
96
|
+
scores = {}
|
|
97
|
+
|
|
98
|
+
for intent, keywords in self.intent_keywords.items():
|
|
99
|
+
score = 0.0
|
|
100
|
+
|
|
101
|
+
# Count matching include keywords
|
|
102
|
+
include_matches = sum(
|
|
103
|
+
1 for kw in keywords["include"]
|
|
104
|
+
if kw in request_lower
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
# Check exclusion keywords (veto)
|
|
108
|
+
exclude_matches = sum(
|
|
109
|
+
1 for kw in keywords["exclude"]
|
|
110
|
+
if kw in request_lower
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
if exclude_matches > 0:
|
|
114
|
+
score = -1.0 # Veto this intent
|
|
115
|
+
else:
|
|
116
|
+
score = include_matches * keywords["confidence_boost"]
|
|
117
|
+
|
|
118
|
+
scores[intent] = score
|
|
119
|
+
|
|
120
|
+
# Find best intent (exclude vetoed)
|
|
121
|
+
valid_scores = {k: v for k, v in scores.items() if v >= 0}
|
|
122
|
+
|
|
123
|
+
if not valid_scores:
|
|
124
|
+
return None, 0.0
|
|
125
|
+
|
|
126
|
+
best_intent = max(valid_scores, key=valid_scores.get)
|
|
127
|
+
# Normalize to 0-1 range (adjust divisor based on typical scores)
|
|
128
|
+
# Most requests have 1-5 keyword matches, so we normalize with factor of 5
|
|
129
|
+
raw_score = valid_scores[best_intent]
|
|
130
|
+
confidence = min(raw_score / 5, 1.0)
|
|
131
|
+
|
|
132
|
+
return best_intent, confidence
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
class CapabilityValidator:
|
|
136
|
+
"""Validate that selected agent has required capabilities (Week 1 addition)"""
|
|
137
|
+
|
|
138
|
+
def __init__(self):
|
|
139
|
+
self.agent_capabilities = {
|
|
140
|
+
"terraform-architect": {
|
|
141
|
+
"can_do": ["infrastructure_creation", "infrastructure_validation"],
|
|
142
|
+
"cannot_do": ["kubernetes_operations", "infrastructure_diagnosis"],
|
|
143
|
+
"requires_context": ["infrastructure", "iac"]
|
|
144
|
+
},
|
|
145
|
+
"gitops-operator": {
|
|
146
|
+
"can_do": ["kubernetes_operations", "infrastructure_diagnosis"],
|
|
147
|
+
"cannot_do": ["infrastructure_creation", "application_development"],
|
|
148
|
+
"requires_context": ["kubernetes", "gitops"]
|
|
149
|
+
},
|
|
150
|
+
"gcp-troubleshooter": {
|
|
151
|
+
"can_do": ["infrastructure_diagnosis"],
|
|
152
|
+
"cannot_do": ["infrastructure_creation", "application_development"],
|
|
153
|
+
"requires_context": ["gcp", "monitoring"]
|
|
154
|
+
},
|
|
155
|
+
"devops-developer": {
|
|
156
|
+
"can_do": ["application_development", "infrastructure_validation"],
|
|
157
|
+
"cannot_do": ["kubernetes_operations", "infrastructure_creation"],
|
|
158
|
+
"requires_context": ["application", "development"]
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
def validate(self, agent: str, intent: str) -> bool:
|
|
163
|
+
"""Check if agent can handle the intent"""
|
|
164
|
+
if agent not in self.agent_capabilities:
|
|
165
|
+
return False
|
|
166
|
+
|
|
167
|
+
capabilities = self.agent_capabilities[agent]
|
|
168
|
+
|
|
169
|
+
# Can't do list is hard veto
|
|
170
|
+
if intent in capabilities["cannot_do"]:
|
|
171
|
+
return False
|
|
172
|
+
|
|
173
|
+
# Can do list is preferred
|
|
174
|
+
return intent in capabilities["can_do"]
|
|
175
|
+
|
|
176
|
+
def find_fallback_agent(self, intent: str, exclude: str = None) -> str:
|
|
177
|
+
"""Find alternative agent if primary fails"""
|
|
178
|
+
for agent, capabilities in self.agent_capabilities.items():
|
|
179
|
+
if agent == exclude:
|
|
180
|
+
continue
|
|
181
|
+
if intent in capabilities["can_do"]:
|
|
182
|
+
return agent
|
|
183
|
+
|
|
184
|
+
return "devops-developer" # Ultimate fallback
|
|
185
|
+
|
|
186
|
+
ROOT_DIR = Path(__file__).resolve().parents[2]
|
|
187
|
+
DEFAULT_TASK_FILE = ROOT_DIR / "spec-kit-tcm-plan" / "specs" / "001-tcm-deployment-plan" / "tasks.md"
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
@dataclass
|
|
191
|
+
class RoutingRule:
|
|
192
|
+
"""Defines routing rules for an agent"""
|
|
193
|
+
agent: str
|
|
194
|
+
keywords: List[str]
|
|
195
|
+
patterns: List[str]
|
|
196
|
+
description: str
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
class AgentRouter:
|
|
200
|
+
"""Unified router that prefers task metadata and falls back to keywords."""
|
|
201
|
+
|
|
202
|
+
def __init__(self, task_file: Optional[Path] = None):
|
|
203
|
+
if task_file:
|
|
204
|
+
candidate = Path(task_file)
|
|
205
|
+
else:
|
|
206
|
+
candidate = DEFAULT_TASK_FILE
|
|
207
|
+
|
|
208
|
+
if candidate and candidate.exists():
|
|
209
|
+
self.task_file: Optional[Path] = candidate
|
|
210
|
+
else:
|
|
211
|
+
self.task_file = None
|
|
212
|
+
|
|
213
|
+
self.tasks_metadata = self._load_tasks_metadata()
|
|
214
|
+
self.routing_rules = self._load_routing_rules()
|
|
215
|
+
|
|
216
|
+
# Week 1 additions: Semantic routing components
|
|
217
|
+
self.intent_classifier = IntentClassifier()
|
|
218
|
+
self.capability_validator = CapabilityValidator()
|
|
219
|
+
|
|
220
|
+
# Week 2 additions: Semantic matcher (with embeddings)
|
|
221
|
+
self.semantic_matcher = None
|
|
222
|
+
if SEMANTIC_MATCHER_AVAILABLE:
|
|
223
|
+
try:
|
|
224
|
+
self.semantic_matcher = SemanticMatcher()
|
|
225
|
+
except Exception as e:
|
|
226
|
+
logger.warning(f"Could not initialize SemanticMatcher: {e}")
|
|
227
|
+
self.semantic_matcher = None
|
|
228
|
+
|
|
229
|
+
def _get_agent_for_intent(self, intent: str) -> str:
|
|
230
|
+
"""Map intent to primary agent (Week 1 addition)"""
|
|
231
|
+
intent_to_agent = {
|
|
232
|
+
"infrastructure_creation": "terraform-architect",
|
|
233
|
+
"infrastructure_diagnosis": "gcp-troubleshooter",
|
|
234
|
+
"kubernetes_operations": "gitops-operator",
|
|
235
|
+
"application_development": "devops-developer",
|
|
236
|
+
"infrastructure_validation": "terraform-architect"
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
return intent_to_agent.get(intent, "devops-developer")
|
|
240
|
+
|
|
241
|
+
def _load_routing_rules(self) -> Dict[str, RoutingRule]:
|
|
242
|
+
"""Load routing rules for all agents"""
|
|
243
|
+
return {
|
|
244
|
+
"gitops-operator": RoutingRule(
|
|
245
|
+
agent="gitops-operator",
|
|
246
|
+
keywords=[
|
|
247
|
+
"pod", "pods", "deployment", "deployments",
|
|
248
|
+
"service", "services", "flux", "helmrelease",
|
|
249
|
+
"kubernetes", "k8s", "namespace", "namespaces",
|
|
250
|
+
"ingress", "configmap", "secret",
|
|
251
|
+
"reconcile", "kustomization"
|
|
252
|
+
],
|
|
253
|
+
patterns=[
|
|
254
|
+
r"check.*pod[s]?",
|
|
255
|
+
r"validate.*deployment",
|
|
256
|
+
r"flux.*status",
|
|
257
|
+
r"helmrelease",
|
|
258
|
+
r"kubernetes.*health",
|
|
259
|
+
r"k8s.*status"
|
|
260
|
+
],
|
|
261
|
+
description="Flux/Kubernetes operations (read-only)"
|
|
262
|
+
),
|
|
263
|
+
|
|
264
|
+
"gcp-troubleshooter": RoutingRule(
|
|
265
|
+
agent="gcp-troubleshooter",
|
|
266
|
+
keywords=[
|
|
267
|
+
"gke", "gcp", "google cloud",
|
|
268
|
+
"cloud sql", "cloudsql", "postgres", "postgresql",
|
|
269
|
+
"redis", "memorystore",
|
|
270
|
+
"iam", "workload identity", "service account",
|
|
271
|
+
"networking", "vpc", "firewall",
|
|
272
|
+
"artifact registry", "gcr",
|
|
273
|
+
"load balancer", "ip address"
|
|
274
|
+
],
|
|
275
|
+
patterns=[
|
|
276
|
+
r"gke.*cluster",
|
|
277
|
+
r"cloud\s*sql",
|
|
278
|
+
r"iam.*binding",
|
|
279
|
+
r"workload.*identity",
|
|
280
|
+
r"gcp.*diagnose",
|
|
281
|
+
r"google.*cloud"
|
|
282
|
+
],
|
|
283
|
+
description="GCP/GKE/IAM diagnostics"
|
|
284
|
+
),
|
|
285
|
+
|
|
286
|
+
"terraform-architect": RoutingRule(
|
|
287
|
+
agent="terraform-architect",
|
|
288
|
+
keywords=[
|
|
289
|
+
"terraform", "terragrunt",
|
|
290
|
+
"infrastructure", "iac", "infrastructure as code",
|
|
291
|
+
"tfstate", "state file",
|
|
292
|
+
"module", "modules",
|
|
293
|
+
"plan", "validate", "fmt"
|
|
294
|
+
],
|
|
295
|
+
patterns=[
|
|
296
|
+
r"terraform.*validate",
|
|
297
|
+
r"terraform.*plan",
|
|
298
|
+
r"terragrunt.*plan",
|
|
299
|
+
r"infrastructure.*code",
|
|
300
|
+
r"iac.*validation"
|
|
301
|
+
],
|
|
302
|
+
description="Terraform/Terragrunt validation (T0-T2)"
|
|
303
|
+
),
|
|
304
|
+
|
|
305
|
+
"devops-developer": RoutingRule(
|
|
306
|
+
agent="devops-developer",
|
|
307
|
+
keywords=[
|
|
308
|
+
"build", "docker", "container", "image",
|
|
309
|
+
"npm", "yarn", "pnpm", "turborepo",
|
|
310
|
+
"test", "tests", "testing",
|
|
311
|
+
"ci", "cd", "pipeline", "github actions",
|
|
312
|
+
"lint", "prettier", "eslint",
|
|
313
|
+
"package", "dependencies"
|
|
314
|
+
],
|
|
315
|
+
patterns=[
|
|
316
|
+
r"build.*image",
|
|
317
|
+
r"docker.*build",
|
|
318
|
+
r"run.*tests",
|
|
319
|
+
r"npm.*install",
|
|
320
|
+
r"turborepo",
|
|
321
|
+
r"ci.*cd",
|
|
322
|
+
r"github.*action"
|
|
323
|
+
],
|
|
324
|
+
description="Application development and CI/CD"
|
|
325
|
+
),
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
def _load_tasks_metadata(self) -> Dict[str, Dict[str, Any]]:
|
|
329
|
+
"""Parse tasks.md metadata to map task IDs to suggested agents."""
|
|
330
|
+
metadata: Dict[str, Dict[str, Any]] = {}
|
|
331
|
+
|
|
332
|
+
if not self.task_file:
|
|
333
|
+
return metadata
|
|
334
|
+
|
|
335
|
+
try:
|
|
336
|
+
lines = self.task_file.read_text(encoding="utf-8").splitlines()
|
|
337
|
+
except Exception as exc: # pragma: no cover - defensive
|
|
338
|
+
logger.warning("Could not read task metadata file %s: %s", self.task_file, exc)
|
|
339
|
+
return metadata
|
|
340
|
+
|
|
341
|
+
current_id: Optional[str] = None
|
|
342
|
+
|
|
343
|
+
for line in lines:
|
|
344
|
+
stripped = line.strip()
|
|
345
|
+
id_match = re.match(r"- \[[ xX]\] ((?:TASK-)?T\d+)", stripped)
|
|
346
|
+
if id_match:
|
|
347
|
+
raw_id = id_match.group(1).upper()
|
|
348
|
+
norm_id = f"T{raw_id.split('-', 1)[1]}" if raw_id.startswith("TASK-") else raw_id
|
|
349
|
+
metadata[norm_id] = {
|
|
350
|
+
"raw_id": raw_id,
|
|
351
|
+
"agent": None,
|
|
352
|
+
"tier": None,
|
|
353
|
+
"confidence": None,
|
|
354
|
+
}
|
|
355
|
+
current_id = norm_id
|
|
356
|
+
continue
|
|
357
|
+
|
|
358
|
+
if current_id and stripped.startswith("<!--") and "Agent:" in stripped:
|
|
359
|
+
comment = stripped.lstrip("<!- ").rstrip("-> ").strip()
|
|
360
|
+
parts = [part.strip() for part in comment.split("|")]
|
|
361
|
+
|
|
362
|
+
for part in parts:
|
|
363
|
+
if "Agent:" in part:
|
|
364
|
+
metadata[current_id]["agent"] = part.split("Agent:", 1)[1].strip()
|
|
365
|
+
|
|
366
|
+
tier_match = re.search(r"T[0-3]", part)
|
|
367
|
+
if tier_match:
|
|
368
|
+
metadata[current_id]["tier"] = tier_match.group(0)
|
|
369
|
+
|
|
370
|
+
# Parse numeric confidence (e.g. 0.90) if present
|
|
371
|
+
conf_match = re.search(r"([0-9]+(?:\.[0-9]+)?)", part)
|
|
372
|
+
if (
|
|
373
|
+
conf_match
|
|
374
|
+
and "Agent" not in part
|
|
375
|
+
and not tier_match
|
|
376
|
+
):
|
|
377
|
+
try:
|
|
378
|
+
metadata[current_id]["confidence"] = float(conf_match.group(1))
|
|
379
|
+
except ValueError:
|
|
380
|
+
pass
|
|
381
|
+
|
|
382
|
+
# Drop entries without agent info
|
|
383
|
+
return {task_id: meta for task_id, meta in metadata.items() if meta.get("agent")}
|
|
384
|
+
|
|
385
|
+
def _route_by_task(self, user_request: str) -> Optional[Dict[str, Any]]:
|
|
386
|
+
"""Return routing decision based on task metadata if present."""
|
|
387
|
+
if not self.tasks_metadata:
|
|
388
|
+
return None
|
|
389
|
+
|
|
390
|
+
matches = re.findall(r"(?:TASK-)?T\d+", user_request, flags=re.IGNORECASE)
|
|
391
|
+
for raw in matches:
|
|
392
|
+
raw_upper = raw.upper()
|
|
393
|
+
norm = f"T{raw_upper.split('-', 1)[1]}" if raw_upper.startswith("TASK-") else raw_upper
|
|
394
|
+
meta = self.tasks_metadata.get(norm)
|
|
395
|
+
if not meta:
|
|
396
|
+
continue
|
|
397
|
+
agent = meta.get("agent")
|
|
398
|
+
if not agent:
|
|
399
|
+
continue
|
|
400
|
+
|
|
401
|
+
base_conf = meta.get("confidence")
|
|
402
|
+
confidence_score = 100 if base_conf is None else max(10, int(round(base_conf * 10)))
|
|
403
|
+
|
|
404
|
+
reason_parts = [f"task {meta.get('raw_id', norm)} metadata"]
|
|
405
|
+
if meta.get("tier"):
|
|
406
|
+
reason_parts.append(f"tier {meta['tier']}")
|
|
407
|
+
if base_conf is not None:
|
|
408
|
+
reason_parts.append(f"confidence {base_conf:.2f}")
|
|
409
|
+
|
|
410
|
+
return {
|
|
411
|
+
"agent": agent,
|
|
412
|
+
"confidence": confidence_score,
|
|
413
|
+
"reason": ", ".join(reason_parts),
|
|
414
|
+
"metadata": meta,
|
|
415
|
+
"task_id": meta.get("raw_id", norm),
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
return None
|
|
419
|
+
|
|
420
|
+
def _calculate_keyword_scores(self, request: str) -> Dict[str, float]:
|
|
421
|
+
"""
|
|
422
|
+
Calculate keyword scores for all intents (Week 2 addition)
|
|
423
|
+
|
|
424
|
+
Used for embedding-enhanced routing
|
|
425
|
+
"""
|
|
426
|
+
request_lower = request.lower()
|
|
427
|
+
scores = {}
|
|
428
|
+
|
|
429
|
+
for intent, keywords in self.intent_classifier.intent_keywords.items():
|
|
430
|
+
score = 0.0
|
|
431
|
+
|
|
432
|
+
# Count matching include keywords
|
|
433
|
+
include_matches = sum(
|
|
434
|
+
1 for kw in keywords["include"]
|
|
435
|
+
if kw in request_lower
|
|
436
|
+
)
|
|
437
|
+
|
|
438
|
+
# Check exclusion keywords (veto)
|
|
439
|
+
exclude_matches = sum(
|
|
440
|
+
1 for kw in keywords["exclude"]
|
|
441
|
+
if kw in request_lower
|
|
442
|
+
)
|
|
443
|
+
|
|
444
|
+
if exclude_matches > 0:
|
|
445
|
+
score = -1.0 # Veto this intent
|
|
446
|
+
else:
|
|
447
|
+
score = include_matches * keywords["confidence_boost"]
|
|
448
|
+
|
|
449
|
+
scores[intent] = score
|
|
450
|
+
|
|
451
|
+
# Filter out negative scores
|
|
452
|
+
valid_scores = {k: v for k, v in scores.items() if v >= 0}
|
|
453
|
+
return valid_scores if valid_scores else {}
|
|
454
|
+
|
|
455
|
+
def _route_semantic(self, user_request: str) -> Tuple[str, float, str]:
|
|
456
|
+
"""
|
|
457
|
+
Route using semantic keywords and capability validation
|
|
458
|
+
Enhanced with embeddings if available (Week 1-2 additions)
|
|
459
|
+
|
|
460
|
+
Returns:
|
|
461
|
+
(agent, confidence, reasoning)
|
|
462
|
+
"""
|
|
463
|
+
# Step 1: Classify intent using semantic keywords
|
|
464
|
+
intent, confidence = self.intent_classifier.classify(user_request)
|
|
465
|
+
|
|
466
|
+
# Week 2: Try to enhance with embedding-based matching
|
|
467
|
+
if self.semantic_matcher and self.semantic_matcher.is_available():
|
|
468
|
+
try:
|
|
469
|
+
# Get keyword scores for embedding matcher
|
|
470
|
+
keyword_scores = self._calculate_keyword_scores(user_request)
|
|
471
|
+
|
|
472
|
+
if keyword_scores:
|
|
473
|
+
# Use embedding-enhanced similarity
|
|
474
|
+
enhanced_intent, enhanced_conf = self.semantic_matcher.find_similar_intent(
|
|
475
|
+
user_request,
|
|
476
|
+
keyword_scores
|
|
477
|
+
)
|
|
478
|
+
|
|
479
|
+
if enhanced_intent is not None:
|
|
480
|
+
intent = enhanced_intent
|
|
481
|
+
confidence = enhanced_conf
|
|
482
|
+
|
|
483
|
+
logger.debug(f"Embedding-enhanced routing: {intent} ({confidence:.3f})")
|
|
484
|
+
except Exception as e:
|
|
485
|
+
logger.warning(f"Embedding enhancement failed, using semantic keywords: {e}")
|
|
486
|
+
# Continue with non-enhanced confidence
|
|
487
|
+
|
|
488
|
+
if intent is None:
|
|
489
|
+
# Fallback to keyword routing
|
|
490
|
+
agent, kw_conf, reason = self.suggest_agent(user_request, verbose=False)
|
|
491
|
+
return agent, kw_conf / 100, f"Semantic: no intent match, used keyword fallback: {reason}"
|
|
492
|
+
|
|
493
|
+
# Step 2: Select agent for intent
|
|
494
|
+
agent = self._get_agent_for_intent(intent)
|
|
495
|
+
|
|
496
|
+
# Step 3: Validate agent has capability
|
|
497
|
+
if not self.capability_validator.validate(agent, intent):
|
|
498
|
+
fallback_agent = self.capability_validator.find_fallback_agent(intent, exclude=agent)
|
|
499
|
+
confidence *= 0.8 # Lower confidence for fallback
|
|
500
|
+
agent = fallback_agent
|
|
501
|
+
|
|
502
|
+
reasoning = f"Semantic: {intent} (conf: {confidence:.2f}) → {agent}"
|
|
503
|
+
return agent, confidence, reasoning
|
|
504
|
+
|
|
505
|
+
def suggest_agent(self, user_request: str, verbose: bool = False) -> Tuple[str, int, str]:
|
|
506
|
+
"""
|
|
507
|
+
Suggest agent based on keywords and patterns in user request
|
|
508
|
+
|
|
509
|
+
Args:
|
|
510
|
+
user_request: User's request text
|
|
511
|
+
verbose: If True, log scoring details
|
|
512
|
+
|
|
513
|
+
Returns:
|
|
514
|
+
Tuple of (agent_name, confidence_score, reason)
|
|
515
|
+
"""
|
|
516
|
+
task_result = self._route_by_task(user_request)
|
|
517
|
+
if task_result:
|
|
518
|
+
if verbose:
|
|
519
|
+
logger.info(
|
|
520
|
+
"Routing via task metadata: %s -> %s",
|
|
521
|
+
task_result["task_id"],
|
|
522
|
+
task_result["agent"],
|
|
523
|
+
)
|
|
524
|
+
return (
|
|
525
|
+
task_result["agent"],
|
|
526
|
+
task_result["confidence"],
|
|
527
|
+
task_result["reason"],
|
|
528
|
+
)
|
|
529
|
+
|
|
530
|
+
# ACTIVATED: Try semantic routing first (Week 1-3 feature flag)
|
|
531
|
+
if self.semantic_matcher and self.semantic_matcher.is_available():
|
|
532
|
+
try:
|
|
533
|
+
agent, sem_conf, reasoning = self._route_semantic(user_request)
|
|
534
|
+
if verbose:
|
|
535
|
+
logger.info(f"Semantic routing: {agent} (confidence: {sem_conf:.2f})")
|
|
536
|
+
# Convert float confidence to int (0-100 scale) for compatibility
|
|
537
|
+
return agent, int(sem_conf * 100), f"semantic | {reasoning}"
|
|
538
|
+
except Exception as e:
|
|
539
|
+
logger.debug(f"Semantic routing failed, fallback to keyword: {e}")
|
|
540
|
+
# Fall through to keyword routing below
|
|
541
|
+
|
|
542
|
+
request_lower = user_request.lower()
|
|
543
|
+
scores = {}
|
|
544
|
+
reasons = {}
|
|
545
|
+
|
|
546
|
+
for agent_name, rule in self.routing_rules.items():
|
|
547
|
+
score = 0
|
|
548
|
+
matched_keywords = []
|
|
549
|
+
matched_patterns = []
|
|
550
|
+
|
|
551
|
+
# Check keywords (1 point each)
|
|
552
|
+
for keyword in rule.keywords:
|
|
553
|
+
if keyword in request_lower:
|
|
554
|
+
score += 1
|
|
555
|
+
matched_keywords.append(keyword)
|
|
556
|
+
|
|
557
|
+
# Check patterns (2 points each - higher weight)
|
|
558
|
+
for pattern in rule.patterns:
|
|
559
|
+
if re.search(pattern, request_lower, re.IGNORECASE):
|
|
560
|
+
score += 2
|
|
561
|
+
matched_patterns.append(pattern)
|
|
562
|
+
|
|
563
|
+
if score > 0:
|
|
564
|
+
scores[agent_name] = score
|
|
565
|
+
reason_parts = []
|
|
566
|
+
if matched_keywords:
|
|
567
|
+
reason_parts.append(f"keywords: {', '.join(matched_keywords[:3])}")
|
|
568
|
+
if matched_patterns:
|
|
569
|
+
reason_parts.append(f"patterns: {len(matched_patterns)} matched")
|
|
570
|
+
reasons[agent_name] = " | ".join(reason_parts)
|
|
571
|
+
|
|
572
|
+
if not scores:
|
|
573
|
+
# No matches - use fallback
|
|
574
|
+
fallback = "devops-developer"
|
|
575
|
+
if verbose:
|
|
576
|
+
logger.info(f"No keyword matches, using fallback: {fallback}")
|
|
577
|
+
return fallback, 0, "fallback (no keyword matches)"
|
|
578
|
+
|
|
579
|
+
# Return highest scoring agent
|
|
580
|
+
best_agent = max(scores, key=scores.get)
|
|
581
|
+
confidence = scores[best_agent]
|
|
582
|
+
reason = reasons[best_agent]
|
|
583
|
+
|
|
584
|
+
if verbose:
|
|
585
|
+
logger.info(f"Agent routing scores: {scores}")
|
|
586
|
+
logger.info(f"Selected: {best_agent} (confidence: {confidence})")
|
|
587
|
+
|
|
588
|
+
return best_agent, confidence, reason
|
|
589
|
+
|
|
590
|
+
def get_agent_description(self, agent_name: str) -> Optional[str]:
|
|
591
|
+
"""Get description of an agent's responsibilities"""
|
|
592
|
+
rule = self.routing_rules.get(agent_name)
|
|
593
|
+
return rule.description if rule else None
|
|
594
|
+
|
|
595
|
+
def list_agents(self) -> List[str]:
|
|
596
|
+
"""List all available agents"""
|
|
597
|
+
return list(self.routing_rules.keys())
|
|
598
|
+
|
|
599
|
+
def explain_routing(self, user_request: str) -> str:
|
|
600
|
+
"""Provide detailed explanation of routing decision."""
|
|
601
|
+
task_result = self._route_by_task(user_request)
|
|
602
|
+
lines = [
|
|
603
|
+
f'Request: "{user_request}"',
|
|
604
|
+
"",
|
|
605
|
+
]
|
|
606
|
+
|
|
607
|
+
if task_result:
|
|
608
|
+
meta = task_result["metadata"]
|
|
609
|
+
lines.append(f"Suggested Agent: {task_result['agent']} (confidence: {task_result['confidence']})")
|
|
610
|
+
lines.append(f"Reason: {task_result['reason']}")
|
|
611
|
+
lines.append("")
|
|
612
|
+
lines.append("Task Metadata:")
|
|
613
|
+
lines.append(f" Task ID: {task_result['task_id']}")
|
|
614
|
+
if meta.get("tier"):
|
|
615
|
+
lines.append(f" Tier: {meta['tier']}")
|
|
616
|
+
if meta.get("confidence") is not None:
|
|
617
|
+
lines.append(f" Confidence: {meta['confidence']:.2f}")
|
|
618
|
+
return "\n".join(lines)
|
|
619
|
+
|
|
620
|
+
# No task metadata match -> fall back to keyword scoring
|
|
621
|
+
agent, confidence, reason = self.suggest_agent(user_request, verbose=False)
|
|
622
|
+
request_lower = user_request.lower()
|
|
623
|
+
|
|
624
|
+
lines.extend([
|
|
625
|
+
f"Suggested Agent: {agent} (confidence: {confidence})",
|
|
626
|
+
f"Reason: {reason}",
|
|
627
|
+
"",
|
|
628
|
+
"Detailed Scoring:",
|
|
629
|
+
])
|
|
630
|
+
|
|
631
|
+
for agent_name, rule in self.routing_rules.items():
|
|
632
|
+
matched_keywords = [kw for kw in rule.keywords if kw in request_lower]
|
|
633
|
+
matched_patterns = [
|
|
634
|
+
pattern for pattern in rule.patterns if re.search(pattern, request_lower, re.IGNORECASE)
|
|
635
|
+
]
|
|
636
|
+
score = len(matched_keywords) + (len(matched_patterns) * 2)
|
|
637
|
+
|
|
638
|
+
if score > 0:
|
|
639
|
+
lines.append(f" {agent_name}: {score} points")
|
|
640
|
+
if matched_keywords:
|
|
641
|
+
lines.append(f" - Keywords: {', '.join(matched_keywords[:5])}")
|
|
642
|
+
if matched_patterns:
|
|
643
|
+
lines.append(f" - Patterns: {len(matched_patterns)} matched")
|
|
644
|
+
|
|
645
|
+
return "\n".join(lines)
|
|
646
|
+
|
|
647
|
+
def main():
|
|
648
|
+
"""CLI interface for testing agent router."""
|
|
649
|
+
parser = argparse.ArgumentParser(description="Unified agent router for Claude Code.")
|
|
650
|
+
parser.add_argument("--task-file", help="Path to tasks.md metadata file (optional).")
|
|
651
|
+
parser.add_argument("--test", action="store_true", help="Run router smoke tests.")
|
|
652
|
+
parser.add_argument("--semantic", action="store_true", help="Use semantic routing (Week 1 addition).")
|
|
653
|
+
parser.add_argument("--explain", metavar="REQUEST", help="Explain routing decision for a request.")
|
|
654
|
+
parser.add_argument("--json", action="store_true", help="Output result as JSON (for programmatic parsing).")
|
|
655
|
+
parser.add_argument("request", nargs="*", help="Free-form request to route.")
|
|
656
|
+
args = parser.parse_args()
|
|
657
|
+
|
|
658
|
+
task_file = Path(args.task_file) if args.task_file else None
|
|
659
|
+
router = AgentRouter(task_file=task_file)
|
|
660
|
+
|
|
661
|
+
if args.test:
|
|
662
|
+
test_cases = [
|
|
663
|
+
"Check pods in default namespace",
|
|
664
|
+
"Validate terraform configuration",
|
|
665
|
+
"Build docker image for API",
|
|
666
|
+
"Diagnose GKE cluster connectivity",
|
|
667
|
+
"Run tests for web application",
|
|
668
|
+
"Check flux reconciliation status",
|
|
669
|
+
"Review Cloud SQL IAM bindings",
|
|
670
|
+
"Plan infrastructure changes with terragrunt",
|
|
671
|
+
]
|
|
672
|
+
|
|
673
|
+
if router.tasks_metadata:
|
|
674
|
+
sample_task = next(iter(router.tasks_metadata.keys()))
|
|
675
|
+
test_cases.insert(0, f"Work on {sample_task}")
|
|
676
|
+
|
|
677
|
+
print("Running agent router smoke tests...\n")
|
|
678
|
+
|
|
679
|
+
for request in test_cases:
|
|
680
|
+
agent, confidence, reason = router.suggest_agent(request, verbose=False)
|
|
681
|
+
print(f'Request: "{request}"')
|
|
682
|
+
print(f" -> Agent: {agent} (confidence: {confidence})")
|
|
683
|
+
print(f" -> Reason: {reason}")
|
|
684
|
+
print()
|
|
685
|
+
return
|
|
686
|
+
|
|
687
|
+
if args.explain:
|
|
688
|
+
print(router.explain_routing(args.explain))
|
|
689
|
+
return
|
|
690
|
+
|
|
691
|
+
if not args.request:
|
|
692
|
+
parser.error("Please provide a request, --test, or --explain.")
|
|
693
|
+
|
|
694
|
+
request = " ".join(args.request)
|
|
695
|
+
|
|
696
|
+
# Week 1 addition: Use semantic routing if flag is set
|
|
697
|
+
if args.semantic:
|
|
698
|
+
agent, confidence, reason = router._route_semantic(request)
|
|
699
|
+
# Normalize confidence to 0-100 for consistency with suggest_agent
|
|
700
|
+
confidence_normalized = int(confidence * 100)
|
|
701
|
+
else:
|
|
702
|
+
agent, confidence_normalized, reason = router.suggest_agent(request, verbose=not args.json)
|
|
703
|
+
confidence = confidence_normalized / 100
|
|
704
|
+
|
|
705
|
+
# JSON output for programmatic parsing
|
|
706
|
+
if args.json:
|
|
707
|
+
result = {
|
|
708
|
+
"agent": agent,
|
|
709
|
+
"confidence": confidence_normalized,
|
|
710
|
+
"reason": reason,
|
|
711
|
+
"semantic": args.semantic,
|
|
712
|
+
"description": router.get_agent_description(agent),
|
|
713
|
+
}
|
|
714
|
+
print(json.dumps(result, indent=2))
|
|
715
|
+
return
|
|
716
|
+
|
|
717
|
+
# Human-readable text output
|
|
718
|
+
print()
|
|
719
|
+
routing_method = "Semantic" if args.semantic else "Keyword"
|
|
720
|
+
print(f"Suggested Agent: {agent} ({routing_method})")
|
|
721
|
+
print(f"Confidence: {confidence_normalized}")
|
|
722
|
+
print(f"Reason: {reason}")
|
|
723
|
+
print()
|
|
724
|
+
description = router.get_agent_description(agent)
|
|
725
|
+
if description:
|
|
726
|
+
print(f"Agent Description: {description}")
|
|
727
|
+
|
|
728
|
+
|
|
729
|
+
if __name__ == "__main__":
|
|
730
|
+
main()
|