@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.
Files changed (91) hide show
  1. package/CHANGELOG.md +315 -0
  2. package/CLAUDE.md +154 -0
  3. package/LICENSE +21 -0
  4. package/README.md +221 -0
  5. package/agents/aws-troubleshooter.md +50 -0
  6. package/agents/claude-architect.md +821 -0
  7. package/agents/devops-developer.md +92 -0
  8. package/agents/gcp-troubleshooter.md +50 -0
  9. package/agents/gitops-operator.md +360 -0
  10. package/agents/terraform-architect.md +289 -0
  11. package/bin/gaia-init.js +620 -0
  12. package/commands/architect.md +97 -0
  13. package/commands/restore-session.md +87 -0
  14. package/commands/save-session.md +88 -0
  15. package/commands/session-status.md +61 -0
  16. package/commands/speckit.add-task.md +144 -0
  17. package/commands/speckit.analyze-task.md +65 -0
  18. package/commands/speckit.implement.md +96 -0
  19. package/commands/speckit.init.md +237 -0
  20. package/commands/speckit.plan.md +88 -0
  21. package/commands/speckit.specify.md +161 -0
  22. package/commands/speckit.tasks.md +188 -0
  23. package/config/AGENTS.md +162 -0
  24. package/config/agent-catalog.md +604 -0
  25. package/config/context-contracts.md +682 -0
  26. package/config/git-standards.md +674 -0
  27. package/config/git_standards.json +69 -0
  28. package/config/orchestration-workflow.md +735 -0
  29. package/hooks/__pycache__/post_tool_use.cpython-312.pyc +0 -0
  30. package/hooks/__pycache__/pre_kubectl_security.cpython-312.pyc +0 -0
  31. package/hooks/__pycache__/pre_tool_use.cpython-312.pyc +0 -0
  32. package/hooks/__pycache__/session_start.cpython-312.pyc +0 -0
  33. package/hooks/__pycache__/subagent_stop.cpython-312.pyc +0 -0
  34. package/hooks/post_tool_use.py +463 -0
  35. package/hooks/pre_kubectl_security.py +205 -0
  36. package/hooks/pre_tool_use.py +530 -0
  37. package/hooks/session_start.py +315 -0
  38. package/hooks/subagent_stop.py +549 -0
  39. package/index.js +92 -0
  40. package/package.json +59 -0
  41. package/speckit/README.en.md +648 -0
  42. package/speckit/README.md +353 -0
  43. package/speckit/governance.md +169 -0
  44. package/speckit/scripts/check-prerequisites.sh +194 -0
  45. package/speckit/scripts/common.sh +126 -0
  46. package/speckit/scripts/create-new-feature.sh +131 -0
  47. package/speckit/scripts/init.sh +42 -0
  48. package/speckit/scripts/setup-plan.sh +95 -0
  49. package/speckit/scripts/update-agent-context.sh +718 -0
  50. package/speckit/templates/adr-template.md +118 -0
  51. package/speckit/templates/agent-file-template.md +23 -0
  52. package/speckit/templates/plan-template.md +233 -0
  53. package/speckit/templates/spec-template.md +116 -0
  54. package/speckit/templates/tasks-template-bkp.md +136 -0
  55. package/speckit/templates/tasks-template.md +345 -0
  56. package/templates/CLAUDE.template.md +170 -0
  57. package/templates/code-examples/approval_gate_workflow.py +141 -0
  58. package/templates/code-examples/clarification_workflow.py +94 -0
  59. package/templates/code-examples/commit_validation.py +86 -0
  60. package/templates/project-context.template.json +126 -0
  61. package/templates/settings.template.json +307 -0
  62. package/tools/__pycache__/agent_router.cpython-312.pyc +0 -0
  63. package/tools/__pycache__/approval_gate.cpython-312.pyc +0 -0
  64. package/tools/__pycache__/clarify_engine.cpython-312.pyc +0 -0
  65. package/tools/__pycache__/clarify_patterns.cpython-312.pyc +0 -0
  66. package/tools/__pycache__/commit_validator.cpython-312.pyc +0 -0
  67. package/tools/__pycache__/context_section_reader.cpython-312.pyc +0 -0
  68. package/tools/__pycache__/routing_dashboard.cpython-312.pyc +0 -0
  69. package/tools/__pycache__/routing_feedback.cpython-312.pyc +0 -0
  70. package/tools/__pycache__/semantic_matcher.cpython-312.pyc +0 -0
  71. package/tools/__pycache__/task_manager.cpython-312.pyc +0 -0
  72. package/tools/agent_capabilities.json +231 -0
  73. package/tools/agent_invoker_helper.py +239 -0
  74. package/tools/agent_router.py +730 -0
  75. package/tools/approval_gate.py +318 -0
  76. package/tools/clarify_engine.py +511 -0
  77. package/tools/clarify_patterns.py +356 -0
  78. package/tools/commit_validator.py +338 -0
  79. package/tools/context_provider.py +181 -0
  80. package/tools/context_section_reader.py +301 -0
  81. package/tools/demo_clarify.py +104 -0
  82. package/tools/generate_embeddings.py +168 -0
  83. package/tools/quicktriage_aws_troubleshooter.sh +45 -0
  84. package/tools/quicktriage_devops_developer.sh +38 -0
  85. package/tools/quicktriage_gcp_troubleshooter.sh +51 -0
  86. package/tools/quicktriage_gitops_operator.sh +47 -0
  87. package/tools/quicktriage_terraform_architect.sh +40 -0
  88. package/tools/semantic_matcher.py +222 -0
  89. package/tools/task_manager.py +547 -0
  90. package/tools/task_manager_README.md +395 -0
  91. 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()